Usługi nie potrzebują nazw

3 lata temu Ze strony Jiří Pudil  

Uwielbiam rozwiązanie Nette Framework polegające na wstrzykiwaniu zależności. Naprawdę. Ten post jest tutaj, aby podzielić się tą pasją, wyjaśniając, dlaczego uważam, że jest to najlepsze rozwiązanie DI w dzisiejszym ekosystemie PHP.

(Ten post został pierwotnie opublikowany na blogu autora.)

Rozwój oprogramowania to niekończący się iteracyjny proces abstrakcji. Znajdujemy odpowiednie abstrakcje świata rzeczywistego w modelowaniu domeny. W programowaniu obiektowym używamy abstrakcji do opisywania i egzekwowania kontraktów między różnymi aktorami w systemie. Wprowadzamy do systemu nowe klasy, aby hermetyzować obowiązki i określać ich granice, a następnie używamy kompozycji, aby zbudować cały system.

Mówię o chęci wyodrębnienia logiki uwierzytelniania z następującego kontrolera:

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

Prawdopodobnie możesz powiedzieć, że sprawdzanie poświadczeń nie należy tam. Nie jest obowiązkiem kontrolera powiedzieć, jakie poświadczenia są ważne – zgodnie z zasadą pojedynczej odpowiedzialności, kontroler powinien mieć tylko jeden powód do zmiany, a ten powód powinien znajdować się w obrębie interfejsu użytkownika aplikacji, a nie procesu uwierzytelniania.

Zróbmy oczywiste wyjście z tego i wyodrębnijmy warunek do klasy Authenticator:

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

Teraz wystarczy, że delegujemy z kontrolera do tego authenticatora. Sprawiliśmy, że authenticator zależnym od kontrolera, i nagle kontroler musi go gdzieś zdobyć:

final class SignInController extends Best\Framework\Controller
{
    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = new Authenticator(); // <= łatwe, po prostu stworzę nowy!
        if ( ! $authenticator->authenticate($username, $password)) {
            return $this->render(__DIR__ . '/error.latte');
        }

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

Ten naiwny sposób będzie działał. Ale tylko do czasu, gdy zostanie zaimplementowane solidniejsze uwierzytelnianie, wymagające od authenticatora zapytania o tabelę bazy danych użytkowników. Authenticator nagle ma własną zależność, powiedzmy UserRepository, która z kolei zależy od instancji Connection, która jest zależna od parametrów konkretnego środowiska. To szybko eskalowało!

Tworzenie instancji ręcznie wszędzie nie jest zrównoważonym sposobem zarządzania zależnościami. Dlatego mamy wzorzec dependency injection, który pozwala kontrolerowi po prostu zadeklarować swoją zależność od Authenticator, i pozwolić, aby to ktoś inny był problemem dostarczenia instancji. Ten ktoś nazywa się dependeny injection container.

Kontener wstrzykiwania zależności jest najwyższym architektem aplikacji – wie, jak rozwiązywać zależności dowolnych usług w systemie i jest odpowiedzialny za ich tworzenie. Kontenery DI są obecnie tak powszechne, że prawie każdy większy framework webowy ma swoją własną implementację kontenera, a nawet istnieją samodzielne pakiety dedykowane do wstrzykiwania zależności, takie jak PHP-DI.

Palenie papryki

Obfitość opcji w końcu zmotywowała grupę programistów do poszukiwania abstrakcji, która uczyniłaby je interoperacyjnymi. Wspólny interfejs był z czasem szlifowany i ostatecznie zaproponowany PHP-FIG w następującej formie:

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

Interfejs ten ilustruje jeden bardzo ważny atrybut kontenerów DI: są jak ogień. Są dobrym sługą, ale łatwo mogą stać się złym panem. Są ogromnie przydatne, o ile wiesz, jak ich używać, ale jeśli używasz ich niepoprawnie, spalają cię. Rozważ następujący pojemnik:

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

So far so good. Implementacja wydaje się dobra według ustalonych przez nas standardów: rzeczywiście wie, jak stworzyć każdą usługę w aplikacji, rekurencyjnie rozwiązując jej zależności. Wszystko jest zarządzane w jednym miejscu, a kontener przyjmuje nawet parametry, dzięki czemu połączenie z bazą danych jest łatwo konfigurowalne. Fajnie!

Ale teraz, widząc jedyne dwie metody ContainerInterface, można pokusić się o użycie kontenera w ten sposób:

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

Gratulacje, właśnie spaliłeś swoją paprykę. Innymi słowy, kontener stał się złym panem. Dlaczego tak jest?

Po pierwsze, polegasz na arbitralnym identyfikatorze usługi: 'authenticator'. We wstrzykiwaniu zależności chodzi o to, aby być przejrzystym w swoich zależnościach, a używanie sztucznego identyfikatora jest wprost sprzeczne z tym pojęciem: sprawia, że kod po cichu zależy od definicji kontenera. Jeśli kiedykolwiek zdarzy ci się zmienić nazwę usługi w kontenerze, musisz znaleźć to odniesienie i zaktualizować je.

A co gorsza, ta zależność jest ukryta: na pierwszy rzut oka z zewnątrz, kontroler zależy tylko od abstrakcji kontenera. Ale jako deweloper, ty musisz mieć wiedzę o tym, jak usługi są nazwane w kontenerze, i że usługa o nazwie authenticator jest w rzeczywistości instancją Authenticator. Twój nowy kolega musi się tego wszystkiego nauczyć. Niepotrzebnie.

Na szczęście możemy uciec się do znacznie bardziej naturalnego identyfikatora: typu usługi. W końcu to wszystko, na czym zależy ci jako programiście. Nie musisz wiedzieć, jaki losowy ciąg jest związany z usługą w kontenerze. Uważam, że ten kod jest znacznie łatwiejszy zarówno do pisania, jak i czytania:

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

Niestety, nie oswoiliśmy jeszcze płomieni. Ani trochę. Większym problemem jest to, że poniżasz kontener do roli lokalizatora usług, co jest ogromnym anty-wzorem. To tak, jakby przynieść komuś całą lodówkę, aby mógł pobrać z niej pojedynczą przekąskę – o wiele bardziej rozsądne jest dostarczenie im tylko przekąski.

Ponownie, zastrzyk zależności polega na przejrzystości, a ten kontroler nadal nie jest przejrzysty w swoich zależnościach. Zależność od authenticatora jest całkowicie ukryta przed światem zewnętrznym, za zależnością od kontenera. To sprawia, że kod jest trudniejszy do odczytania. Albo używać. Lub testować! Wyśmiewanie autentyku w teście jednostkowym wymaga teraz stworzenia całego kontenera wokół niego.

A przy okazji kontroler nadal zależy od definicji kontenera i robi to w dość zły sposób. Jeśli usługa uwierzytelniająca nie istnieje w kontenerze, kod nie zawodzi aż do metody action(), co jest dość późną informacją zwrotną.

Gotowanie czegoś pysznego

Aby być uczciwym, nikt nie może naprawdę winić cię za wpadnięcie w ten martwy punkt. W końcu właśnie podążałeś za interfejsem zaprojektowanym i sprawdzonym przez sprytnych programistów. Rzecz w tym, że wszystkie kontenery wtrysku zależności są z definicji również lokalizatorami usług, a okazuje się, że wzór jest naprawdę jedynym wspólnym interfejsem wśród nich. Ale to nie znaczy, że powinieneś używać ich jako lokalizatorów usług. W rzeczywistości sam PSR ostrzega przed tym.

Oto jak możesz użyć kontenera DI jako dobrego sługi:

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

Kontroler deklaruje zależności jawnie, jasno, przejrzyście w konstruktorze. Zależności nie są już ukryte rozrzucone po klasie. Są również egzekwowane: kontener nie jest w stanie stworzyć instancji SignInController bez dostarczenia niezbędnego Authenticator. Jeśli w kontenerze nie ma authenticatora, wykonanie kończy się niepowodzeniem wcześnie, a nie w metodzie action(). Testowanie tej klasy stało się również o wiele łatwiejsze, ponieważ musisz tylko kpić z usługi authenticator bez żadnego kotła kontenera.

I jeszcze jeden mały, ale bardzo ważny szczegół: wkradła się informacja o typie usługi. Fakt, że jest to instancja Authenticator – wcześniej domniemany i nieznany IDE, narzędziom do analizy statycznej, a nawet deweloperowi nieświadomemu definicji kontenera – jest teraz statycznie wyryty w promowanym parametrze typehint.

Jedynym krokiem, który pozostał, jest nauczenie kontenera, jak tworzyć również 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 { /* . . . */ }
}

