Serviciile nu au nevoie de nume

acum 3 ani De la Jiří Pudil  

Îmi place soluția de injecție a dependențelor din Nette Framework. Chiar îmi place. Această postare este aici pentru a împărtăși această pasiune, explicând de ce cred că este cea mai bună soluție DI din ecosistemul PHP actual.

(Această postare a fost publicată inițial pe blogul autorului).

Dezvoltarea de software este un proces iterativ nesfârșit de abstractizare. Găsim abstracțiuni adecvate ale lumii reale în modelarea domeniului. În programarea orientată pe obiecte, folosim abstracțiuni pentru a descrie și a aplica contracte între diverși actori din cadrul sistemului. Introducem noi clase în sistem pentru a încapsula responsabilitățile și a defini limitele acestora, iar apoi folosim compoziția pentru a construi întregul sistem.

Mă refer la îndemnul de a extrage logica de autentificare din următorul controler:

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

Probabil că vă puteți da seama că verificarea acreditărilor nu are ce căuta acolo. Nu este responsabilitatea controlerului să spună ce acreditări sunt valide – urmând principiul responsabilității unice, controlerul ar trebui să aibă un singur motiv de schimbare, iar acel motiv ar trebui să fie în cadrul interfeței cu utilizatorul aplicației, nu în procesul de autentificare.

Să luăm calea evidentă de ieșire din această situație și să extragem condiția într-o clasă Authenticator:

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

Acum tot ce trebuie să facem este să delegăm de la controler la acest autentificator. Am făcut autentificatorul o dependență a controlerului și, dintr-o dată, controlerul trebuie să o obțină de undeva:

final class SignInController extends Best\Framework\Controller
{
    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = new Authenticator(); // <== ușor, o să creez unul nou!
        if ( ! $authenticator->authenticate($username, $password)) {
            return $this->render(__DIR__ . '/error.latte');
        }

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

Acest mod naiv va funcționa. Dar numai până când va fi implementată o autentificare mai robustă, care necesită ca autentificatorul să interogheze un tabel de utilizatori din baza de date. Authenticator are dintr-o dată o dependență proprie, să zicem o UserRepository, care la rândul său depinde de o instanță Connection, care depinde de parametrii mediului specific. Asta a escaladat rapid!

Crearea manuală de instanțe peste tot nu este o modalitate sustenabilă de gestionare a dependențelor. De aceea avem modelul de injectare a dependențelor, care permite controlorului să declare pur și simplu dependența sa de un Authenticator, și să lase în seama altcuiva să furnizeze efectiv o instanță. Iar acel altcineva se numește container de injecție a dependenței.

Containerul de injecție a dependențelor este arhitectul suprem al aplicației – știe cum să rezolve dependențele oricărui serviciu din sistem și este responsabil de crearea acestora. Containerele DI sunt atât de comune în zilele noastre încât aproape fiecare cadru web important are propria implementare de container și există chiar pachete independente dedicate injecției de dependență, cum ar fi PHP-DI.

Arderea ardeilor

Abundența de opțiuni a motivat în cele din urmă un grup de dezvoltatori să caute o abstractizare care să le facă interoperabile. Interfața comună a fost șlefuită în timp și în cele din urmă a fost propusă către PHP-FIG sub următoarea formă:

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

Această interfață ilustrează un atribut foarte important al containerelor DI: sunt ca focul. Sunt un servitor bun, dar pot deveni cu ușurință un stăpân rău. Ele sunt extrem de utile atâta timp cât știi cum să le folosești, dar dacă le folosești incorect, te ard. Luați în considerare următorul container:

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

Până acum totul e bine. Implementarea pare bună după standardul pe care l-am stabilit: într-adevăr, știe cum să creeze fiecare serviciu din aplicație, rezolvând recursiv dependențele sale. Totul este gestionat într-un singur loc, iar containerul acceptă chiar și parametri, astfel încât conexiunea la baza de date este ușor de configurat. Frumos!

Dar acum, văzând singurele două metode ale ContainerInterface, ați putea fi tentați să folosiți containerul astfel:

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

Felicitări, tocmai v-ați ars ardeii. Cu alte cuvinte, containerul a devenit stăpânul cel rău. De ce se întâmplă asta?

În primul rând, vă bazați pe un identificator de serviciu arbitrar: 'authenticator'. Injectarea dependenței înseamnă să fii transparent în ceea ce privește dependențele, iar utilizarea unui identificator artificial contravine direct acestei noțiuni: face ca codul să depindă în tăcere de definiția containerului. Dacă se întâmplă vreodată să redenumiți serviciul din container, trebuie să găsiți această referință și să o actualizați.

Și ceea ce este mai rău, această dependență este ascunsă: la prima vedere, din exterior, controlerul depinde doar de o abstracțiune a unui container. Dar, în calitate de dezvoltator, tu trebuie să știi cum sunt denumite serviciile în container și că un serviciu numit authenticator este, de fapt, o instanță a Authenticator. Noul tău coleg trebuie să învețe toate acestea. În mod inutil.

Din fericire, putem recurge la un identificator mult mai natural: tipul serviciului. La urma urmei, asta este tot ceea ce vă interesează ca dezvoltator. Nu aveți nevoie să știți ce șir aleatoriu este asociat serviciului din container. Cred că acest cod este mult mai ușor atât de scris, cât și de citit:

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

Din păcate, nu am îmblânzit încă flăcările. Nici măcar un pic. Problema cea mai mare este că înjosiți containerul în rolul de localizator de servicii, ceea ce reprezintă un antimodel uriaș. Este ca și cum i-ai aduce cuiva tot frigiderul pentru ca acesta să poată lua o singură gustare din el – este mult mai rezonabil să îi aduci doar gustarea.

Din nou, injectarea dependențelor are legătură cu transparența, iar acest controler nu este încă transparent în ceea ce privește dependențele sale. Dependența de un autentificator este complet ascunsă de lumea exterioară, în spatele dependenței de container. Acest lucru face ca codul să fie mai greu de citit. Sau utilizați. Sau de testare! Simularea autentificatorului într-un test unitar necesită acum să creați un întreg container în jurul acestuia.

Și, apropo, controlerul depinde în continuare de definiția containerului, și o face într-un mod destul de prost. Dacă serviciul de autentificare nu există în container, codul nu dă greș decât în metoda action(), ceea ce reprezintă un feedback destul de târziu.

Gătește ceva delicios

Ca să fim corecți, nimeni nu vă poate învinovăți pentru că ați ajuns în această fundătură. La urma urmei, nu ați făcut decât să urmați interfața proiectată și dovedită de dezvoltatori inteligenți. Chestia este că toate containerele de injecție a dependențelor sunt prin definiție și localizatori de servicii și se pare că modelul este într-adevăr singura interfață comună între ele. Dar asta nu înseamnă că trebuie să le folosiți ca localizatori de servicii. De fapt, PSR însuși avertizează în legătură cu acest lucru.

Iată cum puteți utiliza un container DI ca un bun servitor:

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

Controlorul declară dependența în mod explicit, clar, transparent în constructor. Dependențele nu mai sunt ascunse și împrăștiate prin clasă. Ele sunt, de asemenea, impuse: containerul nu poate crea o instanță a SignInController fără a furniza Authenticator. Dacă nu există un autentificator în container, execuția eșuează mai devreme, nu în metoda action(). De asemenea, testarea acestei clase a devenit mult mai ușoară, deoarece nu trebuie decât să vă bateți joc de serviciul de autentificare, fără niciun fel de boilerplate al containerului.

Și mai există un ultim detaliu minuscul, dar foarte important: am introdus pe furiș informațiile despre tipul serviciului. Faptul că este o instanță a Authenticator – anterior implicită și necunoscută de IDE, de instrumentele de analiză statică sau chiar de un dezvoltator care nu cunoaște definiția containerului – este acum gravată static în tipul parametrului promovat.

Singurul pas care mai rămâne de făcut este acela de a învăța containerul cum să creeze și controlerul:

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

Ați observat că containerul încă folosește în mod intern abordarea de localizare a serviciilor. Dar asta este în regulă, atâta timp cât este conținut (joc de cuvinte). Singurul loc din afara containerului în care apelarea metodei get este acceptabilă este în index.php, în punctul de intrare al aplicației, unde trebuie să creați containerul însuși și apoi să preluați și să rulați aplicația:

$container = bootstrap();

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

Gema ascunsă

Dar să nu ne oprim aici, permiteți-mi să duc afirmația mai departe: unicul loc în care apelarea metodei get este acceptabilă este în punctul de intrare.

Codul containerului este doar cablare, sunt instrucțiuni de asamblare. Nu este cod executiv. Nu este important, într-un fel. Deși da, este crucial pentru aplicație, asta doar din perspectiva dezvoltatorului. Nu aduce cu adevărat nicio valoare directă pentru utilizator și ar trebui să fie tratat cu acest lucru în minte.

Uitați-vă din nou la container:

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

Aceasta acoperă doar un segment foarte mic și simplu al aplicației. Pe măsură ce aplicația crește, scrierea manuală a containerului devine incredibil de plictisitoare. Așa cum am mai spus, containerul este doar un manual de asamblare – dar este unul excesiv de complicat, cu multe pagini, nenumărate trimiteri încrucișate și o mulțime de avertismente scrise cu litere mici. Vrem să îl transformăm într-un manual de tip IKEA, grafic, concis și cu ilustrații cu oameni care zâmbesc atunci când așează ÅUTHENTICATÖR pe covor în timpul asamblării, astfel încât să nu se spargă.

Aici intervine Nette Framework.

Soluția DI de la Nette Framework utilizează Neon, un format de fișier de configurare asemănător cu YAML, dar pe steroizi. Acesta este modul în care ați defini același container, folosind configurația Neon:

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

Permiteți-mi să subliniez două lucruri demne de remarcat: în primul rând, lista de servicii este cu adevărat o listă, nu o hartă hash – nu există chei, nu există identificatori artificiali de servicii. Nu există authenticator, și nici Authenticator::class. În al doilea rând, nu este nevoie să enumerați în mod explicit nicio dependență nicăieri, în afară de parametrii de conectare la baza de date.

Acest lucru se datorează faptului că Nette Framework se bazează pe auto-cablare. Vă amintiți cum, datorită injecției de dependență, am putut exprima tipul dependenței într-un typehint nativ? Containerul DI utilizează aceste informații, astfel încât, atunci când solicitați o instanță de Authenticator, acesta ocolește în întregime orice nume și găsește serviciul potrivit numai după tipul său.

Ați putea spune că autowiring-ul nu este o caracteristică unică. Și ați avea dreptate. Ceea ce face ca containerul Nette Framework să fie unic este utilizarea sistemului de tipuri al PHP, în timp ce în multe alte framework-uri, autowiring-ul este încă construit intern pe baza numelor de servicii. Există scenarii în care alte containere nu reușesc. Iată cum ați defini serviciul de autentificare în containerul DI al Symfony folosind YAML:

services:
  Authenticator: ~

Secțiunea services este o hartă hash, iar bitul Authenticator este un identificator de serviciu. Tilda reprezintă null în YAML, pe care Symfony îl interpretează ca “folosește identificatorul serviciului ca tip”.

Dar, în curând, cerințele de afaceri se schimbă și trebuie să suportați autentificarea prin LDAP pe lângă o căutare în baza de date locală. Ca prim pas, transformați clasa Authenticator într-o interfață și extrageți implementarea originală într-o clasă LocalAuthenticator:

services:
  LocalAuthenticator: ~

Dintr-o dată, Symfony nu mai știe nimic. Asta pentru că Symfony lucrează cu nume de servicii în loc de tipuri. Controlerul se bazează în continuare în mod corect pe abstracțiune și listează interfața Authenticator ca dependență, dar nu există niciun serviciu numit Authenticator în container. Trebuie să îi dați un indiciu lui Symfony, de exemplu folosind un alias numele serviciului:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Nette Framework, pe de altă parte, nu are nevoie de nume de servicii sau indicii. Nu vă obligă să duplicați în configurație informațiile care sunt deja exprimate în cod (prin intermediul clauzei implements ). Este așezat chiar deasupra sistemului de tipuri al PHP. Știe că LocalAuthenticator este de tipul Authenticator și, atâta timp cât este singurul serviciu care implementează interfața, îl conectează automat cu plăcere acolo unde este solicitată interfața, având în vedere doar această linie de configurare:

services:
    - LocalAuthenticator

Recunosc că, dacă nu sunteți familiarizați cu autowiring, s-ar putea să vi se pară un pic magic și s-ar putea să aveți nevoie de ceva timp pentru a învăța să aveți încredere în el. Din fericire, funcționează în mod transparent și determinist: atunci când containerul nu poate rezolva în mod neechivoc dependențele, acesta aruncă o excepție în timp de compilare care vă ajută să remediați situația. În acest fel, puteți avea două implementări diferite și să controlați în continuare bine unde este utilizată fiecare dintre ele.

În ansamblu, autowiring-ul pune mai puțină încărcătură cognitivă pe dumneavoastră, ca dezvoltator. La urma urmei, vă pasă doar de tipuri și abstracțiuni, așa că de ce ar trebui ca un container DI să vă forțeze să vă intereseze și de implementări și identificatori de servicii? Mai important, de ce ar trebui să vă pese de un container în primul rând? În spiritul injecției de dependență, doriți să puteți declara dependențele și să fie problema altcuiva să le furnizeze. Vreți să vă concentrați în totalitate pe codul aplicației și să uitați de cablaj. Iar DI de la Nette Framework vă permite să faceți acest lucru.

Din punctul meu de vedere, acest lucru face ca soluția DI a Nette Framework să fie cea mai bună din lumea PHP. Vă oferă un container care este fiabil și aplică modele arhitecturale bune, dar în același timp este atât de ușor de configurat și de întreținut încât nu trebuie să vă gândiți deloc la asta.

Sper că această postare a reușit să vă stârnească curiozitatea. Nu uitați să consultați Github repository și docs – sperăm că veți afla că v-am arătat doar vârful icebergului și că întregul pachet este mult mai puternic.