Dienste benötigen keine Namen

vor 4 Jahren von Jiří Pudil  

Ich mag die Lösung des Nette Frameworks für Dependency Injection. Ich liebe sie wirklich. Dieser Artikel ist dazu da, diese Leidenschaft zu teilen und zu erklären, warum ich denke, dass es die beste DI-Lösung im aktuellen PHP-Ökosystem ist.

(Dieser Beitrag wurde ursprünglich im Blog des Autors veröffentlicht.)

Softwareentwicklung ist ein endloser iterativer Prozess der Abstraktion. Geeignete Abstraktionen der realen Welt finden wir in der Domänenmodellierung. In der objektorientierten Programmierung verwenden wir Abstraktionen, um Verträge zwischen verschiedenen Akteuren im System zu beschreiben und durchzusetzen. Wir führen neue Klassen in das System ein, um Verantwortlichkeiten zu kapseln und ihre Grenzen zu definieren, und verwenden dann Komposition, um das gesamte System zu erstellen.

Ich spreche von dem Drang, die Authentifizierungslogik aus dem folgenden Controller zu extrahieren:

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

Sie werden wahrscheinlich erkennen, dass die Überprüfung der Anmeldeinformationen dort nicht hingehört. Der Controller ist nicht dafür verantwortlich zu bestimmen, welche Anmeldeinformationen gültig sind – gemäß dem Single Responsibility Principle sollte der Controller nur einen einzigen Grund zur Änderung haben, und dieser Grund sollte im Rahmen der Benutzeroberfläche der Anwendung liegen, nicht im Authentifizierungsprozess.

Nehmen wir den offensichtlichen Weg und extrahieren die Bedingung in eine Authenticator-Klasse:

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

Jetzt reicht es aus, wenn wir vom Controller an diesen Authenticator delegieren. Wir haben den Authenticator zu einer Abhängigkeit des Controllers gemacht, und der Controller muss ihn plötzlich irgendwoher bekommen:

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

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

Diese naive Methode wird funktionieren. Aber nur so lange, bis eine robustere Authentifizierung implementiert wird, die erfordert, dass der Authenticator eine Datenbanktabelle der Benutzer abfragt. Plötzlich hat Authenticator eine eigene Abhängigkeit, sagen wir UserRepository, die wiederum von einer Connection-Instanz abhängt, die von Parametern der spezifischen Umgebung abhängig ist. Das ist schnell eskaliert!

Instanzen überall manuell zu erstellen, ist keine nachhaltige Methode zur Verwaltung von Abhängigkeiten. Deshalb haben wir das Dependency-Injection-Muster, das es dem Controller ermöglicht, nur die Abhängigkeit von Authenticator zu deklarieren und es jemand anderem zu überlassen, die Instanz tatsächlich bereitzustellen. Und dieser jemand andere wird als Dependency Injection Container bezeichnet.

Der Dependency Injection Container ist der oberste Architekt der Anwendung – er kann die Abhängigkeiten jedes beliebigen Dienstes im System auflösen und ist für deren Erstellung verantwortlich. DI-Container sind heute so verbreitet, dass fast jedes größere Webframework eine eigene Container-Implementierung hat, und es gibt sogar separate Pakete, die sich der Dependency Injection widmen, zum Beispiel PHP-DI.

Der Container als Service Locator

Die Vielzahl der Optionen motivierte schließlich eine Gruppe von Entwicklern, nach einer Abstraktion zu suchen, die sie interoperabel machen würde. Eine gemeinsame Schnittstelle wurde im Laufe der Zeit verfeinert und schließlich für PHP-FIG in folgender Form vorgeschlagen:

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

Diese Schnittstelle veranschaulicht eine sehr wichtige Eigenschaft von DI-Containern: **Sie sind ein guter Diener, können aber leicht zu einem schlechten Herrn werden. Sie sind äußerst nützlich, wenn Sie wissen, wie man sie benutzt, aber wenn Sie sie falsch verwenden, verbrennen Sie sich daran. Nehmen wir den folgenden Container:

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

