How Ninject inadvertently made the case for SOLID Reif für die Hängematte

Gedanken zum Thema Dependency Injection

Published on Tuesday, April 29, 2014 5:00:00 AM UTC in Philosophical & Programming & Tools

In den letzten Jahren hat sich auf breiter Fläche die Erkenntnis durchgesetzt, dass Entwurfsmuster wie Dependency Injection bzw. das allgemeine Prinzip Inversion of Control deutliche Vorteile in der Softwareentwicklung mit sich bringen. Verantwortlichkeiten von Typen werden klarer separiert, die Testbarkeit deutlich erhöht oder vereinfacht, und gleichzeitig sind die Abhängigkeiten zwischen Typen und Objekten durch dedizierte Komponenten an zentraler Stelle klar geregelt. Gleichzeitig sind sich viele Entwickler aber unsicher, ob ihre Verwendung dieser Prinzipien wirklich ideal ist, und nicht selten hat man irgendwie "ein mulmiges Gefühl" bei der Sache. In diesem Artikel möchte ich nicht die Vorteile oder Grundlagen von Dependency Injection erläutern - das ist an diversen anderen Stellen schon mehr als ausführlich geschehen. Stattdessen möchte ich ein paar der Bedenken und häufig wiederkehrende Fragen aufgreifen, die mir in den letzten Jahren immer wieder in Diskussionen begegnet sind.

Vorab

Die hier diskutierten Punkte sind zunächst einmal unabhängig von den verwendeten Technologien und lassen sich auf viele Sprachen übertragen, auch wenn ich die Thematik natürlich aus der Sicht von .NET diskutiere. Man sollte sich auch nicht der Illusion hingeben, dass sich diese Fragestellungen durch geschickte Wahl eines speziellen Frameworks vermeiden ließen. Im .NET-Bereich habe ich diverse Bibliotheken ausgiebig benutzt und im Rahmen meines Projekts Liphofra auch eine einfache Implementierung eines IoC-Containers selbst erstellt. Egal ob Autofac, Tiny IoC, MEF, Unity, StructureMap, Ninject oder Castle Windsor - es gibt zwar jeweils Vor- und Nachteile, aber kein Framework schafft es, bei kritischen Entwicklern dauerhaft Grundsatzdiskussionen zu vermeiden.

Eine typische Entwicklung

Häufig läuft der erste Kontakt mit einem IoC-Container (oder auch dem selbst implementierten Mechanismus) so ab, dass die Entwickler sehr enthusiastisch an das Thema herangehen. Beispielsweise werden durch Abstrahierung in Interfaces und anschließende Constructor Injection die Abhängigkeiten eines Typs definiert, um die Verantwortung für die Erzeugung und konkreten Beziehungen schließlich an zentraler Stelle konfigurieren und meist an ein entsprechendes Framework delegieren zu können. In Web-Anwendungen ist ein geeigneter Ort hierfür die global.asax, in Rich Clients ein Zeitpunkt gleich nach dem Haupteinsprungpunkt.

Im Laufe der Zeit aber mehrt sich die Logik an dieser zentralen Stelle immer stärker und scheint förmlich zu explodieren. Unabhängig davon, ob man einen deklarativen Ansatz etwa per XML-Konfiguration gewählt hat, oder die Konfiguration direkt im Quelltext vornimmt, steht man früher oder später vor hundert Zeilen Verschaltungslogik, die Interfaces auf Typen abbildet, Scopes regelt und Lifetime-Entscheidungen vornimmt. Und dann kommen die Zweifel. Ist das wirklich das Richtige? Und wird uns das nicht sämtliche Performance kosten?

Schnellvorlauf, drei Monate später. Aus den hundert Zeilen sind fünfhundert geworden. Durch seltsame Abhängigkeiten, Pseudo-Singletons und ähnliche Konstrukte müssen manche Konfigurationsschritte plötzlich in bestimmter Reihenfolge ausgeführt werden und können nicht mehr schön sortiert werden. Schlimmer noch, inzwischen ist aus der anfangs einfachen Konfiguration vielleicht sogar teilweise komplexe Logik geworden - "wenn Bedingung A erfüllt ist, dann verwende für Interface X die Implementierung Y, ansonsten Z". Aus den Zweifeln wird Ablehnung: "Hier blickt doch keiner mehr durch!"

