MSDN Magazin > Home > Ausgaben > 2008 > March >  Flexiblere Anwendungen durch Verringerung der S...
Entkopplung
Flexiblere Anwendungen durch Verringerung der Softwareabhängigkeiten
James Kovacs

Themen in diesem Artikel:
  • Das Problem eng gekoppelter Architekturen
  • Probleme mit Tests und Abhängigkeiten
  • Abhängigkeitsumkehr
  • Abhängigkeitsinjektion
In diesem Artikel werden folgende Technologien verwendet:
.NET Framework
Codedownload verfügbar unter: DependencyInjection2008_03.exe (5408 KB)
Browse the Code Online
Nur wenige würden der Behauptung nicht beipflichten, dass das Streben nach einem lose gekoppelten Design etwas Gutes ist. Leider ist die Software, die wir in der Regel entwerfen, viel enger gekoppelt, als beabsichtigt war. Woran können Sie erkennen, ob Ihr Design eng gekoppelt ist? Sie können Ihre Abhängigkeiten mit einem statischen Analysetool wie NDepend analysieren. Doch am einfachsten verschaffen Sie sich einen Eindruck von der Kopplung in Ihrer Anwendung, indem Sie versuchen, eine Ihrer Klassen isoliert zu instanziieren.
Wählen Sie eine Klasse aus Ihrer Geschäftsschicht, z. B. InvoiceService, und kopieren Sie nur deren Code in ein neues Konsolenprojekt. Versuchen Sie anschließend, eine Kompilierung durchzuführen. Wahrscheinlich werden einige Abhängigkeiten wie Invoice, InvoiceValidator usw. fehlen. Kopieren Sie diese Klassen in das Konsolenprojekt, und versuchen Sie es erneut. Wahrscheinlich stellen Sie fest, dass weitere Klassen fehlen. Wenn Sie endlich kompilieren können, stellen Sie u. U. fest, dass Sie einen beträchtlichen Teil Ihrer Codebasis in dem neuen Projekt haben. Es ist, als ob Sie an einem losen Faden ziehen und mit ansehen müssen, wie der ganze Pullover sich auflöst. Jede Klasse ist direkt oder indirekt mit jeder anderen Klasse in Ihrem Design gekoppelt. Änderungen an diesem System vorzunehmen, ist bestenfalls schwierig, da eine Änderung einer Klasse Auswirkungen auf das gesamte System haben kann.
Kopplung soll nicht vollständig vermieden werden. Das ist unmöglich. Zum Beispiel:
string name = "James Kovacs";
Ich habe meinen Code an die System.String-Klasse in Microsoft® .NET Framework gekoppelt. Ist das etwas Schlechtes? Ich würde sagen, nein. Die Wahrscheinlichkeit, dass die Klasse „System.String“ sich in unerwünschter Weise ändert, ist sehr gering, ebenso wie die Wahrscheinlichkeit, dass die Anforderungen sich derart verändern, dass meine Art der Interaktion mit System.String geändert werden muss. Also bereitet mir diese Kopplung keine Sorgen. Der springende Punkt dieser Denkübung ist nicht, die Kopplung zu beseitigen, sondern sich ihrer bewusst zu sein und sicherzustellen, dass Sie Ihre Kopplungen mit Bedacht auswählen.
Sehen Sie sich ein weiteres Beispiel eines Codes an, der häufig in der Datenschicht vieler Anwendungen anzutreffen ist:
SqlConnection conn = new SqlConnection(connectionString);
Oder dieser hier:
XmlDocument settings = new XmlDocument();
settings.Load("Settings.xml");
Wie überzeugt sind Sie, dass Ihre Datenschicht nur mit SQL Server® kommuniziert oder dass Sie Ihre Anwendungseinstellungen immer aus einem XML-Dokument namens „Settings.xml“ laden werden? Hier wird nicht die Absicht verfolgt, ein unendlich erweiterbares, aber ungeheuer komplexes und unbrauchbares generisches Framework zu erstellen. Es geht um Umkehrbarkeit. Wie problemlos können Sie Ihre Meinung in Bezug auf Entwurfsentscheidungen ändern? Haben Sie eine Anwendungsarchitektur, die gut auf Änderungen reagiert?
Warum mache ich mir Sorgen um Änderungen? Da Veränderung praktisch die einzige Konstante in dieser Branche ist. Anforderungen ändern sich, Technologien ändern sich, Entwickler ändern sich, und das Geschäft ändert sich. Geben Sie sich die Möglichkeit, auf diese Änderungen reagieren zu können? Durch das Erstellen lose gekoppelter Designs kann Software besser auf unvermeidliche und oft unvorhersehbare Änderungen reagieren.

