Metaphysik der Abhängigkeitsinjektion

Bild


Die Abhängigkeitsinjektion ist eine häufig verwendete Technik in der objektorientierten Programmierung, mit der die Konnektivität von Komponenten reduziert werden soll. Bei richtiger Anwendung können Sie nicht nur dieses Ziel erreichen, sondern auch wirklich magische Eigenschaften für Ihre Anwendungen erzielen. Wie jede Magie wird diese Technik als eine Reihe von Zaubersprüchen wahrgenommen und nicht als rigorose wissenschaftliche Abhandlung. Dies führt zu einer Fehlinterpretation der Phänomene und folglich zum Missbrauch von Artefakten. In meinem Autorenmaterial schlage ich vor, dass der Leser Schritt für Schritt, kurz und im Wesentlichen, den logischen Weg von den entsprechenden Grundlagen des objektorientierten Designs bis zur Magie der automatischen Abhängigkeitsinjektion beschreitet.

Das Material basiert auf der Entwicklung des Hypo IoC-Containers , den ich in einem früheren Artikel erwähnt habe . In Miniatur-Codebeispielen verwende ich Ruby als eine der übersichtlichsten objektorientierten Sprachen, um kurze Beispiele zu schreiben. Dies sollte Entwicklern in anderen Sprachen keine Probleme bereiten.

Stufe 1: Prinzip der Abhängigkeitsinversion


Entwickler im objektorientierten Paradigma sind täglich mit der Schaffung von Objekten konfrontiert, die wiederum von anderen Objekten abhängen können. Dies führt zu einem Abhängigkeitsgraphen. Angenommen, wir haben es mit einem Objektmodell der Form zu tun:
Bild

- ein Abrechnungsservice (InvoiceProcessor) und ein Benachrichtigungsservice (NotificationService). Der Rechnungsbearbeitungsdienst sendet Benachrichtigungen, wenn bestimmte Bedingungen erfüllt sind. Wir werden diese Logik aus dem Geltungsbereich entfernen. Grundsätzlich ist dieses Modell bereits insofern gut, als einzelne Komponenten für unterschiedliche Verantwortlichkeiten verantwortlich sind. Das Problem liegt darin, wie wir diese Abhängigkeiten implementieren. Ein häufiger Fehler besteht darin, eine Abhängigkeit zu initialisieren, in der diese Abhängigkeit verwendet wird:

class InvoiceProcessor def process(invoice) #      notificationService = NotificationService.new notificationService.notify(invoice.owner) end end 

Dies ist ein Fehler angesichts der Tatsache, dass wir eine hohe Konnektivität von logisch unabhängigen Objekten erhalten (High Coupling). Dies führt zu einer Verletzung des Grundsatzes der einheitlichen Verantwortung - ein abhängiges Objekt muss zusätzlich zu seiner unmittelbaren Verantwortung seine Abhängigkeiten initialisieren; und auch die Schnittstelle des Abhängigkeitskonstruktors „kennen“, was zu einem zusätzlichen Änderungsgrund führt ( „Änderungsgrund“, R. Martin ). Es ist richtiger, diese Art von Abhängigkeit zu übergeben, die außerhalb des abhängigen Objekts initialisiert wurde:

 class InvoiceProcessor def initialize(notificationService) @notificationService = notificationService end def process(invoice) @notificationService.notify(invoice.owner) end end notificationService = NotificationService.new invoiceProcessor = InvoiceProcessor.new(notificationService) 

Dieser Ansatz steht im Einklang mit dem Prinzip der Abhängigkeitsinversion. Jetzt übertragen wir ein Objekt mit einer Schnittstelle zum Senden von Nachrichten. Der Abrechnungsdienst muss nicht mehr wissen, wie er das Benachrichtigungsdienstobjekt erstellt. Beim Schreiben von Komponententests für einen Rechnungsverarbeitungsdienst muss der Entwickler nicht überlegen, wie die Implementierung der Benachrichtigungsdienstschnittstelle durch einen Stub ersetzt werden soll. In Sprachen mit dynamischer Typisierung wie Ruby können Sie jedes Objekt ersetzen, das der Benachrichtigungsmethode entspricht. Mit statischer Typisierung wie C # / Java können Sie die INotificationService-Schnittstelle verwenden, für die es einfach ist, einen Mock zu erstellen. Das Thema der Abhängigkeitsinversion wurde von Alexander Byndyu in einem Artikel , der kürzlich sein 10-jähriges Bestehen feierte, ausführlich beschrieben!