Was nun? In vielen Teams schlägt an diesem Punkt die Stimmung so stark um, dass man sich abrupt wegbewegt von den ursprünglichen Prinzipien und alternative, scheinbar bessere Konzepte sucht: Dezentralisierung der Konfiguration, verzögerte Instanziierung, weniger Abstraktion und Dependency Injection, stärkerer Gebrauch von Service Locator-Mustern und ähnliches. Aber ist so eine Radikalveränderung wirklich nötig?

Zentrale Konfiguration

Tatsächlich ist die zentrale Konfiguration der häufigste Kritikpunkt am Prinzip Dependency Injection: es wird gerne ins Feld geführt, dass diese Art des Zusammenbauens von Abhängigkeiten es zunächst erschwert, die Funktionsweise einer Anwendung in ihrer Gänze verstehen zu können. Beim Betrachten einer beliebigen Klasse sehe ich zwar im Idealfall alle Abhängigkeiten durch einen Blick auf den Konstruktor, aber ich weiß weder, woher diese Abhängigkeiten kommen, noch wie die konkreten Implementierungen aussehen (Interfaces!). Auch moderne Werkzeuge wie Visual Studio und selbst ausgefeilte Hilfsmittel wie ReSharper schaffen es noch nicht, eine einfache, korrekte Navigation durch derart aufgebauten Code zu gewährleisten.

Meine persönliche Meinung ist, dass diese Argumentation und daraus abgeleitete Vorbehalte zwar nicht immer, aber dennoch oft Ausdruck der Unsicherheit und des Misstrauens gegenüber dem eigenen Code sind. Vielen Entwickler fällt es extrem schwer, einfach zu akzeptieren, dass an der aktuell betrachteten Stelle "nur" ein Interface zur Verfügung steht, das eine bestimmte Aufgabe erledigt. Es besteht ein geradezu übermenschliches Bedürfnis, genau zu wissen, wie diese Aufgabe erledigt wird. Ich kenne dieses Verlangen nur zu gut, weil ich es viele Jahre lang an mir selbst beobachten konnte. Oft war es nur Ausdruck der Sorge, dass ich durch Fehlinterpretation einer Schnittstelle oder auf Grund von impliziten Annahmen o.ä. der zur Laufzeit verwendeten Implementierung bei der ungeprüften Benutzung Seiteneffekte auslösen oder unerwünschte Bugs verursachen könnte. Diese Angst ist extrem limitierend im effektiven Umgang mit bestehender Software und für die Arbeit im Team; es sollte ein persönliches Ziel eines jeden Entwicklers sein, sich möglichst frei zu machen von solchen Überlegungen. Das lässt nicht nur einen selbst besser schlafen; man gestaltet auch seine eigenen Implementierungen bewusster und robuster, wenn man weiß, dass andere im Team genauso von seiteneffektfreien, selbsterklärenden Schnittstellen ausgehen.

