Microsoft Dependency Injection - Add vs. TryAdd

Der erste Artikel im neuen Jahr, für das ich allen und speziell dir, liebe Leserin/lieber Leser, alles Gute und vor allem Gesundheit wünsche!

Microsoft hat seinem ASP.NET Framework einen eigenen Dependency Injection Container verpasst, mit dem das flexible Verbinden der zahlreichen in einer Anwendung benötigten funktionalen Komponenten ermöglicht, vereinfacht und damit auch die Unit-Testbarkeit deutlich erhöht wird.
Bei Projekten, die auf Basis von ASP.NET erstellt werden, wird der Dependency Injection Container bereits beim Scaffolding eingebunden, da er einen fundamentalen Bestandteil dieser Anwendungen darstellt. In Form eines NuGet-Pakets lässt sich der Container jedoch auch in anderen .NET-Projekten nutzen.

Dazu fügt man dem Projekt die Referenz zum Paket Microsoft.Extensions.DependencyInjection hinzu und kann so die Interfaces und Klassen aus dem gleichnamigen Namespace sofort nutzen.

Bootstrapping des Microsoft Dependency Injection Containers

Bevor ich nun in das eigentliche Thema des Artikels einsteige, möchte ich die Initialisierung und Verwendung des Dependency Injection Containers anhand einer ASP.NET- sowie einer Konsolenanwendung kurz demonstrieren.

ASP.NET Anwendung

Wie eingangs bereits erwähnt, stellt der Dependency Injection Container einen fundamentalen Bestandteil für ASP.NET Anwendungen dar und wird daher bereits beim Scaffolding eingebunden. Die Initialisierung erfolgt dann ebenfalls bereits im Zuge des im Scaffolding generierten Initialisierungscodes der Anwendung. Als Entwickler müssen wir uns somit lediglich darum kümmern, unsere Interfaces und Klassen im Container zu registrieren (siehe weiter unten) bzw. benötigte Implementierungen in unsere Klassen injizieren zu lassen.

Konsolenanwendung

Beim Anlegen einer neuen Konsolenanwendung auf Basis von Standardvorlagen wird üblicherweise keine Verwendung des Dependency Injection Containers berücksichtigt. Daher ist es notwendig, das NuGet-Paket Microsoft.Extensions.DependencyInjection manuell dem Projekt hinzuzufügen und den notwendigen Code für die Initialisierung selbst zu schreiben.

Dieser ist glücklicherweise jedoch sehr wenig:

// Schritt 1: Neue ServiceCollection anlegen
ServiceCollection serviceCollection = new ServiceCollection();

// Schritt 2: Relevante Interfaces und Klassen registrieren
serviceCollection.AddSingleton<IMyInterface, MyInterfaceImplementation>();
serviceCollection.AddSingleton<ISomebodyElsesInterface, MyImplementationForThatInterface>();

// Schritt 3: Den ServiceProvider erzeugen
IServiceProvider serviceProvider = serviceCollection.BuildServiceProvider();

// Schritt 4: Benötigte Instanzen vom ServiceProvider beziehen
IMyInterface requiredInstance = serviceProvider.GetService<IMyInterface>();

Das in Schritt 2 beschriebene Registrieren der Interfaces und Klassen entspricht in etwa dem, was in der Startup-Klasse einer ASP.NET Anwendung in der Methode ConfigureServices durchgeführt wird.

Nehmen wir an, der Konstruktor von MyInterfaceImplementation würde folgendermaßen aussehen:

public MyInterfaceImplementation(ISomebodyElsesInterface requiredOtherInstance)
{
    // ...
}

Das in Schritt 4 durchgeführte Beziehen einer Instanz aus dem Dependency Injection Container würde dann automatisch dafür sorgen, dass zum Ausführen des Konstruktors von MyInterfaceImplementation bereits eine Instanz der ebenfalls registrierten Klasse MyImplementationForThatInterface als Wert für den Parameter requiredOtherInstance übergeben wird. Wir brauchen diese also nicht separat beziehen.

Add vs. TryAdd

Nun kommen wir langsam zum eigentlichen Kern dieses Artikels.

