Abhängigkeiten im Verborgenen

Abhängigkeiten im Verborgenen

Gestern habe ich einen Vortrag auf der Bulgaria PHP Conference gehalten (übrigens eine tolle Veranstaltung). Nach einem Ad-hoc-Workshop, den ich als Teil des Hallway-Tracks hielt, und einem unterhaltsamen Hackathon, beschloss ich, dass es zu spät war, um zur Party zu gehen. Stattdessen ging ich mit einigen anderen Speakern zurück zum Hotel.

Als ich sah, wie der Tag in den sozialen Medien reflektiert wurde, trug ich noch ein paar Tweets zu einer Konversation bei, die früher am Tag begonnen hatte (hier ist das Material meines Vortrags, auf das sich die Leute beziehen).

Ich schreibe diesen Artikel, um meinen Punkt zu verdeutlichen und allen zu helfen, besser zu verstehen.

Heutzutage wissen wir alle, dass Dependency Injection eine Best Practice ist, die wir immer dann verwenden sollten, wenn ein Objekt einen Kollaborator hat. Anstatt es an Ort und Stelle zu erstellen (wo wir uns mit seinen Abhängigkeiten beschäftigen müssten), verwenden wir Dependency Injection:

class   Something
{
     private   $collaborator ;
 
     public   function   __construct ( Collaborator   $collaborator )
     {
         $this -> collaborator   =   $collaborator ;
     }
 
     // ...
}

Wir delegieren das Problem der Erstellung des Kollaborators an eine andere Stelle. Das bedeutet, dass wir uns nicht um die Erstellung des kollaborierenden Objekts oder seiner Abhängigkeiten kümmern müssen.

Auf diese Weise können wir so gut wie die gesamte Objekterzeugung an einen Ort verlagern: in die Factory. Es gibt einige Ausnahmen, bei denen Sie die Factory nicht zum Erzeugen eines Objekts verwenden wollen oder müssen. Wertobjekte und Domänenobjekte werden in der Regel an Ort und Stelle erstellt.

Jetzt haben wir eine Fabrik, die alle unsere Objekte erstellen kann. Für jedes Objekt, das sie erstellen kann, hat sie eine Methode:

class   Factory
{
     public   function   createSomething ( )
     {
         return   new   Something ( $this -> createCollaborator ( ) ) ;
     }
 
     private   function   createCollaborator ( )
     {
         return   new   Collaborator ;
     }
 
     // ...
}

Um ein Objekt zu erstellen, muss die Fabrik dessen Abhängigkeiten bereitstellen. Im obigen Beispiel muss das Collaborator-Objekt bereitgestellt werden, wenn ein Something-Objekt erstellt werden soll. Das funktioniert sehr gut, wenn es eine Fabrik gibt. Wenn Sie mehrere Fabriken erstellen, müssen Sie mit Fabriken umgehen, die von anderen Fabriken abhängen, weil sie diese benötigen, um Objekte zu erzeugen. Das wird sehr komplex und frustrierend, also tun Sie es bitte nicht. Erstellen Sie eine Fabrik. (Es gibt Möglichkeiten, eine Factory ohne die genannten Nachteile aufzulösen, aber das würde den Rahmen dieses Artikels sprengen).

Dieser Ansatz funktioniert sehr gut, wenn alle Objekte im Voraus erstellt werden können. Eine reale Anwendung wird jedoch einige Laufzeitentscheidungen darüber treffen müssen, welche Objekte erstellt werden sollen. Typische Beispiele sind die Auswahl eines Command Handlers (oder Controllers, wenn Sie darauf bestehen), der ausgeführt werden soll, oder einer View, die gerendert werden soll. Unter der Annahme, dass Ihre Anwendung mindestens eine solide zweistellige Anzahl von Command Handlern unterstützt, ist es offensichtlich, dass wir nicht alle von ihnen im Voraus erstellen und sie beispielsweise in einen Router injizieren können.

