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

3 éve A címről Jiří Pudil  

Imádom a Nette Framework függőségi injektálási megoldását. Tényleg szeretem. Ez a bejegyzés azért van itt, hogy megosszam ezt a szenvedélyt, elmagyarázva, hogy miért gondolom, hogy ez a legjobb DI megoldás a mai PHP ökoszisztémában.

(Ez a bejegyzés eredetileg a szerző blogján jelent meg.)

A szoftverfejlesztés egy végtelen iteratív absztrakciós folyamat. 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 rendszeren belüli különböző szereplők közötti szerződések leírására és érvényesítésére. Új osztályokat vezetünk be a rendszerbe a felelősségek kapszulázására és határaik meghatározására, majd kompozícióval építjük fel az egész rendszert.

Arról a késztetésről beszélek, hogy a hitelesítési logikát kivonjuk a következő vezérlőbő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 láthatja, hogy a hitelesítő adatok ellenőrzése nem oda tartozik. Nem a vezérlő felelőssége, hogy megmondja, milyen hitelesítő adatok érvényesek – az egyetlen felelősség elvét követve a vezérlőnek csak egyetlen okot kell megváltoztatnia, és ennek az oknak az alkalmazás felhasználói felületén belül kell lennie, nem pedig a hitelesítési folyamatban.

Vegyük a kézenfekvő 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 csak annyit kell tennünk, hogy delegáljuk a vezérlőtől ezt a hitelesítőt. A hitelesítőt a a vezérlő függőségévé tettük, és hirtelen a vezérlőnek 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ű, majd 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ódon fog működni. De csak addig, amíg nem valósul meg egy robusztusabb hitelesítés, amely megköveteli, hogy a hitelesítő lekérdezze a felhasználók adatbázisának tábláját. A Authenticator hirtelen saját függőséget kap, mondjuk egy 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 függőségek kezelésének nem fenntartható módja, ha mindenhol kézzel hozunk létre példányokat. Ezért van a dependency injection minta, amely lehetővé teszi a vezérlő számára, hogy csupán deklarálja a függőségét egy Authenticator-től, és hagyja, hogy valaki más problémája legyen egy példány tényleges biztosítása. Ezt a valaki mást pedig függőségi injektálás tartályának nevezzük.

A függőségi injektálási konténer az alkalmazás legfőbb építésze – tudja, hogyan kell feloldani a rendszerben lévő bármely szolgáltatás függőségeit, és felelős azok létrehozásáért. A DI-konténerek manapság annyira elterjedtek, hogy nagyjából minden nagyobb webes keretrendszer rendelkezik saját konténer implementációval, sőt, még önálló, függőségi injektálásra szánt csomagok is léteznek, mint például a PHP-DI.

A paprika elégetése

A rengeteg lehetőség végül arra ösztönözte a fejlesztők egy csoportját, hogy olyan absztrakciót keressenek, amely átjárhatóvá teszi őket. A közös interfész idővel csiszolódott, és végül a következő formában javasolták a PHP-FIG-nek:

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

Ez az interfész a DI-konténerek egyik nagyon fontos tulajdonságát szemlélteti: olyanok, mint a tűz. Jó szolgák, de könnyen válhatnak rossz úrrá. Rendkívül hasznosak, amíg tudod, hogyan kell használni őket, de ha rosszul használod őket, megégetnek. Gondoljunk csak a következő edényre:

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 minden rendben. A megvalósítás jónak tűnik az általunk meghatározott szabvány szerint: valóban tudja, hogyan hozzon létre minden szolgáltatást az alkalmazásban, rekurzívan feloldva a függőségeket. Mindent egy helyen kezel, és a konténer még paramétereket is elfogad, így az adatbázis-kapcsolat könnyen konfigurálható. Szép!

De most, látva a ContainerInterface egyetlen két metódusát, talán kísértésbe eshetünk, hogy így használjuk 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, épp most égette meg a paprikáját. Más szóval a konténer lett a rossz gazda. Miért van ez így?

Először is, egy tetszőleges szolgáltatásazonosítóra támaszkodsz: 'authenticator'. A függőségi injektálás lényege, hogy átlátható legyen a függőségek tekintetében, és egy mesterséges azonosító használata egyenesen ellenkezik ezzel a felfogással: a kódot némán függővé teszi a konténer definíciójától. Ha valaha is átnevezzük a szolgáltatást a konténerben, meg kell találnunk ezt a hivatkozást, és frissítenünk kell.

És ami még rosszabb, ez a függőség rejtett: kívülről nézve első pillantásra a vezérlő csak egy konténer absztrakciójától függ. De fejlesztőként eked tudnod kell, hogyan nevezik el a szolgáltatásokat a konténerben, és hogy a authenticator nevű szolgáltatás valójában a Authenticator egy példánya. Az új kollégádnak mindezt meg kell tanulnia. Feleslegesen.

