Dienstleistungen brauchen keine Namen

vor 3 Jahren von Jiří Pudil  

Ich liebe die Dependency Injection-Lösung von Nette Framework. Das tue ich wirklich. In diesem Beitrag möchte ich diese Leidenschaft teilen und erklären, warum ich denke, dass es die beste DI-Lösung im heutigen PHP-Ökosystem ist.

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

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

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 können wahrscheinlich erkennen, dass die Prüfung der Anmeldeinformationen dort nicht hingehört. Es liegt nicht in der Verantwortung des Controllers, festzustellen, welche Anmeldedaten gültig sind – nach dem Prinzip der einzigen Verantwortung sollte der Controller nur einen einzigen Grund für eine Änderung haben, und dieser Grund sollte in der Benutzeroberfläche der Anwendung liegen, nicht im Prozess der Authentifizierung.

Lassen Sie uns den offensichtlichen Weg gehen und die Bedingung in eine Authenticator Klasse extrahieren:

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

Jetzt müssen wir nur noch vom Controller an diesen Authenticator delegieren. Wir haben den Authentifikator zu einer Abhängigkeit des Controllers gemacht, und plötzlich muss der Controller ihn irgendwoher bekommen:

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

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

Dieser naive Weg wird funktionieren. Aber nur so lange, bis eine robustere Authentifizierung implementiert ist, bei der der Authentifikator eine Datenbanktabelle mit Benutzern abfragen muss. Die Authenticator hat plötzlich eine eigene Abhängigkeit, sagen wir eine UserRepository, die wiederum von einer Connection Instanz abhängt, die wiederum von den Parametern der spezifischen Umgebung abhängig ist. Das ist schnell eskaliert!

Überall von Hand Instanzen zu erstellen, ist kein nachhaltiger Weg, um Abhängigkeiten zu verwalten. Deshalb gibt es das Dependency Injection Pattern, das es dem Controller erlaubt, lediglich seine Abhängigkeit von einer Authenticator zu deklarieren und es jemand anderem zu überlassen, tatsächlich eine Instanz bereitzustellen. Und diese andere Person wird als Dependency Injection Container bezeichnet.

Der Dependency-Injection-Container ist der oberste Architekt der Anwendung – er weiß, wie man die Abhängigkeiten eines beliebigen Dienstes innerhalb des Systems auflöst, und ist für deren Erstellung verantwortlich. DI-Container sind heutzutage so weit verbreitet, dass so gut wie jedes größere Web-Framework seine eigene Container-Implementierung hat, und es gibt sogar eigenständige Pakete, die sich der Dependency Injection widmen, wie z. B. PHP-DI.

Die Paprika brennen

Die Fülle der Optionen hat schließlich eine Gruppe von Entwicklern dazu veranlasst, eine Abstraktion zu suchen, um sie interoperabel zu machen. Die gemeinsame Schnittstelle wurde im Laufe der Zeit verfeinert und schließlich der PHP-FIG in der folgenden Form vorgeschlagen:

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

Diese Schnittstelle verdeutlicht eine sehr wichtige Eigenschaft von DI-Containern: Sie sind wie Feuer. Sie sind ein guter Diener, können aber leicht ein schlechter Meister werden. Sie sind ungeheuer nützlich, solange man weiß, wie sie zu benutzen sind, aber wenn man sie falsch benutzt, verbrennen sie einen. Betrachten Sie den folgenden Behälter:

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 weit, so gut. Die Implementierung scheint nach dem von uns gesetzten Standard gut zu sein: Sie weiß tatsächlich, wie jeder Dienst in der Anwendung zu erstellen ist und löst seine Abhängigkeiten rekursiv auf. Alles wird an einer einzigen Stelle verwaltet, und der Container akzeptiert sogar Parameter, so dass die Datenbankverbindung leicht konfigurierbar ist. Sehr schön!