Das Problem, das wir lösen müssen, ist die Objekterzeugung, die auf bestimmten Parametern basiert, die erst „mitten in der Laufzeit“ verfügbar werden, vielleicht weil sie von einer HTTP-Anfrage abgeleitet sind.

class   HttpPostRequestRouter
{
     public   function   route ( HttpPostRequest   $request )
     {
         switch   ( $request -> getParameter ( 'command' ) )   {
             case   'createAccount' :
                 return   new   CreateAccountCommandHandler (
                     /* ... */
                 ) ;
         }
 
         // ...
     }
}

Dieses Beispiel ist vereinfacht, verdeutlicht aber den Punkt. Aber halt: Wir können den Command Handler nicht hier erstellen, denn dann müssten wir uns mit seinen Abhängigkeiten beschäftigen, zu denen zum Beispiel ein AccountRepository gehören könnte. Müssen wir also die Factory injizieren?

class   HttpPostRequestRouter
{
     private   $factory ;
 
     public   function   __construct ( Factory   $factory )
     {
         $this -> factory   =   $factory ;
     }
 
     public   function   route ( HttpPostRequest   $request )
     {
         switch   ( $request -> getParameter ( 'command' ) )   {
             case   'createAccount' :
                 return   $this
                     -> factory
                     -> createCreateAccountCommandHandler (
                         /* ... */
                     ) ;
         }
 
         // ...
     }
}

Täten wir dies, so hätten wir die Verantwortung für die Erstellung des CreateAccountCommandHandler delegiert und müssten uns nicht mit dessen Abhängigkeiten befassen. Aber die Weitergabe der Factory wird allgemein als schlechte Praxis angesehen. Der Grund ist einfach: Wenn Sie Zugriff auf die Fabrik haben, können Sie jedes Objekt erstellen. Das ist einfach zu viel Macht für einen einzelnen Entwickler. Sie haben keinen Zugriff auf die Datenbank? Nun, lassen Sie die Factory einfach eine andere Verbindung für Sie erstellen, und los geht's.

Aber es kommt noch schlimmer. Anstatt die Factory weiterzugeben, haben viele Entwickler begonnen, einen Service Locator herumzureichen. Das sieht dann in etwa so aus:

class   ServiceLocator
{
     private   $factory ;
 
     public   function   __construct ( Factory   $factory )
     {
         $this -> factory   =   $factory ;
     }
 
     public   function   getService ( $identifier )
     {
         switch   ( $identifier )   {
             case   'createAccountCommandHandler' :
                 return   $this
                     -> factory
                     -> createCreateAccountCommandHandler ( ) ;
 
             // ...
         }
     }
}

Dies ist eine implizite API. Um einen „Dienst“ abzurufen, müssen Sie eine Zeichenkette übergeben. Die Methode getService() kann keinen vernünftigen Rückgabetyp deklarieren, weil sie beliebige Objekte zurückgeben kann (solange sie als Dienste deklariert wurden). Das bedeutet: keine Autovervollständigung in der IDE, es sei denn, Ihre IDE macht irgendeine ernsthafte Magie hinter den Kulissen.

Es wäre viel besser, die API explizit zu machen, und mehrere Methoden zu haben, von denen jede genau einen deklarierten Rückgabetyp hat:

class   ServiceLocator
{
     private   $factory ;
 
     public   function   __construct ( Factory   $factory )
     {
         $this -> factory   =   $factory ;
     }
 
     public   function   getCreateAccountCommandHandler ( )
     {
         return   $this -> factory -> createCreateAccountCommandHandler ( ) ;
     }
 
     // ...
}

So vermeiden wir das lange, hässliche case-Statement und hilft der IDE, Autovervollständigung anzubieten, weil klar ist, was zurückgegeben wird. Der Nachteil: Es gibt jetzt viele Methoden. Eine große öffentliche API. So würden wir den Locator verwenden:

class   HttpPostRequestRouter
{
     private   $serviceLocator ;
 
