Das Dependency Inversion Principle (DIP) und Dependency Injection (DI) sind die wichtigsten Grundlagen für einen modularen, test- und erweiterbaren Aufbau einer Software. Sie bilden damit einen fundamentalen Baustein der agilen Softwareentwicklung.
Ich weiß: es gibt bereits tausende von Büchern und Artikel über dieses Thema. Trotzdem sehe ich immer wieder, dass es nicht wirklich verstanden und in der Praxis – besonders in kleinen und mittleren Projekte – komplett ignoriert wird. Deshalb möchte ich noch einmal einen einfachen Einstieg in das Thema für .net-Entwickler starten.
Das DIP besagt:
A. HIGH LEVEL MODULES SHOULD NOT DEPEND UPON LOW LEVEL MODULES. BOTH SHOULD DEPEND UPON ABSTRACTIONS.
B. ABSTRACTIONS SHOULD NOT DEPEND UPON DETAILS. DETAILSSHOULD DEPEND UPON ABSTRACTIONS.
[The Dependency Inversion Principle, Robert C. Martin, C++ Report, May 1996]
Oder die deutsche Übersetzung von Wikipedia:
A. Module hoher Ebenen sollten nicht von Modulen niedriger Ebenen abhängen. Beide sollten von Abstraktionen abhängen.
B: Abstraktionen sollten nicht von Details abhängen.
Details sollten von Abstraktionen abhängen.
Je niedriger die Ebene eines Modul, desto spezieller sind die Vorgänge, die es definiert. In Modulen höherer Ebenen werden allgemeine Abläufe umgesetzt, welche von Modulen niedrigerer Ebenen benutzt werden.
Die “Ebenen” finde ich immer recht schwer zu greifen, da sie für mich auch ein Ausdruck der Abstraktion sind. Deshalb finde ich es besser als zweite Größe die Volatilität zu verwenden. Von Komponenten, die sich häufig ändern – oder besser gesagt eine hohe Wahrscheinlichkeit haben, dass sie sich ändern – sollten möglichst keine oder wenige andere Komponenten abhängen. Dies leuchtet ein: jedes mal, wenn die Komponente geändert muss, müssen sonst alle Abhängigen Komponenten neu gebaut und getestet werden. Außerdem sollte versucht werden von möglichst abstrakten Komponenten abzuhängen – und nicht von Konkreten.
Wenn wir also von etwas Konkretem abhängen müssen, dann darf es nicht auch noch volatil sein. “Sytem.String” ist z.B. eine konkrete Klasse – von dieser können wir aber gerne abhängen, da sie sich wohl kaum ändern wird.
Ansonsten gilt: je abstrakter und je statischer desto besser.
Ich erkläre das ganze anhand eines Beispiels. Ein “BusinessObject” (BO) repräsentiert eine Einheit unserer Fachdomäne und beinhaltet die eigentliche Geschäftslogik unserer Anwendung. Die Daten werden aktuell in einer SQL-Datenbank gespeichert. Um das BO besser zu testen lagern wir die Funktionen mit Datenbankzugriff in eine eigene Komponente aus. Das Ergebnis sieht etwa so aus:
Das sieht auf den ersten Blick ganz gut aus und entspricht einer gängigen Architektur:
Wir haben eine “Layer” mit der Geschäftslogik, die von einer DataAccessLayer (DAL) abhängt.
Wenn wir jetzt aber die Daten nicht als Primärtypen übertragen wollen (was offensichtlich kein Sinn macht, da bei jeder Änderung sonst an vielen Stellen geändert werden müsste), dann benötigen wir einen “DataContainer” – also eine Klasse, die als Datenspeicher fungiert und sonst keine Logik enthält. Dieser Container wird i.d.R. durch alle Schichten gereicht und im User Interface (UI) für das DataBinding verwendet. Wo definieren wir jetzt den Container? In Core können wir ihn nicht definieren, da wir sonst eine Zirkelabhängigkeit kreieren würden. Wir können ihn also nur in Data oder eine neuen Komponente erstellen.
In Data macht keinen Sinn, da sonst alle anderen Schichten von Data abhängen würden. Bei einem Austausch der Datenbank müssten sonst immer alle Komponenten geändert werden. Eine Option wäre es, eine neue Komponente zu erstellen.
Das wäre ein guter Ansatz – allerdings bliebe die Abhängigkeit zwischen Core und Data bestehen. Data ist aber sehr Konkret, da es sich auf eine spezielle Datenbank bezieht. Dies Abhängigkeit sollte also auf jeden Fall “umgekehrt” werden.
Wie erreichen wir das? Indem wir ein abstraktes Interface in Core definieren, das von SQLRepository implementiert wird.
Das BO kennt also nur ein abstraktes Repository und ist dadurch völlig unabhängig von der tatsächlichen Implementierung.
public interface IRepository { void Delete(IDataContainer c); IDataContainer Get(); IEnumerable<IDataContainer> GetAll(); void Update(IDataContainer c); }
Da das BO keine Kenntnis vom Repository hat müssen wir es ihm “von außen” injizieren. Am besten per Constructor Injection, damit sichergestellt ist, dass alle nötigen Komponenten auch vorhanden sind.
public class BusinessObject { IRepository repo; public virtual void DoDataOperation() { var c = new DataContainer(); this.repo.Delete(c); } public BusinessObject(IRepository dependency) { this.repo = dependency; } }
Das ist jetzt auch schon der ganze Zauber. Wir haben die Abhängigkeit der Komponenten umgekehrt. Außerdem haben wir das BO ganz leicht “testbar” gemacht, da wir jetzt eine “FakeRepository” für Tests injizieren könne. Allerdings haben wir neben den Abhängigkeiten auch die Kontrolle umgekehrt (Inversion of Control). Nicht mehr das BO sondern die Aufrufende Komponente ist jetzt für die Komposition der Abhängigkeiten verantwortlich. Da es in einer Anwendung sehr viele Abhängigkeiten gibt, macht es Sinn, ein Framework für die Verwaltung der Abhängigkeiten zu Verwenden.
Dependency Injection mit Unity
Unity ist ein leichtgewichtiger DI Container von Microsoft Patterns & Practices. Die Seite im MSDN ist sehr gut gemacht und hat auch viele Informationen zu DI allgemein. Ich kann diese Seite nur jedem empfehlen.
Die Installation von Unity erfolgt ganz einfach per NuGet. Danach kann man einfach einen UnityContainer erstellen und die entsprechenden Implementierungen für die entsprechenden Interfaces registrieren (Zeile 5). Über die Methode Resolve<T> des Container kann man dann die Objekte mit allen aufgelösten Abhängigkeiten erstellen lassen (Zeile 7).
[TestMethod] public void BusinessObjectCanDoSomething() { var container = new UnityContainer(); container.RegisterType<IRepository, SQLRepository>(); var bo = container.Resolve<BusinessObject>(); bo.DoDataOperation(); }
Natürlich hat Unity noch viel mehr auf Lager. Die Konfiguration kann über eine Konfigurationsdatei oder per Konvention erfokgen. An dieser Stelle sei aber nur auf die Dokumentation verwiesen.
Cross-Cutting-Concerns
Ein Problem bei DI ist immer, dass die Anzahl der Abhängigkeiten schnell wächst. Besonders, wenn man Komponenten die überall benötigt wird hinzunimmt, wächst die Anzahl rasant an: Logging, Tracing, Security und andere Utilities werden ja in jeder Komponente benötigt. Dies sind die sogenannten Cross-Cutting-Concerns (siehe http://de.wikipedia.org/wiki/Cross-Cutting_Concern). Damit diese unsere eigentlichen Abhängigkeiten nicht “verwässern”, macht es Sinn diese separat zu behandelt. Viele dieser Funktionen kann man sehr elegant über AOP (Aspect Oriented Programming). In kleinen Projekten ist das aber oft zu Aufwändig. Eine einfache Methode dafür finde ich das ServiceLocator-Pattern.
ServiceLocator für Cross-Cutting-Concerns
Das ServiceLocator-Pattern (SL) ist ein sehr umstrittenes Pattern. Statt Dependencies einem Objekt zu injizieren, holt sich das Objekt die aktuelle Instanz bei einem SL ab. Da alle Komponenten dann vom SL abhängen und der SL von allen andren gilt das Ganze als “Anti-Pattern”. Trotzdem finde ich es eine leichtgewichtige Lösung für Cross-Cutting-Concerns. Da von diesen eh alle anderen abhängen ist es für diese Belange sehr geeignet. Natürlich gibt es SL auch schon fertig – man kann sich aber auch selber schnell einen “basteln”. Eine einfache Implementierung könnte si aussehen.
public class ServiceLocator { static readonly ServiceLocator instance = new ServiceLocator(); static readonly IDictionary<Type, object> services = new Dictionary<Type, object> { { typeof(ILogger), new Logger() } }; private ServiceLocator() { } public static ServiceLocator Current { get { return instance; } } public T GetService<T>() { return (T)services[typeof(T)]; } }
Der SL ist einfach nur ein Singelton, über den ich meine Interfaces zu bestimmten Implementierungen mappe. Über Lazy<T> könnte ich einfach auch noch ein Lazy-Loading implementieren. Für Tests kann man ganz einfach über eine Methode “Inject” Fake-Objekte injizieren.
Für Cross-Cutting Belange finde ich dieses Pattern eine einfache und leichtgewichtige Lösung.
AOP mit Unity
Für größere Projekte kann dann AOP eingesetzt werden. Neben vielen kommerziellen Produkten oder großen Projekten wir Spring bietet auch Unity eine AOP-Komponente – die sogenannten Interceptions – an. Beim konfigurieren eine UnityContainers können Extensions hinzugefügt werden. Bei Typenregistrierung kann dann ein “Interceptor” registriert werden.
var container = new UnityContainer(); container.AddNewExtension<Interception>(); container.RegisterType<IRepository, SQLRepository>( new Interceptor<InterfaceInterceptor>(), new InterceptionBehavior<LoggingBehavior>()); var bo = container.Resolve<BusinessObject>(); bo.DoDataOperation();
Ein einfaches Beispiel eines Interceptors, der einen Tracelog schreibt, könnte wie folgt aussehen.
class LoggingBehavior : IInterceptionBehavior { public IMethodReturn Invoke(IMethodInvocation input, GetNextInterceptionBehaviorDelegate getNext) { // Before invoking the method on the original target. WriteLog(String.Format( "Invoking method {0} at {1}", input.MethodBase, DateTime.Now.ToLongTimeString())); // Invoke the next behavior in the chain. var result = getNext()(input, getNext); // After invoking the method on the original target. if (result.Exception != null) { WriteLog(String.Format( "Method {0} threw exception {1} at {2}", input.MethodBase, result.Exception.Message, DateTime.Now.ToLongTimeString())); } else { WriteLog(String.Format( "Method {0} returned {1} at {2}", input.MethodBase, result.ReturnValue, DateTime.Now.ToLongTimeString())); } return result; } public IEnumerable<Type> GetRequiredInterfaces() { return Type.EmptyTypes; } public bool WillExecute { get { return true; } } private void WriteLog(string message) { var logger = new Logger(); logger.Write(message); } }
Die Dokumentation ist auch hier sehr empfehlenswert.
Fazit
Die Verwaltung der Abhängigkeiten von Komponenten in einem Softwaresystem ist der wichtigste Schritt für lose gekoppeltes System, das sich gut testen lässt. Wenn die Grundproblematik verstanden ist, dann gibt es leichtgewichtige und frei verfügbare Tools am Markt, die einem die Arbeit leichter machen. Software wächst in der Regeln dynamisch und wird nicht von einem Architekten am Reißbrett entworfen. Deshalb ist es wichtig, dass alle Teammitglieder das entsprechende Grundlagenverständnis mitbringen. Ich hoffe der Beitrag konnte für einige ein “JumpStart” in die Thematik sein – Literatur und Beispiele findet man genug um dann weiter darauf aufzubauen.