Doch zurück zur zentralen Konfiguration: auch ohne die oben genannten tatsächlichen oder gefühlten Nachteile entwickelt sich häufig eine durchaus verständliche Abneigung gegenüber einer über alle Maßen wachsende, immer unübersichtlicher werdende zentrale Konfigurationskomponente. Manche Entwickler empfinden es auch generell als widernatürlich, die teilweise internen Abhängigkeiten zwischen einzelnen Teilen einer Komponente von außen festzulegen. Auffallend häufig werden zur Auflösung dieser Probleme dann Überlegungen angestellt, die Konfiguration zu "dezentralisieren" und auf mehrere "Module" zu verteilen, manchmal sogar gänzlich den einzelnen Komponenten eines Systems selbst zu überlassen. Um es kurz zu machen: das ist eine furchtbare Idee.

  • Die fundamentale Idee ist ja gerade, den einzelnen Komponenten die Entscheidung zu konkreten Abhängigkeiten und die Verantwortung für die Erzeugung derselben abzunehmen. Das rückgängig zu machen kommt einem Über-Bord-Werfen des gesamten Konzepts gleich.
  • Die Konfiguration auf der höchstmöglichen Ebene (für gewöhnlich die Anwendungsebene selbst, oder zumindest die Ebene von Systemgrenzen) eröffnet die größte Flexibilität. Es erlaubt mir, dieselbe Komponente in verschiedenen Anwendungsszenarien (dazu zählen auch Tests!) unterschiedlich zu konfigurieren - das geht verloren, wenn die Komponente über diese Konfiguration intern selbst entscheidet.
  • Übrigens: wenn dieses Problem erkannt wird, folgen häufig unschöne Workarounds. Manche Frameworks erlauben sogenannte "Rebindings", um Konfigurationen nachträglich nochmal umzubiegen, oder aber man beginnt damit, die Konfiguration der Komponente von außen durch Setzen von Properties zu beeinflussen - womit man semantisch plötzlich alle möglichen Anwendungsszenarien innerhalb der Komponente bekannt macht.
  • Bei transitiven Abhängigkeiten ist plötzlich nicht mehr klar, wer für das Konfigurieren von Drittkomponenten zuständig ist. Schnell merkt man dann auch, dass das "Dezentralisieren" mitnichten die Problematik von notwendigen Reihenfolgen lösen kann, sondern diese u.U. sogar noch verschärft.

Wer glaubt, dass eine zentrale, umfangreiche Konfiguration der Abhängigkeiten undurchschaubar und schwer zu warten ist, der sollte sich mal eine Lösung ansehen, bei der die Abhängigkeiten über 20 Klassenbibliotheken verteilt konfiguriert und auf höheren Ebenen dann wieder per Properties und Rebindings abgeändert werden...

Die Performance-Frage

Ein weiterer interessanter Aspekt von Dependency Injection ist, dass sich viele erst bei der Verwendung einer zentralen Konfiguration bewusst werden, was für eine Arbeit geleistet wird, um die eigene Anwendung hochzufahren. Wenn nach Dutzenden von Abhängigkeitsdefinitionen mit einem einzelnen "Inject"-(oder ähnlichem)-Aufruf plötzlich ein Objektgraph von hunderten oder tausenden Objekten aufgebaut wird, wird manchem Angst und Bange. Wenn dann die Anwendung "in letzter Zeit" tatsächlich auch noch spürbar langsamer geworden ist, ist der Schuldige schnell gefunden: der IoC-Container muss es sein!

Tatsächlich handelt es sich in den allermeisten Fällen um ein rein psychologisches Problem, das nichts mit dem Erzeugen großer Objektgraphen zu tun hat. Das Brot-und-Butter-Geschäft einer objektorientierten Sprache ist ja gerade, Objekte effizient erzeugen, verwalten und wieder zerstören zu können. Der Overhead von hochoptimierten IoC-Container-Implementierungen ist absolut vernachlässigbar. Gerade im Vergleich zu typischen Aufgaben einer modernen Anwendung (Netzwerkkommunikation, Datenbankabfragen, UI-Rendering etc.) ist das Erzeugen selbst zehntausender Objekte um Größenordnungen "billiger". Außerdem ist es ja nicht so, dass man diese Objekte ohne Dependency Injection nicht erzeugen würde - einzig die Tatsache, dass man alles gebündelt und an einer Stelle aufgeschrieben sieht, sorgt für die Bedenken. Wären all die Instanziierungen als "new"-Aufrufe über hunderte Code-Dateien verteilt, würde sich niemand darum scheren.

