Usługi nie potrzebują nazw
Podoba mi się rozwiązanie Nette Framework do wstrzykiwania zależności. Naprawdę je uwielbiam. Ten artykuł jest po to, abym podzielił się tą pasją i wyjaśnił, dlaczego uważam, że jest to najlepsze rozwiązanie DI w obecnym ekosystemie PHP.

(Ten wpis został pierwotnie opublikowany na blogu autora.)
Rozwój oprogramowania to niekończący się iteracyjny proces abstrakcji. Odpowiednie abstrakcje świata rzeczywistego znajdujemy w modelowaniu domenowym. W programowaniu obiektowym używamy abstrakcji do opisu i wymuszania kontraktów między różnymi aktorami w systemie. Wprowadzamy do systemu nowe klasy, aby zamknąć odpowiedzialności i zdefiniować ich granice, a następnie za pomocą kompozycji tworzymy cały system.
Mówię o potrzebie 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 rozpoznajesz, że kontrola poświadczeń tam nie należy. Kontroler nie ma odpowiedzialności za określanie, 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 być w ramach interfejsu użytkownika aplikacji, a nie w procesie uwierzytelniania.
Wybierzmy oczywistą ścieżkę 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 autentykatora. Stworzyliśmy autentykator zależność od kontrolera i kontroler nagle potrzebuje go gdzieś uzyskać:
final class SignInController extends Best\Framework\Controller
{
public function action(string $username, string $password): Best\Framework\Response
{
$authenticator = new Authenticator(); // <== łatwe, po prostu tworzę 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 zadziała. Ale tylko do czasu, aż zostanie
zaimplementowane bardziej solidne uwierzytelnianie, które będzie wymagało,
aby uwierzytelniacz odpytywał tabelę użytkowników w bazie danych. Nagle
Authenticator
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 się skomplikowało!
Tworzenie instancji wszędzie ręcznie nie jest zrównoważonym sposobem
zarządzania zależnościami. Dlatego mamy wzorzec dependency injection, który
pozwala kontrolerowi jedynie zadeklarować zależność od
Authenticator
, a pozostawić komuś innemu faktyczne dostarczenie
instancji. A ten ktoś inny nazywa się kontenerem dependeny
injection.
Kontener dependency injection jest głównym architektem aplikacji – potrafi rozwiązywać zależności dowolnej usługi w systemie i jest odpowiedzialny za ich tworzenie. Kontenery DI są dzisiaj tak powszechne, że prawie każdy większy framework webowy ma własną implementację kontenera, a nawet istnieją oddzielne pakiety poświęcone wstrzykiwaniu zależności, na przykład PHP-DI.
Można się sparzyć
Ilość opcji ostatecznie zmotywowała grupę programistów do poszukiwania abstrakcji, która by je uczyniła interoperabilnymi. Wspólny interfejs został z czasem dopracowany i ostatecznie zaproponowany dla PHP-FIG w następującej postaci:
interface ContainerInterface
{
public function get(string $id): mixed;
public function has(string $id): bool;
}
Ten interfejs ilustruje jedną bardzo ważną cechę kontenerów DI: Są dobrym sługą, ale łatwo mogą stać się złym panem. Są niezwykle przydatne, jeśli wiesz, jak ich używać, ale jeśli używasz ich nieprawidłowo, sparzysz się. Weźmy następujący kontener:
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 { /* . . . */ }
}
Na razie jest dobrze. Implementacja wydaje się być dobra według standardów, które sobie ustaliliśmy: rzeczywiście potrafi stworzyć każdą usługę w aplikacji i rekurencyjnie rozwiązuje jej zależności. Wszystko jest zarządzane w jednym miejscu, a kontener nawet przyjmuje parametry, więc połączenie z bazą danych jest łatwo konfigurowalne. Ładnie!
Ale kiedy teraz widzisz tylko dwie metody ContainerInterface
,
być może kusi cię, aby używać 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 się sparzyłeś. Innymi słowy, kontener stał się złym panem. Dlaczego tak jest?
Po pierwsze, polegasz na dowolnym identyfikatorze usługi:
'authenticator'
. Wstrzykiwanie zależności polega na tym, aby być
transparentnym co do swoich zależności, a użycie sztucznego identyfikatora
idzie prosto przeciwko tej koncepcji: powoduje, że kod jest cicho zależny od
definicji kontenera. Jeśli kiedykolwiek dojdzie do zmiany nazwy usługi w
kontenerze, musisz znaleźć ten odnośnik i go zaktualizować.
A co gorsza, ta zależność jest ukryta: na pierwszy rzut oka z zewnątrz
kontroler zależy tylko od abstrakcji kontenera. Ale jako programista ty
musisz mieć wiedzę o tym, jak nazywają się usługi w kontenerze i że
usługa o nazwie authenticator
jest w rzeczywistości instancją
Authenticator
. Tego wszystkiego musi się nauczyć twój nowy
kolega. Niepotrzebnie.
Na szczęście możemy uciec się do znacznie bardziej naturalnego identyfikatora: typu usługi. To w końcu jedyne, co cię jako programistę interesuje. Nie musisz wiedzieć, jaki losowy ciąg znaków jest przypisany do usługi w kontenerze. Wierzę, że ten kod jest znacznie prostszy do pisania 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, jeszcze nie opanowaliśmy płomieni. Ani trochę. Większym problemem jest to, że pokornie stawiasz kontener w roli lokatora usług, co jest ogromnym antywzorcem. To jak przyniesienie komuś całej lodówki, aby mógł sobie z niej wziąć jedną przekąskę – znacznie rozsądniej jest przynieść mu tylko tę przekąskę.
Ponownie, dependency injection polega na transparentności, a ten kontroler nadal nie jest transparentny co do swoich zależności. Zależność od autentykatora jest całkowicie ukryta przed światem zewnętrznym za zależnością od kontenera. To sprawia, że kod jest trudniejszy do czytania. Lub użycia. Lub testowania! Mockowanie autentykatora w teście jednostkowym wymaga teraz stworzenia wokół niego całego kontenera.
A przy okazji, kontroler nadal zależy od definicji kontenera, i to w
dość zły sposób. Jeśli usługa autentykatora w kontenerze nie istnieje, kod
zawiedzie dopiero w metodzie action()
, co jest dość późną
informacją zwrotną.
Gotowanie czegoś smacznego
Aby być sprawiedliwym, nikt nie może cię winić za to, że wpadłeś w tę ślepą uliczkę. W końcu tylko podążałeś za interfejsem zaprojektowanym i sprawdzonym przez mądrych programistów. Chodzi o to, że wszystkie kontenery do wstrzykiwania zależności są z definicji również lokatorami usług i okazuje się, że wzorzec jest między nimi rzeczywiście jedynym wspólnym interfejsem. Ale to nie znaczy, że powinieneś ich używać jako lokatorów usług. W rzeczywistości sam przepis PSR “ostrzega”(http://www.php-fig.org/…psr-11/meta/#…) przed tym.
W ten sposób możesz użyć kontenera DI jako dobrej usł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);
//...
}
}
W konstruktorze zależność jest zadeklarowana jawnie, jasno
i transparentnie. Zależności nie są już ukryte rozproszone po klasie. Są
również wymuszone: kontener nie jest w stanie utworzyć instancji
SignInController
, nie dostarczając potrzebnego
Authenticator
. Jeśli w kontenerze nie ma żadnego autentykatora,
wykonanie zawiedzie przedwcześnie, a nie w metodzie action()
.
Testowanie tej klasy również stało się znacznie łatwiejsze, ponieważ
wystarczy tylko zamockować usługę autentykatora bez żadnego
boilerplate'u kontenera.
I jeszcze jeden drobny, ale bardzo ważny szczegół: przemyciliśmy do
niego informację o typie usługi. Fakt, że jest to instancja
Authenticator
– wcześniej niejawny i nieznany IDE, narzędziom
analizy statycznej czy nawet programiście nieznającemu definicji
kontenera – jest teraz statycznie wyryty w type hincie promowanego
parametru.
Jedynym krokiem, który pozostaje, jest nauczenie kontenera, jak stworzyć 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 { /* . . . */ }
}
Być może zauważyłeś, że kontener nadal wewnętrznie używa podejścia
lokatora usług. To jednak nie szkodzi, o ile jest zawarte (gra słów
zamierzona). Jedyne miejsce poza kontenerem, gdzie wywołanie metody
get
jest dopuszczalne, to index.php
, w punkcie
wejścia aplikacji, gdzie trzeba stworzyć sam kontener, a następnie
załadować i uruchomić aplikację:
$container = bootstrap();
$application = $container->get(Best\Framework\Application::class);
$application->run();
Ukryty klejnot
Ale nie poprzestawajmy na tym, pozwólcie mi posunąć to twierdzenie dalej:
jedyne miejsce, gdzie wywołanie metody get
jest
dopuszczalne, to punkt wejścia.
Kod kontenera to tylko okablowanie, to instrukcje montażu. To nie jest kod wykonawczy. W pewnym sensie nie jest ważny. Chociaż tak, jest kluczowy dla aplikacji, ale tylko z punktu widzenia programisty. Użytkownikowi w rzeczywistości nie przynosi żadnej bezpośredniej wartości i powinno się go traktować z uwzględnieniem tego faktu.
Spójrz ponownie na kontener:
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 { /* . . . */ }
}
Dotyczy to tylko bardzo małego i prostego segmentu aplikacji. W miarę rozrastania się aplikacji ręczne pisanie kontenera staje się niezwykle męczące. Jak już powiedziałem, kontener to tylko instrukcja montażu – ale jest zbyt skomplikowana, ma wiele stron, niezliczone odsyłacze i mnóstwo uwag napisanych małym drukiem. Chcemy z niej zrobić instrukcję w stylu IKEA, graficzną, zwięzłą i z ilustracjami ludzi, którzy uśmiechają się, kładąc ÅUTHENTICATÖR na dywanie podczas montażu, aby się nie zepsuł.
Tutaj wkracza Nette Framework.
Rozwiązanie DI Nette Framework wykorzystuje Neon, format plików konfiguracyjnych podobny do YAML, ale na sterydach. Tak zdefiniowałbyś ten sam kontener za pomocą konfiguracji Neon:
services:
- SignInController
- Authenticator
- UserRepository
- Connection(%database%)
Pozwólcie mi zwrócić uwagę na dwie godne uwagi rzeczy: po pierwsze, lista
usług jest rzeczywiście listą, a nie mapą haszującą – nie ma tu
żadnych kluczy, żadnych sztucznych identyfikatorów usług. Nie ma żadnego
authenticator
, ani Authenticator::class
. Po drugie,
nigdzie nie musisz jawnie podawać żadnych zależności, oprócz parametrów
połączenia z bazą danych.
To dlatego, że Nette Framework polega na automatycznym okablowaniu
(autowiring). Pamiętasz, jak dzięki dependency injection mogliśmy wyrazić
typ zależności natywnym typehintem? Kontener DI wykorzystuje tę informację,
więc gdy żądasz instancji Authenticator
, całkowicie omija
wszelkie nazwy i znajduje właściwą usługę wyłącznie na podstawie
jej typu.
Możesz argumentować, że autowiring nie jest unikalną cechą. I miałbyś rację. To, co czyni kontener Nette Framework unikalnym, jest wykorzystanie systemu typów PHP, podczas gdy w wielu innych frameworkach autowiring nadal jest wewnętrznie oparty na nazwach usług. Istnieją scenariusze, w których inne kontenery pozostają w tyle. Tak zdefiniowałbyś usługę autentykatora w kontenerze Symfony DI za pomocą języka YAML:
services:
Authenticator: ~
W sekcji services
znajduje się mapa haszująca, a fragment
Authenticator
jest identyfikatorem usługi. Tylda oznacza w YAML
null
, co Symfony interpretuje jako “użyj identyfikatora usługi
jako jej typu”.
Wkrótce jednak wymagania biznesowe się zmienią i potrzebujesz oprócz
lokalnego wyszukiwania w bazie danych wspierać również uwierzytelnianie za
pośrednictwem LDAP. W pierwszym kroku zmienisz klasę
Authenticator
na interfejs, a pierwotną implementację
wyodrębnisz do klasy LocalAuthenticator
:
services:
LocalAuthenticator: ~
Nagle Symfony jest bezradne. To dlatego, że Symfony pracuje z nazwami
usług zamiast z typami. Kontroler nadal poprawnie polega na abstrakcji
i podaje interfejs Authenticator
jako swoją zależność, ale w
kontenerze nie ma żadnej usługi o nazwie Authenticator
.
Musisz dać Symfony wskazówkę, na przykład za pomocą aliasu nazwy
usługi:
services:
LocalAuthenticator: ~
Authenticator: '@LocalAuthenticator'
Nette Framework natomiast nie potrzebuje nazw usług ani wskazówek. Nie
zmusza cię do duplikowania w konfiguracji informacji, które są już wyrażone
w kodzie (poprzez klauzulę implements
). Jest umieszczony
bezpośrednio nad systemem typów PHP. Wie, że LocalAuthenticator
jest typu Authenticator
, a jeśli jest to jedyna usługa
implementująca ten interfejs, z radością automatycznie podłączy ją tam,
gdzie ten interfejs jest wymagany, i to tylko na podstawie tego wiersza
konfiguracji:
services:
- LocalAuthenticator
Przyznaję, że jeśli nie znasz autowiringu, może ci się wydawać trochę magiczny i być może będziesz potrzebować trochę czasu, aby mu zaufać. Na szczęście działa transparentnie i deterministycznie: gdy kontener nie może jednoznacznie rozwiązać zależności, rzuca wyjątek podczas kompilacji, który pomoże ci naprawić sytuację. W ten sposób możesz mieć dwie różne implementacje i nadal mieć dobrą kontrolę nad tym, gdzie która z nich jest używana.
Ogólnie rzecz biorąc, automatyczne okablowanie nakłada na ciebie jako programistę mniejsze obciążenie poznawcze. W końcu dbasz tylko o typy i abstrakcje, tak proč by vás měl kontejner DI nutit starat se také o implementace a identifikátory služeb? A co je důležitější, proč byste se vůbec měli starat o nějaký kontejner? V duchu dependency injection chcete mít možnost prostě deklarovat závislosti a být problémem někoho jiného, kdo je poskytne. Chcete se plně soustředit na kód aplikace a zapomenout na zapojení. A to vám DI Nette Framework umožňuje.
W moich oczach to sprawia, że rozwiązanie DI od Nette Framework jest najlepszym, jakie istnieje 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 o nim w ogóle myśleć.
Mam nadzieję, że ten wpis zdołał wzbudzić twoją ciekawość. Nie zapomnij zajrzeć na Github repozytorium i do dokumentacji – być może odkryjesz, ż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ć