Služby nepotřebují názvy

před 3 lety od Jiří Pudil  

Líbí se mi řešení Nette Frameworku pro dependency injection. Opravdu ho miluji. Tento článek je tu proto, abych se o tuto vášeň podělil a vysvětlil, proč si myslím, že je to nejlepší řešení DI v současném ekosystému PHP.

(Tento příspěvek byl původně publikován na autorově blogu.)

Vývoj softwaru je nekonečný iterativní proces abstrakce. Vhodné abstrakce reálného světa nacházíme v doménovém modelování. V objektově orientovaném programování používáme abstrakce k popisu a vynucování smluv mezi různými aktéry v systému. Zavádíme do systému nové třídy, abychom zapouzdřili odpovědnosti a definovali jejich hranice, a pak pomocí kompozice vytváříme celý systém.

Mluvím o nutkání extrahovat autentizační logiku z následujícího kontroléru:

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);
    }
}

Pravděpodobně poznáte, že kontrola pověření tam nepatří. Kontrolér nemá zodpovědnost za to, aby určoval, jaká pověření jsou platná – podle principu jediné zodpovědnosti by měl mít kontrolér pouze jediný důvod ke změně, a tento důvod by měl být v rámci uživatelského rozhraní aplikace, nikoliv v procesu ověřování.

Vezměme si z toho zřejmou cestu a extrahujme podmínku do třídy Authenticator:

final class Authenticator
{
    public function authenticate(string $username, string $password): bool
    {
        return $username === 'admin' && $password === 'p4ssw0rd!';
    }
}

Nyní stačí, když delegujeme z kontroléru na tento autentizátor. Vytvořili jsme autentikátor závislost na kontroléru a kontrolér ho najednou potřebuje někde získat:

final class SignInController extends Best\Framework\Controller
{
    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = new Authenticator(); // <== snadné, prostě vytvořím nový!
        if ( ! $authenticator->authenticate($username, $password)) {
            return $this->render(__DIR__ . '/error.latte');
        }

        $this->signIn(new Identity($username));
        return $this->redirect(HomepageController::class);
    }
}

Tento naivní způsob bude fungovat. Ale jen do doby, než bude implementováno robustnější ověřování, které bude vyžadovat, aby se ověřovatel dotazoval na databázovou tabulku uživatelů. Najednou má Authenticator vlastní závislost, řekněme UserRepository, která zase závisí na instanci Connection, jež je závislá na parametrech konkrétního prostředí. To se rychle vystupňovalo!

Vytvářet instance všude ručně není udržitelný způsob správy závislostí. Proto máme vzor dependency injection, který umožňuje kontroléru pouze deklarovat závislost na Authenticator, a nechat na někom jiném, aby instanci skutečně poskytl. A tento někdo jiný se nazývá dependeny injection container.

Dependency injection container je vrchním architektem aplikace – umí řešit závislosti libovolné služby v systému a je zodpovědný za jejich vytváření. Kontejnery DI jsou dnes tak běžné, že téměř každý větší webový framework má vlastní implementaci kontejneru, a dokonce existují samostatné balíčky věnované vstřikování závislostí, například PHP-DI.

Pálení pepře

Množství možností nakonec motivovalo skupinu vývojářů k hledání abstrakce, která by je učinila interoperabilními. Společné rozhraní bylo časem vypilováno a nakonec navrženo pro PHP-FIG v následující podobě:

interface ContainerInterface
{
    public function get(string $id): mixed;
    public function has(string $id): bool;
}

Toto rozhraní ilustruje jednu velmi důležitou vlastnost kontejnerů DI: **Jsou dobrým sluhou, ale snadno se mohou stát špatným pánem. Jsou nesmírně užitečné, pokud víte, jak je používat, ale pokud je používáte nesprávně, spálí vás. Vezměme si následující nádobu:

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 { /* . . . */ }
}

Zatím je to dobré. Implementace se zdá být dobrá podle standardů, které jsme si stanovili: skutečně umí vytvořit každou službu v aplikaci a rekurzivně řeší její závislosti. Vše je spravováno na jednom místě a kontejner dokonce přijímá parametry, takže připojení k databázi je snadno konfigurovatelné. Pěkné!

Ale když teď vidíte jen dvě metody ContainerInterface, možná vás to svádí k tomu, abyste kontejner používali takto:

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');
        //...
    }
}

Gratuluji, právě jste si spálili papriku. Jinými slovy, kontejner se stal zlým pánem. Proč tomu tak je?

Za prvé, spoléháte se na libovolný identifikátor služby: 'authenticator'. Vstřikování závislostí je o tom, že je třeba být transparentní ohledně svých závislostí, a použití umělého identifikátoru jde přímo proti tomuto pojetí: způsobuje, že kód je v tichosti závislý na definici kontejneru. Pokud někdy dojde k přejmenování služby v kontejneru, musíte tento odkaz najít a aktualizovat.