Das Problem innerer Abhängigkeit
Sehen Sie sich ein typisches, stark gekoppeltes Design an, wie Sie es in Ihrer durchschnittlichen geschichteten Anwendungsarchitektur finden (siehe Abbildung 1). In einem einfachen Schichtschema gibt es eine Benutzeroberflächenschicht, die mit einer Dienstschicht (oder Geschäftsschicht) kommuniziert, die wiederum mit einer Repositoryschicht (oder Datenschicht) kommuniziert. Die Abhängigkeiten zwischen diesen Schichten fließen in der gleichen Richtung nach unten. Die Repositoryschicht ist sich der Dienstschicht nicht bewusst, die sich wiederum der Benutzeroberflächenschicht nicht bewusst ist.
Abbildung 1 Typische geschichtete Architektur 
Manchmal haben Sie ein paar Schichten mehr, z. B. eine Präsentations- oder Workflowschicht. Doch das Muster von Schichten, die sich nur der unter ihnen liegenden Schicht bewusst sind, ist ziemlich typisch. Schichten als zusammenhängende Cluster der Verantwortlichkeit stellen ein gutes Entwurfsverfahren dar. Die direkte Kopplung von oberen Schichten mit unteren Schichten verstärkt jedoch die Kopplung und erschwert das Testen der Anwendung.
Warum sorge ich mich um die Testbarkeit? Weil die Testbarkeit ein guter Gradmesser für die Kopplung ist. Wenn Sie eine Klasse in einem Test nicht problemlos instanziieren können, haben Sie ein Kopplungsproblem. Zum Beispiel ist die Dienstschicht mit der Repositoryschicht eng vertraut und hängt von dieser ab. Die Dienstschicht kann nicht isoliert von der Repositoryschicht getestet werden. Auf praktischer Ebene bedeutet dies, dass die meisten Tests auf die zugrunde liegende Datenbank, das Dateisystem oder das Netzwerk zugreifen. Dies führt zu verschiedenen Problemen, einschließlich langsamer Tests und hoher Wartungskosten.
Langsame Tests Wenn Tests ausschließlich im Speicher ausgeführt werden können, kann sich die Zeit pro Test im Millisekundenbereich bewegen. Wenn bei Tests auf externe Ressourcen wie eine Datenbank, ein Dateisystem oder ein Netzwerk zugegriffen wird, beträgt die Zeit pro Test oft 100 Millisekunden oder mehr. Wenn Sie bedenken, dass in einem typischen Projekt mit guter Testabdeckung Hunderte oder Tausende von Tests durchgeführt werden, kann dies den Unterschied ausmachen, ob Sie Ihre Tests in einigen Sekunden oder in Minuten oder gar in Stunden ausführen.
Schlechte Fehlerisolierung Ein Fehler in einer Datenschichtkomponente führt häufig dazu, dass auch Tests von Komponenten oberer Schichten fehlschlagen. Anstelle einiger fehlschlagender Tests, anhand derer Sie das Problem schnell isolieren können, haben Sie Hunderte von fehlschlagenden Tests, die das Auffinden des Problems schwierig und zeitaufwändiger machen.
Hohe Wartungskosten Die meisten Tests benötigen einige Anfangsdaten. Wenn durch diese Tests auf die Datenbank zugegriffen wird, müssen Sie sicherstellen, dass sich Ihre Datenbank vor jedem Test in einem bekannten Zustand befindet. Außerdem müssen Sie sicherstellen, dass die Anfangsdaten jedes Tests unabhängig sind von den Anfangsdaten anderer Tests, sonst können Sie sich Problemen in der Testanordnung gegenübersehen, in denen bestimmte Tests fehlschlagen, wenn sie nicht in der vorgesehenen Reihenfolge ausgeführt werden. Die Datenbank in einem bekannten guten Zustand zu halten, ist eine zeitaufwändige und fehleranfällige Aufgabe.
Wenn Sie außerdem die Implementierung einer unteren Schicht ändern müssen, sind Sie oft gezwungen, die oberen Schichten aufgrund impliziter oder expliziter Abhängigkeiten der Schichten von der unteren Schicht ebenfalls zu ändern. Obwohl Sie die Anwendung schichtweise angelegt haben, haben Sie keine lose Kopplung erreicht.
Sehen Sie sich ein konkretes Beispiel an – einen Dienst, der Rechnungen akzeptiert (siehe Abbildung 2). Damit InvoiceService.Submit die Übermittlung einer Rechnung akzeptieren kann, ist es von AuthorizationService, InvoiceValidator und InvoiceRepository abhängig, die im Konstruktor der Klasse erstellt werden. Sie können für InvoiceService keinen Komponententests ohne die konkreten Abhängigkeiten durchführen. Das bedeutet, dass Sie vor dem Ausführen Ihrer Komponententests sicherstellen müssen, dass die Datenbank sich in einem Zustand befindet, in dem keine primären oder eindeutigen Schlüsselverletzungen verursacht werden, wenn die neue Rechnung von InvoiceRepository eingefügt wird. Außerdem darf InvoiceValidator keine Überprüfungsfehler melden. Des Weiteren müssen Sie sicherstellen, dass der Benutzer, der die Komponententests ausführt, über die richtigen Berechtigungen verfügt, damit der Submit-Vorgang von AuthorizationService zugelassen wird.
public class InvoiceService {
  private readonly AuthorizationService authoriazationService;
  private readonly InvoiceValidator invoiceValidator;
  private readonly InvoiceRepository invoiceRepository;
 
  public InvoiceService() {
    authoriazationService = new AuthorizationService();
    invoiceValidator = new InvoiceValidator();
    invoiceRepository = new InvoiceRepository();
  }
 
  public ValidationResults Submit(Invoice invoice) {
    ValidationResults results;
    CheckPermissions(invoice, InvoiceAction.Submit);
    results = ValidateInvoice(invoice);
    SaveInvoice(invoice);
    return results;
  }
 
