Juni 2016

Band 31, Nummer 6

.NET-Grundlagen – Abhängigkeitsinjektion mit .NET Core

Von Mark Michaelis

Mark MichaelisIn meinen letzten beiden Artikeln „Protokollieren mit .NET Core“ (msdn.com/magazine/mt694089) und „Konfiguration in .NET Core“ (msdn.com/magazine/mt632279) habe ich gezeigt, wie .NET Core-Funktionen aus einem ASP.NET Core-Projekt („project.json“) und einem gängigeren .NET 4.6 C#-Projekt (CSPROJ-Datei) genutzt werden können. Anders ausgedrückt: Die Nutzung des neuen Frameworks ist nicht auf die Entwickler beschränkt, die ASP.NET Core-Projekte erstellen. In diesem Beitrag untersuche ich .NET Core weiter und setzte dabei einen Schwerpunkt auf die DI-Funktionen (Dependency Injection, Abhängigkeitsinjektion) von .NET Core. Außerdem beschreibe ich, wie diese ein IoC-Muster (Inversion of Control, Steuerungsumkehr) ermöglichen. Wie bereits gesagt, können .NET Core-Funktionen aus „traditionellen“ CSPROJ-Dateien und den aufkommenden Projekten vom Typ „project.json“ nutzen. Für den Beispielcode verwende ich dieses Mal XUnit aus einem project.json-Projekt.

Warum Abhängigkeitsinjektion?

Mit .NET ist das Instanziieren eines Objekts durch einen Aufruf des Konstruktors über den Operator „new“ (also „new MyService“ oder ein anderer Objekttyp, der instanziiert werden soll) trivial. Ein Aufruf wie dieser erzwingt unglücklicherweise eine eng gekoppelte Verbindung (einen hartcodierten Verweis) des Clientcodes (oder des Anwendungscodes) mit dem instanziierten Objekt sowie einen Verweis auf dessen Assembly/NuGet-Paket. Für allgemeine .NET-Typen stellt dies kein Problem dar. Für Typen die einen „Dienst“ bereitstellen (z. B. Protokollierung, Konfiguration, Zahlung, Benachrichtigung oder sogar DI) kann die Abhängigkeit jedoch unerwünscht sein, wenn Sie die Implementierung des verwendeten Diensts wechseln möchten. In einem Szenario kann ein Client z. B. NLog für die Protokollierung verwenden, während in einem anderen Szenario Log4Net oder Serilog verwendet wird. Der Client, der NLog verwendet, wird es außerdem vorziehen, sein Projekt nicht durch Serilog zu verunreinigen. Ein Verweis auf beide Protokollierungsdienste wäre daher nicht wünschenswert.

DI löst das Problem eines hartcodierten Verweises auf die Dienstimplementierung, indem eine Dereferenzierungsebene bereitgestellt wird. Dabei wird der Dienst nicht direkt mit dem Operator „new“ instanziiert, sondern der Client (oder die Anwendung) fordert stattdessen eine Dienstauflistung oder „Factory“ für die Instanz an. Außerdem wird nicht die Dienstauflistung für einen bestimmten Typ angefordert (und damit ein eng gekoppelter Verweis erstellt), sondern eine Schnittstelle (etwa „ILoggerFactory“). Dies geschieht in der Erwartung, dass der Dienstanbieter (in diesem Fall NLog, Log4Net oder Serilog) die Schnittstelle implementiert.

Als Ergebnis verweist der Client direkt auf die abstrakte Assembly („Logging.Abstractions“) und definiert die Dienstschnittstelle. Es sind jedoch keine Verweise auf die direkte Implementierung erforderlich.

Dieses Muster der Entkopplung der tatsächlichen Instanz, die an den Client zurückgegeben wird, wird als „Steuerungsumkehr“ bezeichnet. Diese Bezeichnung ergibt sich daraus, dass nicht der Client bestimmt, was instanziiert wird (was der Fall wäre, wenn ein expliziter Aufruf des Konstruktors mit dem Operator „new“ erfolgen würde), sondern DI bestimmt, was zurückgegeben wird. DI registriert eine Zuordnung zwischen dem vom Client angeforderten Typ (im Allgemeinen eine Schnittstelle) und dem Typ, der zurückgegeben wird. Außerdem bestimmt DI im Allgemeinen die Lebensdauer des zurückgegebenen Typs. DI legt dabei insbesondere fest, ob eine einzelne Instanz von allen Anforderungen des Typs gemeinsam verwendet wird, eine neue Instanz für jede Anforderung erstellt wird oder ein Vorgang stattfindet, der zwischen diesen beiden Optionen liegt.

