Serviciile nu au nevoie de nume
Î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.
Pentru a trimite un comentariu, vă rugăm să vă conectați