Ein wesentlicher Schritt bei der Verwendung eines Dependency Injection Containers ist das Registrieren von Interfaces und Klassen, damit diese dem Container bekannt sind und er so die angeforderten Instanzen mit all ihren Abhängigkeiten entsprechend erstellen kann.

Zur Registrierung stehen unterschiedliche Methoden zur Verfügung.

Grundlegende Beschreibung der Registrierungsfunktionen

AddSingleton

Erstellt die Registrierung für einen Typ, für den es in der gesamten Anwendung immer nur eine einzige Instanz geben soll.

Nehmen wir an, durch den Befehl serviceCollection.AddSingleton<IMySingletonInterface, MySingletonInterfaceImplementation>() wird eine solche Registrierung erstellt. Wann immer im Laufe der Anwendung eine Instanz durch IMySingletonInterface requiredInstance = serviceProvider.GetService<IMySingletonInterface>() bezogen oder in einen Konstruktor mit der Signatur public AnyOtherClass(IMySingletonInterface requiredInstance) injiziert wird, wird jedesmal exakt dieselbe Instanz dafür herangezogen.

AddScoped

Erstellt die Registrierung für einen Typ, für den pro erstellten Ausführungskontext eine eigene Instanz erstellt werden soll, die den gesamten Ausführungskontext lang erhalten bleibt.

Da diese Beschreibung bestimmt nicht sofort verständlich ist, beschreibe ich es lieber mit einem Beispiel.

Stellen wir uns vor, wir befinden uns in einer ASP.NET Anwendung, die eingehende Requests verarbeiten soll und stellen wir uns auch vor, diese Requests haben nichts miteinander zu tun. Hier möchten wir auch sicherstellen, dass die Instanzen der zur Verarbeitung der Requests benötigten Klassen voneinander isoliert sind - ein perfekter Anwendungsfall für AddScoped.

Nehmen wir an, durch den Befehl serviceCollection.AddScoped<IMyScopedInterface, MyScopedInterfaceImplementation>() wird das angegebene Interface und die implementierende Klasse registriert. Wird nun im Zuge der Verarbeitung eines Requests eine Instanz durch IMyScopedInterface requiredInstance = serviceProvider.GetService<IMyScopedInterface>() bezogen oder in einen Konstruktor mit der Signatur public AnyOtherClass(IMyScopedInterface requiredInstance) injiziert, wird pro Request eine eigene Instanz dafür herangezogen.

ASP.NET kümmert sich dabei automatisch darum, dass für jeden Request ein eigener Ausführungskontext (=Scope) erstellt wird. Möchte man das z.B. in einer Konsolenanwendung ebenfalls nutzen, muss man eigene Ausführungskontexte mittels serviceProvider.CreateScope() anlegen.

AddTransient

Erstellt die Registrierung für einen Typ, für den jedesmal eine neue Instanz erstellt werden soll, wenn eine Instanz des Typs angefordert wird.

Nehmen wir an, durch den Befehl serviceCollection.AddTransient<IMyTransientInterface, MyTransientInterfaceImplementation>() wird das angegebene Interface und die implementierende Klasse registriert. Wann immer in der Anwendung eine Instanz durch IMyTransientInterface requiredInstance = serviceProvider.GetService<IMyTransientInterface>() bezogen oder in einen Konstruktor mit der Signatur public AnyOtherClass(IMyTransientInterface requiredInstance) injiziert wird, erstellt der Dependency Injection Container eine neue und völlig unabhängige Instanz der Klasse.

Mehrfache Registrierung von Typen

Die oben angeführte Beschreibung der einzelnen Funktionen zum Registrieren von Typen im Dependency Injection Container ist sinngemäß leicht und überall im Internet zu finden. Ich möchte an dieser Stelle aber einen Schritt weiter gehen und ein paar Spezialkonstellationen betrachten.

Frage: Was passiert, wenn man AddSingleton/AddScoped/AddTransient für dasselbe Interface mehrfach aufruft?
Gegenfrage: Warum sollte man das tun?