A co je horší, tato závislost je skrytá: na první pohled zvenčí kontrolér závisí pouze na abstrakci kontejneru. Ale jako vývojář vy musíte mít znalosti o tom, jak se služby v kontejneru jmenují a že služba s názvem authenticator je ve skutečnosti instancí Authenticator. To vše se musí naučit váš nový kolega. Zbytečně.

Naštěstí se můžeme uchýlit k mnohem přirozenějšímu identifikátoru: typu služby. To je koneckonců to jediné, co vás jako vývojáře zajímá. Nepotřebujete vědět, jaký náhodný řetězec je přiřazen ke službě v kontejneru. Věřím, že tento kód je mnohem jednodušší na psaní i čtení:

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);
        //...
    }
}

Bohužel jsme ještě nezkrotili plameny. Ani trochu. Větším problémem je, že poníženě stavíte kontejner do role vyhledávače služeb, což je obrovský anti-vzor. Je to jako přinést někomu celou ledničku, aby si z ní mohl vzít jednu svačinu – mnohem rozumnější je přinést mu jen tu svačinu.

Opět platí, že dependency injection je o transparentnosti, a tento kontrolér stále není transparentní ohledně svých závislostí. Závislost na autentizátoru je před okolním světem zcela skryta za závislostí na kontejneru. Tím se kód stává hůře čitelným. Nebo použijte. Nebo testování! Mocking authenticatoru v unit testu nyní vyžaduje, abyste kolem něj vytvořili celý kontejner.

A mimochodem, kontrolér stále závisí na definici kontejneru, a to dost špatným způsobem. Pokud služba authenticator v kontejneru neexistuje, kód selže až v metodě action(), což je dost pozdní zpětná vazba.

Vaření něčeho chutného

Abychom byli spravedliví, nikdo vám nemůže vyčítat, že jste se dostali do této slepé uličky. Koneckonců jste se jen řídili rozhraním navrženým a osvědčeným chytrými vývojáři. Jde o to, že všechny kontejnery pro vstřikování závislostí jsou z definice také lokátory služeb a ukazuje se, že vzor je mezi nimi skutečně jediným společným rozhraním. To ale neznamená, že byste je měli používat jako lokátory služeb. Ve skutečnosti před tím varuje samotný předpis PSR.

Takto můžete kontejner DI použít jako dobrou službu:

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);
        //...
    }
}

V konstruktoru je závislost deklarována explicitně, jasně a transparentně. Závislosti již nejsou skryté roztroušené po třídě. Jsou také vynuceny: kontejner není schopen vytvořit instanci SignInController, aniž by poskytl potřebné Authenticator. Pokud v kontejneru není žádný autentizátor, selže provedení předčasně, nikoliv v metodě action(). Testování této třídy se také stalo mnohem jednodušší, protože stačí pouze zesměšnit službu autentizátoru bez jakéhokoli kotle kontejneru.

A ještě jeden drobný, ale velmi důležitý detail: propašovali jsme do něj informaci o typu služby. Skutečnost, že se jedná o instanci Authenticator – dříve implicitní a neznámá IDE, nástrojům statické analýzy nebo dokonce vývojáři neznalému definice kontejneru – je nyní staticky vyryta do typové nápovědy promovaného parametru.

Jediným krokem, který zbývá, je naučit kontejner, jak vytvořit také kontrolér:

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 { /* . . . */ }
}

Možná jste si všimli, že kontejner stále interně používá přístup vyhledávače služeb. To však nevadí, pokud je obsažen (slovní hříčka). Jediné místo mimo kontejner, kde je volání metody get přípustné, je na adrese index.php, ve vstupním bodě aplikace, kde je třeba vytvořit samotný kontejner a poté načíst a spustit aplikaci:

$container = bootstrap();

$application = $container->get(Best\Framework\Application::class);
$application->run();

Skrytý klenot

Ale nezůstávejme u toho, dovolte mi, abych toto tvrzení posunul dále: jediné místo, kde je volání metody get přípustné, je vstupní bod.

Kód kontejneru je jen zapojení, jsou to instrukce pro sestavení. Není to výkonný kód. Svým způsobem není důležitý. I když ano, je pro aplikaci klíčový, ale pouze z pohledu vývojáře. Uživateli ve skutečnosti nepřináší žádnou přímou hodnotu a mělo by se s ním zacházet s ohledem na tuto skutečnost.

Podívejte se znovu na kontejner:

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 { /* . . . */ }
}

