Služby nepotřebují názvy
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ší.
Chcete-li odeslat komentář, přihlaste se