Serviciile nu au nevoie de nume

acum 4 ani de Jiří Pudil  

Îmi place soluția Nette Framework pentru injecția de dependențe. Chiar o iubesc. Acest articol este aici pentru a împărtăși această pasiune și a explica 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 software este un proces iterativ nesfârșit de abstractizare. Găsim abstracții potrivite ale lumii reale în modelarea domeniului. În programarea orientată pe obiecte, folosim abstracții pentru a descrie și a impune contracte între diferiți actori din sistem. Introducem noi clase în sistem pentru a încapsula responsabilități și a defini limitele lor, apoi folosim compoziția pentru a crea întregul sistem.

Vorbesc despre impulsul de a extrage logica de autentificare din următorul controller:

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 recunoașteți că verificarea credențialelor nu aparține acolo. Controllerul nu are responsabilitatea de a determina ce credențiale sunt valide – conform principiului responsabilității unice, controllerul ar trebui să aibă un singur motiv de modificare, iar acest motiv ar trebui să fie în cadrul interfeței cu utilizatorul aplicației, nu în procesul de autentificare.

Să urmăm calea evidentă și să extragem condiția în clasa Authenticator:

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

Acum este suficient să delegăm din controller către acest autentificator. Am creat autentificatorul dependență de controller, iar controllerul are brusc nevoie să-l 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, pur și simplu creez unul nou!
        if ( ! $authenticator->authenticate($username, $password)) {
            return $this->render(__DIR__ . '/error.latte');
        }

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

Această metodă naivă va funcționa. Dar numai până când va fi implementată o autentificare mai robustă, care va necesita ca autentificatorul să interogheze o tabelă de utilizatori din baza de date. Brusc, Authenticator are propria dependență, să spunem UserRepository, care la rândul său depinde de instanța Connection, care depinde de parametrii mediului specific. Lucrurile au escaladat rapid!

Crearea manuală a instanțelor peste tot nu este un mod sustenabil de a gestiona dependențele. De aceea avem modelul injecției de dependențe, care permite controllerului doar să declare dependența de Authenticator și să lase pe altcineva să furnizeze efectiv instanța. Și acest altcineva se numește container de injecție de dependențe (container).

Containerul de injecție de dependențe este arhitectul șef al aplicației – poate rezolva dependențele oricărui serviciu din sistem și este responsabil pentru crearea lor. Containerele DI sunt atât de comune astăzi încât aproape fiecare framework web major are propria implementare de container și există chiar și pachete separate dedicate injecției de dependențe, de exemplu PHP-DI.

Arderea ardeiului

Numărul mare de opțiuni a motivat în cele din urmă un grup de dezvoltatori să caute o abstracție care să le facă interoperabile. O interfață comună a fost șlefuită în timp și în cele din urmă propusă pentru PHP-FIG în următoarea formă:

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

Această interfață ilustrează o proprietate foarte importantă a containerelor DI: Sunt un slujitor bun, dar pot deveni ușor un stăpân rău. Sunt extrem de utile dacă știi cum să le folosești, dar dacă le folosești incorect, te vor arde. 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 bine. Implementarea pare bună conform standardelor pe care ni le-am stabilit: într-adevăr poate crea fiecare serviciu din aplicație și rezolvă recursiv dependențele sale. Totul este gestionat într-un singur loc și containerul chiar acceptă parametri, astfel încât conexiunea la baza de date este ușor de configurat. Frumos!

Dar acum, văzând doar cele două metode ContainerInterface, s-ar putea să fiți tentați să utilizaț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 ardeiul. Cu alte cuvinte, containerul a devenit un stăpân rău. De ce se întâmplă asta?

În primul rând, vă bazați pe un identificator arbitrar de serviciu: 'authenticator'. Injecția de dependențe înseamnă a fi transparent cu privire la dependențele tale, iar utilizarea unui identificator artificial merge direct împotriva acestei noțiuni: face ca codul să fie dependent în tăcere de definiția containerului. Dacă vreodată un serviciu este redenumit în container, trebuie să găsiți această referință și să o actualizați.

Și ce este mai rău, această dependență este ascunsă: la prima vedere, din exterior, controllerul depinde doar de abstracția containerului. Dar ca dezvoltator, tu trebuie să ai cunoștințe despre cum sunt denumite serviciile în container și că serviciul numit authenticator este de fapt o instanță a Authenticator. Toate acestea trebuie învățate de noul tău coleg. Inutil.

