Storitve ne potrebujejo imen

pred 3 leti od Jiří Pudil  

Všeč mi je rešitev Nette Framework za vbrizgavanje odvisnosti. Resnično. V tem prispevku želim deliti to strast in pojasniti, zakaj menim, da je to najboljša rešitev DI v današnjem ekosistemu PHP.

(Ta prispevek je bil prvotno objavljen na avtorjevem blogu.)

Razvoj programske opreme je neskončen iterativni proces abstrakcije. Ustrezne abstrakcije resničnega sveta najdemo pri modeliranju domen. Pri objektno usmerjenem programiranju uporabljamo abstrakcije za opisovanje in uveljavljanje pogodb med različnimi akterji v sistemu. V sistem vpeljemo nove razrede, da bi zapakirali odgovornosti in določili njihove meje, nato pa s kompozicijo zgradimo celoten sistem.

Govorim o želji, da bi iz naslednjega kontrolnika izvlekli logiko avtentikacije:

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 lahko ugotovite, da preverjanje poverilnic ne sodi tja. Kontroler ni odgovoren za to, da pove, katere poverilnice so veljavne – v skladu z načelom ene odgovornosti bi moral imeti kontroler le en razlog za spremembo in ta razlog bi moral biti v uporabniškem vmesniku aplikacije, ne pa v postopku avtentikacije.

Izberimo očiten izhod iz tega in izvlecimo pogoj v razred Authenticator:

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

Vse, kar moramo storiti, je, da iz kontrolerja prenesemo pooblastilo na ta avtentifikator. Avtentifikator smo naredili tako, da je v odvisnost kontrolerja in nenadoma ga mora kontroler nekje dobiti:

final class SignInController extends Best\Framework\Controller
{
    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = new Authenticator(); // <== enostavno, samo ustvaril bom novo!
        if ( ! $authenticator->authenticate($username, $password)) {
            return $this->render(__DIR__ . '/error.latte');
        }

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

Ta naivni način bo deloval. Vendar le, dokler ne bo implementirana bolj robustna avtentikacija, ki bo od avtentikarja zahtevala, da poizveduje po tabeli podatkovne zbirke z uporabniki. Authenticator ima nenadoma svojo lastno odvisnost, recimo UserRepository, ki je po drugi strani odvisna od primerka Connection, ki je odvisen od parametrov določenega okolja. To se je hitro stopnjevalo!

Ročno ustvarjanje instanc povsod ni trajnostni način upravljanja odvisnosti. Zato imamo vzorec vbrizgavanja odvisnosti, ki upravljavcu omogoča, da zgolj deklarira svojo odvisnost od Authenticator, dejanska zagotovitev primerka pa naj bo težava nekoga drugega. Ta nekdo drug se imenuje kontainer za vbrizgavanje odvisnosti.

Kontejner za vbrizgavanje odvisnosti je vrhovni arhitekt aplikacije – ve, kako razrešiti odvisnosti katere koli storitve v sistemu, in je odgovoren za njihovo ustvarjanje. Kontejnerji za vbrizgavanje odvisnosti so danes tako pogosti, da ima skoraj vsako večje spletno ogrodje svojo implementacijo kontejnerja, obstajajo pa celo samostojni paketi, namenjeni vbrizgavanju odvisnosti, kot je PHP-DI.

Burning the peppers

Obilica možnosti je sčasoma spodbudila skupino razvijalcev, da so poiskali abstrakcijo, ki bi omogočila njihovo interoperabilnost. Skupni vmesnik je bil sčasoma izpopolnjen in nazadnje predlagan PHP-FIG v naslednji obliki:

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

Ta vmesnik ponazarja eno zelo pomembno lastnost vsebnikov DI: So kot ogenj. So dober služabnik, vendar lahko zlahka postanejo slab gospodar. Dokler jih znate uporabljati, so izjemno uporabni, če jih uporabljate nepravilno, pa vas lahko zažgejo. Oglejte si naslednjo posodo:

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

Do zdaj je bilo vse v redu. Izvedba se zdi dobra glede na standard, ki smo ga določili: dejansko zna ustvariti vsako storitev v aplikaciji in rekurzivno rešuje njene odvisnosti. Vse se upravlja na enem mestu, vsebnik pa sprejema celo parametre, tako da je povezavo s podatkovno bazo mogoče preprosto konfigurirati. Lepo!

Toda zdaj, ko vidite samo dve metodi ContainerInterface, vas bo morda zamikalo, da bi vsebnik uporabili takole:

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

Čestitamo, pravkar ste si zažgali papriko. Z drugimi besedami, vsebnik je postal slab gospodar. Zakaj je tako?

Prvič, zanašate se na poljuben identifikator storitve: 'authenticator'. Pri vbrizgavanju odvisnosti gre za transparentnost glede odvisnosti, uporaba umetnega identifikatorja pa je naravnost v nasprotju s tem pojmom: zaradi nje je koda tiho odvisna od definicije vsebnika. Če se zgodi, da storitev v vsebniku preimenujete, morate poiskati to referenco in jo posodobiti.

In kar je še huje, ta odvisnost je skrita: na prvi pogled od zunaj je krmilnik odvisen le od abstrakcije vsebnika. Toda kot razvijalec morate ti vedeti, kako so storitve v vsebniku poimenovane in da je storitev z imenom authenticator v resnici primerek storitve Authenticator. Vaš novi sodelavec se mora vsega tega naučiti. Po nepotrebnem.

Na srečo se lahko zatečemo k veliko bolj naravnemu identifikatorju: vrsti storitve. Navsezadnje je to vse, kar vas kot razvijalca zanima. Ni vam treba vedeti, kateri naključni niz je povezan s storitvijo v vsebniku. Menim, da je to kodo veliko lažje pisati in brati:

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čja težava je, da posodo ponižujete v vlogo iskalnika storitev, kar je zelo slab vzorec. To je tako, kot da bi nekomu prinesli celoten hladilnik, da bi lahko iz njega vzel en sam prigrizek – veliko bolj smiselno bi bilo, če bi mu prinesli samo prigrizek.

Ponovno poudarjamo, da gre pri vbrizgavanju odvisnosti za preglednost, ta krmilnik pa še vedno ni pregleden glede svojih odvisnosti. Odvisnost od overitelja je popolnoma skrita pred zunanjim svetom za odvisnostjo od vsebnika. Zaradi tega je kodo težje brati. Ali pa uporabite. Ali testiranje! Pri preizkušanju enote je zdaj treba ustvariti celoten vsebnik okoli avtentifikatorja.

In mimogrede, krmilnik je še vedno odvisen od definicije vsebnika, in to na precej slab način. Če storitev authenticator ne obstaja v vsebniku, koda odpove šele v metodi action(), kar je precej pozna povratna informacija.

Kuhanje nečesa okusnega

Če smo pošteni, vam nihče ne more očitati, da ste se znašli v tej slepi ulici. Navsezadnje ste le sledili vmesniku, ki so ga zasnovali in preverili pametni razvijalci. Stvar je v tem, da so vsi vsebniki za vbrizgavanje odvisnosti po definiciji tudi iskalniki storitev, in izkazalo se je, da je vzorec v resnici edini skupni vmesnik med njimi. Vendar to ne pomeni, da jih morate uporabljati kot lokatorje storitev. Pravzaprav sam PSR opozarja na to.

Tako lahko zabojnik DI uporabite kot dober služabnik:

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 izrecno, jasno in pregledno razglašena. Odvisnosti niso več skrite in razpršene po razredu. Prav tako so uveljavljene: vsebnik ne more ustvariti primerka SignInController, ne da bi zagotovil potrebne Authenticator. Če v vsebniku ni avtentifikatorja, se izvajanje ne izvede predčasno, ne v metodi action(). Tudi testiranje tega razreda je postalo veliko lažje, saj je treba samo zasmehovati storitev avtentifikatorja brez kakršne koli kotlovnice vsebnika.

In še zadnja drobna, a zelo pomembna podrobnost: vnesli smo informacijo o tipu storitve. Dejstvo, da gre za primerek Authenticator – ki je bilo prej implicitno in ga IDE, orodja za statično analizo ali celo razvijalec, ki ni poznal definicije vsebnika, niso poznali – je zdaj statično vrezano v napotek tipa promoviranega parametra.

Edini korak, ki je ostal, je, da vsebnik naučimo, kako naj ustvari tudi krmilnik:

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

Opazili boste, da vsebnik še vedno interno uporablja pristop s pomočjo iskalnika storitev. Toda to je v redu, dokler je vsebnik vsebovan (besedna igra). Edino mesto zunaj vsebnika, kjer je klicanje metode get sprejemljivo, je v index.php, v vstopni točki aplikacije, kjer je treba ustvariti sam vsebnik in nato pobrati in zagnati aplikacijo:

$container = bootstrap();

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

Skriti dragulj

A ne ustavimo se pri tem, dovolite mi, da to trditev razširim: edino* mesto, kjer je klic metode get sprejemljiv, je vstopna točka.

Koda vsebnika je samo ožičenje, to so navodila za sestavljanje. To ni izvršilna koda. Na nek način ni pomembna. Čeprav je, da, ključna za aplikacijo, je to le z vidika razvijalca. Uporabniku v resnici ne prinaša nobene neposredne vrednosti, zato jo je treba obravnavati z mislijo na to.

Ponovno si oglejte 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 zajema le zelo majhen in preprost del aplikacije. Ko aplikacija raste, postane ročno pisanje vsebnika zelo naporno. Kot sem že dejal, je vsebnik le priročnik za sestavljanje – vendar je preveč zapleten, ima veliko strani, nešteto navzkrižnih sklicev in veliko opozoril z drobnim tiskom. Želimo ga spremeniti v priročnik v slogu IKEA, grafičen, jedrnat in z ilustracijami nasmejanih ljudi, ko med sestavljanjem položijo ÅUTHENTICATÖR na preprogo, da se ne zlomi.

Tu pride na vrsto ogrodje Nette.

Rešitev DI podjetja Nette Framework uporablja Neon, format konfiguracijske datoteke, podoben formatu YAML, vendar na steroidih. Tako bi definirali isti vsebnik z uporabo konfiguracije Neon:

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

Naj poudarim dve pomembni stvari: prvič, seznam storitev je resnično seznam in ne hash map – ni ključev, ni umetnih identifikatorjev storitev. Ni spletne strani authenticator, prav tako ni spletne strani Authenticator::class. Drugič, nikjer vam ni treba izrecno navesti nobenih odvisnosti, razen parametrov povezave s podatkovno bazo.

To je zato, ker se ogrodje Nette Framework zanaša na samodejno povezovanje. Se spomnite, kako smo lahko zaradi vbrizgavanja odvisnosti vrsto odvisnosti izrazili z nativnim tipskim namigom? Posoda DI uporablja te informacije, tako da ko zahtevate primerek Authenticator, v celoti zaobide vsa imena in poišče pravo storitev izključno po njeni vrsti.

Lahko bi rekli, da samodejno povezovanje ni edinstvena lastnost. In imeli bi prav. Edinstvenost vsebnika ogrodja Nette je v tem, da uporablja sistem tipov PHP, medtem ko je v številnih drugih ogrodjih samodejno vnašanje še vedno zgrajeno na podlagi notranjih imen storitev. Obstajajo scenariji, pri katerih so drugi vsebniki pomanjkljivi. Tako bi v vsebniku DI ogrodja Symfony z uporabo zapisa YAML opredelili storitev avtentifikatorja:

services:
  Authenticator: ~

Del services je hash map, bit Authenticator pa je identifikator storitve. Tilda pomeni null v jeziku YAML, kar Symfony razume kot “uporabi identifikator storitve kot njen tip”.

Toda kmalu se poslovne zahteve spremenijo in poleg lokalnega iskanja v podatkovni zbirki morate podpirati tudi avtentikacijo prek LDAP. V prvem koraku spremenite razred Authenticator v vmesnik in izvirno implementacijo izvlečete v razred LocalAuthenticator:

services:
  LocalAuthenticator: ~

Nenadoma je Symfony brez idej. To je zato, ker Symfony dela z imeni storitev namesto z vrstami. Kontroler se še vedno pravilno zanaša na abstrakcijo in kot svojo odvisnost navaja vmesnik Authenticator, vendar v vsebniku ni storitve z imenom Authenticator. Symfonyju morate dati namig, na primer z uporabo vzdevka imenom storitve:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Po drugi strani pa ogrodje Nette ne potrebuje imen storitev ali namigov. Ne sili vas, da v konfiguraciji podvajate informacije, ki so že izražene v kodi (prek stavka implements ). Nameščen je na vrhu sistema tipov PHP. Ve, da LocalAuthenticator je tipa Authenticator, in dokler je edina storitev, ki implementira vmesnik, ga z veseljem samodejno poveže, kjer je vmesnik zahtevan, če je navedena samo ta vrstica konfiguracije:

services:
    - LocalAuthenticator

Priznavam, da se vam bo, če ne poznate samodejnega povezovanja, morda zdelo nekoliko čarobno in boste potrebovali nekaj časa, da se mu naučite zaupati. Na srečo deluje pregledno in deterministično: kadar vsebnik ne more nedvoumno razrešiti odvisnosti, vrže izjemo v času sestavljanja, ki vam pomaga popraviti situacijo. Na ta način imate lahko dve različni izvedbi in še vedno dobro nadzorujete, kje se katera od njih uporablja.

Samodejno povezovanje na splošno pomeni manjšo kognitivno obremenitev za vas kot razvijalca. Navsezadnje vas skrbijo le vrste in abstrakcije, zakaj bi vas torej vsebnik DI prisilil, da skrbite tudi za izvedbe in identifikatorje storitev? Še pomembneje pa je, zakaj bi vas sploh moralo skrbeti za nek vsebnik? V duhu vbrizgavanja odvisnosti si želite, da bi lahko samo razglasili odvisnosti in da bi bil problem nekoga drugega, da jih zagotovi. V celoti se želite osredotočiti na kodo aplikacije in pozabiti na ožičenje. To vam omogoča DI ogrodja Nette.

Zaradi tega je rešitev DI podjetja Nette Framework po mojem mnenju najboljša rešitev v svetu PHP. Zagotavlja vam vsebnik, ki je zanesljiv in uveljavlja dobre arhitekturne vzorce, hkrati pa ga je tako preprosto konfigurirati in vzdrževati, da vam o njem sploh ni treba razmišljati.

Upam, da je ta prispevek spodbudil vašo radovednost. Obvezno si oglejte Github skladišče in dokumentacijo – upam, da boste ugotovili, da sem vam pokazal le vrh ledene gore in da je celoten paket veliko zmogljivejši.