Usługi nie potrzebują nazw
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.
Aby przesłać komentarz, proszę się zalogować