Freilich gibt es ein paar wenige Einschränkungen. Wer etwa Konstruktoren für aufwändige Aufgaben missbraucht, kann zumindest die Startzeit einer Anwendung deutlich gegenüber etwa einer verzögerten Instanziierung erhöhen. In (sehr seltenen) Fällen kommt es auch zum unnötigen Laden von Assemblies in die AppDomain, die man vielleicht gar nicht gebraucht hätte. Aber auch das lässt sich im Zweifelsfall recht einfach und Dependency-Injection-konform lösen, etwa per Lazy oder Factories. Weniger empfehlenswert ist das Herumreichen des IoC-Containers, um Abhängigkeiten "on demand" bei Bedarf auflösen zu können (gerne nett als "Service-Locator-Pattern" verpackt). Das führt zu einer engen Kopplung der Komponenten mit dem Infrastruktur-Thema der Abhängigkeitsauflösung, die man eigentlich nicht haben möchte. Außerdem sieht man einer Komponente dann von außen nicht mehr an, welche Abhängigkeiten sie tatsächlich benötigt, was in der Folge z.B. die Testbarkeit zumindest erschwert.

Wer sich genauer für das Thema Performance im Zusammenhang mit Dependency Injection interessiert und letzte Zweifel ausräumen möchte, dem sei der Vortrag "Big Object Graphs Up Front" von Mark Seemann (Autor des Buchs Dependency Injection in .NET) empfohlen.

Eine Einsicht

Nachdem ich nun viel darüber gesprochen habe, was man tunlichst lassen sollte, müsste eigentlich der Teil des Artikels folgen, in dem die eleganten, erprobten Lösungen ausgeführt werden, die all die genannten Probleme verschwinden lassen, ohne irgendwelche Prinzipien guter Softwareentwicklung aufzugeben. Tatsächlich ist aber die Konfiguration von IoC-Containern ein Problem, das sich zumindest nach meinen Ansprüchen momentan nicht vollumfänglich zufriedenstellend und elegant lösen lässt. Zur Verdeutlichung werfe ich mal ein paar lose Gedanken in den Raum:

  • Da die Konfiguration nicht auf Komponenten-Ebene, sondern auf möglichst "hoher" Ebene erfolgt, müssen Konfigurationen zwangsläufig dupliziert werden, wenn es mehr als eine Anwendung gibt. Beispiel: ein Kommandozeilen-Hilfswerkzeug, das Komponenten mit der Hauptanwendung teilt, muss auch die Konfiguration dazu teilen.
  • Das immer wieder anzutreffende Schaffen einer "allumfassenden" Konfiguration (z.B. ein separates Konfigurationsprojekt), die von allen potenziellen Anwendungen gemeinsam verwendet werden kann, ist eine schlechte Idee: diese Konfiguration muss die Vereinigung der Abhängigkeiten aller Anwendungsfälle mitbringen, und alle Anwendungen besitzen in der Konsequenz damit auch all diese Abhängigkeiten (man denke dabei auch an Konfigurationen für Unit- und Integrationtests usw.).
  • Als Alternative setzen manche Frameworks auf Konventionen oder Reflection, oft auch beides in Kombination, um Konfigurationen automatisiert oder mit nur wenigen nötigen Eingriffen zu erstellen. Das löst zwar in der Tat viele der unangenehmeren Details, allerdings natürlich auf Kosten der Explizität. MEF etwa erlaubt durch die Verwendung von Attributen die Zusammenstellung einer Anwendung etwa komplett nur durch verschiedene Deployments, was zunächst verlockend klingt, aber dieselben negativen Konsequenzen der oben geschilderten "verteilten Konfiguration" zur Folge hat. Ich habe mehrere MEF-getriebene Projekte erlebt, bei der ebenso niemand mehr wusste, was wo verwendet wird, und vor allem auch wann was instanziiert wird. Wer mal einen Tag im Debugger verbracht hat, um Reihenfolgenprobleme mit MEF zu analysieren und hässliche Workarounds dafür einzubauen, kann meine Bedenken sicher nachvollziehen...
  • Auch die Konfiguration durch separate Konfigurationsdateien (z.B. XML) vermeidet direkte Abhängigkeiten etwa zur Kompilierzeit und erlaubt so ggfs. die Wiederverwendung von Konfigurationen oder Teilen davon. Allerdings ist diese Flexibilität freilich auch gleichzeitig der größte Nachteil, da Kompositionsfehler erst zur Laufzeit auftreten und gerade für Anfänger oft nur sehr schwer zu beheben sind - auch wenn sich die Framework-Entwickler in den letzten Jahren zugegebenermaßen wirklich viel Mühe geben, die Fehlermeldungen immer weiter zu verbessern und klarer werden zu lassen.