Wenn Sie nun aber die beiden einzigen Methoden von ContainerInterface sehen, könnten Sie versucht sein, 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 Ihre Paprika verbrannt. Mit anderen Worten: Der Container ist der böse Herr geworden. Warum ist das so?

Erstens verlassen Sie sich auf eine willkürliche Dienstkennung: 'authenticator'. Bei der Dependency Injection geht es darum, die eigenen Abhängigkeiten transparent zu machen, und die Verwendung eines künstlichen Bezeichners widerspricht diesem Konzept: Sie macht den Code stillschweigend abhängig von der Definition des Containers. Wenn Sie den Dienst im Container jemals umbenennen, müssen Sie diesen Verweis finden und aktualisieren.

Und was noch schlimmer ist, diese Abhängigkeit ist versteckt: Auf den ersten Blick von außen betrachtet, hängt der Controller nur von einer Abstraktion eines Containers ab. Aber als Entwickler müssen Sie wissen, wie die Dienste im Container benannt sind und dass ein Dienst namens authenticator in Wirklichkeit eine Instanz von Authenticator ist. Ihr neuer Kollege muss das alles lernen. Unnötigerweise.

Glücklicherweise können wir auf einen viel natürlicheren Bezeichner zurückgreifen: den Diensttyp. Schließlich ist das alles, was Sie als Entwickler interessiert. Sie müssen nicht wissen, welche zufällige Zeichenfolge mit dem Dienst im Container verbunden ist. Ich glaube, dass dieser Code viel einfacher zu schreiben und zu lesen ist:

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 die Flammen noch nicht gebändigt. Nicht ein kleines bisschen. Das größere Problem ist, dass Sie den Container in die Rolle eines Dienstvermittlers degradieren, was ein großes Manko ist. Das ist so, als würde man jemandem den ganzen Kühlschrank bringen, damit er sich einen einzigen Snack daraus holen kann – es ist viel sinnvoller, ihm nur den Snack zu bringen.

Auch hier geht es bei der Dependency Injection um Transparenz, und dieser Controller ist in Bezug auf seine Abhängigkeiten immer noch nicht transparent. Die Abhängigkeit von einem Authenticator ist für die Außenwelt völlig verborgen, hinter der Abhängigkeit vom Container. Das macht den Code schwieriger zu lesen. Oder zu benutzen. Oder zu testen! Um den Authenticator in einem Unit-Test zu testen, müssen Sie nun einen ganzen Container um ihn herum erstellen.

Der Controller hängt übrigens immer noch von der Definition des Containers ab, und zwar auf eine ziemlich schlechte Art und Weise. Wenn der Authenticator-Dienst im Container nicht existiert, schlägt der Code erst in der Methode action() fehl, was eine ziemlich späte Rückmeldung ist.

Etwas Leckeres kochen

Um fair zu sein, kann Ihnen niemand wirklich einen Vorwurf machen, wenn Sie in diese Sackgasse geraten. Schließlich haben Sie sich einfach an die von cleveren Entwicklern entworfene und bewährte Schnittstelle gehalten. Die Sache ist die, dass alle Dependency Injection Container per Definition auch Service Locators sind, und es stellt sich heraus, dass das Muster wirklich die einzige gemeinsame Schnittstelle unter ihnen ist. Das bedeutet aber nicht, dass man sie als Service-Locators verwenden sollte. Die PSR selbst warnt davor.

So können Sie einen DI-Container als einen guten Diener 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);
        //...
    }
}

Der Controller deklariert die Abhängigkeiten explizit, klar und transparent im Konstruktor. Die Abhängigkeiten sind nicht mehr versteckt in der Klasse verstreut. Sie werden auch erzwungen: Der Container ist nicht in der Lage, eine Instanz von SignInController zu erzeugen, ohne den erforderlichen Authenticator bereitzustellen. Wenn es im Container keinen Authenticator gibt, schlägt die Ausführung früh fehl, nicht in der Methode action(). Auch das Testen dieser Klasse ist viel einfacher geworden, da man nur den Authenticator-Dienst nachbilden muss, ohne jegliche Container-Boilerplate.

