A szolgáltatásoknak nincs szükségük nevekre
Tetszik a Nette Framework megoldása a dependency injectionre. Tényleg imádom. Ez a cikk azért van itt, hogy megosszam ezt a szenvedélyt, és elmagyarázzam, miért gondolom úgy, hogy ez a legjobb DI megoldás a jelenlegi PHP ökoszisztémában.

(Ez a bejegyzés eredetileg az szerző blogján jelent meg.)
A szoftverfejlesztés a absztrakció végtelen iteratív folyamata. A valós világ megfelelő absztrakcióit a domain modellezésben találjuk meg. Az objektumorientált programozásban absztrakciókat használunk a rendszer különböző szereplői közötti szerződések leírására és kikényszerítésére. Új osztályokat vezetünk be a rendszerbe a felelősségek beágyazására és határaik meghatározására, majd kompozíció segítségével építjük fel az egész rendszert.
Arról a késztetésről beszélek, hogy ki kell vonni az authentikációs logikát a következő kontrollerből:
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);
}
}
Valószínűleg felismeri, hogy a hitelesítő adatok ellenőrzése nem tartozik ide. A kontrollernek nincs felelőssége annak meghatározásában, hogy mely hitelesítő adatok érvényesek – az egyetlen felelősség elve szerint a kontrollernek csak egyetlen oka lehet a változásra, és ennek az oknak az alkalmazás felhasználói felületén belül kell lennie, nem pedig az authentikációs folyamatban.
Vegyük a nyilvánvaló utat, és vonjuk ki a feltételt egy
Authenticator
osztályba:
final class Authenticator
{
public function authenticate(string $username, string $password): bool
{
return $username === 'admin' && $password === 'p4ssw0rd!';
}
}
Most már elég, ha a kontrollerből delegálunk erre az authentikátorra. Létrehoztuk az authentikátort függőségként a kontrollerhez, és a kontrollernek hirtelen valahonnan meg kell szereznie:
final class SignInController extends Best\Framework\Controller
{
public function action(string $username, string $password): Best\Framework\Response
{
$authenticator = new Authenticator(); // <== könnyű, csak létrehozok egy újat!
if ( ! $authenticator->authenticate($username, $password)) {
return $this->render(__DIR__ . '/error.latte');
}
$this->signIn(new Identity($username));
return $this->redirect(HomepageController::class);
}
}
Ez a naiv módszer működni fog. De csak addig, amíg egy robusztusabb
authentikációt nem implementálnak, amely megköveteli, hogy az authentikátor
lekérdezze a felhasználók adatbázis tábláját. Hirtelen az
Authenticator
-nak saját függősége van, mondjuk
UserRepository
, amely viszont egy Connection
példánytól függ, amely az adott környezet paramétereitől függ. Ez
gyorsan eszkalálódott!
A példányok mindenhol kézi létrehozása nem fenntartható módja a
függőségek kezelésének. Ezért van a dependency injection minta, amely
lehetővé teszi a kontroller számára, hogy csak deklarálja a függőséget
az Authenticator
-tól, és hagyja valaki másra, hogy ténylegesen
biztosítsa a példányt. És ezt a valaki mást dependeny injection
container-nek nevezik.
A dependency injection container az alkalmazás fő építésze – képes megoldani bármely szolgáltatás függőségeit a rendszerben, és felelős azok létrehozásáért. A DI konténerek ma már annyira elterjedtek, hogy szinte minden nagyobb webes keretrendszernek saját konténer implementációja van, sőt, léteznek különálló csomagok is, amelyek a dependency injectionnek szenteltek, például a PHP-DI.
Paprika égetése
A lehetőségek sokasága végül arra ösztönzött egy fejlesztői csoportot, hogy olyan absztrakciót keressenek, amely interoperábilissá tenné őket. A közös interfészt idővel finomították, és végül a PHP-FIG számára a következő formában javasolták:
interface ContainerInterface
{
public function get(string $id): mixed;
public function has(string $id): bool;
}
Ez az interfész egy nagyon fontos tulajdonságát illusztrálja a DI konténereknek: Jó szolgák, de könnyen rossz urakká válhatnak. Rendkívül hasznosak, ha tudod, hogyan kell használni őket, de ha helytelenül használod őket, megégetnek. Vegyük a következő konténert:
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 { /* . . . */ }
}
Eddig jó. Az implementáció jónak tűnik az általunk felállított szabványok szerint: valóban képes létrehozni minden szolgáltatást az alkalmazásban, és rekurzívan megoldja a függőségeit. Minden egy helyen van kezelve, és a konténer még paramétereket is fogad, így az adatbázis-kapcsolat könnyen konfigurálható. Szép!
De most, hogy csak a ContainerInterface
két metódusát látja,
talán kísértést érez arra, hogy így használja a konténert:
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');
//...
}
}
Gratulálok, éppen megégette a paprikát. Más szóval, a konténer rossz úrrá vált. Miért van ez így?
Először is, egy tetszőleges szolgáltatás azonosítóra támaszkodik:
'authenticator'
. A dependency injection arról szól, hogy
átláthatónak kell lenni a függőségeivel kapcsolatban, és egy mesterséges
azonosító használata közvetlenül ellentétes ezzel a koncepcióval: azt
okozza, hogy a kód csendben függ a konténer definíciójától. Ha valaha
átnevezik a szolgáltatást a konténerben, meg kell találnia és frissítenie
kell ezt a hivatkozást.
És ami még rosszabb, ez a függőség rejtett: első pillantásra
kívülről a kontroller csak a konténer absztrakciójától függ. De
fejlesztőként önnek ismernie kell, hogyan nevezik el a
szolgáltatásokat a konténerben, és hogy az authenticator
nevű
szolgáltatás valójában az Authenticator
példánya. Mindezt meg
kell tanulnia az új kollégájának. Feleslegesen.
Szerencsére egy sokkal természetesebb azonosítóhoz folyamodhatunk: a szolgáltatás típusához. Végül is ez az egyetlen dolog, ami fejlesztőként érdekli. Nem kell tudnia, milyen véletlenszerű string van hozzárendelve a szolgáltatáshoz a konténerben. Hiszem, hogy ezt a kódot sokkal egyszerűbb írni és olvasni is:
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);
//...
}
}
Sajnos még nem szelídítettük meg a lángokat. Egyáltalán nem. A nagyobb probléma az, hogy alázatosan a szolgáltatáskereső szerepébe helyezi a konténert, ami egy hatalmas anti-minta. Olyan, mintha valakinek az egész hűtőszekrényt odavinnénk, hogy kivehessen belőle egy falatot – sokkal ésszerűbb csak azt a falatot odavinni neki.
Ismétlem, a dependency injection az átláthatóságról szól, és ez a kontroller még mindig nem átlátható a függőségei tekintetében. Az authentikátor függősége teljesen el van rejtve a külső világ elől a konténer függősége mögött. Ezáltal a kód nehezebben olvashatóvá válik. Vagy használhatóvá. Vagy tesztelhetővé! Az authentikátor mockingja egy unit tesztben most megköveteli, hogy köré építsen egy egész konténert.
És mellesleg, a kontroller még mindig függ a konténer definíciójától,
méghozzá elég rossz módon. Ha az authentikátor szolgáltatás nem létezik
a konténerben, a kód csak az action()
metódusban fog
meghiúsulni, ami elég késői visszajelzés.
Valami finomat főzni
Hogy igazságosak legyünk, senki sem hibáztathatja azért, hogy ebbe a zsákutcába került. Végül is csak követte az okos fejlesztők által tervezett és bevált interfészt. A lényeg az, hogy minden dependency injection konténer definíció szerint szolgáltatáskereső is, és kiderül, hogy a minta valóban az egyetlen közös interfész közöttük. De ez nem jelenti azt, hogy kellene használni őket szolgáltatáskeresőként. Valójában maga a PSR előírás figyelmeztet erre.
Így használhatja a DI konténert jó szolgaként:
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);
//...
}
}
A konstruktorban a függőség explicit módon, világosan és
átláthatóan van deklarálva. A függőségek már nem rejtőznek szét
szórva az osztályban. Ki is vannak kényszerítve: a konténer nem képes
létrehozni a SignInController
példányát anélkül, hogy
biztosítaná a szükséges Authenticator
-t. Ha nincs
authentikátor a konténerben, a végrehajtás korán meghiúsul, nem az
action()
metódusban. Ennek az osztálynak a tesztelése is sokkal
egyszerűbbé vált, mivel csak az authentikátor szolgáltatást kell mockolni
bármiféle konténer boilerplate nélkül.
És még egy apró, de nagyon fontos részlet: becsempésztük a
szolgáltatás típusinformációját. Az a tény, hogy ez egy
Authenticator
példány – korábban implicit és ismeretlen az
IDE, a statikus elemző eszközök vagy akár a konténer definícióját nem
ismerő fejlesztő számára – most statikusan be van vésve a promótált
paraméter típus-hintjébe.
Az egyetlen hátralévő lépés az, hogy megtanítsuk a konténernek, hogyan hozza létre a kontrollert is:
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 { /* . . . */ }
}
Talán észrevette, hogy a konténer továbbra is belsőleg a
szolgáltatáskereső megközelítést használja. Ez azonban nem számít,
amíg tartalmazva van (szójáték). Az egyetlen hely a konténeren kívül,
ahol a get
metódus hívása megengedett, az
index.php
-ban van, az alkalmazás belépési pontjánál, ahol
magát a konténert kell létrehozni, majd betölteni és futtatni az
alkalmazást:
$container = bootstrap();
$application = $container->get(Best\Framework\Application::class);
$application->run();
Rejtett kincs
De ne álljunk meg itt, hadd vigyem tovább ezt az állítást: az
egyetlen hely, ahol a get
metódus hívása megengedett, a
belépési pont.
A konténer kódja csak huzalozás, összeszerelési utasítások. Nem végrehajtó kód. Bizonyos értelemben nem fontos. Bár igen, kulcsfontosságú az alkalmazás számára, de csak a fejlesztő szemszögéből. Valójában nem hoz közvetlen értéket a felhasználónak, és ennek megfelelően kell kezelni.
Nézze meg újra a konténert:
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 { /* . . . */ }
}
Ez csak az alkalmazás egy nagyon kicsi és egyszerű szegmensére vonatkozik. Ahogy az alkalmazás növekszik, a konténer kézi írása hihetetlenül fárasztóvá válik. Ahogy már mondtam, a konténer csak egy összeszerelési útmutató – de túl bonyolult, sok oldalas, számtalan kereszthivatkozással és rengeteg apró betűs figyelmeztetéssel. IKEA stílusú kézikönyvvé akarjuk tenni, grafikussá, tömörré és illusztrációkkal emberekről, akik mosolyognak, miközben az ÅUTHENTICATÖR-t a szőnyegre helyezik összeszerelés közben, hogy ne törjön el.
Itt jön képbe a Nette Framework.
A Nette Framework DI megoldása a Neon-t használja, egy YAML-hez hasonló, de szteroidokon lévő konfigurációs fájlformátumot. Így definiálná ugyanazt a konténert Neon konfigurációval:
services:
- SignInController
- Authenticator
- UserRepository
- Connection(%database%)
Engedje meg, hogy két figyelemre méltó dologra hívjam fel a figyelmet:
először is, a szolgáltatások listája valóban lista, nem hash map –
nincsenek kulcsok, nincsenek mesterséges szolgáltatás azonosítók. Nincs
authenticator
, sem Authenticator::class
. Másodszor,
sehol sem kell explicit módon megadnia semmilyen függőséget, kivéve az
adatbázis-kapcsolat paramétereit.
Ez azért van, mert a Nette Framework az automatikus bekötésre (autowiring)
támaszkodik. Emlékszik, hogyan tudtuk a dependency injectionnek
köszönhetően kifejezni a függőség típusát natív typehinttel? A DI
konténer ezt az információt használja ki, így amikor egy
Authenticator
példányt kér, teljesen megkerül minden nevet, és
kizárólag a típusa alapján találja meg a megfelelő szolgáltatást.
Lehet, hogy azzal érvel, hogy az autowiring nem egyedi tulajdonság. És igaza lenne. Ami a Nette Framework konténerét egyedivé teszi, az a PHP típusrendszerének kihasználása, míg sok más keretrendszerben az autowiring továbbra is belsőleg a szolgáltatásnevekre épül. Vannak olyan forgatókönyvek, amelyekben más konténerek lemaradnak. Így definiálná az authentikátor szolgáltatást a Symfony DI konténerben YAML használatával:
services:
Authenticator: ~
A services
rész egy hash map, és az Authenticator
bit a szolgáltatás azonosítója. A tilde YAML-ben null
-t
jelent, amit a Symfony úgy értelmez, hogy “használd a szolgáltatás
azonosítóját annak típusaként”.
Hamarosan azonban az üzleti követelmények megváltoznak, és a helyi
adatbázis-keresés mellett támogatnia kell az LDAP-n keresztüli
authentikációt is. Első lépésként az Authenticator
osztályt
interfészre cseréli, és az eredeti implementációt egy
LocalAuthenticator
osztályba vonja ki:
services:
LocalAuthenticator: ~
Hirtelen a Symfony tanácstalan. Ez azért van, mert a Symfony
szolgáltatásnevekkel dolgozik típusok helyett. A kontroller továbbra is
helyesen támaszkodik az absztrakcióra, és az Authenticator
interfészt adja meg függőségként, de a konténerben nincs Authenticator
nevű szolgáltatás. Segítséget kell adnia a Symfony-nak, például egy
szolgáltatásnév alias segítségével:
services:
LocalAuthenticator: ~
Authenticator: '@LocalAuthenticator'
A Nette Framework ezzel szemben nem igényel szolgáltatásneveket vagy
segítséget. Nem kényszeríti arra, hogy a konfigurációban megduplázza
azokat az információkat, amelyek már a kódban ki vannak fejezve (az
implements
klauzula révén). Közvetlenül a PHP típusrendszere
fölé van helyezve. Tudja, hogy a LocalAuthenticator
az
Authenticator
típusú, és ha ez az egyetlen szolgáltatás, amely
ezt az interfészt implementálja, örömmel automatikusan beköti oda, ahol ez
az interfész szükséges, és ez csak ezen a konfigurációs soron alapul:
services:
- LocalAuthenticator
Elismerem, hogy ha nem ismeri az autowiringot, kissé mágikusnak tűnhet, és talán szüksége lesz némi időre, hogy megtanuljon bízni benne. Szerencsére átláthatóan és determinisztikusan működik: amikor a konténer nem tudja egyértelműen feloldani a függőségeket, fordításkor kivételt dob, amely segít a helyzet javításában. Így lehet két különböző implementációja, és mégis jó kontrollja van afölött, hogy hol melyiket használják.
Összességében az automatikus bekötés fejlesztőként kisebb kognitív terhet ró önre. Végül is csak a típusok és absztrakciók érdeklik, akkor miért kényszerítené a DI konténer arra, hogy az implementációkkal és a szolgáltatás azonosítókkal is törődjön? És ami még fontosabb, miért kellene egyáltalán törődnie bármilyen konténerrel? A dependency injection szellemében egyszerűen csak deklarálni szeretné a függőségeket, és valaki más problémája legyen azok biztosítása. Teljesen az alkalmazás kódjára szeretne koncentrálni, és elfelejteni a huzalozást. És ezt teszi lehetővé a Nette Framework DI.
Az én szememben ez teszi a Nette Framework DI megoldását a legjobbnak, ami a PHP világában létezik. Olyan konténert ad, amely megbízható és jó architekturális mintákat kényszerít ki, de ugyanakkor olyan könnyen konfigurálható és karbantartható, hogy egyáltalán nem kell gondolkodnia rajta.
Remélem, ennek a bejegyzésnek sikerült felkeltenie a kíváncsiságát. Ne felejtse el megnézni a Github repozitóriumot és a dokumentációt – remélhetőleg rájön, hogy csak a jéghegy csúcsát mutattam meg, és az egész csomag sokkal erősebb.
A hozzászólás elküldéséhez kérjük, jelentkezzen be