Сървисите не се нуждаят от имена
Харесва ми решението на Nette Framework за dependency injection. Наистина го обичам. Тази статия е тук, за да споделя тази страст и да обясня защо мисля, че това е най-доброто 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
, която
зависи от параметрите на конкретната среда.
Това бързо ескалира!
Ръчното създаване на инстанции навсякъде
не е устойчив начин за управление на
зависимостите. Затова имаме патърна dependency
injection, който позволява на контролера само да
декларира зависимост от Authenticator
и да
остави на някой друг действително да
предостави инстанцията. А този някой друг
се нарича dependeny injection container.
Dependency injection контейнерът е върховният архитект на приложението – той може да разрешава зависимостите на всеки сървис в системата и е отговорен за тяхното създаване. DI контейнерите днес са толкова разпространени, че почти всеки по-голям уеб framework има собствена имплементация на контейнер, и дори съществуват самостоятелни пакети, посветени на инжектирането на зависимости, например 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);
//...
}
}
За съжаление, все още не сме укротили пламъците. Ни най-малко. По-големият проблем е, че унизително поставяте контейнера в ролята на локатор на сървиси, което е огромен анти-патърн. Това е като да донесете на някого цял хладилник, за да може да си вземе една закуска – много по-разумно е да му донесете само тази закуска.
Отново, dependency injection е свързано с прозрачност, а този контролер все още не е прозрачен относно своите зависимости. Зависимостта от автентикатора е напълно скрита пред околния свят зад зависимостта от контейнера. Това прави кода по-труден за четене. Или за използване. Или за тестване! Mocking на автентикатора в unit тест сега изисква да създадете цял контейнер около него.
И между другото, контролерът все още
зависи от дефиницията на контейнера, и то по
доста лош начин. Ако сървисът 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, инструменти за статичен
анализ или дори за разработчик, незапознат
с дефиницията на контейнера – сега е
статично гравиран в типовата подсказка на
промотирания параметър.
Единствената стъпка, която остава, е да научим контейнера как да създаде и контролера:
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 разчита на
автоматично свързване. Спомняте ли си как
благодарение на dependency injection можехме да
изразим типа на зависимостта с нативен
typehint? DI контейнерът използва тази
информация, така че когато поискате
инстанция на Authenticator
, той напълно
заобикаля всякакви имена и намира
правилния сървис изключително по
нейния тип.
Можете да възразите, че autowiring не е уникална характеристика. И ще бъдете прави. Това, което прави контейнера на Nette Framework уникален, е използването на типовата система на PHP, докато в много други framework-ове autowiring все още вътрешно е изграден върху имената на сървисите. Има сценарии, в които другите контейнери изостават. Ето как бихте дефинирали сървиса authenticator в контейнера на Symfony DI с помощта на езика 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
Признавам, че ако не сте запознати с autowiring, може да ви се стори малко магическо и може би ще ви е необходимо известно време, за да се научите да му се доверявате. За щастие, той работи прозрачно и детерминистично: когато контейнерът не може еднозначно да разреши зависимостите, той хвърля изключение при компилация, което ви помага да коригирате ситуацията. По този начин можете да имате две различни имплементации и въпреки това да имате добър контрол над това къде се използва всяка от тях.
Като цяло, автоматичното свързване налага по-малко когнитивно натоварване върху вас като разработчик. В крайна сметка вие се интересувате само от типове и абстракции, така че защо DI контейнерът трябва да ви принуждава да се интересувате и от имплементации и идентификатори на сървиси? И което е по-важно, защо изобщо трябва да се интересувате от някакъв контейнер? В духа на dependency injection искате да имате възможност просто да декларирате зависимости и да бъде проблем на някой друг да ги предостави. Искате да се съсредоточите изцяло върху кода на приложението и да забравите за свързването. И това ви позволява DI на Nette Framework.
В моите очи това прави DI решението на Nette Framework най-доброто, което съществува в света на PHP. Предоставя ви контейнер, който е надежден и налага добри архитектурни патърни, но същевременно е толкова лесен за конфигуриране и поддръжка, че не е необходимо изобщо да мислите за него.
Надявам се, че тази статия успя да разпали любопитството ви. Не забравяйте да разгледате Github хранилището и документацията – надявам се да откриете, че съм ви показал само върха на айсберга и че целият пакет е много по-мощен.
За да изпратите коментар, моля, влезте в системата