Давным-давно в далекой Галактике, когда сестры Вачовски еще были братьями, искусственный разум в лице Архитектора поработил человечество и создал Матрицу… Всем привет, это снова Максим Кравец из Holyweb, и сегодня я хочу поговорить про Dependency Injection, то есть про внедрение зависимостей, или просто DI. Зачем? Возможно, просто хочется почувствовать себя Морфеусом, произнеся сакраментальное: «Я не могу объяснить тебе, что такое DI, я могу лишь показать тебе правду».
Вот. Взгляни на этих птиц. Существует программа, чтобы ими управлять. Другие программы управляют деревьями и ветром, рассветом и закатом. Программы совершенствуются. Все они выполняют свою собственную часть работы.
Пифия
Фабула, надеюсь, всем известна — есть Матрица, к ней подключены люди. Люди пытаются освободиться, им мешают Агенты. Главный вопрос — кто победит? Но это будет в конце фильма, а мы с вами пока в самом начале. Так что давайте поставим себя на место Архитектора и подумаем, как нам создать Матрицу?
Что есть программы? Те самые, которые управляют птицами, деревьями, ветром.
Да просто всем нам знакомые классы, у каждого из которых есть свои поля и методы, обеспечивающие реализацию возложенной на этот класс задачи.
Что нам нужно обеспечить для функционирования Матрицы? Механизм внедрения, или (внимание, рояль в кустах), инжекции (Injection) функционала классов, отвечающих за всю вышеперечисленную флору, фауну и прочие природные явления, внутрь Матрицы.
Подождем, пока грузчики установят в кустах очередной музыкальный инструмент, и зададимся вопросом: а что произойдет с Матрицей после того, как мы в нее инжектируем нужный нам функционал? Все правильно — у нее появятся зависимости (Dependency) от внешних по отношению к ней классов.
Пазл сложился? С одной стороны — да. Dependency Injection — это всего лишь механизм внедрения в класс зависимости от другого класса. С другой — что это за механизм, для чего он нужен и когда его стоит использовать?
Первым делом, посмотрим на цитату в начале текста и обратим внимание на предложение: «Программы совершенствуются». То есть — переписываются. Изменяются. Что это означает для нас? Работа нашей Матрицы не должна зависеть от конкретной реализации класса зависимости.
Кажется, ерунда какая-то — зависимость на то и зависимость, чтобы от нее зависеть!
А теперь следите за руками. Мы внедряем в Матрицу не конкретную реализацию зависимости, а абстрактный контракт, и реализуем механизм предоставления конкретной реализации, соответствующей этому контракту! Остались сущие пустяки — понять, как же это все реализовать.
Оставим романтикам рассветы и закаты, птичек и цветочки. Мы, человеки, должны вырваться из под гнета ИИ вообще и Архитектора в частности. Так что будем разбираться с реализацией DI и параллельно — освобождаться из Матрицы. Первая итерация. Создадим класс matrix, непосредственно в нем создадим агента по имени Смит, определим его силу. Там же, внутри Матрицы, создадим и претендента, задав его силу, после чего посмотрим, кто победит, вызвав метод whoWin():
class Matrix {
agent = {
name: 'Smith',
damage: 10000,
};
human = {
name: 'Cypher',
damage: 100,
};
whoWin(): string {
const result = this.agent.damage > this.human.damage
? this.agent.name
: this.human.name;
return result;
}
}
const matrixV1 = new Matrix();
console.log(‘Побеждает ’, matrixV1.whoWin());
Да, Сайфер не самый приятный персонаж, да еще и хотел вернуться в Матрицу, так что на роль первого проигравшего подходит идеально.
Побеждает Smith
Архитектор, конечно, антигерой в рамках повествования, но идея заставить его вручную внести каждого подключенного к Матрице в базовый класс, а потом еще отслеживать рождаемость-смертность и поддерживать код в актуальном состоянии — сродни путевке в ад. Тем более, что физически люди находятся в реальном мире, а в Матрицу проецируется только их образ. Хотите — называйте его аватаром. Мы программисты, нам ближе ссылка или инстанс. Перепишем наш код.
class Human {
name;
damage;
constructor(name, damage) {
this.name = name;
this.damage = damage;
}
get name(): string {
return this.name;
}
get damage(): number {
return this.damage;
}
}
class Matrix {
agent = {
name: 'Smith',
damage: 10000,
};
human;
constructor(challenger) {
this.human = challenger;
}
whoWin(): string {
const result = this.agent.damage > this.human.damage
? this.agent.name
: this.human.name;
return result;
}
Мы добавили класс Human, принимающий на вход конструктора имя и силу человека, и возвращающий их в соответствующих методах. Также мы внесли изменения в наш класс Матрицы — теперь информацию о претенденте на победу он получает через конструктор. Давайте проверим, сможет ли Тринити победить Агента Смита?
const Trinity = new Human('Trinity', 500);
const matrixV1 = new Matrix(Trinity);
console.log('Побеждает ', matrixV1.whoWin());
Увы, Тринити «всего лишь человек» (с), и ее сила по определению не может быть больше, чем у агента, так что итог закономерен.
Побеждает Smith
Но стоп! Давайте посмотрим, что случилось с Матрицей? А случилось то, что класс Matrix и результаты его работы стал зависеть от класса Human! И нашему оператору, отправляющему Тринити в Матрицу, достаточно немного изменить код, чтобы обеспечить победу человечества!
class Human {
…
get damage(): number {
return this.damage * 1000;
}
}
Чем плох подход выше? Тем, что класс Matrix ждет от зависимости challenger, передаваемой в конструктор, наличие метода damage, поскольку именно к нему мы обращаемся в коде. Но об этом знает Архитектор, создавший Матрицу, а не наш оператор! В примере — мы можем угадать. А если не знать заранее название метода? Может быть, надо было написать не damage, а power? Или strength?
Знакомьтесь! Dependency inversion principle, принцип инверсии зависимостей (DIP). Название, кстати, нередко сокращают, убирая слово «принцип» , и тогда остается только Dependency inversion (DI), что вносит путаницу в мысли новичков.
Принцип инверсии зависимостей имеет несколько трактовок, мы приведем лишь две:
Зачем он нам понадобился? Для того, чтобы обеспечить механизм соблюдения контракта для зависимости, о необходимости которого мы говорили при постановке задачи, и отсутствие которого в нашей реализации не дало нам пока наполнить бокалы шампанским.
Давайте внедрим в наш класс Matrix некий абстрактный класс AbstractHuman, а конкретную реализацию в виде класса Human — попросим имплементировать эту абстракцию:
abstract class AbstractHuman {
abstract get name(): string;
abstract get damage(): number;
}
class Human implements AbstractHuman{
name;
damage;
constructor(name, damage) {
this.name = name;
this.damage = damage;
}
get name(): string {
return this.name;
}
get damage(): number {
return this.damage;
}
}
class Matrix {
agent = {
name: 'Smith',
damage: 10000,
};
human;
constructor(challenger: AbstractHuman) {
this.human = challenger;
}
whoWin(): string {
const result = this.agent.damage > this.human.damage
? this.agent.name
: this.human.name;
return result;
}
}
const Morpheus = new Human('Morpheus', 900);
const matrixV2 = new Matrix(Morpheus);
console.log('Побеждает ', matrixV2.whoWin());
Морфеуса жалко, но все же он — не избранный.
Побеждает Smith
Вторая версия Матрицы пока что выигрывает, но что получилось на текущий момент? Класс Matrix больше не зависит от конкретной реализации класса Human — задачу номер один мы выполнили. Класс Human отныне точно знает, какие методы с какими именами в нем должны присутствовать — пока «контракт» в виде абстрактного класса AbstractHuman не будет полностью реализован (имплементирован) в конкретной реализации, мы будем получать ошибку. Задача номер два также выполнена.
Порадуемся за архитектора, кстати! В новой реализации он может создать отдельный класс для мужчин, отдельный для женщин, если понадобится — сделать отдельный класс для брюнеток и отдельный для блондинок… Согласитесь, таким модульным кодом легче управлять.
В бою с Морфеусом побеждает Smith В бою с Тринити побеждает Smith
Думаю, вы уже догадались, что должен сделать наш оператор, чтобы Нео все же победил. Напишем еще один класс для Избранного, слегка отредактировав его силу:
...
class TheOne implements AbstractHuman{
name;
damage;
constructor(name, damage) {
this.name = name;
this.damage = damage;
}
get name(): string {
return this.name;
}
get damage(): number {
return this.damage * 1000;
}
}
…
const Neo = new TheOne('Neo, 500);
const matrixV5 = new Matrix(Neo);
Свершилось!
В бою с Нео побеждает Нео
Давайте посмотрим, кто управляет кодом? В нашем примере мы сами пишем и класс Matrix, и класс Human, сами создаем инстансы и задаем все параметры. Мы управляем нашим кодом. Захотели — внесли изменения и обеспечили победу Тринити.
Увы, по условиям мира, придуманного Вачовски, мы можем лишь вклиниваться в работу Матрицы, добавляя свои кусочки программного кода. Матрица управляет не только фантомами внутри себя, но и тем, как с ней работать извне!
Возможно, авторы трилогии увлекались программированием, потому что ситуация целиком и полностью списана с реальности и даже имеет свое название — Inversion of Control (IoC).
Когда программист работает в фреймворке, он тоже пишет только часть кода, отдельные модули (классы, в которые внедряются зависимости) или сервисы (классы, которые внедряются в модули как зависимости). Причем какие именно (порой вплоть до правил именования файлов) — решает фреймворк.
Кстати, уже использованный нами выше DIP (принцип инверсии зависимостей) — одно из проявлений механизма IoC.
Последний шаг — передача управления разрешением зависимостей. Кому и какой инстанс предоставить, использовать singleton или multiton — также решается не программистом (оператором), а фреймворком (Матрицей).
Вариантов решения задачи множество, но все они сводятся к одной идее.
глобальный объект находит класс, имплементирующий данный интерфейс, при необходимости создает инстанс и передает его в модуль.
Конкретные реализации у каждого фреймворка свои: где-то используется Локатор сервисов/служб (Service Locator), где-то Контейнер DI, чаще называемый IoC Container. Но на уровне базовой функциональности отличия между подходами стираются до неразличимости.
У нас есть класс, который мы планируем внедрить (сервис). Мы сообщаем фреймворку о том, что этот класс нужно отправить в контейнер. Наиболее наглядно это происходит в Angular — мы просто вешаем декоратор Injectable.
@Injectable()
export class SomeService {}
Декоратор добавит к классу набор метаданных и зарегистрирует его в IoC контейнере.
Когда нам понадобится инстанс SomeService, фреймворк обратится к контейнеру, найдет уже существующий или создаст новый инстанс сервиса и вернет его нам.
Плюсы очевидны — вместо монолитного приложения мы работаем с набором отдельных сервисов и модулей, не связанных друг с другом напрямую. Мы не завязаны на конкретную реализацию класса, более того, мы можем подменять их при необходимости просто через конфигурацию. За счет того, что большая часть «технического» кода отныне спрятана в недрах фреймворка, наш код становится компактнее, чище. Его легче рефакторить, тестировать, проводить отладку.
Минусы — написание рабочего кода требует понимания логики работы фреймворка, иначе проект превращается в набор «черных ящиков» с наклейками «я реализую такой-то интерфейс». Кроме того, за любое удобство в программировании приходится платить производительностью. В конечном итоге все всё равно сводится к обычному инстанцированию с помощью new, а дополнительные «обертки», реализующие за нас эту логику, требуют и дополнительных ресурсов.
Окей, если необходимость добавления промежуточного слоя в виде «контракта» более-менее очевидна, то где на практике нам может пригодиться IoC?
Надеюсь, этот краткий список примеров вас убедил в том, что вопроса, использовать или не использовать DI, в современной разработке не стоит. Однозначно использовать. А значит — надо понимать, как это работает. Надеюсь, мне удалось не только помочь Нео в его битве со Смитом, но и вам в понимании, как устроен и работает DI.
Если есть вопросы или дополнения по теме, буду рад продолжить общение в комментариях. Напишите, о чем рассказать в следующей статье? А если хотите познакомиться с нами ближе, я всегда на связи в Телеграме @maximkravec.