Insbesondere Komponententests sind eine allgemeine Anforderung für DI. Nehmen Sie einen Einkaufswagendienst an, der seinerseits von einem Zahlungsdienst abhängt. Stellen Sie sich nun vor, dass Sie den Einkaufswagendienst schreiben, der den Zahlungsdienst nutzt, und Komponententests für den Einkaufswagendienst ausführen möchten, ohne tatsächlich einen echten Zahlungsdienst aufzurufen. Sie möchten stattdessen einen Pseudozahlungsdienst aufrufen. Dies wird mit DI erreicht, indem der Code eine Instanz der Zahlungsdienstschnittstelle aus dem DI-Framework aufruft, anstatt beispielsweise einen neuen „PaymentService“ aufzurufen. Für den Komponententest ist es nun nur noch erforderlich, das DI-Framework so zu „konfigurieren“, dass ein Pseodozahlungsdienst zurückgegeben wird.

Demgegenüber könnte der Produktionshost den Einkaufswagen für die Verwendung einer der (möglicherweise zahlreichen) Optionen des Zahlungsdiensts konfigurieren. Der wichtigste Aspekt dabei ist vielleicht, dass die Verweise nur auf die Zahlungsabstraktion und nicht auf jede spezifische Implementierung erfolgen.

Das grundlegende Prinzip von DI besteht darin, eine Instanz des „Diensts“ bereitzustellen, anstatt den Client die Instanziierung direkt ausführen zu lassen. Einige DI-Frameworks erlauben tatsächlich eine Entkopplung des Hosts von der Referenzierung der Implementierung, indem ein Bindungsmechanismus unterstützt wird, der auf Konfiguration und Reflektion anstatt auf einer Bindung zur Kompilierzeit basiert. Diese Entkopplung wird als Dienstlocatormuster bezeichnet.

.NET Core Microsoft.Extensions.DependencyInjection

Wenn Sie das .NET Core DI-Framework nutzen möchten, benötigen Sie nur einen Verweis auf das NuGet-Paket „Microsoft.Extensions.DependencyInjection.Abstractions“. Dieses stellt Zugriff auf die IServiceCollection-Schnittstelle zur Verfügung, die einen „System.IService­Provider“ bereitstellt, von dem aus „GetService<TService>“ aufgerufen werden kann. Der Typparameter („TService“) identifiziert den Typ des abzurufenden Diensts (im Allgemeinen eine Schnittstelle). Der Anwendungscode ruft daher eine Instanz ab:

ILoggingFactory loggingFactor = serviceProvider.GetService<ILoggingFactory>();

Es sind äquivalente nicht generische GetService-Methoden verfügbar, die „Type“ als Parameter (anstatt einen generischen Parameters) aufweisen. Die generischen Methoden ermöglichen die direkte Zuweisung an eine Variable eines bestimmten Typs, während die nicht generischen Versionen eine explizite Umwandlung erfordern, weil der Rückgabetyp „Object“ ist. Außerdem sind generische Einschränkungen vorhanden, wenn der Diensttyp hinzugefügt wird. Auf diese Weise kann eine Umwandlung vollständig vermieden werden, wenn der Typparameter verwendet wird.

Wenn beim Aufruf von „GetService“ kein Typ beim Auflistungsdienst registriert ist, wird NULL zurückgegeben. Dies ist in Kopplung mit dem NULL-Weitergabeoperator hilfreich, um der App optionales Verhalten hinzuzufügen. Die ähnliche GetRequiredService-Methode löst eine Ausnahme aus, wenn der Diensttyp nicht registriert ist.

