×

Dependency Injection (Einführung)

Dieser Artikel soll eine kurze Einführung in das Thema Dependency Injection (DI) geben und zeigen wie diese Technik eingesetzt wird. Dabei wird auf die Vor- und Nachteile eingegangen, welche sich bei den verschiedenen Implementierungen ergeben. Schließlich werden noch einige Frameworks vorgestellt, die den Einsatz von DI erleichtern, aber auch einige Fallstricke mit sich bringen.

1. Einleitung

Martin Fowler postete 2004 einen Artikel auf seiner Website, in dem er auf das Konzept der „Inversion of Control“ eingeht und mit dem er den Namen „Dependency Injection“ geprägt hat (vgl. [Fow2004]). Mittlerweile hat sich der Begriff Dependency Injection sehr verbreitet. Viele benutzen diesen Begriff und manche haben dabei implizit sogar noch gleich ein entsprechendes Framework im Kopf. Dieser Artikel soll sich mit den Grundlagen von DI beschäftigen. Es soll vor allem darum gehen, welche Probleme durch den Einsatz oder die Nutzung von DI gelöst werden können und wie dadurch die Software-Architektur verbessert werden kann. Wir werden hier nicht konkret mit einem bestimmten Framework arbeiten und dieses tiefer beleuchten, jedoch haben wir unter Frameworks eine Auswahl an aktiven Frameworks zusammengestellt und knapp ihre wichtigsten Features aufgeführt.

Unser Beispielprojekt zeigt die grundlegenden Aspekte von DI auf. Auf die Probleme bei der nachträglichen Integration von DI in bestehende Projekte werden wir in diesem Artikel nicht weiter eingehen. Um das Rad nicht neu erfinden zu müssen, lehnen wir unsere Implementierung an die Klassen aus dem Beispiel im Artikel von Martin Fowler ([Fow2004]) an. Weiterhin beschränken wir uns auf die Sprache Java.

2. Naiver Ansatz

Beginnen wir mit einem kleinen Java-Projekt. Dieses soll exemplarisch Filme aus einer Quelle einlesen und diese nach einem bestimmten Regisseur gefiltert ausgeben (s. Naiver Ansatz).

Figure 1: Naiver Ansatz

Folgender Java-Code wird dafür verwendet:

public class Main {
  public static void main(final String[] args) {
    final MovieLister movieLister = new MovieLister(); //(1)
    System.out.println("Movies by Justin Lin:");
    final List movies = movieLister.moviesDirectedBy("Justin Lin"); //(2)
    movies.stream().forEach(movie -> System.out.println(movie.getTitle()));
  }
}

1 Zunächst wird ein Movielister erzeugt.
2 Der MovieLister soll alle Filme von „Justin Lin“ finden und in einer Liste zurückgeben.

public class MovieLister {
  public List moviesDirectedBy(final String director) {
    final MovieFinder finder = new MovieFinder(); //(1)
    return finder.findAll("my_collection.txt").stream().filter(movie -> movie.getDirector() == director).collect(Collectors.toList()); //(2)
  }
}

1 Der MovieLister erzeugt den MovieFinder.
2 Filtert den Stream von Filmen nach dem gewünschten Regisseur.

2.1. Diskussion

Sofort fällt auf, dass eine sehr starke Kopplung zwischen MovieLister und MovieFinder vorliegt. Daraus resultieren einige Probleme, wenn der Code getestet oder erweitert werden soll oder wenn eine andere Quelle für die Filme verwendet werden soll. Der MovieLister ist hier so stark von der Implementierung des MovieFinder abhängig, dass keine Austauschbarkeit der Filmquelle möglich ist, ohne den Quellcode anzupassen. Der MovieLister verletzt damit das Open-Closed-Prinzip (vgl. [WikiOCP]). Wenn der Code des MovieListers getestet werden soll, muss zudem die gesamte Applikation getestet werden.

3. Ergänzung um ein Interface und eine Factory

Diese enge Kopplung kann durch die Nutzung eines Interfaces MovieFinder aufgelöst werden. Wir definieren uns dieses Interface und implementieren eine Klasse MovieFinderFromFile, die das Interface realisiert. Die Klasse MovieLister benutzt jetzt nur noch den MovieFinder und kennt die konkrete Implementierung nicht mehr.
Wie wird nun der MovieFinderFromFile erzeugt? Man kann hierzu zunächst eine Factory einsetzen und diese zum Erzeugen der konkreten Implementierung nutzen.