Możesz zauważyć, że kontener nadal wewnętrznie używa podejścia lokalizatora usług. Ale to jest w porządku, o ile jest to zawarte (pun intended). Jedyne miejsce poza kontenerem, w którym wywołanie metody get jest dopuszczalne, znajduje się w index.php, w punkcie wejścia aplikacji, gdzie musisz utworzyć sam kontener, a następnie pobrać i uruchomić aplikację:

$container = bootstrap();

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

The hidden gem

Ale nie zatrzymujmy się na tym, pozwólcie, że posunę to stwierdzenie dalej: jedynym miejscem, w którym wywołanie metody get jest dopuszczalne, jest punkt wejścia.

Kod kontenera to tylko okablowanie, to instrukcje montażu. To nie jest kod wykonawczy. Nie jest ważny, w pewnym sensie. Chociaż tak, jest kluczowy dla aplikacji, to tylko z perspektywy dewelopera. Tak naprawdę nie przynosi żadnej bezpośredniej wartości dla użytkownika i powinien być traktowany z tą myślą.

Przyjrzyj się jeszcze raz kontenerowi:

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 obejmuje tylko bardzo mały i prosty segment aplikacji. W miarę jak aplikacja rośnie, ręczne pisanie kontenera staje się niesamowicie uciążliwe. Jak już wspomniałem, kontener to po prostu instrukcja montażu – ale zbyt skomplikowana, z wieloma stronami, niezliczonymi odsyłaczami i mnóstwem ostrzeżeń napisanych drobnym drukiem. Chcemy zamienić ją w instrukcję w stylu IKEA, graficzną, zwięzłą i z ilustracjami ludzi uśmiechających się, gdy kładą ÅUTHENTICATÖR na dywanie podczas montażu, aby się nie złamał.

Tu właśnie wkracza Nette Framework.

