Dependency Injection

Lesezeit: 12 Minuten

Dependency Injection (DI) ist ein Design Pattern zur Umsetzung von Inversion of Control. DI verlagert Auflösung und Erstellung von Abhängigkeiten einer Instanz nach außerhalb dieser Instanz. Durch die Trennung von Erstellung und Verwendung einer Abhängigkeit erreicht DI damit Separation of Concerns. Der Begriff Dependency Injection wurde durch Martin Fowler in einem im Jahr 2004 von ihm verfassten Artikel eingeführt und hat sich seitdem etabliert.

Realisierung

Zur Umsetzung von DI sind vier Partien involviert:

  • Als Dependant Class oder Client bezeichnen wir eine Klasse, die Abhängigkeiten hat, welche über DI gesetzt werden.
  • Abhängigkeiten werden im Client üblicherweise über Interfaces deklariert.
  • Die Dependencies sind die konkreten Implementierungen der Abhängigkeiten.
  • Der Injector löst die Dependencies anhand der Interfaces auf und setzt diese in die Client-Instanz hinein.

Das Muster folgt dem Motto „Ein Client, der Services benutzen möchte, sollte kein Wissen darüber verfügen müssen, wie diese Services zu instanzieren sind“. Als eine von vielen möglichen Analogien zur echten Welt könnt man sagen: „Ein junger Mensch, der gerade seinen Führerschein gemacht hat und ein Auto fahren möchte, sollte dieses nicht selbst zusammenbauen müssen.“

Interfaces sind ein optionaler Teilnehmer in dem oben beschriebenen Geflecht und häufig die bessere Wahl. Übertragen auf das Echte-Welt-Beispiel möchte der junge Mensch nach seiner Führerscheinprüfung alle PKW fahren können, nicht nur ein bestimmtes Modell.

DI fördert die Einhaltung zweier SOLID-Kriterien. Durch die Auslagerung der Verantwortung aus dem Client, konkrete Kenntnisse über die Instanzierung von Abhängigkeiten zu haben, kann dieser eher dem Single Responsibility Principle (SRP) genügen. Die Abstraktion durch Interfaces entspricht dem Dependency Inversion Principle (DIP). Der Client besitzt keine Kenntnisse über die internen Funktionsweisen der Implementierungen, von denen er abhängt, sondern interagiert mit diesen nur über das festgelegte Interface. Dementsprechend wird das DIP verletzt, wenn der Client Abhängigkeiten als konkrete Implementierungen eingebunden hat. Einer Umsetzung von DI steht dies nicht im Weg.

Arten von Dependency Injection

Es gibt verschiedene Arten, DI zu implementieren:

  • Constructor Injection: Abhängigkeiten werden als Parameter an den Konstruktor gegeben.
  • Setter (Property) Injection: Abhängigkeiten werden durch Setter oder direkt in die jeweilige Property gesetzt.
  • Interface Injection: Der Client implementiert ein Interface, um die Abhängigkeit injiziert zu bekommen.

Bei DI handelt es sich um eine Form von Parameter Passing, wobei in allen drei Fällen der Aufruf vom Injector vorgenommen wird. Constructor Injection ist, wenn die Möglichkeit besteht, der bevorzugte Weg, da nur hiermit Immutability erreicht werden kann. Property Injection, also die direkte Interaktion des Injectors mit den Properties des Clients erfordert je nach Sprache Techniken wie Reflection. Interface Injection hat von außen betrachtet den größten Overhead, da der Client für jede Abhängigkeit ein eigenes Interface implementieren muss.

Umsetzung von Dependency Injection

Das folgende Beispiel zeigt, wie Dependency Injection in Java ohne Einsatz eines Frameworks umgesetzt werden kann. Um den Einsatz von Reflection auf ein Minimum zu begrenzen, habe ich den Ansatz der Interface Injection gewählt.

interface Dep1 { void doSmth(); }

interface Dep2 { void execThis(); }

interface NeedsDep1 { void inject(Dep1 dep1); }

interface NeedsDep2 { void inject(Dep2 dep2); }

class Dep1Impl implements Dep1 {
    public void doSmth() { System.out.println("Foo");
} }

class Dep2Impl implements Dep2 {
    public void execThis() { System.out.println("Bar");
} }

class Dep1Mock implements Dep1 { 
    public void doSmth() { System.out.println("mocked Foo");
} }

class Dep2Mock implements Dep2 { 
    public void execThis() { System.out.println("mocked Bar"); 
} }

Dieser recht überschaubare Code enthält vier Interfaces und vier Klassen:

  • 2 Interfaces Dep1 und Dep2, repräsentativ für Abhängigkeiten, die wir im Client angeben wollen
  • 2 Interfaces NeedsDep1 und NeedsDep2, die der Client implementieren muss, damit die Abhängigkeiten injiziert werden
  • 2 Implementierungen Dep1Impl und Dep2Impl, die die konkreten Implementierungen der Abhängigkeiten darstellen
  • 2 Implementierungen Dep1Mock und Dep2Mock, die anstelle der Implementierungen in den Unit-Tests des Clients verwendet werden sollen
