Сайт вебмастера

Dependency injection (внедрение зависимости)

20-11-2019Время чтения ~ 6 мин.PHP/ООП 13850

Dependency injection (внедрение зависимости) — одна из самых загадочных и запутанных тем для программистов. С одной стороны DI можно описать конкретным php-кодом, но потом выяснится, что он повторяет существующий паттерн программирования. И тогда мы начинаем пускаться в абстракцию, пытаясь хоть как-то объяснить принципиальные различия.

Ситуацию усложняет тот момент, что в разных php-фреймворках подход к этому вопросу сильно разнится: как реализация, так и терминология. Здесь опять же играет тот факт, что PHP заимствует все эти идеи из других языков, но из-за концептуальных различий (например с Java), в итоге приводит к этой самой путанице.

Инверсия управления

Пожалуй стоит начать с инверсии управления (Inversion of Control, IoC). Поскольку это только принцип, то он не описывает конкретную реализацию. Чтобы понять IoC достаточно представить себе обычную программу, где мы указываем последовательность выполнения команд. В данном случае программист сам решает как именно управлять программой. Если нужно изменить алгоритм, то мы переписываем саму программу.

Со временем программа усложняется и выясняется, что какие-то её модули могут выполнять однотипные, но всё-таки разные задачи. Переписывать программу каждый раз окажется достаточно затратно, поэтому в программе предусматривается механизм, который позволяет как бы «вклиниваться» в её выполнение.

Мы с этим постоянно сталкиваемся и хорошим примером будут современные php-фреймворки. По сути это программы, которые работают «сами по себе», но мы можем внедряться в их работу какими-то небольшими участками кода. Сам фреймворк при этом не переписывается каждый раз. В этом случае и говорят об инверсии управления — здесь уже фреймворк управляет программистом.

Главное назначение IoC — возможность расширения, но весь контроль за выполнением лежит на фреймворке.

Принцип инверсии зависимостей

Dependency inversion principle (DIP) — принцип IoC с уклоном в сторону ООП. Он состоит из двух формулировок:

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

Смысл DIP сводится к тому, чтобы уменьшить зависимость между объектами с помощью абстрактных классов или интерфейсов. И это уже можно выразить в коде. Рассмотрим пример.

class Connect {
    ...
}
 
class DB {
     private $con;
  
     public function __construct(Connect $connection) {
         $this->con = $connection;
     }
}

Класс DB имеет жесткую связь с классом Connect, что как раз и нарушает принцип DIP. Поэтому следует выделить абстракцию более высокого уровня и связать классы через него. Например можно создать интерфейс.

interface IConnect {
    ...
}
 
class Connect implements IConnect {
    ...
}
 
class DB {
     private $con;
 
     public function __construct(IConnect $connection) {
         $this->con = $connection;
     }
}

Класс DB теперь завязан не на конкретную реализацию Connect, а на интерфейс IConnect. То есть прямой связи между Connect и DB больше нет.

В качестве примера паттерна, где отсутствует DIP можно привести Абстрактную фабрику.

Dependency injection (внедрение зависимости)

Обратите внимание, что класс DB всё равно зависит от другого класса. Когда мы инстанцируем DB, то необходимо передать в его конструктор другой объект. Вот это, собственно, и есть внедрение зависимости в «чистом» виде.

$connect = new Connect();
$db = new DB($connect);
 
$db->...;

Посмотрите на пример реализации Dependency injection, который я выложил на гитхабе. Это «классический» пример с конфигурацией, и который по сути представляет собой паттерн Strategy (Стратегия).

Но когда говорят о Dependency injection, то рассматривают более высокий уровень приложения, а не взаимодействие между одиночными классами. Да, конкретная реализация может быть самой произвольной, но суть DI проявляется именно на уровне самого фреймворка — то есть то, какие именно зависимости можно в него внедрить.

Контейнер зависимостей (IoC-контейнер)

Для того, чтобы получить какую-то практическую пользу от Dependency injection, на уровне фреймворка создаётся IoC-контейнер, который хранит все зависимости. Сам по себе контейнер обычно представляет собой глобальный объект приложения ($app), к которому можно привязать произвольный класс. Во многих php-фреймворках контейнеры достаточно сложны и позволяют автоматически определить зависимости между классами. Например если создать зависимость для класса DB, то контейнер определит и необходимый для него Connect.