Figure 2: Factory mit Interface

public class MovieLister {
  public List moviesDirectedBy(final String director) {
    final MovieFinder finder = MovieFinderFactory.getMovieFinder(); //(1)
    return finder.findAll().stream().filter(movie -> movie.getDirector() == director).collect(Collectors.toList());
  }
}

1 Der MovieFinder wird nun durch die Factory erzeugt.

MovieListerTest.java

public class MovieListerTest {
  private MovieLister movieLister;
  private List movies;
  @Before
  public void setup() {
    this.movies = ImmutableList.of(new Movie("Avatar", "James Cameron"),new Movie("Fight Club", "David Fincher"));
    final MovieFinder movieFinder = Mockito.mock(MovieFinder.class);
    when(movieFinder.findAll()).thenReturn(this.movies);
    MovieFinderFactory.setMovieFinderForTesting(movieFinder); //(1)
    this.movieLister = new MovieLister();
  }
  @Test
  public void testMoviesDirectedByDavidFincher() {
    final List expectedMovies = ImmutableList.of(this.movies.get(1));
    assertThat(this.movieLister.moviesDirectedBy("David Fincher"), is(expectedMovies));
  }
  //... more tests
  @After
  public void cleanup() {
    MovieFinderFactory.setMovieFinderForTesting(null); //(2)
  }
}

1 Zum Testen wird in die Factory ein Mock-Finder gesetzt.
2 Es darf nicht vergessen werden, den Mock-Finder wieder zu löschen, um ungewolltes Verhalten zu vermeiden.

3.1. Diskussion

Wie man schnell erkennt, ist der MovieLister nun nicht mehr direkt von der konkreten Implementierung abhängig, sondern nutzt das Interface.
Wir haben also die Nutzung von der eigentlichen Implementierung getrennt. Über eine MovieFinderFactory wird nun die konkrete Implementierung erzeugt. Dadurch ist im Normalfall ein Austausch der Implementierung leicht möglich, ohne den Code von MovieLister anzupassen.

Wird die Klasse MovieFinderFactory genau angeschaut, fällt auf, dass eine zweite statische Methode setMovieFinderForTesting(MovieFinder) in der Factory existiert. Diese Methode wird benötigt, um den MovieLister testbar zu machen. In einem Unit-Test kann dadurch über setMovieFinderForTest ein „MockMovieFinder“ gesetzt werden.

Das erste offensichtliche Problem bei diesem Ansatz besteht darin, dass Quellcode für Tests im produktiven Quellcode landet. Dies ist weder „clean code“ noch sehr erstrebenswert. Außerdem treten Seiteneffekte auf, da beim Ende der Tests der TestMock aus der Factory wieder entfernt werden muss. Dies wird unweigerlich im weiteren Verlauf des Softwareprojekts zu Seiteneffekten führen.

4. Injektion über den Konstruktor

Wir haben festgestellt, dass die Nutzung einer Factory innerhalb der Klasse MovieLister problematisch ist. Implizit muss sich die Klasse MovieLister immer noch um die Erzeugung eines MovieFinder kümmern. Es wäre doch viel besser, wenn der MovieLister über seinen Konstruktor nach außen „signalisiert“, dass er einen MovieFinder benötigt.

Figure 3: Setzen des MovieFinders über den Konstruktor von MovieLister

Daraus ergibt sich folgender Code:

MovieLister.java

public class MovieLister {
  private final MovieFinder movieFinder;
  public MovieLister(final MovieFinder movieFinder) {
    this.movieFinder = movieFinder; //(1)
  }
  public List moviesDirectedBy(final String director) {
    return this.movieFinder.findAll().stream().filter(movie -> movie.getDirector() == director).collect(Collectors.toList());
  }
}

1 Hier ist sehr schön zu erkennen, dass von außen ein MovieFinder injiziert wird. Die konkrete Implementierung bleibt verborgen.

Das Erzeugen eines MovieLister wird in Main.java erledigt.

Main.java