  private void CheckPermissions(Invoice invoice, InvoiceAction action) {
    if(authoriazationService.IsActionAllowed(invoice, action) == false) {
      throw new SecurityException(
        "Insufficient permissions to submit this invoice");
    }
  }
 
  private ValidationResults ValidateInvoice(Invoice invoice) {
    return invoiceValidator.Validate(invoice);
  }
 
  private void SaveInvoice(Invoice invoice) {
    invoiceRepository.Save(invoice);
  }
}

Das ist ein bisschen viel verlangt. Wenn es in einer dieser abhängigen Komponenten Probleme gibt, entweder durch Code- oder Datenfehler, schlagen die InvoiceService-Tests unerwartet fehl. Selbst wenn der Test bestanden wird, beträgt die gesamte Ausführungszeit vom Einrichten der richtigen Daten in der Datenbank über das Ausführen der Tests bis hin zum Bereinigen jeglicher Daten, die von dem Test erstellt wurden, einige hundert Millisekunden. Selbst wenn Sie die Kosten des Setups und der Bereinigung amortisieren, indem Sie die Tests in einem Batch gruppieren und die Skripts vor und nach dem Batch ausführen, ist die Ausführungszeit immer noch viel länger, als wenn Sie die Tests speicherintern hätten ausführen können.
Darüber hinaus besteht hier noch ein subtileres Problem. Angenommen, Sie möchten InvoiceRepository Überwachungsunterstützung hinzufügen. Sie wären gezwungen, ein AuditingInvoiceRepository zu erstellen oder InvoiceRepository selbst zu ändern. Aufgrund der Kopplung zwischen InvoiceService und seinen untergeordneten Komponenten haben Sie bezüglich der Einführung neuer Funktionen ins System nicht viele Optionen.

Abhängigkeitsumkehr
Sie können Ihre Komponente höherer Ebene, InvoiceService, von ihren Abhängigkeiten untergeordneter Schichten entkoppeln, indem Sie über Schnittstellen statt über ihre konkreten Klassen mit Ihren Abhängigkeiten interagieren:
public class InvoiceService : IInvoiceService {
    private readonly IAuthorizationService authService;
    private readonly IInvoiceValidator invoiceValidator;
    private readonly IInvoiceRepository invoiceRepository;
    ...
}
Diese einfache Änderung hinsichtlich der Verwendung von Schnittstellen (oder einer abstrakten Basisklasse) bedeutet, dass Sie beliebige Abhängigkeiten durch eine alternative Implementierung ersetzen können. Statt ein InvoiceRepository zu erstellen, können Sie ein AuditingInvoiceRepository erstellen (wobei davon ausgegangen wird, dass AuditingInvoiceRepository IInvoiceRepository implementiert). Dies bedeutet auch, dass Sie während des Testens eine Ersetzung durch Fakes oder Mocks vornehmen können. Dieses Entwurfsverfahren wird als vertragsgemäßes Programmieren bezeichnet.
Das zum Entkoppeln von Komponenten höherer Ebene von solchen Komponenten niedrigerer Ebene angewendete Prinzip wird als Prinzip der Abhängigkeitsumkehr bezeichnet. Wie Robert C. Martin in seinem Artikel zu dem Thema (objectmentor.com/resources/articles/dip.pdf) sagt: „Module hoher Ebene sollten nicht von untergeordneten Modulen abhängen. Beide sollten von Abstraktionen abhängen.“
In diesem Fall sind jetzt sowohl InvoiceService als auch InvoiceRepository von der Abstraktion abhängig, die von IInvoiceRepository bereitgestellt wird. Das Problem wurde jedoch nicht vollständig gelöst, es wurde lediglich verlagert. Obwohl die konkreten Implementierungen nur von einer Schnittstelle abhängen, bleibt die Frage, wie die konkreten Klassen einander „finden“.
InvoiceService benötigt immer noch konkrete Implementierungen seiner Abhängigkeiten. Sie könnten diese Abhängigkeiten einfach im Konstruktor von InvoiceService instanziieren, doch dann wären Sie nicht viel besser dran als zuvor. Wenn Sie ein AuditingInvoiceRepository verwenden möchten, müssten Sie InvoiceService dennoch ändern, um ein AuditingInvoiceRepository zu instanziieren. Darüber hinaus müssten Sie jede Klasse ändern, die von IInvoiceRepository abhängig ist, um stattdessen ein AuditingInvoiceRepository zu instanziieren. Es gibt keine einfache Möglichkeit, AuditingInvoiceRepository global gegen InvoiceRepository auszutauschen.
Eine Lösung besteht darin, eine Factory zum Erstellen von IInvoiceRepository-Instanzen zu verwenden. Dadurch wird ein zentraler Ort bereitgestellt, um einfach durch Änderung der Factory-Methode zu AuditingInvoiceRepository zu wechseln. Ein anderer Name für dieses Verfahren ist „Dienstidentifizierung“ (service location), und die für das Verwalten von Instanzen verantwortliche Factoryklasse wird „Service Locator“ genannt:
public InvoiceService() {
  this.authorizationService = 
    ServiceLocator.Find<IAuthorizationService>();
  this.invoiceValidator = ServiceLocator.Find<IInvoiceValidator>();
  this.invoiceRepository = ServiceLocator.Find<IInvoiceRepository>();
} 
Die Funktionalität innerhalb von ServiceLocator könnte auf Daten basieren, die aus einer Konfigurationsdatei oder Datenbank gelesen wurden, oder sie könnte direkt mit dem Code verknüpft sein. In jedem Fall haben Sie jetzt eine zentralisierte Objekterstellung für Ihre Abhängigkeiten.
Komponententests isolierter Komponenten können durchgeführt werden, indem der Service Locator mit Fake- oder Mockobjekten statt mit wirklichen Implementierungen konfiguriert wird. So kann ServiceLocator.Find<IInvoiceRepository> während des Testens ein FakeInvoiceRepository zurückgeben, das der Rechnung bei der Speicherung einen bekannten primären Schlüssel zugewiesen, die Rechnung aber nicht wirklich in der Datenbank gespeichert hat. Sie können Ihr komplexes Setup und die Beendigung der Datenbank beseitigen und bekannte Daten von Ihren Fakeabhängigkeiten zurückgeben. (Weitere Informationen finden Sie in der Randleiste „Ist es klug, Abhängigkeiten zu fälschen?“.)
Dienstidentifizierung hat jedoch einige Nachteile. Zunächst einmal sind Abhängigkeiten in der Klasse höherer Ebene verborgen. Anhand der öffentlichen Signatur können Sie nicht feststellen, dass InvoiceService von AuthorizationService, InvoiceValidator oder InvoiceRepository abhängt, nur durch Untersuchen des Codes.
Wenn Sie verschiedene konkrete Typen für die gleiche Schnittstelle bereitstellen müssen, müssen Sie auf überladene Find-Methoden zurückgreifen. Dadurch müssen Sie Entscheidungen darüber fällen, ob beim Implementieren der Factoryklasse ein alternativer Typ erforderlich ist. Sie können z. B. ServiceLocator nicht neu konfigurieren, um zum Bereitstellungszeitpunkt bestimmte IInvoiceRepository-Anforderungen durch ein AuditingInvoiceRepository zu ersetzen. Doch sogar mit diesen Nachteilen ist Dienstidentifizierung leicht zu verstehen und besser als das Hartcodieren Ihrer Abhängigkeiten.