Bis jetzt ist alles gut. Die Implementierung scheint nach den von uns festgelegten Standards gut zu sein: Sie kann tatsächlich jeden Dienst in der Anwendung erstellen und löst rekursiv seine Abhängigkeiten auf. Alles wird an einem Ort verwaltet, und der Container akzeptiert sogar Parameter, sodass die Datenbankverbindung leicht konfigurierbar ist. Schön!

Aber wenn Sie jetzt nur die beiden Methoden von ContainerInterface sehen, sind Sie vielleicht versucht, den Container so zu verwenden:

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

Herzlichen Glückwunsch, Sie haben gerade den Container als Service Locator missbraucht. Mit anderen Worten, der Container ist zu einem bösen Herrn geworden. Warum ist das so?

Erstens verlassen Sie sich auf einen beliebigen Dienstidentifikator: 'authenticator'. Bei der Dependency Injection geht es darum, transparent über seine Abhängigkeiten zu sein, und die Verwendung eines künstlichen Identifikators widerspricht diesem Konzept direkt: Es macht den Code stillschweigend von der Containerdefinition abhängig. Wenn ein Dienst im Container jemals umbenannt wird, müssen Sie diesen Verweis finden und aktualisieren.

Und was noch schlimmer ist, diese Abhängigkeit ist versteckt: Auf den ersten Blick hängt der Controller von außen nur von der Container-Abstraktion ab. Aber als Entwickler müssen Sie Kenntnisse darüber haben, wie die Dienste im Container heißen und dass der Dienst namens authenticator tatsächlich eine Instanz von Authenticator ist. All dies muss Ihr neuer Kollege lernen. Unnötigerweise.

Glücklicherweise können wir auf einen viel natürlicheren Identifikator zurückgreifen: den Typ des Dienstes. Das ist schließlich das Einzige, was Sie als Entwickler interessiert. Sie müssen nicht wissen, welche zufällige Zeichenkette dem Dienst im Container zugewiesen ist. Ich glaube, dieser Code ist viel einfacher zu schreiben und zu lesen:

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

Leider haben wir das Problem noch nicht gelöst. Nicht im Geringsten. Das größere Problem ist, dass Sie den Container demütigend in die Rolle eines Service Locators versetzen, was ein riesiges Anti-Pattern ist. Es ist, als würde man jemandem einen ganzen Kühlschrank bringen, damit er sich einen Snack daraus nehmen kann – viel vernünftiger ist es, ihm nur den Snack zu bringen.

Nochmals, Dependency Injection dreht sich um Transparenz, und dieser Controller ist immer noch nicht transparent bezüglich seiner Abhängigkeiten. Die Abhängigkeit vom Authenticator ist hinter der Abhängigkeit vom Container vollständig vor der Außenwelt verborgen. Dadurch wird der Code schwerer lesbar. Oder zu verwenden. Oder zu testen! Das Mocking des Authenticators in einem Unit-Test erfordert nun, dass Sie einen ganzen Container darum herum erstellen.

Und übrigens hängt der Controller immer noch von der Containerdefinition ab, und zwar auf eine ziemlich üble Weise. Wenn der Authenticator-Dienst im Container nicht existiert, schlägt der Code erst in der action()-Methode fehl, was ein ziemlich spätes Feedback ist.

Der richtige Weg: Dependency Injection

Um fair zu sein, niemand kann Ihnen vorwerfen, in diese Sackgasse geraten zu sein. Schließlich haben Sie sich nur an die von klugen Entwicklern entworfene und bewährte Schnittstelle gehalten. Der Punkt ist, dass alle Dependency Injection Container per Definition auch Service Locators sind, und es stellt sich heraus, dass das Muster tatsächlich die einzige gemeinsame Schnittstelle zwischen ihnen ist. Das bedeutet aber nicht, dass Sie sie als Service Locators verwenden sollten. Tatsächlich warnt die PSR-Spezifikation selbst davor.

So können Sie den DI-Container als guten Dienst verwenden:

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