public class Main {
  public static void main(final String[] args) {
    final MovieFinder movieFinder = new MovieFinderFromFile("my_collection.txt");
    final MovieLister movieLister = new MovieLister(movieFinder); //(1)
    System.out.println("Movies by Justin Lin:");
    final List movies = movieLister.moviesDirectedBy("Justin Lin");
    movies.stream().forEach(movie -> System.out.println(movie.getTitle()));
  }
}

1 Hier wird eine konkrete Implementierung eines MovieFinder in den MovieLister injiziert.

Durch diese Umgestaltung der Klassen und die Vermeidung einer Factory werden die resultierenden Unit-Tests einfacher:

MovieListerTest.java

public class MovieListerTest {
  private MovieLister movieLister;
  private List movies;
  @Before
  public void setup() {
    this.movies = ImmutableList.of(new Movie("Avatar", "James Cameron"),new Movie("Fight Club", "David Fincher"));
    final MovieFinder movieFinder = Mockito.mock(MovieFinder.class);
    when(movieFinder.findAll()).thenReturn(this.movies);
    this.movieLister = new MovieLister(movieFinder);
  }
  // ... more tests
  @Test
  public void testMoviesDirectedByDavidFincher() {
    final List expectedMovies = ImmutableList.of(this.movies.get(1));
    assertThat(this.movieLister.moviesDirectedBy("David Fincher"), is(expectedMovies));
  }
}

4.1. Diskussion

Zunächst stellen wir fest, dass durch die Vermeidung der Factory keine Vermischung von Test- und Produktivcode statt findet. Außerdem verringern wir die Anzahl der Abhängigkeiten und vermeiden Seiteneffekte. Instanzen werden an einer zentralen Stelle (hier: Main.java) erzeugt. Stellt man sich nun vor, dass man eine sehr große Anzahl an Klassen vorliegen hat, müssten diese alle händisch in Main.java instanziiert und je nach Abhängigkeit in die entsprechenden Objekte injiziert werden. Diese undankbare Aufgabe kann sehr gut automatisiert werden. Der Programmierer müsste lediglich bestimmen, zu welchem Interface welche Implementierung (abhängig vom Kontext) verknüpft werden soll.

Genau das erledigen die oft genannten DI-Frameworks für uns. Misko Hevery hat dazu einen sehr interessanten Artikel auf dem Google-Testing-Blog geschrieben mit dem Titel „Where Have All The Singletons Gone“ ([Hev2008]).

5. Arten von Dependency Injection

Es ist auf verschiedenen Wegen möglich, die gewünschten Objekte zur Laufzeit zu injizieren. Man kann dabei zwischen drei Arten unterscheiden:

  • Constructor Injection
  • Setter Injection
  • Field Injection (nur mit Framework)

5.1. Constructor Injection

Wie in Injektion über den Konstruktor angedeutet, werden mittels Constructor Injection alle Abhängigkeiten einer Klasse über die Konstruktoren von außen injiziert. Dadurch werden automatisch auch die benötigten Abhängigkeiten definiert, welche der Erzeuger des Objekts zur Verfügung stellen muss. Einige werden behaupten, dass dadurch riesige Konstruktoren geschaffen werden, die nicht mehr zu überblicken seien. Man sollte sich jedoch eher die Frage stellen, ob die vorliegende Klasse in diesem Fall nicht zu viele Verantwortlichkeiten besitzt.

Bei Constructor Injection werden zum Start der Software alle Objekte erzeugt, die benötigt werden. Dies könnte unter Umständen zu langen Ladezeiten führen, und es werden Objekte erzeugt, die möglicherweise für bestimmte UseCases gar nicht verwendet werden. Für solche Fälle müssen durch das Entwicklerteam verschiedene Instanziierungsstrategien erarbeitet werden.

5.2. Setter Injection

Bei einer Setter Injection werden über setter-Methoden die Abhängigkeiten injiziert:

MovieLister ml = new MovieLister();
ml.setFinder(new MovieFinderImpl());