Abhängigkeitsinjektion
Ist es klug, Abhängigkeiten zu fälschen?
Sie fragen sich u. U., ob es gefährlich ist, Ihre Abhängigkeiten zu „fälschen“. Erzielen Sie dadurch nicht falsche Erfolge? Nun, tatsächlich sollten Sie bereits über Tests verfügen, mit denen die ordnungsgemäße Funktionsweise Ihrer Abhängigkeiten, wie z. B. des richtigen InvoiceRepository, überprüft wird. Diese Tests sollten mit der eigentlichen Datenbank kommunizieren und überprüfen, ob InvoiceRepository sich richtig verhält.
Wenn Sie wissen, dass InvoiceRepository.Save funktioniert, warum müssen Sie es mit jedem einzelnen Test erneut testen, der von InvoiceRepository abhängt? Sie verlangsamen nur Ihre Tests höherer Ebene, wenn Sie eine Verbindung zur Datenbank herstellen, und wenn es ein Problem mit InvoiceRepository gibt, werden nicht nur Ihre InvoiceRepository-Tests fehlschlagen, sondern auch Ihre InvoiceService-Tests und alle anderen Komponenten, die von InvoiceRepository abhängen.
Wenn ein InvoiceService-Test fehlgeschlagen ist, aber InvoiceRepository nicht, bedeutet das, dass Sie einen Test für InvoiceRepository übersehen haben. Dieser Mangel kann besser von Integrationstests erkannt werden, bei denen die Komponente mit ihren konkreten Abhängigkeiten getestet wird. Diese werden langsamer aber auch seltener ausgeführt als Komponententests mit Fake- oder Mockabhängigkeiten.
Wenn wir annehmen, dass InvoiceRepository funktioniert, weil seine Komponententests bestanden wurden, haben Sie nun zwei Wahlmöglichkeiten. Sie können komplexe Skripts erstellen und pflegen, um sicherzustellen, dass die Daten in der Datenbank richtig sind, damit InvoiceRepository für jeden InvoiceService-Test die erwarteten Daten zurückgibt. Sie können auch eine Fake- oder Mockimplementierung von InvoiceRepository erstellen, die die erwarteten Daten zurückgibt. Diese zweite Option ist viel einfacher und funktioniert in der Praxis gut.