To se týká pouze velmi malého a jednoduchého segmentu aplikace. Jak se aplikace rozrůstá, ruční psaní kontejneru se stává neuvěřitelně únavným. Jak jsem již řekl, kontejner je jen montážní příručka – ale je příliš složitá, má mnoho stránek, nespočet křížových odkazů a spoustu upozornění psaných malým písmem. Chceme z něj udělat manuál ve stylu IKEA, grafický, stručný a s ilustracemi lidí, kteří se usmívají, když při montáži pokládají ÅUTHENTICATÖR na koberec, aby se nerozbil.

Zde přichází na řadu Nette Framework.

Řešení DI Nette Framework využívá Neon, formát konfiguračních souborů podobný YAML, ale na steroidech. Takto byste definovali stejný kontejner pomocí konfigurace Neon:

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

Dovolte mi upozornit na dvě pozoruhodné věci: zaprvé, seznam služeb je skutečně seznam, nikoli hashovací mapa – nejsou zde žádné klíče, žádné umělé identifikátory služeb. Neexistuje žádný authenticator, ani Authenticator::class. Za druhé, nikde nemusíte explicitně uvádět žádné závislosti, kromě parametrů připojení k databázi.

To proto, že Nette Framework spoléhá na automatické zapojení. Pamatujete si, jak jsme díky dependency injection mohli vyjádřit typ závislosti nativním typehintem? Kontejner DI tuto informaci využívá, takže když požadujete instanci Authenticator, zcela obejde jakákoli jména a najde správnou službu výhradně podle jejího typu.

Můžete namítnout, že autowiring není jedinečnou vlastností. A měli byste pravdu. To, co činí kontejner Nette Framework jedinečným, je využití typového systému PHP, zatímco v mnoha jiných frameworcích je autowiring stále interně postaven na jménech služeb. Existují scénáře, ve kterých ostatní kontejnery zaostávají. Takto byste definovali službu autentizátoru v kontejneru Symfony DI pomocí jazyka YAML:

services:
  Authenticator: ~

V části services je hash mapa a bit Authenticator je identifikátor služby. Tilda znamená v YAML null, což Symfony interpretuje jako „použij identifikátor služby jako její typ“.

Brzy se však obchodní požadavky změní a vy potřebujete kromě lokálního vyhledávání v databázi podporovat i ověřování prostřednictvím LDAP. V prvním kroku změníte třídu Authenticator na rozhraní a původní implementaci extrahujete do třídy LocalAuthenticator:

services:
  LocalAuthenticator: ~

Najednou je Symfony bezradný. To proto, že Symfony pracuje s názvy služeb místo s typy. Kontrolér se stále správně spoléhá na abstrakci a uvádí rozhraní Authenticator jako svou závislost, ale v kontejneru není žádná služba se jménem Authenticator. Musíte dát Symfony nápovědu, například pomocí aliasu jméno služby:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Nette Framework naproti tomu názvy služeb ani nápovědy nepotřebuje. Nenutí vás duplikovat v konfiguraci informace, které jsou již vyjádřeny v kódu (prostřednictvím klauzule implements ). Je umístěn přímo nad typovým systémem PHP. Ví, že LocalAuthenticator je typu Authenticator, a pokud je to jediná služba, která toto rozhraní implementuje, s radostí ji automaticky připojí tam, kde je toto rozhraní požadováno, a to pouze na základě tohoto řádku konfigurace:

services:
    - LocalAuthenticator

Uznávám, že pokud autowiring neznáte, může vám připadat trochu magický a možná budete potřebovat nějaký čas, abyste se mu naučili důvěřovat. Naštěstí funguje transparentně a deterministicky: když kontejner nemůže jednoznačně vyřešit závislosti, vyhodí při kompilaci výjimku, která vám pomůže situaci napravit. Tímto způsobem můžete mít dvě různé implementace a přesto mít dobrou kontrolu nad tím, kde se která z nich používá.

Celkově na vás jako na vývojáře automatické zapojení klade menší kognitivní zátěž. Koneckonců se staráte pouze o typy a abstrakce, tak proč by vás měl kontejner DI nutit starat se také o implementace a identifikátory služeb? A co je důležitější, proč byste se vůbec měli starat o nějaký kontejner? V duchu dependency injection chcete mít možnost prostě deklarovat závislosti a být problémem někoho jiného, kdo je poskytne. Chcete se plně soustředit na kód aplikace a zapomenout na zapojení. A to vám DI Nette Framework umožňuje.

V mých očích je díky tomu řešení DI od Nette Framework tím nejlepším, které ve světě PHP existuje. Poskytuje vám kontejner, který je spolehlivý a prosazuje dobré architektonické vzory, ale zároveň se tak snadno konfiguruje a udržuje, že o něm nemusíte vůbec přemýšlet.

Doufám, že se tomuto příspěvku podařilo podnítit vaši zvědavost. Nezapomeňte se podívat na Github repozitář a do dokumentace – snad zjistíte, že jsem vám ukázal jen špičku ledovce a že celý balík je mnohem mocnější.