Im Konstruktor wird die Abhängigkeit explizit, klar und transparent deklariert. Abhängigkeiten sind nicht mehr versteckt über die Klasse verstreut. Sie werden auch erzwungen: Der Container ist nicht in der Lage, eine Instanz von SignInController zu erstellen, ohne den erforderlichen Authenticator bereitzustellen. Wenn im Container kein Authenticator vorhanden ist, schlägt die Ausführung vorzeitig fehl, nicht erst in der action()-Methode. Das Testen dieser Klasse ist ebenfalls viel einfacher geworden, da Sie nur den Authenticator-Dienst mocken müssen, ohne jeglichen Container-Boilerplate.

Und noch ein kleines, aber sehr wichtiges Detail: Wir haben die Information über den Typ des Dienstes hineingeschmuggelt. Die Tatsache, dass es sich um eine Instanz von Authenticator handelt – zuvor implizit und unbekannt für IDEs, statische Analysewerkzeuge oder sogar für einen Entwickler, der die Containerdefinition nicht kennt – ist nun statisch im Typehint des promoted Parameters eingraviert.

Der einzige verbleibende Schritt ist, dem Container beizubringen, wie er auch den Controller erstellt:

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

Sie haben vielleicht bemerkt, dass der Container intern immer noch den Service-Locator-Ansatz verwendet. Das ist jedoch in Ordnung, solange er eingekapselt ist (Wortspiel beabsichtigt). Der einzige Ort außerhalb des Containers, an dem ein Aufruf der get-Methode zulässig ist, ist in index.php, dem Einstiegspunkt der Anwendung, wo der Container selbst erstellt und dann die Anwendung geladen und ausgeführt werden muss:

$container = bootstrap();

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

Das versteckte Juwel: Nette DI

Aber bleiben wir nicht dabei stehen, erlauben Sie mir, diese Behauptung weiterzutreiben: Der einzige Ort, an dem ein Aufruf der get-Methode zulässig ist, ist der Einstiegspunkt.

Der Container-Code ist nur Verdrahtung, es sind Anweisungen zum Zusammenbau. Es ist kein ausführbarer Code. In gewisser Weise ist er nicht wichtig. Obwohl er ja für die Anwendung entscheidend ist, aber nur aus der Sicht des Entwicklers. Dem Benutzer bringt er tatsächlich keinen direkten Wert und sollte unter Berücksichtigung dieser Tatsache behandelt werden.

Schauen Sie sich den Container noch einmal an:

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

Dies betrifft nur ein sehr kleines und einfaches Segment der Anwendung. Wenn die Anwendung wächst, wird das manuelle Schreiben des Containers unglaublich mühsam. Wie ich bereits sagte, ist der Container nur eine Montageanleitung – aber sie ist zu komplex, hat viele Seiten, unzählige Querverweise und viele Warnhinweise in kleiner Schrift. Wir wollen daraus eine Anleitung im IKEA-Stil machen, grafisch, prägnant und mit Illustrationen von Menschen, die lächeln, während sie bei der Montage ÅUTHENTICATÖR auf den Teppich legen, damit er nicht zerbricht.

Hier kommt das Nette Framework ins Spiel.

Die DI des Nette Frameworks verwendet Neon, ein Konfigurationsdateiformat ähnlich YAML, aber auf Steroiden. So würden Sie denselben Container mit einer Neon-Konfiguration definieren:

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

Lassen Sie mich auf zwei bemerkenswerte Dinge hinweisen: Erstens ist die Liste der Dienste tatsächlich eine Liste, keine Hashmap – es gibt keine Schlüssel, keine künstlichen Dienstidentifikatoren. Es gibt keinen authenticator, nicht einmal Authenticator::class. Zweitens müssen Sie nirgendwo explizit Abhängigkeiten angeben, außer den Parametern für die Datenbankverbindung.

Das liegt daran, dass das Nette Framework auf Autowiring setzt. Erinnern Sie sich, wie wir dank Dependency Injection den Typ der Abhängigkeit mit einem nativen Typehint ausdrücken konnten? Der DI-Container nutzt diese Information, sodass er, wenn Sie eine Instanz von Authenticator anfordern, jegliche Namen vollständig umgeht und den richtigen Dienst ausschließlich anhand seines Typs findet.