class Client implements NeedsDep1, NeedsDep2 {
    private Dep1 dep1;
    private Dep2 dep2;
    public void inject(Dep1 dep1) { this.dep1 = dep1; }
    public void inject(Dep2 dep2) { this.dep2 = dep2; }

    public void runCode() {
        dep1.doSmth();
        dep2.execThis();
    }
}

Der Client ist sehr überschaubar, er ruft nacheinander Dep1 und Dep2 auf und implementiert die Methoden, um die Abhängigkeiten injiziert zu bekommen.

public class DependencyInjectionExample {
    private static final boolean production = true;

    public static void main(String... args) throws Exception {
        final Client client = (Client) new Injector()
            .create(Client.class, production 
                ? new ProdConfig() 
                : new TestConfig());
        client.runCode();
    }
}

Diese Klasse ist der Einstiegspunkt. Über den Injector und eine Konfiguration wird der Client instanziert und dabei seine Abhängigkeiten aufgelöst. Führt man diesen Code aus, erhält man als Ausgabe:

Foo
Bar

Setzt man das Flag production auf false, so erhält man stattdessen folgende Ausgabe:

mocked Foo
mocked Bar

Wie aber sind Injector und Konfiguration beschaffen, um dies zu erreichen?

class Injector {
    public Object create(Class clazz, InjectorConfig cfg) throws Exception {
        final var obj = create(clazz);
        if (obj instanceof NeedsDep1) {
            ((NeedsDep1)obj).inject((Dep1) 
                create(cfg.getMapping(Dep1.class)));
        }
        if (obj instanceof NeedsDep2) {
            ((NeedsDep2)obj).inject((Dep2) 
                create(cfg.getMapping(Dep2.class)));
        }
        return obj;
    }
    private Object create(Class clazz) throws Exception {
        return clazz.getDeclaredConstructor().newInstance(); }
}

Wie man sieht, hat der Injector keine direkte Beziehung zum Client. Der Injector kennt die Interfaces, die zur Umsetzung von Interface Injection existieren und lädt die konkrete Implementierung des Interfaces aus der Konfiguration. Die dort hinterlegte Klasse wird in der Methode create() per Reflection instanziert und über die Interface-Methode inject() in das angefragte Objekt, den Client, eingeschleust.

class InjectorConfig {
    private final Map<Class, Class> classes = new HashMap<>();
    public void addMapping(Class class1, Class class2) {
        classes.put(class1, class2); 
    }
    public Class getMapping(Class clazz) { 
        return classes.get(clazz); 
    }
}

class ProdConfig extends InjectorConfig {
    {
        addMapping(Dep1.class, Dep1Impl.class);
        addMapping(Dep2.class, Dep2Impl.class);
    }
}

class TestConfig extends InjectorConfig {
    {
        addMapping(Dep1.class, Dep1Mock.class);
        addMapping(Dep2.class, Dep2Mock.class);
    }
}

Die Konfiguration ist in diesem Fall nur ein einfaches Mapping von Interfaces auf konkrete Implementierungen. In den Ableitungen ProdConfig und TestConfig werden die Mapping-Strukturen gefüllt. Für Produktion sind die Implementierungen Dep1Impl und Dep2Impl hinterlegt, für Test hingegen Dep1Mock und Dep2Mock.

Framework oder selbst umsetzen?

Das Beispiel oben hat viele Schwächen:

  • Es werden nur Standard-Konstruktoren ohne Parameter unterstützt
  • Es werden keine Factories unterstützt
  • Constructor und Setter Injection werden nicht unterstützt
  • Jede neue Abhängigkeit erfordert ein weiteres Interface, Code im Injector und die Eintragung in eine oder mehrere Konfigurationen

Grundsätzlich ist es besser, ein bestehendes DI-Framework zu verwenden, als eines selbst zu schreiben. Wer in Java kein großes Framework wie eine JEE-Implementierung oder Spring einsetzen möchte, kann Guice für DI verwenden. Für Python gibt es PyContainer, für Ruby Copland und für Perl Bread::Board. Das JavaScript-Framework Angular setzt DI ein, ebenso wie das PHP-Framework Symfony.

In Java empfinde ich einen deklarativen Ansatz über Annotationen als wesentlich angenehmer, als die Umsetzung über „herkömmlichen Code“ oder XML-Konfiguration, was meist deutlich textlastiger ist. In einem Spring-Projekt hatte ich kürzlich für eine komplexe Datenstruktur einen Cache eingeführt, der prototypisch in mehreren Varianten mit Guava, Redis und Apache Ignite implementiert war. Durch die DI-Unterstützung von Spring konnte ich von einer Variante auf eine andere umstellen, indem ich nur die Deklaration @Primary von der vorherig verwendeten Implementierung entfernt und bei der neu ausgewählten Implementierung eingetragen habe.