     public   function   __construct ( ServiceLocator   $serviceLocator )
     {
         $this -> serviceLocator   =   $serviceLocator ;
     }
 
     public   function   route ( HttpPostRequest   $request )
     {
         switch   ( $request -> getParameter ( 'command' ) )   {
             case   'createAccount' :
                 return   $this
                     -> serviceLocator
                     -> getCreateAccountCommandHandler (
                         /* ... */
                     ) ;
         }
 
         // ...
     }
}

Der Service Locator entkoppelt unseren Router weiter von der Objekterstellung, die die Factory übernimmt. Aber diese Lösung leidet immer noch unter demselben Problem: Die API ist zu groß. Sie können jeden Dienst lokalisieren (und damit erstellen). Und es ist viel zu einfach, so gut wie jedes Objekt zu einem Dienst zu machen.

Zweifelsohne muss die Auswahl des Befehlshandlers eine Laufzeitentscheidung des Routers sein. Aber die API des Service Locators ist einfach zu groß, um eine Instanz davon herumzureichen. Es ist auch ein Verstoß gegen das Prinzip der Schnittstellentrennung (das „I“ in SOLID), weil es den Router zwingt, von ziemlich vielen API-Methoden abzuhängen, die er nicht verwendet.

Ein Service Locator mit einer impliziten API hat erhebliche Nachteile: Er versteckt Abhängigkeiten. Ich nenne das Abhängigkeiten im Verborgenen, und es ist ein Antipattern. Wenn wir diese Abhängigkeiten explizit machen, haben wir am Ende einen Service Locator, der viele Methoden hat. Aber warum sollten wir nur einen Service Locator erstellen? Ein kleinerer würde für den Router ausreichen:

class   HttpPostRequestRouter
{
     private   $commandHandlerLocator ;
 
     public   function   __construct ( CommandHandlerLocator   $commandHandlerLocator )
     {
         $this -> commandHandlerLocator   =   $commandHandlerLocator ;
     }
 
     public   function   route ( HttpPostRequest   $request )
     {
         return   $this -> commandHandlerLocator -> locateCommandHandlerFor (
             $request -> getParameter ( 'command' )
         ) ;
     }
}

Wie wir sehen können, ist der Router ein Sonderfall: In diesem Beispiel ist wirklich nichts mehr im Router vorhanden. Die gesamte Funktionalität wurde in den Locator verlagert. Der Router ist ein Locator: Er wählt (lokalisiert) den Befehlshandler auf der Grundlage einer Anforderung aus. Im wirklichen Leben würde der Router noch andere Dinge tun, zum Beispiel sicherstellen, dass der Benutzer das Command ausführen darf.

Schauen wir uns ein anderes Beispiel an. Irgendwo müssen wir die View auswählen:

class   Something
{
     private   $viewLocator ;
 
     public   function   __construct ( ViewLocator   $viewLocator )
     {
         $this -> viewLocator   =   $viewLocator ;
     }
 
     public   function   doWork ( )
     {
         // ...
 
         $this -> viewLocator -> locateViewFor ( $result ) ;
 
         // ...
     }
}

Für die Zwecke dieses Beispiels ist es uns egal, was $result ist. Es kann ein Result-Objekt sein, das von einem Command Handler zurückgegeben wird.

All das funktioniert, weil wir die Belange strikt trennen und dem Single Responsibility Principle (dem „S“ in SOLID) folgen: Der Locator wählt Objekte aus und die Factory erzeugt Objekte. Wir können den einen großen Service Locator, von dem viele Entwickler denken, dass sie ihn brauchen, in kleinere (und harmlose) Service Locator aufteilen. Wir können das nicht mit der Factory selbst machen, denn das würde zum „die Fabrik braucht eine weitere Fabrik“ Abhängigkeitsproblem führen.

Es läuft alles auf die Frage hinaus, wann Sie entscheiden können, welche Objekte instanziiert werden sollen.