Wie Sie sehen, ist der Code sehr einfach und trivial. Was jedoch fehlt, ist eine Möglichkeit zum Abrufen einer Instanz des Dienstanbieters, für den „GetService“ aufgerufen werden soll. Die Lösung besteht einfach darin, zuerst den Standardkonstruktor von „ServiceCollection“ zu instanziieren und dann den Typ zu registrieren, den der Dienst bereitstellen soll. Ein Beispiel dafür zeigt Abbildung 1. Dabei wird davon ausgegangen, dass jede Klasse („Host“, „Application“ und „PaymentService“) in separaten Assemblys implementiert wird. Außerdem weiß zwar die Host-Assembly, welche Protokollierungen verwendet werden sollen, es ist jedoch kein Verweis auf Protokollierungen in „Application“ oder „PaymentService“ vorhanden. Analog dazu enthält die Host-Assembly keinen Verweis auf die PaymentServices-Assembly. Schnittstellen werden ebenfalls in separaten „Abstraktionsassemblys“ implementiert. Die ILogger-Schnittstelle wird z. B. in der Assembly „Microsoft.Extensions.Logging.Abstractions“ definiert.

Abbildung 1: Registrieren und Anfordern eines Objekts aus Abhängigkeitsinjektion

public class Host
{
  public static void Main()
  {
    IServiceCollection serviceCollection = new ServiceCollection();
    ConfigureServices(serviceCollection);
    Application application = new Application(serviceCollection);
    // Run
    // ...
  }
  static private void ConfigureServices(IServiceCollection serviceCollection)
  {
    ILoggerFactory loggerFactory = new Logging.LoggerFactory();
    serviceCollection.AddInstance<ILoggerFactory>(loggerFactory);
  }
}
public class Application
{
  public IServiceProvider Services { get; set; }
  public ILogger Logger { get; set; }
    public Application(IServiceCollection serviceCollection)
  {
    ConfigureServices(serviceCollection);
    Services = serviceCollection.BuildServiceProvider();
    Logger = Services.GetRequiredService<ILoggerFactory>()
            .CreateLogger<Application>();
    Logger.LogInformation("Application created successfully.");
  }
  public void MakePayment(PaymentDetails paymentDetails)
  {
    Logger.LogInformation(
      $"Begin making a payment { paymentDetails }");
    IPaymentService paymentService =
      Services.GetRequiredService<IPaymentService>();
    // ...
  }
  private void ConfigureServices(IServiceCollection serviceCollection)
  {
    serviceCollection.AddSingleton<IPaymentService, PaymentService>();
  }
}
public class PaymentService: IPaymentService
{
  public ILogger Logger { get; }
  public PaymentService(ILoggerFactory loggerFactory)
  {
    Logger = loggerFactory?.CreateLogger<PaymentService>();
    if(Logger == null)
    {
      throw new ArgumentNullException(nameof(loggerFactory));
    }
    Logger.LogInformation("PaymentService created");
  }
}

Konzeptmäßig können Sie den ServiceCollection-Typ als Name-Wert-Paar ansehen. Dabei ist der Name der Typ eines Objekts (im Allgemeinen eine Schnittstelle), das Sie später abrufen möchten, und der Wert ist entweder der Typ, der die Schnittstelle implementiert, oder der Algorithmus (Delegat) zum Abrufen dieses Typs. Der Aufruf von „AddInstance“ in der Host.Configure­Services-Methode in Abbildung 1 registriert daher, dass jede Anforderung des ILoggerFactory-Typs die gleiche LoggerFactory-Instanz zurückgibt, die in der ConfigureServices-Methode erstellt wurde. Als Ergebnis sind „Application“ und „PaymentService“ in der Lage, „ILoggerFactory“ ohne jede Kenntnis (selbst ohne Assembly/NuGet-Verweis) der implementierten und konfigurierten Protokollierungen abzurufen. Ebenso stellt die Anwendung eine MakePayment-Methode ohne Kenntnis des zu verwendenden Zahlungsdiensts bereit.

Beachten Sie, dass „ServiceCollection“ GetService- oder GetRequiredService-Methoden nicht direkt zur Verfügung stellt. Diese Methoden sind stattdessen aus dem „IServiceProvider“ verfügbar, der von der ServiceCollection.BuildServiceProvider-Methode zurückgegeben wird. Außerdem sind die einzigen vom Anbieter verfügbaren Dienste die Dienste, die vor dem Aufruf von „BuildServiceProvider“ hinzugefügt werden.