Wenn Sie für eine Komponente höherer Ebene einen Komponententest durchführen, möchten Sie Fake- oder Mockimplementierungen für deren Abhängigkeiten bereitstellen. Doch statt einen Service Locator mit den Fakes oder Mocks zu konfigurieren, die dann von der Komponente höherer Ebene nachgeschlagen werden, könnten Sie die Abhängigkeiten über einen parametrisierten Konstruktor direkt an die Komponente höherer Ebene übergeben. Dieses Verfahren wird Abhängigkeitsinjektion genannt. In Abbildung 3 sehen Sie ein Beispiel dafür.
[Test]
public void CanSubmitNewInvoice() {
  Invoice invoice = new Invoice();
  ValidationResults validationResults = new ValidationResults();
  IAuthorizationService authorizationService = 
    mockery.CreateMock<IAuthorizationService>();
  IInvoiceValidator invoiceValidator = 
    mockery.CreateMock<IInvoiceValidator>();
  IInvoiceRepository invoiceRepository = 
    mockery.CreateMock<IInvoiceRepository>();
 
  using(mockery.Record()) {
    Expect.Call(authorizationService.IsActionAllowed(
      invoice, InvoiceAction.Submit)).Return(true);

    Expect.Call(invoiceValidator.Validate(invoice))
      .Return(validationResults);
    invoiceRepository.Save(invoice);
  }
 
  using(mockery.Playback()) {
    IInvoiceService service = new InvoiceService(authorizationService, 
      invoiceValidator, invoiceRepository);
    service.Submit(invoice);
  }
}

In diesem Beispiel werden Mockobjekte für die Abhängigkeiten von InvoiceService erstellt und dann an den InvoiceService-Konstruktor übergeben. (Weitere Informationen zu Mockobjektframeworks finden Sie in „Komponententest: Das Kontinuum der Testdoubles“ von Mark Seemann unter msdn.microsoft.com/msdnmag/issues/07/09/MockTesting.) In wenigen Worten: Sie legen das Verhalten von InvoiceService fest, indem Sie dessen Interaktion mit den Mocks definieren, statt den Zustand von InvoiceService nach dem Ausführen des Tests zu überprüfen.
Mithilfe von Abhängigkeitsinjektion können Sie problemlos Ihre Komponenten höherer Ebene mit ihren Abhängigkeiten in Komponententests bereitstellen. Es stellt sich jedoch immer noch die Frage, wie Sie außerhalb eines Komponententests nach den Abhängigkeiten einer Klasse suchen können: entweder während die Anwendung ausgeführt wird oder bei einem Integrationstest. Es wäre unklug, von der Benutzeroberflächenschicht zu erwarten, dass sie die Dienstschicht mit ihren Abhängigkeiten versorgt oder dass die Dienstschicht der Repositoryschicht ihre Abhängigkeiten übermittelt. Sie hätten ein schwerwiegenderes Problem als vorher. Doch angenommen, die Benutzeroberflächenschicht wäre dafür verantwortlich, die Dienstschicht mit ihren Abhängigkeiten zu versorgen:
// Somewhere in UI Layer
InvoiceSubmissionPresenter presenter = 
  new InvoiceSubmissionPresenter(
    new InvoiceService(
      new AuthorizationService(), 
      new InvoiceValidator(), 
      new InvoiceRepository()));
Wie Sie sehen können, müsste die Benutzeroberfläche sich nicht nur ihrer eigenen Abhängigkeiten bewusst sein, sondern auch der Abhängigkeiten ihrer Abhängigkeiten und so weiter und so fort bis hinunter zur Datenschicht. Dies ist offensichtlich keine ideale Situation. Der einfachste Weg, dieses Dilemma zu beheben, besteht in einem Verfahren, das „Abhängigkeitsinjektion des armen Mannes“ genannt wird.
Dabei werden die Abhängigkeiten mit dem Standardkonstruktor der Komponente höherer Ebene bereitgestellt:
public InvoiceService() :
  this(new AuthorizationService(), 
    new InvoiceValidator(), 
    new InvoiceRepository()) { }
Beachten Sie, wie das Meiste an den überladenen Konstruktor delegiert wird. Dadurch wird sichergestellt, dass die Initialisierungslogik der Klasse identisch ist, unabhängig davon, mit welchem Konstruktor eine Instanz erstellt wird. Die einzige Stelle, an der die Klasse an konkrete Abhängigkeiten gekoppelt ist, ist der Standardkonstruktor. Die Klasse bleibt testbar, da Sie immer noch den überladenen Konstruktor haben, mit dem Sie die Abhängigkeiten der Klasse während des Komponententests bereitstellen können.

Container
Jetzt ist es an der Zeit, IoC-Container (Inversion of Control, Steuerumkehrung) vorzustellen, die einen zentralen Ort zum Verwalten von Abhängigkeiten bieten. In der Praxis ist ein Container nichts weiter als ein nützliches Dictionary mit Schnittstellen im Unterschied zum Implementieren von Typen. In seiner einfachsten Form ist ein IoC-Container nur ein Service Locator mit einem anderen Namen. Später werde ich darauf eingehen, wie ein Container viel mehr als nur eine Dienstidentifizierung sein kann.
Zurück zum vorliegenden Problem: Sie möchten InvoiceService vollständig von konkreten Implementierungen seiner Abhängigkeiten abkoppeln. Wie alle Probleme mit Software können Sie dieses lösen, indem Sie eine weitere Schicht der Dereferenzierung hinzufügen. Sie führen den Begriff eines Abhängigkeitsauflösers ein, der einer konkreten Implementierung eine Schnittstelle zuordnet. Dann verwenden Sie eine generische Methode, die eine Schnittstelle T akzeptiert und einen Typ zurückgibt, der diese Schnittstelle implementiert:
public interface IDependencyResolver {
    T Resolve<T>();
}
Implementieren Sie den SimpleDependencyResolver, bei dem ein Dictionary verwendet wird, um Zuordnungsinformationen zwischen Schnittstellen und Objekten zu speichern, die diese Schnittstellen implementieren. Es wird eine Möglichkeit benötigt, das Dictionary zu Beginn aufzufüllen. Dies geschieht durch die Register<T>(Object obj)-Methode (siehe Abbildung 4). Beachten Sie, dass die Register-Methode sich nicht in der IDependencyResolver-Schnittstelle befinden muss, da Abhängigkeiten nur vom Ersteller des SimpleDependencyResolver registriert werden. In der Regel wird dies von einer Hilfsklasse erledigt, die während des Starts der Anwendung in der Main-Methode aufgerufen wurde.
public class SimpleDependencyResolver : IDependencyResolver 
{
  private readonly Dictionary<Type, object> m_Types = 
    new Dictionary<Type, object>();
    
