Storitve ne potrebujejo imen
Všeč mi je rešitev Nette Framework za dependency injection. Resnično jo obožujem. Ta članek je tu zato, da delim to strast in pojasnim, zakaj mislim, da je to najboljša rešitev DI v trenutnem ekosistemu PHP.

(Ta prispevek je bil prvotno objavljen na avtorjevem blogu.)
Razvoj programske opreme je neskončen iterativni proces abstrakcije. Ustrezne abstrakcije realnega sveta najdemo v domenskem modeliranju. V objektno usmerjenem programiranju uporabljamo abstrakcije k opisu in uveljavljanju pogodb med različnimi akterji v sistemu. V sistem uvajamo nove razrede, da zapremo odgovornosti in definiramo njihove meje, in potem s pomočjo kompozicije ustvarimo celoten sistem.
Govorim o potrebi ekstrahirati avtentikacijsko logiko iz naslednjega kontrolerja:
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);
}
}
Verjetno prepoznate, da preverjanje poverilnic tja ne spada. Kontroler nima odgovornosti za to, da bi določal, katere poverilnice so veljavne – po principu ene same odgovornosti bi moral imeti kontroler le en razlog za spremembo, in ta razlog bi moral biti v okviru uporabniškega vmesnika aplikacije, ne pa v procesu avtentikacije.
Vzemimo si iz tega očitno pot in ekstrahirajmo pogoj v razred
Authenticator
:
final class Authenticator
{
public function authenticate(string $username, string $password): bool
{
return $username === 'admin' && $password === 'p4ssw0rd!';
}
}
Zdaj zadostuje, če delegiramo iz kontrolerja na ta avtentikator. Ustvarili smo avtentikator odvisnost na kontrolerju in kontroler ga nenadoma potrebuje nekje pridobiti:
final class SignInController extends Best\Framework\Controller
{
public function action(string $username, string $password): Best\Framework\Response
{
$authenticator = new Authenticator(); // <== enostavno, preprosto ustvarim nov!
if ( ! $authenticator->authenticate($username, $password)) {
return $this->render(__DIR__ . '/error.latte');
}
$this->signIn(new Identity($username));
return $this->redirect(HomepageController::class);
}
}
Ta naiven način bo deloval. Ampak le do takrat, ko bo implementirana
robustnejša avtentikacija, ki bo zahtevala, da se avtentikator poizveduje po
podatkovni tabeli uporabnikov. Nenadoma ima Authenticator
lastno
odvisnost, recimo UserRepository
, ki spet odvisna od instance
Connection
, ki je odvisna od parametrov konkretnega okolja. To se
je hitro stopnjevalo!
Ustvarjati instance povsod ročno ni vzdržen način upravljanja odvisnosti.
Zato imamo vzorec dependency injection, ki omogoča kontrolerju le deklarirati
odvisnost na Authenticator
, in pustiti nekomu drugemu, da instanco
dejansko zagotovi. In ta nekdo drug se imenuje dependeny injection
container.
Dependency injection container je vrhovni arhitekt aplikacije – zna reševati odvisnosti katerekoli storitve v sistemu in je odgovoren za njihovo ustvarjanje. DI vsebniki so danes tako običajni, da skoraj vsak večji spletni framework ima lastno implementacijo vsebnika, in celo obstajajo samostojni paketi posvečeni vbrizgavanju odvisnosti, na primer PHP-DI.
Žganje popra
Količina možnosti je na koncu motivirala skupino razvijalcev k iskanju abstrakcije, ki bi jih naredila interoperabilnimi. Skupni vmesnik je bil sčasoma izpiljen in na koncu predlagan za PHP-FIG v naslednji obliki:
interface ContainerInterface
{
public function get(string $id): mixed;
public function has(string $id): bool;
}
Ta vmesnik ilustrira eno zelo pomembno lastnost DI vsebnikov: **So dober sluga, ampak zlahka postanejo slab gospodar. So izjemno uporabni, če veste, kako jih uporabljati, ampak če jih uporabljate napačno, vas bodo opekli. Vzemimo naslednji vsebnik:
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 { /* . . . */ }
}
Zaenkrat je dobro. Implementacija se zdi dobra po standardih, ki smo si jih zastavili: dejansko zna ustvariti vsako storitev v aplikaciji in rekurzivno rešuje njene odvisnosti. Vse je upravljano na enem mestu in vsebnik celo sprejema parametre, tako da je povezava z bazo podatkov enostavno konfigurabilna. Lepo!
Ampak ko zdaj vidite le dve metodi ContainerInterface
, vas morda
mika, da bi vsebnik uporabljali tako:
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');
//...
}
}
Čestitam, pravkar ste si opekli poper. Z drugimi besedami, vsebnik je postal zli gospodar. Zakaj je tako?
Prvič, zanašate se na poljuben identifikator storitve:
'authenticator'
. Vbrizgavanje odvisnosti je o tem, da je treba
biti transparenten glede svojih odvisnosti, in uporaba umetnega identifikatorja
gre neposredno proti temu pojmu: povzroča, da je koda tiho odvisna od
definicije vsebnika. Če kdaj pride do preimenovanja storitve v vsebniku,
morate ta sklic najti in posodobiti.
In kar je še huje, ta odvisnost je skrita: na prvi pogled od zunaj kontroler
odvisen le od abstrakcije vsebnika. Ampak kot razvijalec vi morate imeti
znanje o tem, kako se storitve v vsebniku imenujejo in da je storitev
z imenom authenticator
dejansko instanca
Authenticator
. Vse to se mora naučiti vaš novi kolega.
Nepotrebno.
Na srečo se lahko zatečemo k veliko bolj naravnemu identifikatorju: tipu storitve. To je konec koncev edino, kar vas kot razvijalca zanima. Ne potrebujete vedeti, kateri naključni niz je dodeljen k storitvi v vsebniku. Verjamem, da je ta koda veliko enostavnejša za pisanje in branje:
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);
//...
}
}
Na žalost še nismo ukrotili plamenov. Niti malo. Večji problem je, da ponižno postavljate vsebnik v vlogo iskalca storitev, kar je ogromen anti-vzorec. Je kot prinesti nekomu cel hladilnik, da si iz njega lahko vzame eno malico – veliko bolj razumno je prinesti mu le tisto malico.
Spet velja, da dependency injection je o transparentnosti, in ta kontroler še vedno ni transparenten glede svojih odvisnosti. Odvisnost na avtentikatorju je pred okoliškim svetom popolnoma skrita za odvisnostjo na vsebniku. S tem postane koda težje berljiva. Ali uporabljiva. Ali testirana! Mocking avtentikatorja v unit testu zdaj zahteva, da okoli njega ustvarite celoten vsebnik.
In mimogrede, kontroler še vedno odvisen od definicije vsebnika, in to na
precej slab način. Če storitev avtentikator v vsebniku ne obstaja, koda
odpove šele v metodi action()
, kar je precej pozna povratna
informacija.
Kuhanje nečesa okusnega
Da bi bili pravični, vam nihče ne more očitati, da ste prišli v to slepo ulico. Konec koncev ste le sledili vmesniku zasnovanemu in preizkušenemu s strani pametnih razvijalcev. Gre za to, da vsi vsebniki za vbrizgavanje odvisnosti so po definiciji tudi lokatorji storitev in izkaže se, da je vzorec med njimi dejansko edini skupni vmesnik. To pa ne pomeni, da bi jih morali uporabljati kot lokatorje storitev. Pravzaprav pred tem svaruje sam predpis PSR.
Tako lahko DI vsebnik uporabite kot dobro storitev:
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);
//...
}
}
V konstruktorju je odvisnost deklarirana eksplicitno, jasno in
transparentno. Odvisnosti niso več skrite raztresene po razredu. So tudi
vsiljene: vsebnik ni sposoben ustvariti instance SignInController
,
ne da bi zagotovil potreben Authenticator
. Če v vsebniku ni
nobenega avtentikatorja, izvedba odpove prezgodaj, ne pa v metodi
action()
. Testiranje te klase je prav tako postalo veliko
enostavnejše, saj zadostuje le zmockati storitev avtentikatorja brez
kakršnegakoli kotla vsebnika.
In še ena drobna, a zelo pomembna podrobnost: pretihotapili smo vanj
informacijo o tipu storitve. Dejstvo, da gre za instanco
Authenticator
– prej implicitno in neznano IDE, orodjem
statične analize ali celo razvijalcu neznanemu definiciji vsebnika – je zdaj
statično vgravirano v tipsko pomoč promoviranega parametra.
Edini korak, ki ostane, je naučiti vsebnik, kako ustvariti tudi kontroler:
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 { /* . . . */ }
}
Morda ste opazili, da vsebnik še vedno interno uporablja pristop iskalca
storitev. To pa ne moti, če je vsebovan (besedna igra). Edino mesto izven
vsebnika, kjer je klic metode get
dopusten, je na naslovu
index.php
, v vstopni točki aplikacije, kjer je treba ustvariti
sam vsebnik in potem naložiti in zagnati aplikacijo:
$container = bootstrap();
$application = $container->get(Best\Framework\Application::class);
$application->run();
Skriti dragulj
Ampak ne ostanimo pri tem, dovolite mi, da to trditev potisnem naprej:
edino mesto, kjer je klic metode get
dopusten, je
vstopna točka.
Koda vsebnika je le ožičenje, so navodila za sestavljanje. Ni izvajalna koda. Na nek način ni pomembna. Čeprav da, je za aplikacijo ključna, ampak le z vidika razvijalca. Uporabniku dejansko ne prinaša nobene neposredne vrednosti in bi se moralo z njo ravnati z upoštevanjem te dejanskosti.
Poglejte ponovno vsebnik:
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 { /* . . . */ }
}
To se nanaša le na zelo majhen in preprost segment aplikacije. Ko se aplikacija razrašča, ročno pisanje vsebnika postane neverjetno utrujajoče. Kot sem že rekel, vsebnik je le montažni priročnik – ampak je preveč zapleten, ima veliko strani, nešteto križnih referenc in veliko opozoril napisanih z malim tiskom. Želimo ga narediti za priročnik v stilu IKEA, grafičen, jedrnat in z ilustracijami ljudi, ki se nasmihajo, ko pri montaži polagajo ÅUTHENTICATÖR na preprogo, da se ne razbije.
Tukaj pride na vrsto Nette Framework.
Rešitev DI Nette Framework uporablja NEON, format konfiguracijskih datotek podoben YAML, ampak na steroidih. Tako bi definirali isti vsebnik s pomočjo konfiguracije NEON:
services:
- SignInController
- Authenticator
- UserRepository
- Connection(%database%)
Dovolite mi opozoriti na dve opazni stvari: prvič, seznam storitev je
dejansko seznam, ne hash mapa – ni ključev, ni umetnih identifikatorjev
storitev. Ni authenticator
, niti Authenticator::class
.
Drugič, nikjer vam ni treba eksplicitno navajati nobenih odvisnosti, razen
parametrov povezave z bazo podatkov.
To zato, ker Nette Framework temelji na samodejnem ožičenju. Se spomnite,
kako smo zahvaljujoč dependency injection lahko izrazili tip odvisnosti
z nativnim typehintom? DI vsebnik to informacijo izkoristi, tako da ko
zahtevate instanco Authenticator
, popolnoma obejde kakršnakoli
imena in najde pravo storitev izključno po njenem tipu.
Lahko ugovarjate, da autowiring ni edinstvena lastnost. In imeli bi prav. Kar naredi vsebnik Nette Framework edinstven, je izkoriščanje tipskega sistema PHP, medtem ko v mnogih drugih frameworkih je autowiring še vedno interno zgrajen na imenih storitev. Obstajajo scenariji, v katerih drugi vsebniki zaostajajo. Tako bi definirali storitev avtentikatorja v vsebniku Symfony DI s pomočjo jezika YAML:
services:
Authenticator: ~
V odseku services
je hash mapa in bit
Authenticator
je identifikator storitve. Tilda pomeni v YAML
null
, kar Symfony interpretira kot “uporabi identifikator
storitve kot njen tip”.
Kmalu pa se bodo poslovne zahteve spremenile in potrebujete poleg lokalnega
iskanja v bazi podatkov podpirati tudi avtentikacijo prek LDAP. V prvem koraku
spremenite razred Authenticator
na vmesnik in prvotno
implementacijo ekstrahirate v razred LocalAuthenticator
:
services:
LocalAuthenticator: ~
Nenadoma je Symfony brezraden. To zato, ker Symfony dela z imeni storitev
namesto s tipi. Kontroler se še vedno pravilno zanaša na abstrakcijo in
navaja vmesnik Authenticator
kot svojo odvisnost, ampak v vsebniku
ni nobene storitve z imenom Authenticator
. Morate dati
Symfony namig, na primer s pomočjo aliasa imena storitve:
services:
LocalAuthenticator: ~
Authenticator: '@LocalAuthenticator'
Nette Framework nasprotno imena storitev niti namigov ne potrebuje. Ne sili
vas, da v konfiguraciji podvajate informacije, ki so že izražene v kodi
(prek klavzule implements
). Je postavljen neposredno nad tipski
sistem PHP. Ve, da LocalAuthenticator
je tipa
Authenticator
, in če je to edina storitev, ki ta vmesnik
implementira, jo z veseljem samodejno priključi tam, kjer je ta vmesnik
zahtevan, in to samo na podlagi te vrstice konfiguracije:
services:
- LocalAuthenticator
Priznam, da če autowiringa ne poznate, vam lahko deluje nekoliko magično in morda boste potrebovali nekaj časa, da se mu naučite zaupati. Na srečo deluje transparentno in deterministično: ko vsebnik ne more enoznačno rešiti odvisnosti, vrže pri prevajanju izjemo, ki vam pomaga situacijo popraviti. Na ta način lahko imate dve različni implementaciji in še vedno imate dober nadzor nad tem, kje se katera izmed njih uporablja.
Na splošno na vas kot razvijalca samodejno ožičenje nalaga manjšo kognitivno obremenitev. Konec koncev skrbite le za tipe in abstrakcije, zakaj bi vas torej DI vsebnik silil skrbeti tudi za implementacije in identifikatorje storitev? In kar je pomembnejše, zakaj bi se sploh morali ukvarjati s kakšnim vsebnikom? V duhu dependency injection želite imeti možnost preprosto deklarirati odvisnosti in biti problem nekoga drugega, ki jih zagotovi. Želite se popolnoma osredotočiti na kodo aplikacije in pozabiti na ožičenje. In to vam omogoča DI Nette Framework.
V mojih očeh je zaradi tega rešitev DI od Nette Framework najboljša, ki v svetu PHP obstaja. Zagotavlja vam vsebnik, ki je zanesljiv in uveljavlja dobre arhitekturne vzorce, ampak hkrati se tako enostavno konfigurira in vzdržuje, da o njem ni treba sploh razmišljati.
Upam, da je temu prispevku uspelo spodbuditi vašo radovednost. Ne pozabite pogledati na Github repozitorij in v dokumentacijo – upam, da boste ugotovili, da sem vam pokazal le vrh ledene gore in da je celoten paket veliko močnejši.
Če želite oddati komentar, se prijavite