Сервісам не потрібні імена
Мені подобається рішення 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, інструментам статичного
аналізу або навіть розробнику, незнайомому
з визначенням контейнера – тепер статично
викарбуваний у підказці типу промотованого
параметра.
Єдиний крок, що залишився, — навчити контейнер, як створити також контролер:
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, тоді як у багатьох інших фреймворках автовайринг все ще внутрішньо побудований на іменах сервісів. Існують сценарії, в яких інші контейнери відстають. Ось як би ви визначили сервіс автентифікатора в контейнері 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 репозиторій та документацію – сподіваюся, ви виявите, що я показав вам лише верхівку айсберга, і що весь пакет набагато потужніший.
Щоб залишити коментар, будь ласка, увійдіть до системи