„Microsoft.Framework.DependencyInjection.Abstractions“ enthält außerdem eine statische Hilfsklasse namens „ActivatorUtilities“, die Folgendes bereitstellt: einige nützliche Methoden für Konstruktorparameter, die nicht bei „IServiceProvider“ registriert sind, einen benutzerdefinierten ObjectFactory-Delegaten sowie Methoden für den Fall, dass Sie eine Standardinstanz erstellen möchten, wenn ein Aufruf von „GetService“ NULL zurückgibt (siehe bit.ly/1WIt4Ka#ActivatorUtilities).

Lebensdauer des Diensts

In Abbildung 1 rufe ich die IServiceCollection AddInstance<TService>(TService implementationInstance)-Erweiterungsmethode auf. „Instance“ ist eine von vier verschiedenen TService-Lebensdaueroptionen, die in .NET Core DI verfügbar sind. Diese Option legt nicht nur fest, dass der Aufruf von „GetService“ ein Objekt vom Typ „TService“ zurückgibt, sondern auch, dass die jeweilige „implementationInstance“, die bei „AddInstance“ registriert ist, zurückgegeben wird. Anders gesagt: Die Registerierung bei „AddInstance“ speichert die spezifische implementationInstance-Instanz, damit diese mit jedem Aufruf von „GetService“ (oder „GetRequiredService“) mit dem TService-Typparameter der AddInstance-Methode zurückgegeben werden kann.

Im Gegensatz dazu besitzt die IServiceCollection AddSingleton<TService>-Erweiterungsmethode keinen Parameter für eine Instanz und baut stattdessen darauf, dass „TService“ über den Konstruktor eine Instanziierungsmöglichkeit aufweist. Ein Standardkonstruktor funktioniert zwar. „Microsoft.Extensions.Dependency­Injection“ unterstützt jedoch auch nicht standardmäßige Konstruktoren, deren Parameter ebenfalls registriert werden. Sie können z. B. den folgenden Aufruf ausführen:

IPaymentService paymentService = Services.GetRequiredService<IPaymentService>()

DI übernimmt in diesem Fall den Abruf der konkreten ILoggingFactory-Instanz und nutzt diese bei der Instanziierung der PaymentService-Klasse, für die eine „ILoggingFactory“ in ihrem Konstruktor erforderlich ist.

Wenn eine solche Möglichkeit im TService-Typ nicht verfügbar ist, können Sie stattdessen die Überladung der AddSingleton-Erweiterungsmethode nutzen, die einen Delegaten vom Typ „Func<IServiceProvider, TService> implementationFactory“ annimmt – eine Factorymethode für die Instanziierung von „TService“. Unabhängig davon, ob Sie die Factorymethode angeben, stellt die Dienstauflistungsimplementierung sicher, dass immer nur eine Instanz des TService-Typs erstellt wird. Auf diese Weise wird gewährleistet, dass eine Singletoninstanz vorhanden ist. Nach dem ersten Aufruf von „GetService“, der die TService-Instanziierung auslöst, wird immer die gleiche Instanz für die Lebensdauer der Dienstauflistung zurückgegeben.

„IServiceCollection“ enthält außerdem die Erweiterungsmethoden „AddTransient(Type serviceType, Type implementationType)“ und „AddTransient(Type serviceType, Func<IServiceProvider, TService> implementationFactory)“. Diese ähneln „AddSingleton“. Es wird jedoch bei jedem Aufruf dieser Methoden eine neue Instanz zurückgegeben. So wird sichergestellt, dass immer eine neue Instanz des TService-Typs vorhanden ist.

Schließlich sind noch verschiedene AddScoped-Typerweiterungsmethoden vorhanden. Diese Methoden wurden so definiert, dass die gleiche Instanz in einem angegebenen Kontext zurückgegeben wird und immer eine neue Instanz erstellt wird, wenn sich der Kontext (als „Scope“ (Bereich) bezeichnet) ändert. Das Verhalten von ASP.NET Core ist konzeptuell der Bereichslebensdauer zugeordnet. Im Wesentlichen wird für jede HttpContext-Instanz eine neue Instanz erstellt, und bei jedem Aufruf von „GetService“ im gleichen „HttpContext“ wird die identische TService-Instanz zurückgegeben.

Zusammenfassend kann gesagt werden, dass vier Lebensdaueroptionen für die von der Dienstauflistungsimplementierung zurückgegebenen Objekte möglich sind: „Instance“, „Singleton“, „Transient“ und „Scoped“. Die letzten drei Optionen werden in der ServiceLifetime-Enumeration (bit.ly/1SFtcaG) definiert. „Instance“ fehlt jedoch, weil es sich um einen besonderen Fall von „Scoped“ handelt, in dem sich der Kontext nicht ändert.

Weiter oben in diesem Artikel habe ich „ServiceCollection“ konzeptmäßig als Name-Wert-Paar vorgestellt, bei dem der TService-Typ als Lookup dient. Die tatsächliche Implementierung des ServiceCollection-Typs erfolgt in der ServiceDescription-Klasse (siehe bit.ly/1SFoDgu). Diese Klasse stellt einen Container für die Informationen zur Verfügung, die zum Instanziieren von „TService“ erforderlich sind: den „ServiceType“ („TService“), den „Implementation­Type“ oder den ImplementationFactory-Delegaten sowie die „ServiceLifetime“. Neben den ServiceDescriptor-Konstruktoren sind verschiedene statische Factorymethoden für „ServiceDescriptor“ vorhanden, die die Instanziierung von „ServiceDescriptor“ selbst unterstützen.

Unabhängig davon, mit welcher Lebensdauer Sie Ihren „TService“ registrieren, muss der „TService“ selbst ein Verweistyp und kein Werttyp sein. Wenn Sie einen Typparameter für „TService“ verwenden (anstatt „Type“ als einen Parameter zu übergeben), überprüft der Compiler dies mit einer generischen Klasseneinschränkung. Es wird jedoch z. B. nicht überprüft, ob ein „TService“ vom Typ „object“ verwendet wird. Die Übergabe dieses Typs muss ebenso wie die Übergabe anderer nicht eindeutiger Schnittstellen (z. B. „IComparable“) aus dem folgenden Grund unbedingt vermieden werden: Wenn Sie ein Element vom Typ „object“ registrieren, wird unabhängig davon, welchen „TService“ Sie im Aufruf von „GetService“ angeben, immer das als ein TService-Typ registrierte Objekt zurückgegeben.

Abhängigkeitsinjektion für die DI-Implementierung

ASP.NET nutzt DI in einem solchen Ausmaß, dass DI tatsächlich auch im DI-Framework selbst verwendet werden kann. Anders ausgedrückt: Sie sind nicht auf die Verwendung der ServiceCollection-Implementierung des DI-Mechanismus in „Microsoft.Extensions.DependencyInjection“ angewiesen. Solange Klassen verwendet werden, die „IServiceCollection“ (definiert in „Microsoft.Extensions.DependencyInjection.Abstractions“, siehe bit.ly/1SKdm1z) oder IServiceProvider (definiert im System-Namespace des .NET Core Bibliotheksframeworks) implementieren, können Sie Ihr eigenes DI-Framework verwenden oder eines der anderen bekannten DI-Frameworks einschließlich Ninject (ninject.org @IanfDavis: Respekt dafür, dieses Framework seit Jahren zu verwalten) und Autofac (autofac.org) nutzen.

Eine Anmerkung zu ActivatorUtilities

„Microsoft.Framework.DependencyInjection.Abstractions“ enthält außerdem eine statische Hilfsklasse, die Folgendes bereitstellt: einige nützliche Methoden für Konstruktorparameter, die nicht bei „IServiceProvider“ registriert sind, einen benutzerdefinierten ObjectFactory-Delegaten sowie Methoden für den Fall, dass Sie eine Standardinstanz erstellen möchten, wenn ein Aufruf von „GetService“ NULL zurückgibt. Beispiele für die Verwendung dieser Hilfsklasse finden Sie im MVC-Framework und in der SignalR-Bibliothek. Im ersten Fall ist eine Methode mit der Signatur „CreateInstance<T>(IServiceProvider provider, params object[] parameters)“ vorhanden, die das Übergeben von Konstruktorparametern an einen Typ ermöglicht, der beim DI-Framework für Argumente registriert ist, die nicht registriert sind. Gegebenenfalls müssen Sie eine Leistungsanforderung berücksichtigen, die verlangt, dass Lambdafunktionen, die Ihre Typen generieren, kompilierte Lambdas sein müssen. In diesem Fall kann die Methode „CreateFactory(Type instanceType, Type[] argumentTypes)“ hilfreich sein, die eine „ObjectFactory“ zurückgibt. Das erste Argument ist der Typ, der von einem Consumer gesucht wird, und das zweite Argument stellt alle Konstruktortypen in der richtigen Reihenfolge dar, die mit dem Konstruktor des ersten Typs übereinstimmen, den Sie verwenden möchten. In der Implementierung werden diese Komponenten in einem kompilierten Lambda zusammengefasst, das extrem leistungsfähig ist, wenn es mehrmals aufgerufen wird. Zuletzt bietet noch die Methode „GetServiceOrCreateInstance<T>(IServiceProvider provider)“ eine einfache Möglichkeit zum Bereitstellen einer Standardinstanz eines Typs, der ggf. optional an einem anderen Ort registriert wurde. Dies ist insbesondere dann sinnvoll, wenn Sie DI vor dem Aufruf zulassen. Wenn dies jedoch nicht eintritt, ist eine Fallbackimplementierung verfügbar.

Zusammenfassung

Ebenso wie bei .NET Core-Protokollierung und -Konfiguration stellt der .NET Core DI-Mechanismus eine relativ einfache Implementierung seiner Funktionen bereit. Auch wenn Sie die erweiterten DI-Funktionen einiger anderer Frameworks wahrscheinlich nicht finden werden – die .NET Core-Version ist einfach und eignet sich hervorragend für erste Schritte. Außerdem kann die .NET Core-Implementierung (wie auch bei Protokollierung und Konfiguration) durch eine weiter entwickelte Implementierung ersetzt werden. Sie können daher das .NET Core DI-Framework ggf. als „Wrapper“ nutzen, über den Sie andere DI-Frameworks einbinden können, wenn dies in Zukunft erforderlich sein sollte. Auf diese Weise müssen Sie keinen eigenen „benutzerdefinierten“ DI-Wrapper definieren und können den Wrapper von .NET Core als Standardwrapper nutzen, für den jeder beliebige Client bzw. jede Anwendung eine benutzerdefinierte Implementierung einbinden kann.

Bemerkenswert ist, dass ASP.NET Core DI umfassend nutzt. Dies ist zweifelsfrei eine großartige Herangehensweise, wenn diese Funktionen benötigt werden, und insbesondere dann ein wichtiger Aspekt, wenn versucht wird, Pseudoimplementierungen einer Bibliothek in Ihren Komponententests zu ersetzen. Der Nachteil besteht darin, dass anstelle eines einfachen Aufrufs eines Konstruktors mit dem Operator „new“ die Komplexität von DI-Registrierung und GetService-Aufrufen erforderlich ist. Ich frage mich, ob vielleicht die Sprache C# diesen Vorgang vereinfachen könnte. Unter Berücksichtigung des aktuellen Entwurfs von C# 7.0 wird dies aber so bald nicht der Fall sein.


Mark Michaelis ist der Gründer von IntelliTect und arbeitet als leitender technischer Architekt und Trainer. Seit fast zwei Jahrzehnten ist er ein Microsoft MVP und Microsoft-Regionalleiter seit 2007. Michaelis arbeitet in verschiedenen Microsoft-Softwareentwicklungs-Reviewteams mit, einschließlich C#, Microsoft Azure, SharePoint und Visual Studio ALM. Er hält Vorträge bei Entwicklerkonferenzen und hat zahlreiche Bücher geschrieben. Sein aktuelles Werk trägt den Titel „Essential C# 6.0 (5th Edition)“ (itl.tc/­EssentialCSharp). Sie können ihn auf Facebook unter facebook.com/Mark.Michaelis, über seinen Blog unter IntelliTect.com/Mark, auf Twitter: @markmichaelis oder per E-Mail unter mark@IntelliTect.com erreichen.

Unser Dank gilt den folgenden technischen Experten von IntelliTect für die Durchsicht dieses Artikels: Kelly Adams, Kevin Bost, Ian Davis und Phil Spokas