Din fericire, ne putem baza pe un identificator mult mai natural: tipul serviciului. Acesta este, la urma urmei, singurul lucru care te interesează ca dezvoltator. Nu trebuie să știi ce șir aleatoriu este atribuit unui serviciu în container. Cred că acest cod este mult mai ușor de scris ș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, încă nu am stins flăcările. Nici pe departe. Problema mai mare este că umilitor plasați containerul în rolul de localizator de servicii, ceea ce este un anti-pattern uriaș. Este ca și cum ai aduce cuiva întregul frigider pentru a-și putea lua o gustare – mult mai rezonabil este să-i aduci doar gustarea.

Din nou, injecția de dependențe înseamnă transparență, iar acest controller încă nu este transparent cu privire la dependențele sale. Dependența de autentificator este complet ascunsă lumii exterioare în spatele dependenței de container. Acest lucru face codul mai greu de citit. Sau de utilizat. Sau de testat! Mocking-ul autentificatorului într-un test unitar necesită acum să creați un întreg container în jurul lui.

Și apropo, controllerul încă depinde de definiția containerului, și într-un mod destul de rău. Dacă serviciul autentificator nu există în container, codul va eșua abia în metoda action(), ceea ce este un feedback destul de târziu.

Gătirea a ceva gustos

Pentru a fi corecți, nimeni nu vă poate învinovăți că ați ajuns în această fundătură. La urma urmei, ați urmat doar interfața proiectată și dovedită de dezvoltatori inteligenți. Ideea este că toate containerele de injecție de dependențe sunt, prin definiție, și localizatoare de servicii și se dovedește că modelul este într-adevăr singura interfață comună între ele. Dar asta nu înseamnă că ar trebui să le utilizați ca localizatoare de servicii. De fapt, avertizează împotriva acestui lucru chiar specificația PSR.

Iată cum puteți utiliza containerul DI ca un serviciu bun:

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

În constructor, dependența este declarată explicit, clar și transparent. Dependențele nu mai sunt ascunse împrăștiate prin clasă. Ele sunt, de asemenea, impuse: containerul nu este capabil să creeze o instanță a SignInController fără a furniza Authenticator-ul necesar. Dacă nu există niciun autentificator în container, execuția va eșua prematur, nu în metoda action(). Testarea acestei clase a devenit, de asemenea, mult mai ușoară, deoarece trebuie doar să simulați (mock) serviciul autentificator fără niciun boiler-plate de container.

Și încă un detaliu mic, dar foarte important: am strecurat informația despre tipul serviciului. Faptul că este o instanță a Authenticator – anterior implicit și necunoscut IDE-ului, instrumentelor de analiză statică sau chiar dezvoltatorului necunoscător al definiției containerului – este acum gravat static în type hint-ul parametrului promovat.

Singurul pas rămas este să învățăm containerul cum să creeze și controllerul:

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

Poate ați observat că containerul încă utilizează intern abordarea de localizator de servicii. Acest lucru nu contează, însă, atâta timp cât este conținut (joc de cuvinte intenționat). Singurul loc în afara containerului unde apelul metodei get este permis este la index.php, în punctul de intrare al aplicației, unde trebuie creat containerul însuși și apoi încărcată și rulată aplicația:

$container = bootstrap();

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

Bijuteria ascunsă

Dar să nu ne oprim aici, permiteți-mi să duc această afirmație mai departe: singurul loc unde apelul metodei get este permis este punctul de intrare.

Codul containerului este doar cablare, sunt instrucțiuni de asamblare. Nu este cod executabil. Într-un fel, nu este important. Chiar dacă da, este crucial pentru aplicație, dar numai din perspectiva dezvoltatorului. Utilizatorului, de fapt, nu îi aduce nicio valoare directă și ar trebui tratat având în vedere acest lucru.

Priviți din nou containerul:

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

Acest lucru se referă doar la un segment foarte mic și simplu al aplicației. Pe măsură ce aplicația crește, scrierea manuală a containerului devine incredibil de obositoare. Așa cum am spus deja, containerul este doar un manual de asamblare – dar este prea complicat, are multe pagini, nenumărate referințe încrucișate și o mulțime de avertismente scrise cu litere mici. Vrem să-l transformăm într-un manual în stil IKEA, grafic, concis și cu ilustrații ale oamenilor care zâmbesc în timp ce așază ÅUTHENTICATÖR pe covor în timpul asamblării, pentru a nu se sparge.

Aici intervine Nette Framework.

