Услуги не нуждаются в названиях

3 года назад от Jiří Pudil  

Мне нравится решение 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 настолько распространены сегодня, что практически каждый крупный веб-фреймворк имеет свою собственную реализацию контейнера, и даже существуют отдельные пакеты, посвященные инъекции зависимостей, такие как 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, инструментам статического анализа или даже разработчику, не знающему определения контейнера – теперь статически вырезан в typehint продвигаемого параметра.

Осталось только научить контейнер создавать контроллер:

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 полагается на автоподключение. Помните, как благодаря инъекции зависимостей мы смогли выразить тип зависимости в нативном указателе типа? Контейнер DI использует эту информацию, так что когда вам требуется экземпляр Authenticator, он полностью обходит любые имена и находит нужный сервис исключительно по его типу.

Вы можете возразить, что автоподключение не является уникальной особенностью. И вы будете правы. Что делает контейнер Nette Framework уникальным, так это использование системы типов PHP, в то время как во многих других фреймворках autowiring все еще строится на внутренних именах сервисов. Существуют сценарии, в которых другие контейнеры не справляются. Вот как можно определить сервис аутентификатора в DI-контейнере Symfony с помощью YAML:

services:
  Authenticator: ~

Раздел services – это хэш-карта, а бит Authenticator – идентификатор сервиса. Тильда обозначает null в YAML, что 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 и документацией – надеюсь, вы поймете, что я показал вам только верхушку айсберга, и что весь пакет гораздо мощнее.

Последние сообщения