Dadurch bleiben die Konstruktoren schlank und konkrete Implementierungen können zur Laufzeit ausgetauscht werden. Ob diese Möglichkeit auch gewünscht ist, sollte jedoch im Vorfeld geklärt werden. Durch Setter Injection besteht die Gefahr von inkonsistentem Verhalten zur Laufzeit oder von NullPointerExceptions, da an einer bestimmten Stelle beispielsweise die Injection durch einen Setter vergessen wurde. Des Weiteren werden durch Setter die Klassen „verschmutzt“ und Code, der nur der Erzeugung und Injection dienlich ist, in fachliche Klassen hineingezogen. Dies bricht auch die Kapselung der Daten auf.

5.3. Field Injection

Wie der Name schon sagt, wird bei Field Injection die konkrete Abhängigkeit direkt in die Klassenattribute geschrieben. Viele haben dies bestimmt schon gesehen, da spätestens bei Field Injection Annotations wie beispielsweise @Inject zum Einsatz kommen. Hier werden durch einen Mechanismuss (Reflection) vor der Instanziierung der Klasse alle Attribute mit der Annotation @Inject gesammelt, und es wird geschaut, ob für den bestimmten Datentyp bereits eine konkrete Implementierung bekannt ist.

class MovieLister {
  @Inject
  MovieFinder movieFinder
}

Field Injection vermeidet die Nachteile der Setter Injection und übergroße Konstruktoren. Die unter Constructor-Injection disktutierten Nachteile werden hierbei jedoch nicht beseitigt. Zudem schafft die Verwendung von Framnework-spezifischen Annortationen eine Abhängigkeit zu diesem konkret verwendeten Framework.

6. Vor- und Nachteile von DI

Durch DI können Abhängigkeiten von Klassen auf ein Minimum verringert werden. Klassen werden sich nur noch auf die fachlichen Interfaces oder Klassen beziehen können und vermeiden somit, die konkreten Implementierungen kennen zu müssen. Dadurch wird eine sehr lose Kopplung untereinander gefördert.

Durch den Einsatz von DI wird die Austauschbarkeit der Implementierungen erleichtert bzw. erst ermöglicht und in diesem Zuge ebenso die Testbarkeit kleinerer Bestandteile erleichtert. Die Konfiguration des System ist mit der Hilfe von DI automatisch auch auslagerbar, z.B. in eine Konfigurationsdatei. Die Frage, wo nun das ein oder andere Objekt erzeugt wird, kann fast entfallen.
Auf der anderen Seite kann es sich als schwierig gestalten, modernes DI (mit der Nutzung von Frameworks) in bestehenden Legacy-Code und alten und bereits stark gewachsenen Softwareprojekten einzuführen. Hier muss meist ein komplettes Refactoring der Architekturen durchgeführt werden, was im Allgemeinen sehr schwer zu realisieren ist. Meist wird auch der Kunde nicht bereit sein, für diesen Mehraufwand aufzukommen.

Durch die frühe Objekt-Instanziierung kann außerdem die Startzeit eines komplexen Systems durchaus leiden. Dies gilt es ebenfalls abzuwägen.
Ohne die Nutzung von entsprechenden Frameworks kann DI über den manuellen Weg („ich kümmere mich selbst um die Erzeugung und Injizierung“) sehr mühsam und auch fehleranfällig werden.

7. Frameworks

Folgend eine kleine Liste an aktiven Frameworks, die automatisiertes Dependency Injection ermöglichen.

  • Google Guice (https://github.com/google/guice)
    o Constructor, Setter + Field Injection
    o Konfiguration über Java (Annotationen + Module)
    o Objektbaum: Validierung zur Laufzeit
  • Spring Framework (http://projects.spring.io/spring-framework)
    o Constructor + Setter Injection
    o Konfiguration über xml-Dateien (Annotationen auch möglich)
    o Objektbaum: Validierung zur Laufzeit
  • Glassfish HK2 (https://hk2.java.net/)
    o Constructor, Setter + Field Injection
    o Konfiguration über Java (Annotationen)
    o Zusätzliche Metadaten benötigt (HK2 Metadata Generator)
    o Objektbaum: Validierung zur Laufzeit
  • Dagger (https://google.github.io/dagger)
    o Constructor + Field Injection
    o Konfiguration über Java (Annotationen)
    o Dagger Code Generator
    o Objektbaum: Validierung zur Compile-Zeit

8. Quellen

 

Beitragsbild: Fotolia © Elnur

Von Simon Flachs | 28.11.2017
Simon Flachs

Softwareentwicklung