Soluția DI a Nette Framework utilizează Neon, un format de fișier de configurare similar cu YAML, dar pe steroizi. Iată cum ați defini același container folosind configurația Neon:

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

Permiteți-mi să subliniez două lucruri remarcabile: în primul rând, lista de servicii este într-adevăr o listă, nu o hartă hash – nu există chei, nici identificatori artificiali de servicii. Nu există niciun authenticator, nici Authenticator::class. În al doilea rând, nu trebuie să specificați explicit nicio dependență nicăieri, cu excepția parametrilor conexiunii la baza de date.

Acest lucru se datorează faptului că Nette Framework se bazează pe cablare automată (autowiring). Vă amintiți cum, datorită injecției de dependențe, am putut exprima tipul dependenței cu un typehint nativ? Containerul DI utilizează această informație, așa că atunci când solicitați o instanță a Authenticator, ocolește complet orice nume și găsește serviciul corect exclusiv după tipul său.

Ați putea obiecta că autowiring-ul nu este o caracteristică unică. Și ați avea dreptate. Ceea ce face containerul Nette Framework unic este utilizarea sistemului de tipuri 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 rămân în urmă. Iată cum ați defini serviciul autentificator în containerul Symfony DI folosind limbajul YAML:

services:
  Authenticator: ~

În secțiunea services este o hartă hash, iar bitul Authenticator este identificatorul serviciului. Tilda înseamnă null în YAML, ceea ce Symfony interpretează ca “utilizează identificatorul serviciului ca tipul său”.

Curând, însă, cerințele de afaceri se vor schimba și aveți nevoie să suportați și autentificarea prin LDAP, pe lângă căutarea locală în baza de date. În primul pas, schimbați clasa Authenticator într-o interfață și extrageți implementarea originală în clasa LocalAuthenticator:

services:
  LocalAuthenticator: ~

Brusc, Symfony este derutat. Acest lucru se datorează faptului că Symfony lucrează cu nume de servicii în loc de tipuri. Controllerul se bazează încă corect pe abstracție și specifică interfața Authenticator ca dependență, dar în container nu există niciun serviciu cu numele Authenticator. Trebuie să-i dați lui Symfony un indiciu, de exemplu, folosind un alias de nume de serviciu:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Nette Framework, pe de altă parte, nu are nevoie de nume de servicii sau de indicii. Nu vă obligă să duplicați în configurație informații care sunt deja exprimate în cod (prin clauza implements). Este plasat direct deasupra sistemului de tipuri PHP. Știe că LocalAuthenticator este de tip Authenticator, și dacă este singurul serviciu care implementează această interfață, îl va conecta automat cu bucurie acolo unde este necesară această interfață, și asta doar pe baza acestei linii de configurație:

services:
    - LocalAuthenticator

Recunosc că, dacă nu sunteți familiarizați cu autowiring-ul, s-ar putea să vi se pară puțin magic și poate veți avea nevoie de ceva timp pentru a învăța să aveți încredere în el. Din fericire, funcționează transparent și determinist: atunci când containerul nu poate rezolva în mod unic dependențele, aruncă o excepție la compilare, care vă ajută să remediați situația. În acest fel, puteți avea două implementări diferite și totuși să aveți un control bun asupra locului în care se utilizează fiecare dintre ele.

În general, ca dezvoltator, cablarea automată impune o sarcină cognitivă mai mică. La urma urmei, vă pasă doar de tipuri și abstracții, așa că de ce ar trebui containerul DI să vă oblige să vă pese și de implementări și identificatori de servicii? Și, mai important, de ce ar trebui să vă pese deloc de vreun container? În spiritul injecției de dependențe, doriți să aveți posibilitatea de a declara pur și simplu dependențele și să fie problema altcuiva să le furnizeze. Doriți să vă concentrați pe deplin asupra codului aplicației și să uitați de cablare. Și asta vă permite DI-ul Nette Framework.

În ochii mei, acest lucru face ca soluția DI de la Nette Framework să fie cea mai bună care există în lumea PHP. Vă oferă un container care este fiabil și impune modele arhitecturale bune, dar în același timp este atât de ușor de configurat și întreținut încât nu trebuie să vă gândiți deloc la el.

Sper că această postare a reușit să vă stârnească curiozitatea. Nu uitați să aruncați o privire la repository-ul Github și la documentație – sper că veți descoperi că v-am arătat doar vârful aisbergului și că întregul pachet este mult mai puternic.