Rozwiązanie [DI(https://github.com/nette/di) Nette Framework] wykorzystuje Neon, format plików konfiguracyjnych podobny do YAML, ale na sterydach. Oto jak zdefiniowałbyś ten sam kontener, używając konfiguracji Neon:

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

Pozwolę sobie zwrócić uwagę na dwie warte uwagi rzeczy: po pierwsze, lista usług jest naprawdę listą, a nie mapą haszującą – nie ma żadnych kluczy, żadnych sztucznych identyfikatorów usług. Nie ma authenticator, nie ma też Authenticator::class. Po drugie, nigdzie nie trzeba jawnie wymieniać żadnych zależności, poza parametrami połączenia z bazą danych.

To dlatego, że Nette Framework opiera się na autowiring. Pamiętasz, jak dzięki wstrzykiwaniu zależności mogliśmy wyrazić typ zależności w natywnym typehint? Kontener DI korzysta z tych informacji, dzięki czemu, gdy wymagamy instancji Authenticator, całkowicie pomija wszelkie nazwy i znajduje odpowiednią usługę wyłącznie po jej typie.

Możesz twierdzić, że autowiring nie jest unikalną cechą. I miałbyś rację. To, co czyni kontener Nette Framework unikalnym, to wykorzystanie systemu typów PHP, podczas gdy w wielu innych frameworkach autowiring jest wciąż budowany wewnętrznie na nazwach usług. Istnieją scenariusze, w których inne kontenery nie spełniają swojej roli. Oto jak zdefiniowałbyś usługę authenticator w kontenerze DI Symfony używając YAML:

services:
  Authenticator: ~

Sekcja services jest mapą hashową, a bit Authenticator jest identyfikatorem usługi. Tylda oznacza null w YAML, co Symfony interpretuje jako “użyj identyfikatora usługi jako jej typu”.

Ale wkrótce, wymagania biznesowe zmieniają się i musisz wspierać uwierzytelnianie poprzez LDAP oprócz lokalnego lookupu bazy danych. Jako pierwszy krok, zamieniasz klasę Authenticator w interfejs i wyodrębniasz oryginalną implementację w klasie LocalAuthenticator:

services:
  LocalAuthenticator: ~

Nagle, Symfony jest bezradny. To dlatego, że Symfony pracuje z nazwami usług zamiast typów. Kontroler nadal poprawnie opiera się na abstrakcji i wymienia interfejs Authenticator jako swoją zależność, ale w kontenerze nie ma usługi o nazwie Authenticator. Musisz dać Symfony wskazówkę, na przykład używając aliasu nazwa usługi:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Nette Framework, z drugiej strony, nie potrzebuje nazw usług ani podpowiedzi. Nie zmusza Cię do powielania w konfiguracji informacji, które są już wyrażone w kodzie (poprzez klauzulę implements ). Usytuowany jest na samym szczycie systemu typów PHP. Wie, że LocalAuthenticator jest typu Authenticator, i tak długo jak jest jedyną usługą, która implementuje ten interfejs, z radością podłącza go tam, gdzie jest on wymagany, biorąc pod uwagę tylko tę linię konfiguracji:

services:
    - LocalAuthenticator

Przyznaję, że jeśli nie jesteś zaznajomiony z autowiringiem, może czuć się nieco magicznie i możesz potrzebować trochę czasu, aby nauczyć się mu ufać. Na szczęście działa przejrzyście i deterministycznie: gdy kontener nie może jednoznacznie rozwiązać zależności, rzuca wyjątek czasu kompilacji, który pomaga naprawić sytuację. W ten sposób możesz mieć dwie różne implementacje i nadal dobrze kontrolować, gdzie każdy z nich jest używany.

Jako całość, autowiring nakłada mniejsze obciążenie poznawcze na ciebie jako dewelopera. W końcu dbasz tylko o typy i abstrakcje, więc dlaczego kontener DI powinien zmuszać cię do dbania również o implementacje i identyfikatory usług? Co ważniejsze, dlaczego powinieneś nawet dbać o jakiś kontener w pierwszej kolejności? W duchu wtrysku zależności chcesz być w stanie po prostu zadeklarować zależności i być to czyjś problem, aby je zapewnić. Chcesz w pełni skupić się na kodzie aplikacji i zapomnieć o okablowaniu. A DI w Nette Framework pozwala ci na to.

W moich oczach czyni to rozwiązanie Nette Framework's DI najlepszym rozwiązaniem tam w świecie PHP. Daje Ci kontener, który jest niezawodny i wymusza dobre wzorce architektoniczne, ale jednocześnie jest tak łatwy w konfiguracji i utrzymaniu, że nie musisz się nad nim w ogóle zastanawiać.

Mam nadzieję, że ten post zdołał wzbudzić Twoją ciekawość. Koniecznie sprawdź Github repozytorium i docs – mam nadzieję, że dowiesz się, że pokazałem ci tylko wierzchołek góry lodowej i że cały pakiet jest znacznie potężniejszy.