  public T Resolve<T>() {
    return (T)m_Types[typeof(T)];
  }

  public void Register<T>(object obj) {
    if(obj is T == false) {
      throw new InvalidOperationException(
        string.Format("The supplied instance does not implement {0}",
        typeof(T).FullName));
    }
    m_Types.Add(typeof(T), obj);            
  }
}

Wie findet CompanyService den SimpleDependencyResolver, damit nach dessen Abhängigkeiten gesucht werden kann? Wir könnten einen IDependencyResolver an jede Klasse übergeben, die einen benötigt, doch das wird schnell unhandlich. Die einfachste Lösung besteht darin, die konfigurierte SimpleDependencyResolver-Instanz an einem global zugänglichen Speicherort zu platzieren. Dies können Sie mithilfe des statischen Gatewaymusters erreichen. (Sie hätten auch das Singletonmuster verwenden können, doch Singletons sind bekanntermaßen schwierig zu testen. Sie sind eine der häufigsten Ursachen für eng gekoppelten Code, der schwierig zu testen ist, da sie wenig mehr als verkappte globale Variable sind. Vermeiden Sie sie nach Möglichkeit.)
Sehen Sie sich das statische Gateway an, das ich IoC nenne. (Ein weiterer möglicher Name wäre „DependencyResolver“, aber IoC ist kürzer.) Die statischen Methoden des IoC stimmen mit den Methoden von IDependencyResolver überein. (Beachten Sie, dass IDependencyResolver nicht von IoC implementiert wird, da statische Klassen keine Schnittstellen implementieren können.) Es gibt auch eine Initialize-Methode, von der der richtige IDependencyResolver akzeptiert wird. Vom statischen IoC-Gateway werden einfach alle Resolve<T>-Anforderungen an den konfigurierten IDependencyResolver weitergeleitet:
public class IoC {
  private static IDependencyResolver s_Inner;

  public static void Initialize(IDependencyResolver resolver) {
    s_Inner = resolver;
  }

  public static T Resolve<T>() {
    return s_Inner.Resolve<T>();
  }
}
Beim Starten der Anwendung wird IoC mit dem konfigurierten SimpleDependencyResolver initialisiert. Sie können jetzt die Abhängigkeitsinjektion des armen Mannes im Standardkonstruktor durch IoC.Resolve ersetzen:
public InvoiceService() :
  this(IoC.Resolve<IAuthorizationService>(), 
  IoC.Resolve<IInvoiceValidator>(), 
  IoC.Resolve<IInvoiceRepository>()) { }
Beachten Sie, dass Sie den Zugriff auf den inneren IDependencyResolver nicht synchronisieren müssen, da dieser nach dem Starten der Anwendung nur gelesen aber nie aktualisiert wird.
Die IoC-Klasse bietet einen weiteren Vorteil: Sie fungiert in Ihrer Anwendung als Antibeschädigungsschicht. Wenn Sie einen anderen IoC-Container verwenden möchten, implementieren Sie einfach einen Adapter, der IDependencyResolver implementiert. Obwohl IoC in Ihrer Anwendung ausgiebig verwendet wird, haben Sie sich nicht an einen bestimmten Container gekoppelt.

Umfassende IoC-Container
Ein einfacher IoC-Container wie SimpleDependencyResolver ermöglicht Ihnen, lose gekoppelte Komponenten zusammenzuheften. Ihm fehlen jedoch viele der Features, die in umfassenden IoC-Containern vorhanden sind, als da wären:
  • Erweiterte Konfigurationsoptionen wie XML, Code oder Skript
  • Verwaltung der Gültigkeitsdauer: Singleton/vorübergehend/ threadspezifisch/zusammengelegt
  • Automatisches Verknüpfen von Abhängigkeiten
  • Die Möglichkeit zur Integration neuer Funktionalität
Jedes dieser Features wird im Folgenden näher erläutert. Ich habe „Castle Windsor“, einen weit verbreiteten Open-Source-IoC-Container, als konkretes Beispiel verwendet. Viele Container können durch eine externe XML-Datei konfiguriert werden. Windsor kann z. B. wie folgt konfiguriert werden:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <components>
    <component id="Foo"
      service="JamesKovacs.IoCArticle.IFoo, JamesKovacs.IoCArticle"
      type="JamesKovacs.IoCArticle.Foo, JamesKovacs.IoCArticle"/>
  </components>