Und es gibt noch ein letztes winziges, aber sehr wichtiges Detail: Wir haben die Information über den Typ des Dienstes eingeschleust. Die Tatsache, dass es sich um eine Instanz von Authenticator handelt – zuvor impliziert und unbekannt für die IDE, statische Analysetools oder sogar einen Entwickler, der die Container-Definition nicht kennt – ist nun statisch in den Typehint des Promoted-Parameters eingearbeitet.

Der einzige Schritt, der noch aussteht, ist, dem Container beizubringen, wie er den Controller erstellen soll:

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 werden feststellen, dass der Container intern immer noch den Service-Locator-Ansatz verwendet. Aber das ist in Ordnung, solange er enthalten ist (Wortspiel beabsichtigt). Der einzige Ort außerhalb des Containers, an dem der Aufruf der Methode get akzeptabel ist, befindet sich im index.php, im Einstiegspunkt der Anwendung, an dem Sie den Container selbst erstellen und dann die Anwendung abrufen und ausführen müssen:

$container = bootstrap();

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

Das versteckte Juwel

Aber das ist noch nicht alles: Der einzige Ort, an dem der Aufruf der Methode get zulässig ist, ist der Einstiegspunkt.

Der Code des Containers ist nur Verdrahtung, es sind Montageanweisungen. Es ist kein ausführender Code. Er ist in gewisser Weise nicht wichtig. Er ist zwar entscheidend für die Anwendung, aber nur aus der Sicht des Entwicklers. Er bringt dem Benutzer keinen direkten Nutzen und sollte daher mit diesem Gedanken behandelt werden.

Werfen Sie noch einmal einen Blick auf den Container:

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

Dieser deckt nur einen sehr kleinen und einfachen Teil der Anwendung ab. Wenn die Anwendung wächst, wird das Schreiben des Containers von Hand unglaublich mühsam. Wie ich bereits gesagt habe, ist der Container nur ein Montagehandbuch – aber ein übermäßig kompliziertes, mit vielen Seiten, unzähligen Querverweisen und vielen kleingedruckten Warnungen. Wir wollen daraus eine Anleitung im Stil von IKEA machen, anschaulich, prägnant und mit Abbildungen von Menschen, die lächeln, wenn sie das ÅUTHENTICATÖR beim Zusammenbau auf den Teppich legen, damit es nicht kaputtgeht.

An dieser Stelle kommt Nette Framework ins Spiel.

Die DI-Lösung von Nette Framework verwendet Neon, ein Konfigurationsdateiformat ähnlich wie YAML, aber auf Steroiden. So würden Sie denselben Container mit der Neon-Konfiguration definieren:

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

Lassen Sie mich auf zwei bemerkenswerte Dinge hinweisen: Erstens ist die Liste der Dienste wirklich eine Liste, keine Hash-Map – es gibt keine Schlüssel, keine künstlichen Dienstbezeichner. Es gibt kein authenticator, und auch kein Authenticator::class. Zweitens müssen Sie nirgendwo explizit irgendwelche Abhängigkeiten auflisten, abgesehen von den Parametern für die Datenbankverbindung.

Das liegt daran, dass sich Nette Framework auf die automatische Verdrahtung stützt. Erinnern Sie sich daran, dass wir dank der Dependency Injection den Typ der Abhängigkeit in einem nativen Typehint ausdrücken konnten? Der DI-Container verwendet diese Information, so dass er, wenn Sie eine Instanz von Authenticator benötigen, alle Namen umgeht und den richtigen Dienst nur über seinen Typ findet.

