Погружение во внедрение зависимостей (DI), или как взломать Матрицу
Давным-давно в далекой Галактике, когда сестры Вачовски еще были братьями, искусственный разум в лице Архитектора поработил человечество и создал Матрицу… Всем привет, это снова Максим Кравец из Holyweb, и сегодня я хочу поговорить про Dependency Injection, то есть про внедрение зависимостей, или просто DI. Зачем? Возможно, просто хочется почувствовать себя Морфеусом, произнеся сакраментальное: «Я не могу объяснить тебе, что такое DI, я могу лишь показать тебе правду».
Постановка задачи
«Взгляни на этих птиц. Существует программа, чтобы ими управлять. Другие программы управляют деревьями и ветром, рассветом и закатом. Программы совершенствуются. Все они выполняют свою собственную часть работы» — Пифия
Фабула, надеюсь, всем известна — есть Матрица, к ней подключены люди. Люди пытаются освободиться, им мешают Агенты. Главный вопрос — кто победит? Но это будет в конце фильма, а мы с вами пока в самом начале. Так что давайте поставим себя на место Архитектора и подумаем, как нам создать Матрицу?
Что есть программы? Те самые, которые управляют птицами, деревьями, ветром.
Да просто всем нам знакомые классы, у каждого из которых есть свои поля и методы, обеспечивающие реализацию возложенной на этот класс задачи.
Что нам нужно обеспечить для функционирования Матрицы? Механизм внедрения, или (внимание, рояль в кустах), инжекции (Injection) функционала классов, отвечающих за всю вышеперечисленную флору, фауну и прочие природные явления, внутрь Матрицы.
Подождем, пока грузчики установят в кустах очередной музыкальный инструмент, и зададимся вопросом: а что произойдет с Матрицей после того, как мы в нее инжектируем нужный нам функционал? Все правильно — у нее появятся зависимости (Dependency) от внешних по отношению к ней классов.
Пазл сложился? С одной стороны — да. Dependency Injection — это всего лишь механизм внедрения в класс зависимости от другого класса. С другой — что это за механизм, для чего он нужен и когда его стоит использовать?
Первым делом, посмотрим на цитату в начале текста и обратим внимание на предложение: «Программы совершенствуются». То есть — переписываются. Изменяются. Что это означает для нас? Работа нашей Матрицы не должна зависеть от конкретной реализации класса зависимости.
Кажется, ерунда какая-то — зависимость на то и зависимость, чтобы от нее зависеть!
А теперь следите за руками. Мы внедряем в Матрицу не конкретную реализацию зависимости, а абстрактный контракт, и реализуем механизм предоставления конкретной реализации, соответствующей этому контракту! Остались сущие пустяки — понять, как же это все реализовать.
Внедрение зависимости в чистом виде
Оставим романтикам рассветы и закаты, птичек и цветочки. Мы, человеки, должны вырваться из под гнета ИИ вообще и Архитектора в частности. Так что будем разбираться с реализацией DI и параллельно — освобождаться из Матрицы. Первая итерация. Создадим класс matrix, непосредственно в нем создадим агента по имени Смит, определим его силу. Там же, внутри Матрицы, создадим и претендента, задав его силу, после чего посмотрим, кто победит, вызвав метод whoWin():
Да, Сайфер не самый приятный персонаж, да еще и хотел вернуться в Матрицу, так что на роль первого проигравшего подходит идеально.
Побеждает Smith
Архитектор, конечно, антигерой в рамках повествования, но идея заставить его вручную внести каждого подключенного к Матрице в базовый класс, а потом еще отслеживать рождаемость-смертность и поддерживать код в актуальном состоянии — сродни путевке в ад. Тем более, что физически люди находятся в реальном мире, а в Матрицу проецируется только их образ. Хотите — называйте его аватаром. Мы программисты, нам ближе ссылка или инстанс. Перепишем наш код.
Мы добавили класс Human, принимающий на вход конструктора имя и силу человека, и возвращающий их в соответствующих методах. Также мы внесли изменения в наш класс Матрицы — теперь информацию о претенденте на победу он получает через конструктор. Давайте проверим, сможет ли Тринити победить Агента Смита?
Увы, Тринити «всего лишь человек» (с), и ее сила по определению не может быть больше, чем у агента, так что итог закономерен.
Побеждает Smith
Но стоп! Давайте посмотрим, что случилось с Матрицей? А случилось то, что класс Matrix и результаты его работы стал зависеть от класса Human! И нашему оператору, отправляющему Тринити в Матрицу, достаточно немного изменить код, чтобы обеспечить победу человечества!
Пьем шампанское и расходимся по домам?
Чем плох подход выше? Тем, что класс Matrix ждет от зависимости challenger, передаваемой в конструктор, наличие метода damage, поскольку именно к нему мы обращаемся в коде. Но об этом знает Архитектор, создавший Матрицу, а не наш оператор! В примере — мы можем угадать. А если не знать заранее название метода? Может быть, надо было написать не damage, а power? Или strength?
Инверсия зависимостей
Знакомьтесь! Dependency inversion principle, принцип инверсии зависимостей (DIP). Название, кстати, нередко сокращают, убирая слово «принцип» , и тогда остается только Dependency inversion (DI), что вносит путаницу в мысли новичков.
Принцип инверсии зависимостей имеет несколько трактовок, мы приведем лишь две:
- Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
- Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Зачем он нам понадобился? Для того, чтобы обеспечить механизм соблюдения контракта для зависимости, о необходимости которого мы говорили при постановке задачи, и отсутствие которого в нашей реализации не дало нам пока наполнить бокалы шампанским.
Давайте внедрим в наш класс Matrix некий абстрактный класс AbstractHuman, а конкретную реализацию в виде класса Human — попросим имплементировать эту абстракцию:
Морфеуса жалко, но все же он — не избранный.
Побеждает Smith
Вторая версия Матрицы пока что выигрывает, но что получилось на текущий момент? Класс Matrix больше не зависит от конкретной реализации класса Human — задачу номер один мы выполнили. Класс Human отныне точно знает, какие методы с какими именами в нем должны присутствовать — пока «контракт» в виде абстрактного класса AbstractHuman не будет полностью реализован (имплементирован) в конкретной реализации, мы будем получать ошибку. Задача номер два также выполнена.
Порадуемся за архитектора, кстати! В новой реализации он может создать отдельный класс для мужчин, отдельный для женщин, если понадобится — сделать отдельный класс для брюнеток и отдельный для блондинок… Согласитесь, таким модульным кодом легче управлять.
В бою с Морфеусом побеждает Smith
В бою с Тринити побеждает Smith
Думаю, вы уже догадались, что должен сделать наш оператор, чтобы Нео все же победил. Напишем еще один класс для Избранного, слегка отредактировав его силу:
Свершилось!
В бою с Нео побеждает Нео
Инверсия управления
Давайте посмотрим, кто управляет кодом? В нашем примере мы сами пишем и класс Matrix, и класс Human, сами создаем инстансы и задаем все параметры. Мы управляем нашим кодом. Захотели — внесли изменения и обеспечили победу Тринити.
Увы, по условиям мира, придуманного Вачовски, мы можем лишь вклиниваться в работу Матрицы, добавляя свои кусочки программного кода. Матрица управляет не только фантомами внутри себя, но и тем, как с ней работать извне!
Возможно, авторы трилогии увлекались программированием, потому что ситуация целиком и полностью списана с реальности и даже имеет свое название — Inversion of Control (IoC).
Когда программист работает в фреймворке, он тоже пишет только часть кода, отдельные модули (классы, в которые внедряются зависимости) или сервисы (классы, которые внедряются в модули как зависимости). Причем какие именно (порой вплоть до правил именования файлов) — решает фреймворк.
Кстати, уже использованный нами выше DIP (принцип инверсии зависимостей) — одно из проявлений механизма IoC.
К-контейнер н-нада?
Последний шаг — передача управления разрешением зависимостей. Кому и какой инстанс предоставить, использовать singleton или multiton — также решается не программистом (оператором), а фреймворком (Матрицей).
Вариантов решения задачи множество, но все они сводятся к одной идее.
- на верхнем уровне приложения создается глобальный объект,
- в этом объекте регистрируется абстрактный интерфейс и класс, который его имплементирует,
- модуль запрашивает необходимый ему интерфейс (абстрактный класс),
глобальный объект находит класс, имплементирующий данный интерфейс, при необходимости создает инстанс и передает его в модуль.
Конкретные реализации у каждого фреймворка свои: где-то используется Локатор сервисов/служб (Service Locator), где-то Контейнер DI, чаще называемый IoC Container. Но на уровне базовой функциональности отличия между подходами стираются до неразличимости.
У нас есть класс, который мы планируем внедрить (сервис). Мы сообщаем фреймворку о том, что этот класс нужно отправить в контейнер. Наиболее наглядно это происходит в Angular — мы просто вешаем декоратор Injectable.
Декоратор добавит к классу набор метаданных и зарегистрирует его в IoC контейнере.
Когда нам понадобится инстанс SomeService, фреймворк обратится к контейнеру, найдет уже существующий или создаст новый инстанс сервиса и вернет его нам.
Крестики-нолики, а точнее — плюсы и минусы
Плюсы очевидны — вместо монолитного приложения мы работаем с набором отдельных сервисов и модулей, не связанных друг с другом напрямую. Мы не завязаны на конкретную реализацию класса, более того, мы можем подменять их при необходимости просто через конфигурацию. За счет того, что большая часть «технического» кода отныне спрятана в недрах фреймворка, наш код становится компактнее, чище. Его легче рефакторить, тестировать, проводить отладку.
Минусы — написание рабочего кода требует понимания логики работы фреймворка, иначе проект превращается в набор «черных ящиков» с наклейками «я реализую такой-то интерфейс». Кроме того, за любое удобство в программировании приходится платить производительностью. В конечном итоге все всё равно сводится к обычному инстанцированию с помощью new, а дополнительные «обертки», реализующие за нас эту логику, требуют и дополнительных ресурсов.
Вместо заключения, или как это использовать практически?
Окей, если необходимость добавления промежуточного слоя в виде «контракта» более-менее очевидна, то где на практике нам может пригодиться IoC?
Кейс 1 — тестирование.
- У вас есть модуль, который отвечает за оформление покупки в интернет-магазине.
- Функционал списания средств мы вынесем в отдельный сервис и внедрим его через DI. Этот сервис будет обращаться к реальному эквайрингу банка Х.
- Нам нужно протестировать работу модуля в целом, но мы не готовы совершать реальную покупку при каждом тесте.
- Решение — напишем моковый сервис, имплементирующий тот же контракт, что и «боевой», и для теста — через IoC будем вызывать моковую реализацию.
Кейс 2 — расширение функционала.
- Модуль — прежний, оформление покупки в интернет-магазине.
- Поступает задача — добавить возможность оплаты не только в банке Х, но и в банке Y.
- Мы пишем еще один платежный сервис, реализующий взаимодействие с банком Y и имплементирующий тот же контракт, что и сервис банка X.
- Плюсы — мы можем подменять банк, мы можем давать пользователю выбор, в какой банк платить. При этом мы никак не меняем наш основной модуль.
Кейс 3 — управление на уровне инфраструктуры.
- Модуль — прежний.
- Для production — работаем с «боевым» сервисом платежей.
- Для разработки — подгружаем моковый вариант, симулирующий списание средств.
- Для тестового окружения — пишем третий сервис, который будет симулировать списание средств, а заодно вести расширенный лог состояния.
Надеюсь, этот краткий список примеров вас убедил в том, что вопроса, использовать или не использовать DI, в современной разработке не стоит. Однозначно использовать. А значит — надо понимать, как это работает. Надеюсь, мне удалось не только помочь Нео в его битве со Смитом, но и вам в понимании, как устроен и работает DI.
Если есть вопросы или дополнения по теме или хотите познакомиться с нами ближе, напишите нам на career@holyweb.ru