Auf den ersten Blick macht es tatsächlich wenig Sinn eine Registrierung für dasselbe Interface mehrfach zu erstellen. Aber es könnte zumindest unabsichtlich passieren, entweder weil man die entsprechende Codezeile versehentlich dupliziert hat, oder weil man zum Registrieren eine der zahlreichen und sehr komfortablen Extension Methods verwendet und darin eine erneute Registrierung erfolgt.

Ich werde das Verhalten des Dependency Injection Containers anhand von AddSingleton beschreiben, wobei sich AddScoped und AddTransient analog verhalten.

Nehmen wir an, wir registrieren das Interface IMySingletonInterface mit derselben implementierenden Klasse MySingletonInterfaceImplementation doppelt, wobei wir die Instanz hier explizit erzeugen und eine Instanzkennung an den Konstruktor übergeben.

serviceCollection.AddSingleton<IMySingletonInterface>(new MySingletonInterfaceImplementation("Singleton:Instance:1"));
serviceCollection.AddSingleton<IMySingletonInterface>(new MySingletonInterfaceImplementation("Singleton:Instance:2"));

Nehmen wir auch an, das Interface IMySingletonInterface definiert ein Property mit der Signatur string InstanceId { get; }, durch das wir die bei der Erstellung angegebene Instanzkennung abfragen können.

Was passiert nun also, wenn wir eine Instanz vom Dependency Injection Container beziehen?

// Beziehen einer Instanz vom Dependency Injection Container
IMySingletonInterface requiredInstance = serviceProvider.GetService<IMySingletonInterface>();

// Ausgeben der Instanzkennung
Console.WriteLine(requiredInstance.InstanceId);

Die Ausgabe lautet: Singleton:Instance:2
Die zweite Registrierung hat also die erste einfach überschrieben.

Aber ist das wirklich so?
Der ServiceProvider bietet neben der Methode GetService auch die Methode GetServices, mit der man mehrere Instanzen beziehen kann.

// Beziehen einer (oder mehrerer?) Instanzen vom Dependency Injection Container
IEnumerable<IMySingletonInterface> requiredInstances = serviceProvider.GetServices<IMySingletonInterface>();

// Ausgeben der Instanzkennung(en)
foreach (IMySingletonInterface requiredInstance in requiredInstances)
{
    Console.WriteLine(requiredInstance.InstanceId);
}

Die Ausgabe lautet nun: Singleton:Instance:1 und Singleton:Instance:2

Wir sehen also, dass keine Registrierung verloren geht und mehrfache Registrierungen durch GetServices sogar explizit unterstützt werden! (Übrigens lassen sich mehrere Instanzen auch injizieren, wenn der Konstruktor folgende Signatur hat: public AnyOtherClass(IEnumerable<IMySingletonInterface> requiredInstances))

Wir sehen auch, dass die Reihenfolge der Registrierung relevant ist, da bei Anforderung einer einzelnen Instanz immer eine Instanz aus der letzten Registrierung herangezogen wird.

Mehrfache Registrierung nutzen

Wir haben gesehen, dass man mittels der Methode GetServices bzw. durch Injizierung eines IEnumerable-Generics mit dem gewünschten Typ eine Liste von registrierten Services von Dependency Injection Container beziehen kann.

Wofür kann man das nun nutzen?

Spontan fallen mir folgende Anwendungsgebiete ein, wobei die Liste absolut keinen Anspruch auf Vollständigkeit stellt:

  • Verteilen von Benachrichtigungen/Ereignissen
    Wir können beispielsweise ein Interface mit dem Namen IMyEvents anbieten und Nutzer unserer Komponente können dieses Interface nach ihren Anforderungen implementieren und registrieren. Wann immer unsere Logik dann Ereignisse verteilen möchte, holen wir uns eine Liste aller registrierten Implementierungen aus dem Container und rufen jede davon auf.
  • Verarbeiten derselben Daten
    Unsere Anwendung könnte beispielsweise Bilder von einer Webcam holen und diese dann an alle registrierten Services senden, die das IMyImageAnalyzer Interface unterstützen.
    Zugegeben ist das sehr ähnlich dem vorigen Anwendungsfall.

