Давным-давно в далекой Галактике, когда сестры Вачовски еще были братьями, искусственный разум в лице Архитектора поработил человечество и создал Матрицу… Всем привет, это снова Максим Кравец из 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), что вносит путаницу в мысли новичков.

Принцип инверсии зависимостей имеет несколько трактовок, мы приведем лишь две:

  1. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.

  2. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Зачем он нам понадобился? Для того, чтобы обеспечить механизм соблюдения контракта для зависимости, о необходимости которого мы говорили при постановке задачи, и отсутствие которого в нашей реализации не дало нам пока наполнить бокалы шампанским.

Давайте внедрим в наш класс 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?  

Кейс 1 — тестирование.

  • У вас есть модуль, который отвечает за оформление покупки в интернет-магазине. 

  • Функционал списания средств мы вынесем в отдельный сервис и внедрим его через DI. Этот сервис будет обращаться к реальному эквайрингу банка Х.

  • Нам нужно протестировать работу модуля в целом, но мы не готовы совершать реальную покупку при каждом тесте.

  • Решение — напишем моковый сервис, имплементирующий тот же контракт, что и «боевой», и для теста — через IoC будем вызывать моковую реализацию. 

Кейс 2 — расширение функционала.

  • Модуль — прежний, оформление покупки в интернет-магазине.

  • Поступает задача — добавить возможность оплаты не только в банке Х, но и в банке Y. 

  • Мы пишем еще один платежный сервис, реализующий взаимодействие с банком Y и имплементирующий тот же контракт, что и сервис банка X.

  • Плюсы — мы можем подменять банк, мы можем давать пользователю выбор, в какой банк платить. При этом мы никак не меняем наш основной модуль.

Кейс 3 — управление на уровне инфраструктуры.

  • Модуль — прежний.

  • Для production — работаем с «боевым» сервисом платежей.

  • Для разработки — подгружаем моковый вариант, симулирующий списание средств.

  • Для тестового окружения — пишем третий сервис, который будет симулировать списание средств, а заодно вести расширенный лог состояния.

Надеюсь, этот краткий список примеров вас убедил в том, что вопроса, использовать или не использовать DI, в современной разработке не стоит. Однозначно использовать. А значит —  надо понимать, как это работает. Надеюсь, мне удалось не только помочь Нео в его битве со Смитом, но и вам в понимании, как устроен и работает DI. 

Если есть вопросы или дополнения по теме, буду рад продолжить общение в комментариях. Напишите, о чем рассказать в следующей статье? А если хотите познакомиться с нами ближе, я всегда на связи в Телеграме @maximkravec