Man könnte argumentieren, dass Autowiring kein einzigartiges Feature ist. Und Sie hätten Recht. Was den Container des Nette Frameworks einzigartig macht, ist die Nutzung des PHP-Typensystems, während in vielen anderen Frameworks das Autowiring intern immer noch auf Dienstnamen aufbaut. Es gibt Szenarien, bei denen andere Container versagen. So würden Sie den Authenticator-Service im DI-Container von Symfony mit YAML definieren:

services:
  Authenticator: ~

Der Abschnitt services ist eine Hash-Map und das Bit Authenticator ist ein Service-Identifikator. Die Tilde steht in YAML für null, was von Symfony als “use the service identifier as its type” interpretiert wird.

Doch schon bald ändern sich die geschäftlichen Anforderungen, und Sie müssen neben einer lokalen Datenbankabfrage auch eine Authentifizierung über LDAP unterstützen. In einem ersten Schritt verwandeln Sie die Klasse Authenticator in eine Schnittstelle und extrahieren die ursprüngliche Implementierung in eine Klasse LocalAuthenticator:

services:
  LocalAuthenticator: ~

Plötzlich ist Symfony ahnungslos. Das liegt daran, dass Symfony mit Dienstnamen statt mit Typen arbeitet. Der Controller verlässt sich immer noch korrekt auf die Abstraktion und listet die Schnittstelle Authenticator als seine Abhängigkeit auf, aber es gibt keinen Servicenamen Authenticator im Container. Sie müssen Symfony einen Hinweis geben, zum Beispiel mit einem Servicenamen Alias:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Nette Framework hingegen benötigt keine Dienstnamen oder Hinweise. Es zwingt Sie nicht dazu, in der Konfiguration die Informationen zu duplizieren, die bereits im Code (über die implements -Klausel) ausgedrückt sind. Es setzt direkt auf dem PHP-Typsystem auf. Es weiß, dass LocalAuthenticator vom Typ Authenticator ist, und solange es der einzige Dienst ist, der die Schnittstelle implementiert, verdrahtet es sie gerne automatisch, wenn die Schnittstelle angefordert wird, wenn man nur diese Zeile der Konfiguration angibt:

services:
    - LocalAuthenticator

Ich gebe zu, dass es sich ein wenig magisch anfühlen könnte, wenn Sie mit Autowiring nicht vertraut sind, und dass Sie vielleicht etwas Zeit brauchen, um zu lernen, ihm zu vertrauen. Glücklicherweise funktioniert es transparent und deterministisch: Wenn der Container Abhängigkeiten nicht eindeutig auflösen kann, wirft er eine Compile-Time-Exception, die Ihnen hilft, die Situation zu beheben. Auf diese Weise können Sie zwei verschiedene Implementierungen haben und trotzdem die Kontrolle darüber behalten, wo jede von ihnen verwendet wird.

Insgesamt bedeutet Autowiring eine geringere kognitive Belastung für Sie als Entwickler. Schließlich kümmern Sie sich nur um Typen und Abstraktionen, warum also sollte ein DI-Container Sie zwingen, sich auch um Implementierungen und Service-Identifikatoren zu kümmern? Und noch wichtiger: Warum sollten Sie sich überhaupt um einen Container kümmern? Im Sinne der Dependency Injection wollen Sie die Abhängigkeiten einfach deklarieren können, und es ist das Problem eines anderen, sie bereitzustellen. Sie wollen sich ganz auf den Anwendungscode konzentrieren und die Verkabelung vergessen. Und die DI des Nette Frameworks erlaubt Ihnen das zu tun.

In meinen Augen macht das die DI-Lösung von Nette Framework zur besten, die es in der PHP-Welt gibt. Sie bietet Ihnen einen Container, der zuverlässig ist und gute architektonische Muster 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 Neugierde geweckt. Schauen Sie sich auf jeden Fall das Github repository und die docs an – hoffentlich werden Sie feststellen, dass ich Ihnen nur die Spitze des Eisbergs gezeigt habe und dass das gesamte Paket weitaus leistungsfähiger ist.