В терминах DI все внедренные зависимости — это сервисы, соответственно контейнер управляет этими сервисами: регистрирует и предоставляет к ним доступ. Наверное самый простой пример — это регистрация класса как сервис с помощью файла конфигурации.

// регистрация сервисов
[
	'session' => '\system\Lib\Session',
	'cookies' => '\system\Core\Cookies',
]
 
// использование
$session = get_service('session'); // объект \system\Lib\Session
$cookies = get_service('cookies'); // объект \system\Core\Cookies

Функция get_service() получает доступ к хранилищу сервисов по короткому имени (иногда об этом говорят «тагизация» — от tag, метка). Теперь, если нужно получить доступ к классу сессий в любой части приложения, достаточно получить его с помощью функции. Она в свою очередь проверит есть уже готовый объект, и если нет, то создаст его.

Очень часто упоминается Локатор сервисов/служб (Service Locator), который обычно противопоставляется контейнеру Dependency injection. Однако, сколько я не пытался понять между ними разницу, то ничего не вышло. По сути они выполняют одну и туже работу — предоставляют доступ к сервисам. А конкретная реализация, что DI, что SL разная во фреймворках, что вообще стирает между ним грань.

Рассмотрим в качестве примера CodeIgniter 4, который тоже ввёл поддержку сервисов.

Например есть класс таймера:

$timer = new \CodeIgniter\Debug\Timer();

Это очень простой и понятный код, который сразу показывает с каким классом мы работаем. C Service это уже будет так:

$timer = \Config\Services::timer();

Теперь мы получаем объект, хотя понятия не имеем какой именно класс реально используется. В данном примере timer — системный сервис (который нельзя изменить), но выполняет точно такое же инстанцирование с помощью new. Ну и для того, чтобы ещё больше «размыть» код предлагается использовать и обычную функцию:

$timer = service('timer');

- которая вызывает Config\Services, который уже в свою очередь создает объект через new.

В простом виде контейнер может быть создан в виде отдельного «глобального» класса Registry, который представляет собой Singleton (или Multiton).

Другие контейнеры могут быть достаточно сложными, чтобы автоматически находить связи между классами. Делается это с помощью Reflection. На практике за такую «магию» приходится платить скоростью — даже простые тесты показывают, что работа через Reflection уменьшает скорость работы в 5-10 раз.

Плюсы и минусы контейнера DI

Главный плюс в том, что мы можем рассматривать приложение, как набор сервисов или модулей. Сам по себе код получается чуть компактней и не нужно завязываться на имя класса. Например если мы решим использовать другой класс для сессий, то достаточно будет поменять его только в конфигурации. Если он реализует тот же самый интерфейс, то проблем никаких не возникнет.

Это положительно сказывается на последующем рефакторинге или отладке кода. Даже разработка может идти параллельно, поскольку сервисы фактически отделены от самого приложения.

Теперь про минусы.

За такое удобство приходится платить скорость работы и повышенным потреблением памяти программы. Как ни крути, но всё в итоге сводится к обычному инстанцированию с помощью new и все дополнительные обёртки никак не улучшат функционал класса.

Значительно ухудшается понимание кода. Для того, чтобы понять что за сервис, необходимо вначале найти какой именно класс и файл за него отвечает. Когда сервисов много, то код превращается в набор «черных ящиков», которые непонятно для чего предназначены.

Отдельно стоит отметить, что ухудшается и сам процесс написания кода: автоподстановка в редакторах просто не будет работать, как при обычном использование переменных объектов.

Из всего этого стоит сделать простой вывод: сервисы хороши в меру. Если какой-то класс использует другой в единичных случаях, то нет смысла регистрировать его как сервис. Дополнительная связь здесь лишняя и обычного ООП будет вполне достаточно.

См. продолжение: Dependency Injection простыми словами

Похожие записи
Комментарии (3) RSS
1 Влад 2020-04-09 10:58:52

Про SOLID бы еще статейу


2 Jun proger 2021-04-22 01:53:13

Больше спасибо за статью, очень доходчиво и понятно получилось разобраться с DI


3 junior OOP 2022-02-10 18:26:08

Спасибо за Ваш труд!