Vorteile von Dependency Injection

  • Lesbarkeit: DI reduziert den Boilerplate Code im Client, indem Instanzierungscode ausgelagert wird. Damit fällt der Blick auf das Wesentliche, was die Verantwortlichkeit des Clients ausmacht, leichter.
  • Wiederverwendbarkeit und bessere Wartung: DI fördert eine lose Kopplung und hohe Kohäsion. Durch die Interaktion über Interfaces ist es wahrscheinlicher, dass sich bei der Entwicklung mehr Gedanken um das Design derselbigen gemacht werden, als wenn der Client „kreuz und quer“ mit allem interagiert, was die Implementierung der Abhängigkeit so hergibt.
  • Testbarkeit: Es ist per Design sehr leicht, Implementierungen in Unit-Tests durch Mocks zu ersetzen.
  • Flexibilität: Der Client ist nur durch sein eigenes Verhalten definiert und verlässt sich nicht auf Implementierungsdetails seiner Abhängigkeiten.
  • Konfigurierbarkeit: Abhängigkeiten können sehr leicht externalisiert werden, z.B. in Konfigurationsdateien oder einen Datenspeicher.
  • Plugin-Fähigkeit: Bei der Entwicklung von Plugins durch Dritte besteht unter Umständen keine Möglichkeit, den Quellcode der Anwendung direkt zu kompilieren. Per DI können Plugins leicht über eine Konfiguration ausgelagert und vom Injector zur Laufzeit nachgeladen werden.
  • Refactoring von Legacy-Anwendungen: Legacy-Abhängigkeiten können leicht durch neue ersetzt werden, wenn die Anwendung DI verwendet.
  • Principle of Least Surprise: Wenn der Client seine Abhängigkeiten nur über Interfaces kennt, ist schnell ersichtlich, wie der Client mit diesen interagieren kann. Böse Überraschungen, wie dass ein Client sich auf nicht-dokumentierte, nicht-offensichtliche Implementierungsdetails seiner Abhängigkeiten verlässt, werden damit besser vermieden.
  • Soft Coded: DI ist ein bisschen wie das Auslagern von Konfiguration in Konfigurationsdateien. Hartkodierte „Konstanten“ im Code werde vermieden, im Fall von DI die direkte Instanzierung (in Java über new), die schwerer zu mocken ist als einfach die Konfiguration auszutauschen.

Nachteile von Dependency Injection

  • Mehr Code: DI bedeutet eine höhere Komplexität und mehr Code. Mehr Code führt potenziell zu mehr Fehlern. Mehr Code bedeutet einen größeren Wartungs- und Entwicklungsaufwand und in einigen Fällen, je nach Framework, sicherlich auch Dopplungen.
  • Erschwertes Debugging: Die Trennung von Erstellung und Verwendung von Abhängigkeiten erschwert das Debugging, da beides an verschiedenen Stellen stattfindet.
  • IDE-Features: Die Einbindung von Interfaces kann die Navigation in einer IDE erschweren. DI wird häufig durch Features wie Reflection erreicht, was Funktionen wie Call Hierarchy, Find Usages oder Safe Refactorings negativ beeinträchtigen kann.
  • Abhängigkeit: Durch die Verwendung eines DI-Frameworks bindet man sich an dieses. In einer gewachsenen Anwendung kann es sehr aufwändig sein, auf ein anderes DI-Framework zu wechseln oder DI komplett auszubauen.
  • Transparenz: DI geht zu Lasten der Transparenz, da es weniger offensichtlich ist, welche Abhängigkeit im Client genau verwendet wird.
  • Laufzeitfehler: Fehler werden durch die Einführung von DI von der Kompilierzeit in die Laufzeit verlagert. Dadurch werden sie später entdeckt, im schlimmsten Fall erst in Produktion.
  • Zentralisierung: DI zentralisiert das Wissen, welche Abhängigkeiten wie implementiert sind. Der Injector kennt sämtliche Interfaces und Dependencies und hat damit selbst eine sehr hohe Komplexität.

Wann setze ich Dependency Injection ein?

Den Einsatz von DI mache ich von folgenden Fragestellungen abhängig:

  • Habe ich mehrere Implementierungen meiner Abhängigkeiten?
  • Habe ich mehrere Konfigurationen, die ich per DI laden kann?
  • Hilft mir DI bessere Unit-Tests zu schreiben?
  • Habe ich verschiedene Scopes, z.B. Singleton, bei denen mich ein DI-Framework unterstützen kann?
  • Gibt es in meiner verwendeten Sprache ein etabliertes und für meine Zwecke geeignetes DI-Framework? Die konkrete Umsetzung von DI kann enorm beeinflussen, ob sich DI im Nachhinein als gute oder schlechte Wahl herausstellt. Immerhin zieht sich die Integration durch die komplette Software.
- Artikel zu Dependency Injection von Martin Fowler