Сервисам не нужны имена
Мне нравится решение Nette Framework для внедрения зависимостей. Я действительно его люблю. Эта статья здесь для того, чтобы поделиться этой страстью и объяснить, почему я считаю, что это лучшее решение DI в современной экосистеме PHP.

(Этот пост был первоначально опубликован в блоге автора.)
Разработка программного обеспечения — это бесконечный итеративный процесс абстракции. Подходящие абстракции реального мира мы находим в доменном моделировании. В объектно-ориентированном программировании мы используем абстракции для описания и обеспечения соблюдения контрактов между различными участниками системы. Мы вводим в систему новые классы, чтобы инкапсулировать ответственности и определить их границы, а затем с помощью композиции создаем всю систему.
Я говорю о побуждении извлечь логику аутентификации из следующего контроллера:
final class SignInController extends Best\Framework\Controller
{
public function action(string $username, string $password): Best\Framework\Response
{
if ($username !== 'admin' || $password !== 'p4ssw0rd!') {
return $this->render(__DIR__ . '/error.latte');
}
$this->signIn(new Identity($username));
return $this->redirect(HomepageController::class);
}
}
Вероятно, вы узнаете, что проверка учетных данных здесь неуместна. Контроллер не несет ответственности за определение того, какие учетные данные действительны — согласно принципу единственной ответственности, у контроллера должна быть только одна причина для изменения, и эта причина должна быть в рамках пользовательского интерфейса приложения, а не в процессе аутентификации.
Возьмем очевидный путь и извлечем условие
в класс Authenticator
:
final class Authenticator
{
public function authenticate(string $username, string $password): bool
{
return $username === 'admin' && $password === 'p4ssw0rd!';
}
}
Теперь достаточно делегировать из контроллера этому аутентификатору. Мы создали аутентификатор зависимость от контроллера, и контроллеру внезапно нужно где-то его получить:
final class SignInController extends Best\Framework\Controller
{
public function action(string $username, string $password): Best\Framework\Response
{
$authenticator = new Authenticator(); // <== легко, просто создам новый!
if ( ! $authenticator->authenticate($username, $password)) {
return $this->render(__DIR__ . '/error.latte');
}
$this->signIn(new Identity($username));
return $this->redirect(HomepageController::class);
}
}
Этот наивный способ будет работать. Но
только до тех пор, пока не будет реализована
более надежная аутентификация, которая
потребует, чтобы аутентификатор запрашивал
таблицу пользователей в базе данных.
Внезапно у Authenticator
появляется
собственная зависимость, скажем
UserRepository
, которая в свою очередь
зависит от экземпляра Connection
, который
зависит от параметров конкретной среды. Это
быстро обострилось!
Создание экземпляров везде вручную — это
неустойчивый способ управления
зависимостями. Поэтому у нас есть паттерн
внедрения зависимостей, который позволяет
контроллеру только объявлять зависимость
от Authenticator
и оставлять кому-то
другому задачу фактического
предоставления экземпляра. И этот кто-то
другой называется DI-контейнер
контейнер.
DI-контейнер является главным архитектором приложения — он умеет разрешать зависимости любого сервиса в системе и отвечает за их создание. Контейнеры DI сегодня настолько распространены, что почти каждый крупный веб-фреймворк имеет собственную реализацию контейнера, и даже существуют отдельные пакеты, посвященные внедрению зависимостей, например PHP-DI.
Обжигающий перец
Множество вариантов в конечном итоге побудило группу разработчиков искать абстракцию, которая сделала бы их взаимозаменяемыми. Общий интерфейс со временем был отточен и в конечном итоге предложен для PHP-FIG в следующем виде:
interface ContainerInterface
{
public function get(string $id): mixed;
public function has(string $id): bool;
}
Этот интерфейс иллюстрирует одно очень важное свойство DI-контейнеров: **Они — хороший слуга, но легко могут стать плохим господином. Они чрезвычайно полезны, если вы знаете, как их использовать, но если вы используете их неправильно, они вас обожгут. Возьмем следующий контейнер:
final class Container implements ContainerInterface
{
private array $factories = [];
public function __construct(array $parameters)
{
$this->factories['authenticator'] = fn() => new Authenticator($this->get('userRepository'));
$this->factories['userRepository'] = fn() => new UserRepository($this->get('connection'));
$this->factories['connection'] = fn() => new Connection($parameters['database']);
}
public function get(string $id): mixed { /* . . . */ }
public function has(string $id): bool { /* . . . */ }
}
Пока все хорошо. Реализация кажется хорошей по стандартам, которые мы установили: она действительно умеет создавать каждый сервис в приложении и рекурсивно разрешает его зависимости. Все управляется в одном месте, и контейнер даже принимает параметры, так что подключение к базе данных легко настраивается. Отлично!
Но теперь, видя только два метода
ContainerInterface
, возможно, у вас возникнет
соблазн использовать контейнер так:
final class SignInController extends Best\Framework\Controller
{
public function __construct(
private ContainerInterface $container,
) {}
public function action(string $username, string $password): Best\Framework\Response
{
$authenticator = $this->container->get('authenticator');
//...
}
}
Поздравляю, вы только что обожглись перцем. Другими словами, контейнер стал злым господином. Почему это так?
Во-первых, вы полагаетесь на произвольный
идентификатор сервиса: 'authenticator'
.
Внедрение зависимостей — это о том, чтобы
быть прозрачным в отношении своих
зависимостей, и использование
искусственного идентификатора идет прямо
против этой концепции: это делает код
неявно зависимым от определения
контейнера. Если когда-либо сервис в
контейнере будет переименован, вам
придется найти эту ссылку и обновить ее.
И что еще хуже, эта зависимость скрыта: на
первый взгляд снаружи контроллер зависит
только от абстракции контейнера. Но как
разработчик вы должны знать, как
называются сервисы в контейнере и что
сервис с именем authenticator
на самом деле
является экземпляром Authenticator
. Всему
этому должен научиться ваш новый коллега.
Излишне.
К счастью, мы можем прибегнуть к гораздо более естественному идентификатору: типу сервиса. В конце концов, это единственное, что вас интересует как разработчика. Вам не нужно знать, какая случайная строка присвоена сервису в контейнере. Я считаю, что этот код гораздо проще писать и читать:
final class SignInController extends Best\Framework\Controller
{
public function __construct(
private ContainerInterface $container,
) {}
public function action(string $username, string $password): Best\Framework\Response
{
$authenticator = $this->container->get(Authenticator::class);
//...
}
}
К сожалению, мы еще не укротили пламя. Совсем нет. Большая проблема в том, что вы униженно ставите контейнер в роль локатора сервисов, что является огромным анти-паттерном. Это как принести кому-то целый холодильник, чтобы он мог взять из него одну закуску — гораздо разумнее принести ему только эту закуску.
Опять же, внедрение зависимостей — это о прозрачности, и этот контроллер все еще не прозрачен в отношении своих зависимостей. Зависимость от аутентификатора полностью скрыта от внешнего мира за зависимостью от контейнера. Это делает код хуже читаемым. Или используемым. Или тестируемым! Мокирование аутентификатора в юнит-тесте теперь требует, чтобы вы создали вокруг него целый контейнер.
И кстати, контроллер все еще зависит от
определения контейнера, и довольно плохим
образом. Если сервис аутентификатора в
контейнере не существует, код завершится
ошибкой только в методе action()
, что
является довольно поздней обратной
связью.
Готовим что-то вкусное
Справедливости ради, никто не может винить вас за то, что вы попали в этот тупик. В конце концов, вы просто следовали интерфейсу, разработанному и проверенному умными разработчиками. Дело в том, что все контейнеры для внедрения зависимостей по определению также являются локаторами сервисов, и оказывается, что этот паттерн действительно является единственным общим интерфейсом между ними. Но это не значит, что вы должны использовать их как локаторы сервисов. На самом деле, сам стандарт PSR предупреждает об этом.
Вот как вы можете использовать DI-контейнер как хороший сервис:
final class SignInController extends Best\Framework\Controller
{
public function __construct(
private Authenticator $authenticator,
) {}
public function action(string $username, string $password): Best\Framework\Response
{
$areCredentialsValid = $this->authenticator->authenticate($username, $password);
//...
}
}
В конструкторе зависимость объявлена
явно, ясно и прозрачно. Зависимости больше
не скрыты, разбросанные по классу. Они также
принудительно проверяются: контейнер не
способен создать экземпляр
SignInController
, не предоставив
необходимый Authenticator
. Если в
контейнере нет аутентификатора, выполнение
завершится ошибкой преждевременно, а не в
методе action()
. Тестирование этого
класса также стало намного проще, так как
достаточно только замокать сервис
аутентификатора без какого-либо
бойлерплейта контейнера.
И еще одна мелкая, но очень важная деталь:
мы протащили информацию о типе сервиса. Тот
факт, что это экземпляр Authenticator
—
ранее неявный и неизвестный IDE,
инструментам статического анализа или даже
разработчику, незнакомому с определением
контейнера — теперь статически
выгравирован в type hint продвинутого
параметра.
Единственный оставшийся шаг — научить контейнер создавать и контроллер:
final class Container implements ContainerInterface
{
private array $factories = [];
public function __construct(array $parameters)
{
$this->factories[SignInController::class] = fn() => new SignInController($this->get(Authenticator::class));
$this->factories[Authenticator::class] = fn() => new Authenticator($this->get(UserRepository::class));
$this->factories[UserRepository::class] = fn() => new UserRepository($this->get(Connection::class));
$this->factories[Connection::class] = fn() => new Connection($parameters['database']);
}
public function get(string $id): mixed { /* . . . */ }
public function has(string $id): bool { /* . . . */ }
}
Возможно, вы заметили, что контейнер все
еще внутренне использует подход локатора
сервисов. Однако это не имеет значения, если
он инкапсулирован (игра слов). Единственное
место вне контейнера, где вызов метода
get
допустим, — это index.php
, в
точке входа приложения, где нужно создать
сам контейнер, а затем загрузить и
запустить приложение:
$container = bootstrap();
$application = $container->get(Best\Framework\Application::class);
$application->run();
Скрытая жемчужина
Но не будем останавливаться на этом,
позвольте мне продвинуть это утверждение
дальше: единственное место, где вызов
метода get
допустим, — это
точка входа.
Код контейнера — это просто проводка, это инструкции по сборке. Это не исполняемый код. В некотором смысле он не важен. Хотя да, он ключевой для приложения, но только с точки зрения разработчика. Пользователю он на самом деле не приносит никакой прямой ценности, и к нему следует относиться с учетом этого факта.
Посмотрите снова на контейнер:
final class Container implements ContainerInterface
{
private array $factories = [];
public function __construct(array $parameters)
{
$this->factories[SignInController::class] = fn() => new SignInController($this->get(Authenticator::class));
$this->factories[Authenticator::class] = fn() => new Authenticator($this->get(UserRepository::class));
$this->factories[UserRepository::class] = fn() => new UserRepository($this->get(Connection::class));
$this->factories[Connection::class] = fn() => new Connection($parameters['database']);
}
public function get(string $id): mixed { /* . . . */ }
public function has(string $id): bool { /* . . . */ }
}
Это касается только очень маленького и простого сегмента приложения. По мере роста приложения ручное написание контейнера становится невероятно утомительным. Как я уже сказал, контейнер — это просто руководство по сборке, но оно слишком сложное, имеет много страниц, бесчисленные перекрестные ссылки и множество предупреждений, написанных мелким шрифтом. Мы хотим сделать из него руководство в стиле IKEA, графическое, краткое и с иллюстрациями людей, которые улыбаются, когда при сборке кладут ÅUTHENTICATÖR на ковер, чтобы он не разбился.
Здесь на помощь приходит Nette Framework.
Решение DI Nette Framework использует Neon, формат конфигурационных файлов, похожий на YAML, но на стероидах. Вот как бы вы определили тот же контейнер с помощью конфигурации Neon:
services:
- SignInController
- Authenticator
- UserRepository
- Connection(%database%)
Позвольте мне обратить внимание на две
примечательные вещи: во-первых, список
сервисов — это действительно список, а не
хэш-карта — здесь нет ключей, нет
искусственных идентификаторов сервисов.
Нет никакого authenticator
, ни
Authenticator::class
. Во-вторых, нигде не нужно
явно указывать какие-либо зависимости,
кроме параметров подключения к базе
данных.
Это потому, что Nette Framework полагается на
автоматическое связывание (autowiring). Помните,
как благодаря внедрению зависимостей мы
могли выразить тип зависимости нативным
typehint? DI-контейнер использует эту
информацию, поэтому, когда вы запрашиваете
экземпляр Authenticator
, он полностью
обходит любые имена и находит правильный
сервис исключительно по его типу.
Вы можете возразить, что автовайринг не является уникальной особенностью. И вы будете правы. То, что делает контейнер Nette Framework уникальным, — это использование системы типов PHP, в то время как во многих других фреймворках автовайринг все еще внутренне построен на именах сервисов. Существуют сценарии, в которых другие контейнеры отстают. Вот как бы вы определили сервис аутентификатора в контейнере Symfony DI с помощью языка YAML:
services:
Authenticator: ~
В разделе services
находится хэш-карта,
и бит Authenticator
является
идентификатором сервиса. Тильда в YAML
означает null
, что Symfony интерпретирует
как “используй идентификатор сервиса как
его тип”.
Однако вскоре бизнес-требования
изменятся, и вам потребуется поддерживать
аутентификацию через LDAP в дополнение к
локальному поиску в базе данных. На первом
шаге вы измените класс Authenticator
на
интерфейс и извлечете исходную реализацию
в класс LocalAuthenticator
:
services:
LocalAuthenticator: ~
Внезапно Symfony оказывается в
растерянности. Это потому, что Symfony работает
с именами сервисов вместо типов. Контроллер
все еще правильно полагается на абстракцию
и указывает интерфейс Authenticator
как
свою зависимость, но в контейнере нет
сервиса с именем Authenticator
. Вы
должны дать Symfony подсказку, например, с
помощью псевдонима имени сервиса:
services:
LocalAuthenticator: ~
Authenticator: '@LocalAuthenticator'
Nette Framework, напротив, не нуждается ни в
именах сервисов, ни в подсказках. Он не
заставляет вас дублировать в конфигурации
информацию, которая уже выражена в коде
(через директиву implements
). Он
расположен прямо над системой типов PHP. Он
знает, что LocalAuthenticator
является
типом Authenticator
, и если это
единственный сервис, реализующий этот
интерфейс, он с радостью автоматически
подключит его там, где этот интерфейс
требуется, и это только на основе этой
строки конфигурации:
services:
- LocalAuthenticator
Признаю, что если вы не знакомы с автовайрингом, он может показаться вам немного магическим, и, возможно, вам потребуется некоторое время, чтобы научиться ему доверять. К счастью, он работает прозрачно и детерминированно: когда контейнер не может однозначно разрешить зависимости, он выбрасывает исключение при компиляции, которое поможет вам исправить ситуацию. Таким образом, вы можете иметь две разные реализации и при этом хорошо контролировать, где какая из них используется.
В целом, на вас как на разработчика автоматическое связывание накладывает меньшую когнитивную нагрузку. В конце концов, вы заботитесь только о типах и абстракциях, так почему же DI-контейнер должен заставлять вас заботиться также о реализациях и идентификаторах сервисов? И что еще важнее, почему вы вообще должны заботиться о каком-либо контейнере? В духе внедрения зависимостей вы хотите иметь возможность просто объявлять зависимости и чтобы их предоставление было проблемой кого-то другого. Вы хотите полностью сосредоточиться на коде приложения и забыть о связывании. И это вам позволяет DI Nette Framework.
В моих глазах это делает решение DI от Nette Framework лучшим из существующих в мире PHP. Оно предоставляет вам контейнер, который надежен и продвигает хорошие архитектурные паттерны, но в то же время так легко настраивается и поддерживается, что вам не нужно о нем вообще думать.
Надеюсь, этому посту удалось разжечь ваше любопытство. Не забудьте заглянуть в Github репозиторий и в документацию — возможно, вы обнаружите, что я показал вам лишь верхушку айсберга и что весь пакет гораздо мощнее.
Чтобы оставить комментарий, пожалуйста, войдите в систему