Hold the Framework – Untersuchung von Abhängigkeitsinjektionsmustern
Veröffentlicht: 2022-03-11Herkömmliche Ansichten zur Inversion of Control (IoC) scheinen eine scharfe Grenze zwischen zwei verschiedenen Ansätzen zu ziehen: dem Service Locator und den Dependency Injection (DI)-Mustern.
Praktisch jedes Projekt, das ich kenne, enthält ein DI-Framework. Die Leute fühlen sich von ihnen angezogen, weil sie eine lockere Kopplung zwischen Clients und ihren Abhängigkeiten (normalerweise durch Konstruktorinjektion) mit minimalem oder keinem Boilerplate-Code fördern. Während dies für eine schnelle Entwicklung großartig ist, finden einige Leute, dass es das Nachverfolgen und Debuggen von Code erschweren kann. Die „Magie hinter den Kulissen“ wird meist durch Reflexion erreicht, was eine ganze Reihe neuer Probleme mit sich bringen kann.
In diesem Artikel untersuchen wir ein alternatives Muster, das sich gut für Java 8+ und Kotlin-Codebasen eignet. Es behält die meisten Vorteile eines DI-Frameworks bei, ist aber so unkompliziert wie ein Service Locator, ohne dass externe Tools erforderlich sind.
Motivation
- Vermeiden Sie externe Abhängigkeiten
- Reflexion vermeiden
- Fördern Sie die Konstruktorinjektion
- Laufzeitverhalten minimieren
Ein Beispiel
Im folgenden Beispiel modellieren wir eine TV-Implementierung, bei der verschiedene Quellen zum Abrufen von Inhalten verwendet werden können. Wir müssen ein Gerät konstruieren, das Signale von verschiedenen Quellen (z. B. terrestrisch, Kabel, Satellit usw.) empfangen kann. Wir werden die folgende Klassenhierarchie aufbauen:
Beginnen wir nun mit einer traditionellen DI-Implementierung, bei der ein Framework wie Spring alles für uns verdrahtet:
public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }
Wir bemerken einiges:
- Die TV-Klasse drückt eine Abhängigkeit von einer TvSource aus. Ein externes Framework sieht dies und fügt eine Instanz einer konkreten Implementierung (terrestrisch oder Kabel) ein.
- Das Konstruktor-Injektionsmuster ermöglicht einfaches Testen, da Sie problemlos TV-Instanzen mit alternativen Implementierungen erstellen können.
Wir haben einen guten Start hingelegt, aber wir erkennen, dass die Einführung eines DI-Frameworks dafür ein bisschen übertrieben sein könnte. Einige Entwickler haben Probleme beim Debuggen von Konstruktionsproblemen gemeldet (lange Stack-Traces, nicht nachvollziehbare Abhängigkeiten). Unser Kunde hat auch zum Ausdruck gebracht, dass die Herstellungszeiten etwas länger als erwartet sind und unser Profiler Verlangsamungen bei reflektierenden Anrufen zeigt.
Eine Alternative wäre die Anwendung des Service Locator-Musters. Es ist unkompliziert, verwendet keine Reflektion und könnte für unsere kleine Codebasis ausreichend sein. Eine andere Alternative besteht darin, die Klassen in Ruhe zu lassen und den Code für die Abhängigkeitsposition um sie herum zu schreiben.
Nachdem wir viele Alternativen evaluiert haben, entscheiden wir uns für die Implementierung als Hierarchie von Anbieterschnittstellen. Jede Abhängigkeit hat einen zugeordneten Anbieter, der die alleinige Verantwortung dafür trägt, die Abhängigkeiten einer Klasse zu lokalisieren und eine eingefügte Instanz zu erstellen. Wir werden den Anbieter auch zu einer inneren Schnittstelle für die Benutzerfreundlichkeit machen. Wir nennen es Mixin Injection, weil jeder Anbieter mit anderen Anbietern gemischt wird, um seine Abhängigkeiten zu lokalisieren.
Die Details, warum ich mich für diese Struktur entschieden habe, sind in Details und Begründung ausgearbeitet, aber hier ist die Kurzversion:
- Es trennt das Abhängigkeitsstandortverhalten.
- Das Erweitern von Schnittstellen fällt nicht in das Diamantproblem.
- Schnittstellen haben Standardimplementierungen.
- Fehlende Abhängigkeiten verhindern Kompilierung (Bonuspunkte!).
Das folgende Diagramm zeigt, wie die Abhängigkeiten und die Anbieter interagieren, und die Implementierung wird unten veranschaulicht. Wir fügen auch eine Hauptmethode hinzu, um zu demonstrieren, wie wir unsere Abhängigkeiten zusammensetzen und ein TV-Objekt konstruieren können. Eine längere Version dieses Beispiels finden Sie auch auf diesem GitHub.
public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }
Ein paar Anmerkungen zu diesem Beispiel:
- Die TV-Klasse hängt von einer TvSource ab, kennt aber keine Implementierung.
- Der TV.Provider erweitert den TvSource.Provider, da er die tvSource()-Methode benötigt, um eine TvSource zu erstellen, und sie verwenden kann, selbst wenn sie dort nicht implementiert ist.
- Die terrestrischen und Kabelquellen können vom Fernseher austauschbar verwendet werden.
- Die Schnittstellen Terrestrial.Provider und Cable.Provider bieten konkrete TvSource-Implementierungen.
- Die Hauptmethode hat eine konkrete Implementierung MainContext von TV.Provider, die zum Abrufen einer TV-Instanz verwendet wird.
- Das Programm erfordert zur Kompilierzeit eine TvSource.Provider-Implementierung, um einen Fernseher zu instanziieren, daher schließen wir Cable.Provider als Beispiel ein.
Details und Begründung
Wir haben das Muster in Aktion und einige der Gründe dafür gesehen. Sie sind vielleicht noch nicht davon überzeugt, dass Sie es jetzt verwenden sollten, und Sie hätten Recht; es ist nicht gerade eine Wunderwaffe. Ich persönlich glaube, dass es dem Service-Locator-Muster in den meisten Aspekten überlegen ist. Im Vergleich zu DI-Frameworks muss man jedoch abwägen, ob die Vorteile den Aufwand für das Hinzufügen von Boilerplate-Code überwiegen.
Anbieter erweitern andere Anbieter, um ihre Abhängigkeiten zu lokalisieren
Wenn ein Anbieter einen anderen erweitert, werden Abhängigkeiten miteinander verbunden. Dies stellt die grundlegende Grundlage für eine statische Validierung bereit, die die Erstellung ungültiger Kontexte verhindert.
Einer der Hauptschmerzpunkte des Dienstlokalisierungsmusters besteht darin, dass Sie eine generische GetService<T>()
Methode aufrufen müssen, die Ihre Abhängigkeit irgendwie auflöst. Zur Kompilierzeit haben Sie keine Garantie dafür, dass die Abhängigkeit jemals im Locator registriert wird, und Ihr Programm könnte zur Laufzeit fehlschlagen.
Das DI-Muster spricht dies auch nicht an. Die Auflösung von Abhängigkeiten erfolgt normalerweise durch Reflektion durch ein externes Tool, das dem Benutzer größtenteils verborgen bleibt und auch zur Laufzeit fehlschlägt, wenn Abhängigkeiten nicht erfüllt werden. Tools wie CDI von IntelliJ (nur in der kostenpflichtigen Version verfügbar) bieten ein gewisses Maß an statischer Verifizierung, aber nur Dagger mit seinem Annotation-Präprozessor scheint dieses Problem von Haus aus anzugehen.
Klassen behalten die typische Konstruktorinjektion des DI-Musters bei
Dies ist nicht erforderlich, wird aber von der Entwickler-Community definitiv gewünscht. Einerseits können Sie sich einfach den Konstruktor ansehen und sehen sofort die Abhängigkeiten der Klasse. Andererseits ermöglicht es die Art von Unit-Tests, an die sich viele Menschen halten, nämlich das Testobjekt mit Scheinen seiner Abhängigkeiten zu erstellen.

