Storitve ne potrebujejo imen

pred 4 leti od Jiří Pudil  

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.