In der Summe empfinde ich die aktuelle Situation immer noch als recht unbefriedigend. Statt der einen klaren Empfehlung kann ich daher nur kurz erläutern, welche Eckpunkte ich momentan für gewöhnlich einhalte, wenn ich die Wahl habe:

  • Konfigurationen so "hoch" wie möglich ansiedeln, also nie in Komponenten, sondern immer in den Anwendungen bzw. Teilsystemen (z.B. Client/Server)
  • Konfiguration im Code, also möglichst explizit und ohne Konventions- oder Reflection-basierte Ansätze
  • Wiederverwendung von Standard-Konfigurationen, sofern sinnvoll, durch Code-File-Linking

Den letzten Punkt sollte ich noch kurz erläutern: wie erwähnt bevorzuge ich explizite, ausprogrammierte Ansätze jederzeit vor scheinbar bequemen Alternativen wie Konventionen, Reflection oder externen Konfigurationsdateien. Ich finde es gut, wenn mir zum einen der Compiler sagen kann, dass ggfs. Referenzen fehlen oder sich Schnittstellen verändert haben, und wenn ich andererseits an einer einzigen, zentralen Stelle die tatsächliche Konfiguration einer Anwendung durchlesen kann.

Auf der anderen Seite verhindert genau das ja aus oben erwähnten Gründen eine sinnvolle und einfache Wiederverwendung von Konfigurationen. Um zu vermeiden, dass ich tatsächlich hunderte Zeilen von Code duplizieren und ggfs. bei Änderungen an fünfzig Stellen Mappings nachpflegen muss, greife ich daher auf das Mittel der Code-File-Verlinkung in Visual Studio zurück, sofern das sinnvoll ist (d.h. sofern eine sinnvolle Standard-Konfiguration existiert, die in mehreren Anwendungen verwendet werden kann). Das zwingt mich gleichzeitig auch dazu, die Konfiguration einzelner Komponenten auf eigene "Bootstrap"-Klassen oder zumindest auf mehrere Dateien zu verteilen, was den typischen Konfigurations-Moloch ein wenig vermeiden hilft. In der Konsequenz kann ich dann damit also die Konfiguration einer Komponente Z aus Anwendung A unverändert in Anwendung B übernehmen und muss sie nur einmal pflegen. Die Sicherheit zur Kompilierzeit bleibt erhalten, ebenso die Explizität. Gleichzeitig habe ich aber auch die Freiheit, etwa in den Unit-Tests der Komponente eine ganz andere Konfiguration zu verwenden.

Diese Vorgehensweise erscheint mir momentan sinnvoll. Allerdings ist die Verlockung auch dabei groß, schnell über das Ziel hinaus zu schießen. Wenn zwei Konfigurationen nur in einem kleinen Detail abweichen, beginnt man gerne mal mit partiellen Klassen oder gar bedingter Kompilierung, um dennoch einen Teil wiederverwenden zu können. Im Handumdrehen bewegt man sich dabei in eine ganz unerwünschte Richtung und pflegt bald erneut komplexe, undurchschaubare Logik. Es ist also stete Vorsicht angesagt.

Ich hoffe, dass meine Überlegungen ein wenig zum Nachdenken anregen können. Wie immer diskutiere ich gerne über Alternativen oder Denkfehler :).

Tags: .NET · Dependency Injection · Inversion Of Control