Сервісам не потрібні назви

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, а створення екземпляру є проблемою когось іншого. І цей хтось інший називається контейнер ін'єкції залежності.

Контейнер ін'єкції залежностей є верховним архітектором програми – він знає, як вирішувати залежності будь-якого сервісу в системі і відповідає за їх створення. Контейнери 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, в той час як у багатьох інших фреймворках автопідключення все ще базується на іменах сервісів. Існують сценарії, де інші контейнери не підходять. Ось як ви можете визначити службу автентифікації в 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-контейнер повинен змушувати вас також дбати про реалізації та ідентифікатори сервісів? Більш того, чому ви взагалі повинні піклуватися про якийсь контейнер в першу чергу? У дусі ін'єкції залежностей, ви хочете мати можливість просто декларувати залежності, а їх реалізація нехай буде чиєюсь проблемою. Ви хочете повністю зосередитися на коді програми і забути про проводку. І Nette Framework's DI дозволяє вам це зробити.

На мою думку, це робить рішення DI від Nette Framework найкращим у світі PHP. Воно дає вам надійний контейнер, який підтримує хороші архітектурні патерни, але в той же час настільки простий в налаштуванні і підтримці, що вам взагалі не доведеться про це думати.

Сподіваюся, ця стаття змогла розпалити вашу цікавість. Обов'язково перегляньте репозиторій Github і документи – сподіваюся, ви зрозумієте, що я показав вам лише верхівку айсберга і що весь пакет є набагато потужнішим.