In ASP.NET wird beispielsweise das IClaimsTransformation Interface auf diese Art verwendet. Bei jedem Request werden alle registrierten Services, die dieses Interface implementieren, aufgerufen um den Sicherheitskontext aufzubauen. Damit ist es sehr einfach möglich, diesen Kontext mit eigenen Informationen anzureichern.

Mehrfache Registrierung vermeiden

Nachdem wir nun betrachtet haben, welchen Nutzen eine Mehrfachregistrierung haben kann, wollen wir auch auf die andere Seite, die Einzelregistrierung, näher eingehen.

Ich habe vorher schon angesprochen, dass eine unbewusste Mehrfachregistrierung durch Nutzung von Extension Methods entstehen könnte. Diese Tatsache ist mir bewusst geworden, als ich selber eine solche Methode implementiert habe, um den meine Komponente nutzenden Entwicklern mehr Komfort zu bieten.

Dabei habe ich mir beim Erstellen einer Registrierung die Frage gestellt, “Wie kann ich sicherstellen, dass ich eine vorangegangene Registrierung nicht überschreibe?”

Nach einiger Recherche bin ich über drei sehr handliche und einfach zu nutzende Funktionen gestolpert:

  • TryAddSingleton
  • TryAddScoped
  • TryAddTransient

Diese sind im Namespace Microsoft.Extensions.DependencyInjection.Extensions definiert und um sie nutzen zu können, muss dieser Namespace per using Anweisung eingebunden werden.

Die Funktionen selbst machen exakt das gleiche wie ihre Pendants ohne Try-Präfix mit dem einen Unterschied, dass sie eine Registrierung nur dann erstellen, wenn noch keine typgleiche Registrierung vorhanden ist.

Sehen wir uns das Beispiel von vorhin nocheinmal an, verwenden jedoch anstelle von AddSingleton nun TryAddSingleton.

serviceCollection.TryAddSingleton<IMySingletonInterface>(new MySingletonInterfaceImplementation("Singleton:Instance:1"));
serviceCollection.TryAddSingleton<IMySingletonInterface>(new MySingletonInterfaceImplementation("Singleton:Instance:2"));

Was passiert nun also, wenn wir eine Instanz vom Dependency Injection Container beziehen?

// Beziehen einer Instanz vom Dependency Injection Container
IMySingletonInterface requiredInstance = serviceProvider.GetService<IMySingletonInterface>();

// Ausgeben der Instanzkennung
Console.WriteLine(requiredInstance.InstanceId);

Die Ausgabe lautet: Singleton:Instance:1
Die zweite Registrierung wurde also gar nicht mehr erstellt.

Wir prüfen das zusätzlich mit der Methode GetServices.

// Beziehen einer (oder mehrerer?) Instanzen vom Dependency Injection Container
IEnumerable<IMySingletonInterface> requiredInstances = serviceProvider.GetServices<IMySingletonInterface>();

// Ausgeben der Instanzkennung(en)
foreach (IMySingletonInterface requiredInstance in requiredInstances)
{
    Console.WriteLine(requiredInstance.InstanceId);
}

Die Ausgabe lautet nun ebenfalls: Singleton:Instance:1

Es existiert also tatsächlich nur eine Registrierung für IMySingletonInterface.

Beispielanwendung

Mir ist bewusst, dass Entwicklerinnen und Entwickler viele lieber Sourcecode als geschriebenen Text lesen, daher habe ich für diesen Artikel auch eine Beispielanwendung erstellt, in der die oben beschriebenen und noch weitere Konstellationen angewendet werden.

  • Mehrfachregistrierung mittels AddSingleton
  • Mehrfachregistrierung mittels TryAddSingleton
  • Mehrfachregistrierung mittels Replace
  • Mehrfachregistrierung mittels Mischung aus AddSingleton und AddScoped
  • Mehrfachregistrierung mittels Mischung aus AddSingleton und TryAddScoped

Die Anwendung ist auf GitHub verfügbar.

Anregungen zu weiteren Konstellationen oder Fragen bitte entweder per Email oder als Issue in GitHub.

Weiterführende Informationen