Услугите не се нуждаят от имена

преди 3 години От Jiří Pudil  

Обичам решението за инжектиране на зависимости на Nette Framework. Наистина го обичам. Целта на тази статия е да споделя тази страст и да обясня защо смятам, че това е най-доброто решение за внедряване на зависимости в днешната екосистема на 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, а реалното предоставяне на инстанция да е проблем на някой друг. И този някой друг се нарича контейнер за инжектиране на зависимости.

Контейнерът за инжектиране на зависимости е върховният архитект на приложението – той знае как да разрешава зависимостите на всяка услуга в системата и отговаря за създаването им. Контейнерите за инжектиране на зависимости са толкова разпространени в днешно време, че почти всяка голяма уеб рамка има своя собствена имплементация на контейнер, а дори има и самостоятелни пакети, посветени на инжектирането на зависимости, като например 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);
        //...
    }
}

За съжаление, все още не сме укротили пламъците. Нито за миг. По-големият проблем е, че принизявате контейнера до ролята на локатор на услуги, което е огромен антимодел. Това е все едно да донесете на някого целия хладилник, за да може той да вземе една закуска от него – много по-разумно е да му донесете само закуската.

Отново, инжектирането на зависимости е за прозрачност, а този контролер все още не е прозрачен за своите зависимости. Зависимостта от автентификатора е напълно скрита от външния свят, зад зависимостта от контейнера. Това прави кода по-труден за четене. Или използвайте. Или да се тества! Сега за да се изпита автентификаторът в unit test, трябва да се създаде цял контейнер около него.

И между другото, контролерът все още зависи от дефиницията на контейнера, и то по доста лош начин. Ако услугата authenticator не съществува в контейнера, кодът се проваля едва в метода 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 { /* . . . */ }
}

Това обхваща само много малък и прост сегмент от приложението. С разрастването на приложението писането на контейнера на ръка става изключително досадно. Както вече казах, контейнерът е просто ръководство за сглобяване – но то е прекалено сложно, с много страници, безброй препратки и много предупреждения с дребен шрифт. Искаме да го превърнем в ръководство в стил ИКЕА, графично, кратко и с илюстрации на хора, които се усмихват, когато поставят ÅUTHENTICATÖR върху килима по време на монтажа, за да не се счупи.

Тук се намесва Nette Framework.

Решението DI solution на Nette Framework използва Neon, формат за конфигурационни файлове, подобен на YAML, но на стероиди. Ето как бихте дефинирали същия контейнер, използвайки конфигурацията Neon:

services:
    - SignInController
    - Authenticator
    - UserRepository
    - Connection(%database%)

Позволете ми да отбележа две забележителни неща: първо, списъкът на услугите е наистина списък, а не хеш карта – няма ключове, няма изкуствени идентификатори на услуги. Няма authenticator, нито пък Authenticator::class. Второ, никъде не е необходимо изрично да изброявате каквито и да било зависимости, освен параметрите на връзката с базата данни.

Това е така, защото Nette Framework разчита на автоматично свързване. Спомняте ли си как благодарение на инжектирането на зависимости успяхме да изразим типа на зависимостта в нативен typehint? Контейнерът 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 – надявам се, че ще разберете, че съм ви показал само върха на айсберга и че целият пакет е много по-мощен.

Последни публикации