На барабане буква D: Принцип инверсии зависимостей на практике
Принцип инверсии зависимостей - самый заметный организационный принцип в архитектурных диаграммах.
В статье с правилами зависимостей я рассказывал про принцип
ацикличности зависимостей и как разорвать цикл с помощью принципа инверсии зависимостей. В этой статье я покажу как
применять его на практике. Погнали!
Кейс
Чистая архитектура диктует базовое правило: внутренний слой никогда не должен ссылаться ни на что из внешнего уровня.
Возьмем в качестве примера Application слой с use-case CreatePost. CreatePost создает новый Пост в блоге и сохраняет его
в БД. Мы видим на картинке ниже следующую зависимость
PostRepository является частью инфраструктурного слоя, и в результате такой зависимости мы получаем нарушение базового
правила чистой архитектуры.
CreatePostHandler:85 - отвечает за создание конкретного объекта, я бы хотел инкапсулировать эту часть, переложить эту
ответственность на другой компонент, в данном классе я хочу сосредоточиться на пользовательском сценарии, а именно
создание поста. Не хочу лишний раз думать о том, как создать зависимость для работы с репозиторием, где брать данные для
подключения и т.д. Мою проблему решает IoC и способы, которые он предлагает: шаблон “Фабрика”, локатор служб, внедрения
зависимостей (DI);
класс CreatePostHandler - зависит напрямую от DbalPostRepository, что приводит к тому, что CreatePostHandler
закрыт для расширения, и мы вынужденны будем модифицировать класс при изменении способа хранения сущности Post.
Нарушается принцип открытости и закрытости - буква O из SOLID;
усложняет тестирование - тест обработчика CreatePostHandler будет требовать соединения с БД, т.к. используется DbalPostRepository,
для упрощения можно было создать фейковый класс репозитория для теста InMemoryRepository.
publicfunctionofId(string$id): Post { if (isset($this->storage[$id]) === false) { thrownew\OutOfBoundsException('Post not found'); }
return$this->storage[$id]; } }
Тестирование, когда используется фейковые классы, называется традиционным, также есть подход к тестированию через
моки и стабы, но это уже совершенно другая история.
В качестве решения применим принцип инверсии зависимостей: выделяем интерфейс репозитория, кладем его туда, где он
используется, я положу его в домен. В use-case CreatePost работаем только с этим интерфейсом, и затем его реализуем в
слое инфраструктуры.
Я положил интерфейс репозитория в домен. Тема, где именно такой репозиторий должен лежать (в Application или Domain)
достаточно холиварная. Но я руководствуюсь тем, что если репозиторий отвечает за персистеность агрегата, то стоит его
положить рядом с этим агрегатом.
Линия пересекающая стрелочку обозначает архитектурную границу, зависимости пересекают линию в одном направлении -
в сторону интерфейса (в сторону более абстрактной сущности).
Теперь у нас CreatePostHandler (слой Application) зависит от PostRepository (слой Domain), DbalPostRepository и
InMemoryRepository также зависят от PostRepository (слой Domain). Цикличность зависимостей отсутствуют, таким
образом, мы добились соблюдения принципа ацикличности зависимостей.
В строке 11 мы самостоятельно создаем объект DbalPostRepository. Объект достаточно простой, и кажется, что проблем нет.
Но я многие вещи упрощаю, давайте усложним немного, чтобы придать значимость для DI. Взглянем на методы
DbalPostRepository::save и DbalPostRepository::ofId, внутри используется фабрика ConnectionFactory::fromEnv,
которая возвращает Doctrine\DBAL\Connection, проблема в том, что мы сильно зависим от фабрики ConnectionFactory и
переменного окружения. Также, данный код создает постоянно новые экземпляры соединения, что может быть тоже чревато
в высоконагруженных системах, или в асинхронных приложениях (ага, на php тоже есть асинхронность). Решение проблемы
сделать Doctrine\DBAL\Connection зависимостью
Взглянем на 12-14 строки, теперь мы берем на себя ответственность по созданию Doctrine\DBAL\Connection и правильному
созданию DbalPostRepository с нужным Connection.
Давайте нагоним жути: в CreatePostHandler помимо зависимости PostRepository может быть “валидатор” уникальности слага,
публикатор событий, контроль прав доступа и т.д. и каждый из них может иметь свои зависимости, что становится большой
проблемой для создания CreatePostHandler.
Есть такой паттерн Inversion of Control (IoC), который определяет, что объекты не создают другие объекты, а получают их из
внешних источников.
Dependency Injection (DI) - является подтипом IoC, и он реализует внедрение зависимостей через конструкторы, сеттеры,
рефлексию, а также может работать на уровне интерфейсов.
Для примера мы возьмем библиотеку php-di/php-di, мы можем попросить её создать объект CreatePostHandler, строки 12-13:
$container = new DI\Container(); $handler = $container->get(CreatePostHandler::class);
$response = $handler($command);
echojson_encode([ 'id' => $response->id ]);
exit(0); } }
Контейнер php-di вернет объект CreatePostHandler со всеми необходимыми зависимостями, но есть нюанс в этом примере,
мы получим ошибки:
у нас есть интерфейс PostRepository и две его реализации DbalPostRepository и InMemoryRepository, DI не сможет
выбрать самостоятельно реализацию, поэтому мы должны указать её явно;
также DbalPostRepository требует Connection, которому нужны хост БД, порт, логин и пароль, DI не знает откуда их
брать.
В строке 14-15 явно указываем, что для интерфейса PostRepository::class необходимо использовать
реализацию DbalPostRepository::class, функция DI\autowire говорит контейнеру DI, что он должен создать и найти все
зависимости самостоятельно.
В строках 16-17 просим создать \Doctrine\DBAL\Connection::class через фабрику, для это используем метод DI\factory,
принимающий callable тип.
Таким образом, мы применили DI на практике и облегчили себе жизнь по созданию объектов. Но согласитесь, что есть пока
какой-то слабый профит, потому что выглядит так, словно в каждом контроллере необходимо будет конфигурировать контейнер.
Самый просто вариант - создать фабрику, которая будет возвращать сконфигурированный контейнер
if (is_array($handler)) { if (class_exists($handler[0]) === false) { thrownewException( sprintf('Class %s not found', $handler[0]) ); }
$classHandler = new$handler[0](); if (method_exists($classHandler, $handler[1]) === false) { thrownewException( sprintf('Method %s of %s not found', $handler[1], $handler[0]) ); }
$classHandler->{$handler[1]}(); }
break; }
В index.php происходит инициализация библиотеки nikic/FastRoute, в строках 5-10 задается роутинг, нас
интересуют строки 30-56, здесь прописан кейс, когда роутинг определил обработчик маршрута.
У контейнера php-di есть метод call, он как и call_user_func, вызывает переданную первым аргументом функцию, но
особенность call в том, что при вызове функции он сам может найти и передать нужные аргументы на основе указанных типов
или явно заданных зависимостях.
боритесь с циклами зависимостей, а лучше никогда их не создавайте, держите в голове важное правило слоенной архитектуры,
и не превращайте её в солевую;
используйте IoC, в частном случае DI, для упрощения жизни и чистоты в коде;