</configuration>
XML-Konfiguration ist vorteilhaft, da sie geändert werden kann, ohne die Anwendung erneut zu kompilieren. Doch häufig ist ein Neustart der Anwendung erforderlich, damit die Änderungen wirksam werden. Sie hat jedoch auch ihre Nachteile, da XML-Konfiguration sehr weitschweifig sein kann. Fehler werden erst zur Laufzeit entdeckt, und generische Typen werden mithilfe der Backtick-Schreibweise der CLR deklariert, nicht mit der vertrauteren generischen Schreibweise von C#. (Company.Application.IValidatorOf<Invoice> wird als Company.Application.IValidatorOf`1[[Company.Application.Invoice, Company.Application]], Company.Application geschrieben.)
Neben XML können Sie Windsor auch mit C# oder einer beliebigen anderen Microsoft .NET Framework-kompatiblen Sprache konfigurieren. Wenn Sie Ihren Konfigurationscode in einer separaten Assembly isoliert haben, erfordert das Ändern der Konfiguration einfach das erneute Kompilieren der Konfigurationsassembly und das Neustarten der Anwendung.
Mit Binsor können Sie ein Skript für die Windsor-Konfiguration erstellen. Dabei handelt es sich um eine domänenspezifische Sprache (domain-specific language, DSL), die speziell zum Konfigurieren von Windsor erstellt wurde. Mit Binsor können Sie Ihre Konfigurationsdateien in Boo schreiben. (Boo ist eine statisch typisierte CLR-Sprache, die sich auf die Erweiterbarkeit von Sprache und Compiler konzentriert. Dadurch eignet sie sich sehr gut zum Schreiben von DSLs.) In Binsor könnte die vorherige XML-Konfigurationsdatei wie folgt neu geschrieben werden:
import JamesKovacs.IoCArticle
Component("Foo", IFoo, Foo)
Die Sache wird interessanter, wenn Sie feststellen, dass Boo eine vollständige Programmiersprache ist. Das bedeutet, dass Sie mit Binsor automatisch Typen in Windsor registrieren können, ohne Komponentenregistrierungen manuell hinzufügen zu müssen wie in einer XML-basierten Konfiguration:
import System.Reflection
serviceAssembly = Assembly.Load("JamesKovacs.IoCArticle.IoCContainer")
for type in serviceAssembly.GetTypes():  
  continue if type.IsInterface or type.IsAbstract or 
    type.GetInterfaces().Length == 0
  Component(type.FullName, type.GetInterfaces()[0], type)
Selbst wenn Sie nicht mit Boo vertraut sind, sollte die Absicht des Codes klar sein. Indem dem Namespace „JamesKovacs.IoCArticle.Service“ einfach ein neuer Dienst hinzugefügt wird, wird dieser Dienst automatisch als Standardimplementierung für seine Dienstschnittstelle registriert. Angenommen, ich erstelle folgende Klasse:
public class AuthorizationService : IAuthorizationService {
   ...
}
Wenn von einer beliebigen anderen Klasse eine Abhängigkeit von IAuthorizationService deklariert wird, indem sie als Parameter für deren Konstruktor eingeschlossen wird, wird diese mit Binsor automatisch verknüpft, ohne dass die Abhängigkeit in einer Konfigurationsdatei explizit angegeben werden muss. Weitere Informationen zu Binsor und Boo finden Sie unter ayende.com/Blog/category/451.aspx bzw. unter boo.codehaus.org.

Verwaltung der Gültigkeitsdauer
Der SimpleDependencyResolver gibt immer die gleiche Instanz zurück, die für eine Schnittstelle registriert wurde. Dadurch wird aus dieser Instanz im Grunde ein Singleton. Sie könnten den SimpleDependencyResolver ändern, damit ein konkreter Typ anstelle einer Instanz registriert wird. Anschließend könnten Sie verschiedene Factorys verwenden, um Instanzen des konkreten Typs zu erstellen. Eine Singletonfactory würde immer die gleiche Instanz zurückgeben. Eine vorübergehende Factory würde immer eine neue Instanz zurückgeben. Eine threadspezifische Factory würde eine Instanz pro anfordernden Thread unterhalten.
Ihre Instanziierungsstrategie ist so flexibel wie Ihre Vorstellungskraft. Dies ist genau das, was Windsor bietet. Indem Attribute auf die XML-Konfigurationsdatei angewendet werden, können Sie ändern, welche Art von Factory verwendet wird, um Instanzen eines bestimmten konkreten Typs zu erstellen. Standardmäßig werden von Windsor Singletoninstanzen verwendet. Wenn Sie jedes Mal ein neues Foo zurückgeben möchten, wenn ein IFoo vom Container angefordert wurde, ändern Sie die Konfiguration einfach in:
<component id="Foo"
  service="JamesKovacs.IoCArticle.IFoo, JamesKovacs.IoCArticle"
  type="JamesKovacs.IoCArticle.Foo, JamesKovacs.IoCArticle"
  lifestyle="transient"/>

