В этом параграфе мы поговорим об инверсии управления
(англ. Inversion of Control, IoC).
Это приём, который позволяет снизить связность внутри приложения за счёт делегирования функциональностей внешним исполнителям. То есть вы буквально вычленяете участки кода из своей программы и перемещаете их во внешние пакеты или находите аналогичные реализации в сторонних пакетах.
Сперва мы разберёмся с терминами — определим, из каких сущностей состоит инверсия управления. Затем рассмотрим последовательность шагов для применения этой техники и потренируемся её применять. И, наконец, рассмотрим пример из практики и разберём, как инверсия управления связана с фреймворками.
Разбираемся с терминами
Прежде чем перейти к нюансам, давайте определим основные термины и убедимся, что понимаем их правильно. Как мы сказали выше, инверсия управления предполагает, что мы выносим наружу (то есть делегируем) некую функциональность. Эту функциональность называют аспектом управления
.
Когда вы делегируете какие-либо задачи, вы передаёте поток управления
внешнему исполнителю. А раз так, вам необходимо рассказать, что именно вы хотите, чтобы было исполнено. Список требований и условий называется контрактом
. Контракт в программировании предусматривает три условия:
-
Условие вызова (
предусловие
) — при каких условиях начнется исполнение. -
Условие корректности (
инвариант
) — не создаётся невозможных условий. Например, баланс на счёте не стал отрицательным. -
Условие выполнения (
постусловие
) — условие, при котором работа считается выполненной.
Вот пример из жизни.
Представьте, что Пете нужно подшить брюки. В теории он может сделать это сам. Но тогда ему придётся брать ответственность за результат на себя. Шить он особо не умеет, швейной машинки у него нет. Поэтому он решает обратиться в ателье. Работа по укорачиванию брюк — это аспект управления
.
Петя приходит в ателье и объясняет задачу. Эта часть сделки — предусловие
.
Когда он приходит обратно к назначенному сроку, то ожидает увидеть свои брюки, а не чужие. К тому же это всё ещё должны быть брюки, а не шорты. Это — инвариант
.
Затем он проверяет работу: что брюки ушили ровно на пять сантиметров, как требовалось, а не на десять. Это — постусловие
. И если всё в порядке — оплачивает работу.
Когда Петя передал ответственность за работу ателье — это называется потоком управления
. Его временная передача в процессе исполнения обязательств тоже часть инверсии управления.
Дальше мы разберём, как находить все эти сущности в коде.
Техника инверсии управления
Многие разработчики интуитивно применяют технику инверсии управления. Это как в спорте: вы можете интуитивно правильно выполнять многие приёмы и не задумываться над техникой. Однако иногда знание правильной техники может сыграть вам на руку. Иногда одна небольшая деталь может повлиять на результаты.
Поэтому наша задача — научиться понимать и выделять основные детали техники инверсии управления, её составные и последовательность шагов.
Наша техника такая:
а) Определить, что является потребителем.
б) Определить, какой аспект управления
мы хотим вынести.
в) Определить предусловия
, инварианты
и постусловия
.
г) Реализовать вынос и изменить направление потока управления.
Необходимо отработать этот процесс до автоматизма, чтобы выполнение инверсии управления происходило само собой, подобно техничной комбинации приёмов борца.
В этом нам поможет упражнение.
Упражнение для отработки техники
Давайте выполним небольшое упражнение на примере абстрактной фичи (например, аренды транспорта), которая использует для HTTP-запросов клиент Dio
.
Ситуация: программисты стали жаловаться на то, что жёсткая зависимость на Dio
доставляет им неудобства, так как в их проекте используются другие пакеты (например, http
) и приходится отдельно создавать экземпляр Dio
именно для этого пакета.
Попробуйте выделить и записать в таблицу все составляющие техники инверсии управления на примере одной фичи. Пожалуйста, прежде чем продолжить чтение, остановитесь и выполните упражнение, это значительно улучшит ваше понимание темы.
Ничего страшного, если с первого раза не получится, почти ни у кого не получается, даже у крайне опытных разработчиков!
Потребитель |
Аспект Управления |
Контракт: предусловия, инварианты, постусловия |
Что делаем |
ваш ответ |
ваш ответ |
Предусловия: Инварианты: Постусловия: |
ваш ответ |
Ответ
Можно подумать, что потребитель здесь — приложение, и это понятно: ведь всё-таки приложение использует пакет. Но в нашем контексте потребитель — сама фича. Потому что сейчас она сама выбирает, какой HTTP-клиент использовать для сетевых запросов. Это нас подводит и к ответу на вопрос, что является аспектом управления — HTTP-клиент. Именно к нему мы будем прилагать усилие по переносу ответственности.
Контракт включается в себя три характеристики:
-
Предусловие
— необходимость произвести HTTP-запрос. -
Инвариант
— бизнес-логика не зависит от конкретной реализации HTTP-клиента, выполнение запроса всегда заканчивается возвратом данных либо исключением. -
Постусловие
— запрос выполнен, получили все данные: код и тело ответа.
Заголовок примечания
Возможно, вы сформулировали ответы по-другому или нашли дополнительные характеристики контракта. Это нормально, так как тут может быть большое пространство для размышлений.
Теперь давайте ответим на вопрос, какие действия необходимо предпринять для выполнения инверсии управления. Рассмотрим диаграмму взаимодействия фичи и приложения в текущем состоянии.
Как вы можете видеть, внутри пакета ExternalPackage
создаётся зависимость HttpClient
, в которую зашито использование клиента Dio
из одноимённого пакета. В свою очередь, приложение с помощью фабрики ExternalPackageFactory
создаёт экземпляр класса ExternalPackage
. В связи с этим наше приложение получает транзитивную зависимость в виде библиотеки dio
. Подробнее о зависимостях и их видах мы рассказывали ранее.
Подобные зависимости не всегда плохи, однако в некоторых случаях они могут быть нежелательны. Например, библиотека dio
может использовать несовместимые с вашей версией приложения библиотеки, из-за чего вы не сможете использовать интересующий вас пакет.
Одно из возможных решений проблемы — создание интерфейса HTTP-клиента. Благодаря этому поток управления
направлен в сторону приложения, когда мы хотим сделать HTTP-запрос. При этом приложение само решает, как именно выполнять запрос и что при этом использовать: Dio
или Http
.
Инверсия управления на примере
Давайте рассмотрим конкретный пример. В кредитных системах для принятия решения о выдаче кредита используют такой показатель, как ПДН (предельная долговая нагрузка). Рассчитывается он так:
ПДН = денежные обязательства / доход
Каждый из операндов можно рассчитывать по-разному. Для расчёта денежных обязательств могут использоваться такие сервисы, как НБКИ или Equifax. Они предоставляют все долговые обязательства заёмщика по запросу.
Аналогично и с доходом. Мы можем узнать от заёмщика, какой у него доход, или рассчитать на основе выписки о расходах.
Давайте смоделируем ситуацию, когда расчёт долговых обязательств и доход осуществляется всегда по одной схеме, тогда диаграмма классов будет выглядеть так:
Класс PDNCalculator
имеет зависимость от класса доступа к базе данных DBAccessor
и на API-клиент сервиса НБКИ. С помощью метода calcPDN()
происходит расчёт долговой нагрузки, остальные два метода — вспомогательные, они рассчитывают доход и долговые обязательства. В этой версии доход берётся из базы данных, а данные о доходах — со слов клиента.
Если у нас всего один возможный источник данных и изменений не предполагается, можно закрыть глаза на жесткую зависимость от сервиса НБКИ.Но представьте: завтра вам говорят, что теперь для расчёта долговой нагрузки нужно использовать и Equifax, а для расчёта дохода — данные о расходах клиента.Тогда вам придётся добавить новые и отредактировать старые методы. Код становится сложнее для восприятия и поддержки.
В этом примере мы добавили отдельный метод _getEquifaxDebt
, который рассчитывает долговую нагрузку с помощью сервиса Equifax, а также внедрили новую зависимость — API-клиент Equifax. Новый метод _getMonthlyIncomeViaExpenses
рассчитывает доход с помощью информации о расходах клиента.
Давайте чуть повнимательнее приглядимся к методу calcPDN()
.
Его код будет выглядеть так
1double calcPDN(String customerId) {
2 // получаем объект клиента
3 final customer = _dbAccessor.getCustomerById(customerId);
4
5 double monthlyIncome = 0;
6
7 // определяем стратегию расчёта дохода
8 if (customer.monthlyIncomeStrategy == MonthlyIncomeStrategy.customerInformation) {
9 monthlyIncome = getMonthlyIncome(customerId);
10 } else if (customer.monthlyIncomeStrategy == MonthlyIncomeStrategy.viaExpenses) {
11 monthlyIncome = _getMonthlyIncomeViaExpenses(customerId)
12 }
13
14 double monthlyDebt = 0;
15
16 // определяем стратегию расчёта расходов
17 if (customer.monthlyDebtStrategy == MonthlyDebtStrategy.nbch) {
18 monthlyDebt = _getNBCHDebt(customerId);
19 } else if (customer.monthlyDebtStrategy == MonthlyDebtStrategy.equifax) {
20 monthlyDebt = _getEquifaxDebt(customerId);
21 }
22
23 // делаем проверку, чтобы избежать деления на 0
24 if (monthlyIncome == 0) return 1;
25
26 // основная формула с расчётом ПДН
27 final pdn = monthlyDebt / monthlyIncome;
28
29 // значение ПДН может находиться в промежутке от 0 до 1
30 return min(max(pdn, 0), 1);
31}
Как можно заметить, метод calcPDN()
, помимо расчёта показателя ПДН, стал принимать решение о том, какую методику расчёта использовать для долговой нагрузки и дохода. Нетрудно понять, что метод занимается не своим делом, так как, если отойти от деталей реализации, метод должен только лишь делить долговые обязательства на доход клиента.
Из этого можно сделать вывод, что с добавлением новых методик расчёта сложность метода растёт прямо пропорционально их количеству. Поток управления в этой схеме при этом остаётся в рамках одного класса.
Давайте попробуем применить инверсию управления. Для начала выясним, какой аспект управления
тут присутствует. Видим, что это выбор стратегии расчёта дохода и задолженности. Давайте попробуем задать контракты, которые бы позволили нам выбирать стратегии расчётов извне и очистить наш метод от функциональности, которую он не должен выполнять:
С помощью интерфейсов MonthDebtCalculator
и MonthlyIncomeCalculator
мы вынесли ответственность за принятие решения о выборе стратегии расчёта долговой нагрузки и дохода на создателя объекта PDNCalculator. Это позволит нам освободить метод calcPDN()
от излишней ответственности.
При такой схеме новый код метода calcPDN
будет выглядеть следующим образом:
1double calcPDN(Customer customer) {
2 final debt = _monthlyDebtCalculator.calc(customer);
3 final income = _monthlyIncomeCalculator.calc(customer);
4
5 if (income == 0) return 1;
6
7 final pdn = debt / income;
8
9 return min(max(pdn, 0), 1);
10}
Согласитесь, код теперь выглядит намного понятнее и проще.
Аспект управления
выбора стратегии возложен на внешнего потребителя. Поток выполнения передаётся на внешние классы, которые были переданы извне. Теперь класс PTICalculator
не обладает ответственностью выбора стратегий расчёта, а реализует только свою логику. Ответственность за выбор стратегии накладывается на код, который создаёт PDNCalculator
, а значит, управление инвертировано.
Давайте ещё раз закрепим знание о составляющих частях инверсии управления: аспект управления, контракт и поток управления. Контракт появляется для того, чтобы потребители объекта могли им управлять и знали как это делать. Часто контракты могут быть неявными, особенно когда у нас всего одна стратегия исполнения кода. Однако контракт присутствует всегда.
Позднее, с развитием программы и расширением функциональности, такие контракты становятся более явными и приобретают воплощение в виде интерфейса или других вариантов описания.
Инверсия управления и фреймворки
Общая характеристика любого фреймворка — это инверсия управления. То есть фреймворк определяет структуру вашей программы, и это можно считать контрактом, который вы исполняете. Это очень похоже на принцип Голливуда:
Don't call us, we'll call you
Фреймворки забирают на себя ответственность за вызовы исполняемых частей в нужных местах программы. В пример можно привести множество веб-фреймворков, использующих концепцию MVC: laravel, yii2, CodeIgniter.
Контракт такой: разработчик создаёт модель, представление и контроллер, а фреймворк вызывает это всё в необходимой последовательности и в нужное время.
Другой пример — это жизненный цикл
виджетов в Flutter. Мы могли бы сами следить за работой виджета, проверять значения и вызывать функции в нужное время, но специально для нас определили контракт, мы можем реализовать методы init()
, dispose()
, didChangeDependencies()
, didUpdateWidget()
и другие. И эти методы будут вызваны в определённое время, с определённым предусловием и заданным инвариантом.
Есть и другое классное объяснение связи инверсии управления
и фреймворков:
Одной из важных характеристик фреймворка является то, что методы, определённые пользователем для адаптации фреймворка, часто будут вызываться изнутри самого фреймворка, а не из кода приложения пользователя.
Фреймворк часто играет роль основной программы при координации и упорядочивании действий приложения. Такая инверсия управления даёт фреймворкам возможность служить расширяемыми каркасами.
Методы, предоставляемые пользователем, адаптируют общие алгоритмы, определённые в фреймворке, к конкретному приложению.
То есть именно инверсия управления делает фреймворки такими удобными и позволяет им забирать на себя значимую часть нагрузки при создании приложений, сайтов и других программных систем.
Итак, мы рассмотрели технику инверсии управления, а также её составляющие: аспект управления, контракт и поток управления.
Плюс выполнили упражнение на определение характеристик инверсии управления — для развития навыков применения этой техники. А ещё — рассмотрели влияние инверсии управления на фреймворки, что позволяет прочертить черту между ними и библиотеками.
Инверсия управления встречается разработчикам очень часто, поэтому так важно знать, как она работает. В следующих параграфах этой главы вы детальнее познакомитесь с другими инструментами, основанными на этой технике: инъекцией зависимостей, сервис-локатором и так далее.