Dies bedeutet nicht, dass andere Muster nicht unterstützt werden. Man könnte sogar feststellen, dass Mixin Injection das Erstellen komplexer Abhängigkeitsdiagramme zum Testen vereinfacht, da Sie nur eine Kontextklasse implementieren müssen, die den Anbieter Ihres Themas erweitert. Der MainContext
ist ein perfektes Beispiel, bei dem alle Schnittstellen Standardimplementierungen haben, sodass er eine leere Implementierung haben kann. Das Ersetzen einer Abhängigkeit erfordert nur das Überschreiben ihrer Anbietermethode.
Schauen wir uns den folgenden Test für die TV-Klasse an. Es muss einen Fernseher instanziieren, aber anstatt den Klassenkonstruktor aufzurufen, verwendet es die TV.Provider-Schnittstelle. Der TvSource.Provider hat keine Standardimplementierung, also müssen wir ihn selbst schreiben.
public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }
Jetzt fügen wir der TV-Klasse eine weitere Abhängigkeit hinzu. Die CathodeRayTube-Abhängigkeit bewirkt, dass ein Bild auf dem Fernsehbildschirm erscheint. Es ist von der TV-Implementierung entkoppelt, da wir in Zukunft vielleicht auf LCD oder LED umsteigen wollen.
public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }
Wenn Sie dies tun, werden Sie feststellen, dass der Test, den wir gerade geschrieben haben, immer noch wie erwartet kompiliert und bestanden wird. Wir haben dem TV eine neue Abhängigkeit hinzugefügt, aber wir haben auch eine Standardimplementierung bereitgestellt. Das bedeutet, dass wir es nicht nachahmen müssen, wenn wir nur die echte Implementierung verwenden möchten, und unsere Tests können komplexe Objekte mit jeder gewünschten Ebene der nachgeahmten Granularität erstellen.
Dies ist praktisch, wenn Sie etwas Bestimmtes in einer komplexen Klassenhierarchie verspotten möchten (z. B. nur die Datenbankzugriffsschicht). Das Muster ermöglicht die einfache Einrichtung der Art von geselligen Tests, die manchmal Einzeltests vorgezogen werden.
Unabhängig von Ihrer Präferenz können Sie sicher sein, dass Sie sich jeder Form von Tests zuwenden können, die Ihren Anforderungen in jeder Situation besser entspricht.
Vermeiden Sie externe Abhängigkeiten
Wie Sie sehen können, gibt es keine Verweise oder Erwähnungen auf externe Komponenten. Dies ist der Schlüssel für viele Projekte, die Größen- oder sogar Sicherheitsbeschränkungen haben. Es hilft auch bei der Interoperabilität, da Frameworks sich nicht auf ein bestimmtes DI-Framework festlegen müssen. In Java gab es Bemühungen wie JSR-330 Dependency Injection für Java Standard, die Kompatibilitätsprobleme mindern.
Reflexion vermeiden
Service-Locator-Implementierungen verlassen sich normalerweise nicht auf Reflektion, aber DI-Implementierungen tun dies (mit der bemerkenswerten Ausnahme von Dagger 2). Dies hat den Hauptnachteil, dass der Anwendungsstart verlangsamt wird, da das Framework Ihre Module scannen, den Abhängigkeitsgraphen auflösen, Ihre Objekte reflektierend erstellen muss usw.
Mixin Injection erfordert, dass Sie den Code schreiben, um Ihre Dienste zu instanziieren, ähnlich wie beim Registrierungsschritt im Dienstlokalisierungsmuster. Durch diese kleine zusätzliche Arbeit werden reflektierende Aufrufe vollständig entfernt, wodurch Ihr Code schneller und unkomplizierter wird.
Zwei Projekte, die kürzlich meine Aufmerksamkeit erregt haben und davon profitieren, Reflexionen zu vermeiden, sind Graals Substrate VM und Kotlin/Native. Beide werden in nativen Bytecode kompiliert, und dies erfordert, dass der Compiler im Voraus über alle reflektierenden Aufrufe informiert ist, die Sie vornehmen werden. Im Fall von Graal ist es in einer JSON-Datei angegeben, die schwer zu schreiben ist, nicht statisch überprüft werden kann und nicht einfach mit Ihren bevorzugten Tools umgestaltet werden kann. Die Verwendung von Mixin Injection, um Reflexionen von vornherein zu vermeiden, ist eine großartige Möglichkeit, die Vorteile der nativen Kompilierung zu nutzen.
Laufzeitverhalten minimieren
Indem Sie die erforderlichen Schnittstellen implementieren und erweitern, bauen Sie den Abhängigkeitsgraphen Stück für Stück auf. Jeder Anbieter sitzt neben der konkreten Umsetzung, die Ordnung und Logik in Ihr Programm bringt. Diese Art der Schichtung wird Ihnen vertraut sein, wenn Sie zuvor das Mixin-Muster oder das Kuchenmuster verwendet haben.
An dieser Stelle könnte es sich lohnen, über die MainContext-Klasse zu sprechen. Es ist die Wurzel des Abhängigkeitsgraphen und kennt das Gesamtbild. Diese Klasse umfasst alle Anbieterschnittstellen und ist der Schlüssel zum Aktivieren statischer Prüfungen. Wenn wir auf das Beispiel zurückgehen und Cable.Provider aus seiner Implementierungsliste entfernen, sehen wir dies deutlich:
static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider
Was hier passiert ist, ist, dass die App die konkrete zu verwendende TvSource nicht angegeben hat und der Compiler den Fehler abgefangen hat. Mit Service Locator und Reflection-based DI hätte dieser Fehler unbemerkt bleiben können, bis das Programm zur Laufzeit abstürzte – selbst wenn alle Unit-Tests erfolgreich waren! Ich glaube, diese und die anderen Vorteile, die wir gezeigt haben, überwiegen die Nachteile des Schreibens der Boilerplate, die erforderlich ist, damit das Muster funktioniert.
Zirkuläre Abhängigkeiten abfangen
Kehren wir zum CathodeRayTube-Beispiel zurück und fügen eine zirkuläre Abhängigkeit hinzu. Angenommen, wir möchten, dass eine TV-Instanz eingefügt wird, also erweitern wir TV.Provider:
public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }
Der Compiler erlaubt keine zyklische Vererbung und wir können diese Art von Beziehung nicht definieren. Die meisten Frameworks schlagen in diesem Fall zur Laufzeit fehl, und Entwickler neigen dazu, dies zu umgehen, nur um das Programm zum Laufen zu bringen. Auch wenn dieses Anti-Pattern in der realen Welt zu finden ist, ist es normalerweise ein Zeichen für schlechtes Design. Wenn der Code nicht kompiliert werden kann, sollten wir ermutigt werden, nach besseren Lösungen zu suchen, bevor es für eine Änderung zu spät ist.
Bewahren Sie die Einfachheit in der Objektkonstruktion
Eines der Argumente für SL gegenüber DI ist, dass es unkompliziert und einfacher zu debuggen ist. Aus den Beispielen geht hervor, dass das Instanziieren einer Abhängigkeit nur eine Kette von Provider-Methodenaufrufen ist. Um die Quelle einer Abhängigkeit zurückzuverfolgen, müssen Sie einfach in den Methodenaufruf einsteigen und sehen, wo Sie landen. Das Debuggen ist einfacher als bei beiden Alternativen, da Sie direkt vom Anbieter genau dorthin navigieren können, wo Abhängigkeiten instanziiert werden.
Lebensdauer
Einem aufmerksamen Leser ist vielleicht aufgefallen, dass diese Implementierung das Problem der Lebensdauer nicht anspricht. Alle Aufrufe von Provider-Methoden instanziieren neue Objekte, was dem Prototype-Bereich von Spring ähnelt.
Diese und andere Überlegungen sind etwas außerhalb des Rahmens dieses Artikels, da ich lediglich die Essenz des Musters ohne störende Details präsentieren wollte. Die vollständige Nutzung und Implementierung in einem Produkt müsste jedoch die vollständige Lösung mit lebenslangem Support berücksichtigen.
Fazit
Unabhängig davon, ob Sie an Abhängigkeitsinjektionsframeworks gewöhnt sind oder Ihre eigenen Service Locators schreiben, möchten Sie vielleicht diese Alternative erkunden. Erwägen Sie die Verwendung des Mixin-Musters, das wir gerade gesehen haben, und sehen Sie, ob Sie Ihren Code sicherer und einfacher zu begründen machen können.