Szerencsére egy sokkal természetesebb azonosítóhoz folyamodhatunk: a szolgáltatás típusához. Végül is fejlesztőként csak ez érdekli. Nem kell tudnia, hogy milyen véletlenszerű karakterlánc kapcsolódik a konténerben lévő szolgáltatáshoz. Úgy vélem, ezt a kódot sokkal könnyebb í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 kicsit sem. A nagyobb probléma az, hogy a konténert a szolgáltatáskereső szerepére alacsonyítod le, ami egy hatalmas anti-mintázat. Ez olyan, mintha valakinek az egész hűtőszekrényt hoznád, hogy egyetlen rágcsálnivalót hozzon ki belőle – sokkal ésszerűbb lenne, ha csak a rágcsálnivalót hoznád neki.

Ismétlem, a függőségi injektálás az átláthatóságról szól, és ez a vezérlő még mindig nem átlátható a függőségeit illetően. A hitelesítőtől való függőség teljesen el van rejtve a külvilág elől, a konténertől való függőség mögött. Ez megnehezíti a kód olvashatóságát. Vagy használja. Vagy tesztelni! Az autentikátor modellezése egy egységtesztben most egy egész konténert kell létrehozni körülötte.

És mellesleg a vezérlő még mindig a konténer definíciójától függ, méghozzá elég rosszul. Ha az autentikátor szolgáltatás nem létezik a konténerben, a kód csak a action() metódusban hibázik, ami elég késői visszajelzés.

Valami finomat főzünk

Hogy igazságosak legyünk, senki sem hibáztathat igazán azért, hogy ebbe a zsákutcába kerültünk. Elvégre csak követted az okos fejlesztők által tervezett és bevált felületet. A helyzet az, hogy minden függőségi injektáló konténer definíció szerint szolgáltatáslokátor is, és kiderült, hogy a minta tényleg az egyetlen közös felület közöttük. De ez nem jelenti azt, hogy kell használni őket szolgáltatáslokátorokként. Valójában maga a PSR figyelmeztet erre.

Így használhatsz egy 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 vezérlő explicit módon, egyértelműen, átláthatóan deklarálja a függőséget a konstruktorban. A függőségek többé nincsenek elrejtve az osztályban szétszórva. Kényszerítve is vannak: a konténer nem képes létrehozni a SignInController egy példányát a szükséges Authenticator megadása nélkül. Ha nincs hitelesítő a konténerben, akkor a végrehajtás korán, nem a action() metódusban sikertelen. Az osztály tesztelése is sokkal egyszerűbbé vált, mert csak az autentikátor szolgáltatást kell mockolni, mindenféle konténer boilerplate nélkül.

És van még egy utolsó apró, de nagyon fontos részlet: becsempésztük a szolgáltatás típusára vonatkozó információt. Az a tény, hogy ez egy példánya a Authenticator – korábban csak burkoltan és ismeretlenül 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 bele van vésve a promotált paraméter typehintjébe.

Már csak az a lépés van hátra, hogy megtanítsuk a konténernek, hogyan hozza létre a vezérlőt 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 { /* . . . */ }
}

Észreveheti, hogy a konténer belsőleg még mindig a szolgáltatáskereső megközelítést használja. De ez nem baj, amíg ez benne van (szóviccnek szánva). Az egyetlen hely a konténeren kívül, ahol a get metódus meghívása elfogadható, az a index.php, az alkalmazás belépési pontja, ahol magát a konténert kell létrehozni, majd lekérni és futtatni az alkalmazást:

$container = bootstrap();

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

A rejtett gyöngyszem

De ne álljunk meg itt, hadd vigyem tovább az állítást: az egyetlen hely, ahol a get metódus meghívása elfogadható, az a belépési pont.

A konténer kódja ugyanis csak vezetékezés, ez assembly utasítás. Ez nem végrehajtó kód. Bizonyos értelemben nem fontos. Bár igen, az alkalmazás szempontjából kulcsfontosságú, de ez csak a fejlesztő szemszögéből nézve. A felhasználó számára nem igazán jelent közvetlen értéket, és ezt szem előtt tartva kell kezelni.

Nézd 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 az alkalmazásnak csak egy nagyon kis és egyszerű szegmensét fedi le. Ahogy az alkalmazás növekszik, a konténer kézzel történő megírása hihetetlenül fárasztóvá válik. Mint már mondtam, a konténer csak egy összeszerelési kézikönyv – de egy túlságosan bonyolult kézikönyv, sok oldallal, számtalan kereszthivatkozással és rengeteg apró betűs figyelmeztetéssel. Szeretnénk egy IKEA-stílusú kézikönyvet készíteni belőle, grafikusan, tömören, és olyan illusztrációkkal, amelyeken mosolyognak az emberek, amikor az ÅUTHENTICATÖR-t szerelés közben a szőnyegre fektetik, hogy ne törjön össze.

Itt jön a képbe a Nette Framework.

A Nette Framework DI-megoldás a Neon-t használja, egy konfigurációs fájlformátumot, amely hasonló a YAML-hez, de szteroidokon. Így definiálná ugyanazt a konténert a Neon konfiguráció használatával:

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

