Usługi nie potrzebują nazw

4 lata temu przez Jiří Pudil  

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.