A szolgáltatásoknak nincs szükségük nevekre

4 éve írta Jiří Pudil  

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.