Ebene 2: Registrierung verwandter Objekte


Die Anwendung des Prinzips der Abhängigkeitsinversion scheint keine komplizierte Übung zu sein. Im Laufe der Zeit treten jedoch aufgrund der zunehmenden Anzahl von Objekten und Beziehungen neue Herausforderungen auf. NotificationService kann von anderen Diensten als InvoiceProcessor verwendet werden. Darüber hinaus kann er selbst von anderen Diensten abhängig sein, die wiederum von Dritten abhängen, usw. Einige Komponenten können auch nicht immer in einer einzigen Kopie verwendet werden. Die Hauptaufgabe besteht darin, die Antwort auf die Frage zu finden: Wann müssen Abhängigkeiten erstellt werden?
Um dieses Problem zu beheben, können Sie versuchen, eine Lösung zu erstellen, die auf einem assoziativen Array von Abhängigkeiten basiert. Eine beispielhafte Oberfläche seiner Arbeit könnte folgendermaßen aussehen:

 registry.add(InvoiceProcessor) .depends_on(NotificationService) registry.add(NotificationService) .depends_on(ServiceX) invoiceProcessor = registry.resolve(InvoiceProcessor) invoiceProcessor.process(invoice) 

Es ist nicht schwer in die Praxis umzusetzen:

Bild

Bei jedem Aufruf von container.resolve () wenden wir uns an die Factory, die Abhängigkeitsinstanzen erstellt und dabei das in der Registrierung beschriebene Abhängigkeitsdiagramm rekursiv umgeht. Im Falle von container.resolve (InvoiceProcessor) wird folgendes ausgeführt:

  1. factory.resolve (InvoiceProcessor) - Die Factory fordert die InvoiceProcessor-Abhängigkeiten im Register an und erhält einen NotificationService, der ebenfalls zusammengestellt werden muss.
  2. factory.resolve (NotificationService) - Die Factory, die NotificationService-Abhängigkeiten im Register anfordert, empfängt ServiceX, das ebenfalls zusammengestellt werden muss.
  3. factory.resolve (ServiceX) - hat keine Abhängigkeiten, erstellt, kehrt zum Aufrufstapel zu Schritt 1 zurück und ruft ein zusammengesetztes Objekt vom Typ InvoiceProcessor ab.

Jede Komponente kann von mehreren anderen abhängen. Die naheliegende Frage lautet also: Wie werden die Designer-Parameter den resultierenden Abhängigkeitsinstanzen korrekt zugeordnet? Ein Beispiel:

 class InvoiceProcessor def initialize(notificationService, paymentService) # ... end end 