Sie könnten einwenden, dass Autowiring keine einzigartige Eigenschaft ist. Und Sie hätten Recht. Was den Container des Nette Frameworks einzigartig macht, ist die Nutzung des PHP-Typsystems, während in vielen anderen Frameworks Autowiring intern immer noch auf Dienstnamen basiert. Es gibt Szenarien, in denen andere Container hinterherhinken. So würden Sie den Authenticator-Dienst im Symfony DI-Container mit YAML definieren:

services:
  Authenticator: ~

Im Abschnitt services befindet sich eine Hashmap, und der Teil Authenticator ist der Dienstidentifikator. Die Tilde bedeutet in YAML null, was Symfony als “verwende den Dienstidentifikator als seinen Typ” interpretiert.

Bald ändern sich jedoch die Geschäftsanforderungen, und Sie müssen neben der lokalen Suche in der Datenbank auch die Authentifizierung über LDAP unterstützen. Im ersten Schritt ändern Sie die Klasse Authenticator in ein Interface und extrahieren die ursprüngliche Implementierung in die Klasse LocalAuthenticator:

services:
  LocalAuthenticator: ~

Plötzlich ist Symfony ratlos. Das liegt daran, dass Symfony mit Dienstnamen statt mit Typen arbeitet. Der Controller verlässt sich immer noch korrekt auf die Abstraktion und gibt das Interface Authenticator als seine Abhängigkeit an, aber im Container gibt es keinen Dienst mit dem Namen Authenticator. Sie müssen Symfony einen Hinweis geben, zum Beispiel mit einem Alias für den Dienstnamen:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Das Nette Framework hingegen benötigt weder Dienstnamen noch Hinweise. Es zwingt Sie nicht, Informationen in der Konfiguration zu duplizieren, die bereits im Code ausgedrückt sind (über die implements-Klausel). Es sitzt direkt auf dem PHP-Typsystem auf. Es weiß, dass LocalAuthenticator vom Typ Authenticator ist, und wenn es der einzige Dienst ist, der dieses Interface implementiert, wird es ihn gerne automatisch dort einbinden, wo dieses Interface benötigt wird, und zwar nur auf Basis dieser Konfigurationszeile:

services:
    - LocalAuthenticator

Ich gebe zu, wenn Sie Autowiring nicht kennen, mag es Ihnen ein wenig magisch vorkommen, und Sie benötigen vielleicht etwas Zeit, um ihm zu vertrauen. Glücklicherweise funktioniert es transparent und deterministisch: Wenn der Container Abhängigkeiten nicht eindeutig auflösen kann, wirft er während der Kompilierung eine Ausnahme, die Ihnen hilft, die Situation zu bereinigen. Auf diese Weise können Sie zwei verschiedene Implementierungen haben und dennoch gute Kontrolle darüber behalten, wo welche davon verwendet wird.

Insgesamt legt Autowiring Ihnen als Entwickler eine geringere kognitive Last auf. Schließlich kümmern Sie sich nur um Typen und Abstraktionen, warum sollte Sie der DI-Container also zwingen, sich auch um Implementierungen und Dienstidentifikatoren zu kümmern? Und was noch wichtiger ist, warum sollten Sie sich überhaupt um irgendeinen Container kümmern? Im Geiste der Dependency Injection möchten Sie einfach Abhängigkeiten deklarieren können und es das Problem eines anderen sein lassen, sie bereitzustellen. Sie möchten sich voll auf den Anwendungscode konzentrieren und die Verdrahtung vergessen. Und das ermöglicht Ihnen das DI des Nette Frameworks.

In meinen Augen macht dies die DI-Lösung des Nette Frameworks zur besten, die es in der PHP-Welt gibt. Sie bietet Ihnen einen Container, der zuverlässig ist und gute Architekturmuster durchsetzt, aber gleichzeitig so einfach zu konfigurieren und zu warten ist, dass Sie überhaupt nicht darüber nachdenken müssen.

Ich hoffe, dieser Beitrag hat Ihre Neugier geweckt. Schauen Sie sich unbedingt das Github-Repository und die Dokumentation an – Sie werden hoffentlich feststellen, dass ich Ihnen nur die Spitze des Eisbergs gezeigt habe und das gesamte Paket viel mächtiger ist.