Automatisches Verknüpfen von Abhängigkeiten
Das automatische Verknüpfen von Abhängigkeiten bedeutet, dass der Container die Abhängigkeiten des angeforderten Typs untersuchen und diese Abhängigkeiten erstellen kann, ohne dass der Entwickler einen Standardkonstruktor bereitstellen muss:
public InvoiceService(IAuthorizationService authorizationService,
  IInvoiceValidator invoiceValidator, 
  IInvoiceRepository invoiceRepository) {
  ...
}
Wenn ein Client vom Container einen IInvoiceService anfordert, stellt der Container fest, dass für den konkreten Typ konkrete Implementierungen von IAuthorizationService, IInvoiceValidator und IInvoiceRepository erforderlich sind. Die geeigneten konkreten Typen werden nachgeschlagen, vorhandene Abhängigkeiten werden aufgelöst, und die Abhängigkeiten werden erstellt. Diese Abhängigkeiten werden dann verwendet, um InvoiceService zu erstellen. Das automatische Verknüpfen macht das Verwenden von Standardkonstruktoren unnötig. Dadurch wird der Code vereinfacht, und die Abhängigkeit vieler Klassen vom statischen IoC-Gateway wird entfernt.
Indem Sie anstelle konkreter Implementierungen vertragsgemäß codieren und einen Container verwenden, wird Ihre Architektur viel flexibler und offener gegenüber Änderungen sein. Wie können Sie konfigurierbare Überwachungsprotokollierung für InvoiceRepository implementieren? In einer eng gekoppelten Architektur müssten Sie InvoiceRepository ändern. Außerdem würden Sie eine Einstellung in der Anwendungskonfiguration benötigen, um anzuzeigen, ob die Überwachungsprotokollierung eingeschaltet wurde.
Gibt es in einer lose gekoppelten Architektur eine bessere Möglichkeit? Sie könnten einen AuditingInvoiceRepositoryAuditor implementieren, mit dem IInvoiceRepository implementiert wird. Mit dem Auditor wird nur die Überwachungsfunktionalität implementiert, die dann an das tatsächliche InvoiceRepository delegiert wird, das in seinem Konstruktor bereitgestellt wird. Dieses Muster wird „Decorator“ genannt (siehe Abbildung 5).
public class AuditingInvoiceRepository : IInvoiceRepository {
  private readonly IInvoiceRepository invoiceRepository;
  private readonly IAuditWriter auditWriter;

  public AuditingInvoiceRepository(IInvoiceRepository invoiceRepository, 
    IAuditWriter auditWriter) {
    this.invoiceRepository = invoiceRepository;
    this.auditWriter = auditWriter;
  }

  public void Save(Invoice invoice) {
    auditWriter.WriteEntry("Invoice was written by a user.");
    invoiceRepository.Save(invoice);
  }
}

Um die Überwachung einzuschalten, konfigurieren Sie den Container dafür, ein mit AuditingInvoiceRepository versehenes InvoiceRepository zurückzugeben, wenn um ein IInvoiceRepository gebeten wird. Clients werden nichts davon bemerken, da sie immer noch mit einem IInvoiceRepository kommunizieren. Dieser Ansatz bietet eine Menge Vorteile:
  1. Da InvoiceRepository nicht geändert wurde, gibt es keine Möglichkeit, dessen Code zu ruinieren.
  2. AuditingInvoiceRepository kann unabhängig von InvoiceRepository implementiert und getestet werden. Dadurch können Sie sicherstellen, dass die Überwachung richtig funktioniert, unabhängig davon, ob Sie eine reale Datenbank haben.
  3. Sie können mehrere Decorators für Überwachung, Sicherheit, Zwischenspeicherung oder andere Zwecke verfassen, ohne die Komplexität von InvoiceRepository zu erhöhen. Anders ausgedrückt, wird der Ansatz mit dem Decorator in einem lose gekoppelten System besser skaliert, wenn neue Funktionen hinzugefügt werden.
  4. Container bieten eine interessante Erweiterbarkeitsmethode für Anwendungen. Es besteht keine Notwendigkeit, AuditingInvoiceRepository in der gleichen Assembly wie InvoiceRepository oder IInvoiceRepository zu implementieren. Es kann problemlos in einer Assembly eines Drittanbieters implementiert werden, auf die durch die Konfigurationsdatei verwiesen wird.

Entkopplung
Obwohl Ihre Softwarearchitektur schichtweise angelegt ist, sind Ihre Schichten wahrscheinlich immer noch eng gekoppelt, was das Testen und die weitere Entwicklung Ihrer Anwendung behindern kann. Sie können das Design jedoch entkoppeln. Durch die Verwendung von Abhängigkeitsumkehr und Abhängigkeitsinjektion können Sie die Vorteile der Verwendung vertragsgemäßer Codierung anstelle konkreter Implementierung nutzen. Indem eine Umkehr des Steuerelementcontainers eingeführt wird, können Sie die Flexibilität Ihrer Architektur erhöhen. Letztendlich wird Ihr lose gekoppeltes Design besser auf Änderungen reagieren.

James Kovacs ist unabhängiger Architekt, Entwickler und Schulungsleiter und hat äußerst vielseitige Interessen. Er lebt in Calgary, Kanada, und hat sich auf Agile-Entwicklung mittels .NET Framework spezialisiert. Er ist Microsoft MVP für Lösungsarchitektur und besitzt einen Masters-Abschluss der Harvard University. James Kovacs kann unter jkovacs@post.harvard.edu oder www.jameskovacs.com erreicht werden.

Page view tracker