In Sprachen mit statischer Typisierung kann der Parametertyp als Selektor dienen:

 class InvoiceProcessor { constructor(notificationService: NotificationService, paymentService: PaymentService) { // ... } } 

In Ruby können Sie die Konvention verwenden - verwenden Sie einfach den Typnamen im Format snake_case. Dies ist der erwartete Parametername.

Stufe 3: Verwaltung der Lebensdauer von Abhängigkeiten


Wir haben bereits eine gute Lösung für das Abhängigkeitsmanagement. Die einzige Einschränkung besteht darin, dass bei jedem Aufruf eine neue Instanz der Abhängigkeit erstellt werden muss. Was aber, wenn wir nicht mehr als eine Instanz einer Komponente erstellen können? Zum Beispiel ein Pool von Verbindungen zur Datenbank. Tiefer graben, und wenn wir eine kontrollierte Lebensdauer von Abhängigkeiten bieten müssen? Schließen Sie beispielsweise die Verbindung zur Datenbank nach Abschluss der HTTP-Anforderung.
Es wird deutlich, dass InstanceFactory als Ersatzkandidat für die ursprüngliche Lösung in Frage kommt. Aktualisiertes Diagramm:

Bild

Die logische Lösung besteht darin, eine Reihe von Strategien ( Strategy, GoF ) zu verwenden, um Instanzen von Komponenten zu erhalten. Jetzt erstellen wir beim Aufrufen von Container :: resolve nicht immer neue Instanzen. Daher ist es angebracht, Factory in Resolver umzubenennen. Bitte beachten Sie, dass die Container :: register-Methode einen neuen Parameter hat - life_time (lifetime). Dieser Parameter ist optional. Standardmäßig ist sein Wert "transient" (transient), was dem zuvor implementierten Verhalten entspricht. Die Singleton-Strategie ist auch offensichtlich: Bei ihrer Verwendung wird nur eine Instanz der Komponente erstellt, die jedes Mal zurückgegeben wird.
Scope ist eine etwas komplexere Strategie. Anstelle von „vorübergehenden Pfaden“ und „Einzelgängern“ muss häufig etwas dazwischen verwendet werden - eine Komponente, die während des gesamten Lebens einer anderen Komponente vorhanden ist. Ein ähnliches Beispiel kann ein Webanwendungsanforderungsobjekt sein, bei dem es sich um den Kontext des Vorhandenseins von Objekten handelt, z. B. HTTP-Parameter, Datenbankverbindung, Modellaggregate. Während der gesamten Gültigkeitsdauer der Anforderung sammeln und verwenden wir diese Abhängigkeiten. Nach deren Zerstörung gehen wir davon aus, dass auch alle diese Abhängigkeiten zerstört werden. Um diese Funktionalität zu implementieren, muss eine ziemlich komplexe, geschlossene Objektstruktur entwickelt werden:

Bild

Das Diagramm zeigt ein Fragment, das Änderungen in den Klassen Component und LifetimeStrategy im Kontext der Implementierung der Gültigkeitsdauer des Gültigkeitsbereichs widerspiegelt. Das Ergebnis war eine Art „Doppelbrücke“ (ähnlich der Bridge, GoF- Vorlage). Mithilfe der komplexen Vererbungs- und Aggregationstechniken wird Component zum Kern des Containers. Das Diagramm ist übrigens mehrfach vererbt. Wo Programmiersprache und Gewissen es erlauben, können Sie es so belassen. In Ruby verwende ich Verunreinigungen. In anderen Sprachen können Sie die Vererbung durch eine andere Brücke ersetzen:
Bild

Das Sequenzdiagramm zeigt den Lebenszyklus der Sitzungskomponente, der an die Lebensdauer der Anforderungskomponente gebunden ist:

Bild

Wie Sie aus dem Diagramm ersehen können, wird zu einem bestimmten Zeitpunkt, wenn die Anforderungskomponente ihre Mission abgeschlossen hat, die Freigabemethode aufgerufen, mit der der Prozess des Zerstörens des Gültigkeitsbereichs gestartet wird.

Stufe 4: Abhängigkeitsinjektion


Bisher habe ich darüber gesprochen, wie man die Registrierung von Abhängigkeiten ermittelt und dann Komponenten gemäß dem Diagramm der gebildeten Beziehungen erstellt und zerstört. Und wofür ist es? Angenommen, wir verwenden dies als Teil von Ruby on Rails:

 class InvoiceController < ApplicationController def pay(params) invoice_repository = registry.resolve(InvoiceRepository) invoice_processor = registry.resolve(InvoiceProcessor) invoice = invoice_repository.find(params[:id]) invoice_processor.pay(invoice) end end 

Code, der auf diese Weise geschrieben wird, ist nicht lesbarer, testbarer oder flexibler. Rails kann nicht dazu gezwungen werden, Controller-Abhängigkeiten über seinen Konstruktor zu injizieren, da dies nicht vom Framework bereitgestellt wird. In ASP.NET MVC wird dies jedoch beispielsweise auf einer grundlegenden Ebene implementiert. Um den automatischen Mechanismus zur Auflösung von Abhängigkeiten optimal zu nutzen, müssen Sie die Inversion of Control-Technik (IoC, Inversion of Control) implementieren. Dies ist ein Ansatz, bei dem die Verantwortung für das Auflösen von Abhängigkeiten über den Anwendungscode hinausgeht und beim Framework liegt. Betrachten Sie ein Beispiel.
Stellen Sie sich vor, wir entwerfen so etwas wie Rails von Grund auf neu. Wir implementieren das folgende Schema:

Bild

Die Anwendung empfängt die Anforderung, der Router ruft die Parameter ab und weist den entsprechenden Controller an, diese Anforderung zu verarbeiten. Ein solches Schema kopiert bedingt das Verhalten eines typischen Webframeworks mit nur geringem Unterschied - der IoC-Container ist an der Erstellung und Implementierung von Abhängigkeiten beteiligt. Aber hier stellt sich die Frage, wo der Container selbst entsteht. Um möglichst viele Objekte der zukünftigen Anwendung abzudecken, muss unser Framework bereits in einem sehr frühen Stadium seines Betriebs einen Container erstellen. Offensichtlich gibt es keinen passenderen Ort als die App Builder App. Es ist auch der am besten geeignete Ort, um alle Abhängigkeiten zu konfigurieren:

 class App #   - ,      . def initialize @container = Container.new @container .register(Controller) .using_lifetime(:transient) # ,     @container .register(InvoiceService) .using_lifetime(:singleton) # ,     @container .register(Router) .using_lifetime(:singleton) #  end #     -     , #      . def call(env) router = @container.resolve(Router) router.handle(env.path, env.method, env.params) end end 

Jede Anwendung hat einen Einstiegspunkt, beispielsweise die Hauptmethode. In diesem Beispiel ist der Einstiegspunkt die Aufrufmethode. Ziel dieser Methode ist es, den Router aufzurufen, um eingehende Anforderungen zu verarbeiten. Der Einstiegspunkt sollte der einzige Ort sein, an dem der Container direkt angerufen werden kann - von diesem Moment an sollte der Container auf der Strecke bleiben, und alle nachfolgenden Zaubereien sollten „unter der Haube“ stattfinden. Die Implementierung des Controllers in einer solchen Architektur sieht wirklich ungewöhnlich aus. Obwohl wir es nicht explizit instanziieren, hat es einen Konstruktor mit Parametern:

 class Controller #   . #    . def initialize(invoice_service) @invoice_service = invoice_service end def create_invoice(params) @invoice_service.create(params) end end 

Die Umgebung "versteht", wie Controller-Instanzen erstellt werden. Dies ist dank des Abhängigkeitsinjektionsmechanismus möglich, der durch den im Herzen der Webanwendung eingebetteten IoC-Container bereitgestellt wird. Im Konstruktor des Controllers können Sie nun alles auflisten, was für dessen Betrieb erforderlich ist. Hauptsache, die entsprechenden Komponenten sind im Container registriert. Wenden wir uns nun der Router-Implementierung zu:

 class Router #         -  #      #     . def initialize(controller) @controller = controller end def handle(path, method, params) #  ""- if path == '/invoices' && method == 'POST' @controller.create(params) end end end 

Beachten Sie, dass der Router vom Controller abhängig ist. Wenn wir uns an die Abhängigkeitseinstellungen erinnern, ist der Controller eine kurzlebige Komponente und der Router ein ständiger Einzelgänger. Wie kann das sein? Die Antwort ist, dass die Komponenten keine Instanzen der entsprechenden Klassen sind, wie es von außen aussieht. Tatsächlich handelt es sich hierbei um Proxy-Objekte ( Proxy, GoF ) mit der Instanz der Factory-Methode ( Factory-Methode, GoF ). Sie geben eine Instanz der Komponente gemäß der zugewiesenen Strategie zurück. Da der Controller als "transient" registriert ist, behandelt der Router beim Zugriff immer seine neue Instanz. Das Sequenzdiagramm zeigt einen ungefähren Arbeitsmechanismus:

Bild

Das heißt Neben dem Abhängigkeitsmanagement übernimmt ein gutes Framework, das auf einem IoC-Container basiert, auch die Verantwortung für das korrekte Management der Lebensdauer von Komponenten.

Fazit


Die Abhängigkeitsinjektionstechnik kann eine ziemlich ausgefeilte interne Implementierung haben. Dies ist der Preis für die Übertragung der Komplexität der Implementierung flexibler Anwendungen in den Kern des Frameworks. Der Benutzer solcher Frameworks kann sich nicht um die rein technischen Aspekte kümmern, sondern mehr Zeit für die komfortable Entwicklung der Geschäftslogik von Anwendungsprogrammen aufwenden. Mit einer hochwertigen DI-Implementierung schreibt ein Anwendungsprogrammierer zunächst testbaren, gut unterstützten Code. Ein gutes Beispiel für die Implementierung von Dependency Injection ist das Dandy- Framework, das in meinem vorherigen Artikel Orthodox Backend beschrieben wurde .

Source: https://habr.com/ru/post/de480364/


All Articles