Hadd mutassak rá két figyelemre méltó dologra: először is, a szolgáltatások listája valóban egy lista, nem pedig egy hash-térkép – nincsenek kulcsok, nincsenek mesterséges szolgáltatásazonosítók. Nincs authenticator, és nincs Authenticator::class sem. Másodszor, az adatbázis-kapcsolat paramétereitől eltekintve sehol sem kell explicit módon felsorolni a függőségeket.

Ez azért van, mert a Nette Framework az automatikus kapcsolásra támaszkodik. Emlékszik, hogy a függőségi injektálásnak köszönhetően a függőség típusát egy natív tipehintben tudtuk kifejezni? A DI konténer ezt az információt használja, így amikor a Authenticator egy példányára van szükség, a rendszer teljesen megkerül minden nevet, és kizárólag a típusa alapján találja meg a megfelelő szolgáltatást.

Lehet azzal érvelni, hogy az autowiring nem egyedi tulajdonság. És igaza is lenne. Ami a Nette Framework konténerét egyedivé teszi, az a PHP típusrendszerének felhasználása, míg sok más keretrendszerben az autowiring még mindig a szolgáltatásnevekre épül belsőleg. Vannak olyan forgatókönyvek, amelyekben más konténerek alulmaradnak. A Symfony DI konténerében így definiálnád az autentikátor szolgáltatást YAML segítségével:

services:
  Authenticator: ~

A services szakasz egy hash-térkép, a Authenticator bit pedig a szolgáltatás azonosítója. A tilde a YAML-ben a null -t jelöli, amit a Symfony úgy értelmez, hogy “használd a szolgáltatás azonosítóját a típusaként”.

Hamarosan azonban megváltoznak az üzleti követelmények, és a helyi adatbázis-keresés mellett támogatni kell az LDAP-on keresztüli hitelesítést is. Első lépésként a Authenticator osztályt interfésszé alakítja, és az eredeti implementációt kivonja egy LocalAuthenticator osztályba:

services:
  LocalAuthenticator: ~

Hirtelen a Symfony tanácstalan. Ez azért van, mert a Symfony típusok helyett szolgáltatásnevekkel dolgozik. A vezérlő továbbra is helyesen támaszkodik az absztrakcióra, és a Authenticator interfészt sorolja fel függőségként, de a konténerben nincs * Authenticator nevű szolgáltatás névvel. A Symfony-nak egy tippet kell adnia, például egy service name alias használatával:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

A Nette Frameworknek viszont nincs szüksége szolgáltatásnevekre vagy tippekre. Nem kényszeríti arra, hogy a konfigurációban megkettőzze azokat az információkat, amelyeket már a kódban (a implements záradékon keresztül) kifejezett. A PHP típusrendszerének tetején helyezkedik el. Tudja, hogy a LocalAuthenticator az Authenticator típusú, és amíg ez az egyetlen olyan szolgáltatás, amely megvalósítja az interfészt, addig boldogan automatikusan beköti, ahol az interfészt kérik, ha csak ezt a konfigurációs sort adjuk meg:

services:
    - LocalAuthenticator

Elismerem, hogy ha nem ismered az automatikus kapcsolást, akkor ez egy kicsit varázslatosnak tűnhet, és szükséged lehet egy kis időre, hogy megtanulj bízni benne. Szerencsére átláthatóan és determinisztikusan működik: ha a konténer nem tudja egyértelműen feloldani a függőségeket, akkor egy fordítási idejű kivételt dob, amely segít a helyzet javításában. Így lehet két különböző implementációd, és mégis jól kézben tarthatod, hogy melyik hol kerül felhasználásra.

Összességében az autowiring kevesebb kognitív terhet ró rád mint fejlesztőre. Végül is Ön csak a típusokkal és az absztrakciókkal törődik, miért kellene egy DI-konténernek arra kényszerítenie, hogy az implementációkkal és a szolgáltatásazonosítókkal is törődjön? Ami még fontosabb, miért kellene egyáltalán törődnie néhány konténerrel? A függőségi injektálás szellemében azt szeretné, ha egyszerűen csak deklarálhatná a függőségeket, és valaki más problémája lenne, hogy biztosítsa azokat. Teljes mértékben az alkalmazáskódra akarsz koncentrálni, és el akarod felejteni a vezetékeket. A Nette Framework DI-je pedig lehetővé teszi ezt.

Az én szememben ez teszi a Nette Framework DI megoldását a legjobbá a PHP világában. Olyan konténert ad, amely megbízható és jó architektúrális mintákat érvényesít, ugyanakkor olyan egyszerűen konfigurálható és karbantartható, hogy egyáltalán nem kell gondolkodnia rajta.

Remélem, ezzel a bejegyzéssel sikerült felkeltenem a kíváncsiságodat. Mindenképpen nézd meg a Github repository és a docs – remélhetőleg megtudod, hogy csak a jéghegy csúcsát mutattam meg, és hogy az egész csomag sokkal erősebb.