Microsoft Extensions Dependency Injection

28. April 2020

Nach Konfiguration im letzten Beitrag soll es diesmal um Dependency Injection gehen.

Dependency Injection ist insofern ein Sonderfall, als es im klassischen .NET Framework nicht enthalten ist. Allerdings hat sich die Nutzung eines DI-Containers in den letzten Jahren weitgehend durchgesetzt, so dass offensichtlich auch Microsoft nicht darauf verzichten wollte. Tatsächlich sind die Microsoft Extensions konsequent auf die Nutzung von Dependency Injection ausgelegt, was ein wesentlicher Grund für die Flexibilität und Erweiterbarkeit ist.

Von [mein Lieblings-DI-Container] …

Es gibt mittlerweile eine ganze Reihe von DI-Containern, insbesondere auch solche, die eine gewisse Verbreitung gefunden haben: Unity, Castle.Windsor, SimpleInjector, Autofac – um nur einige zu nennen, über die ich in den letzten Jahren in Projekten gestolpert bin.

Im Grundsatz bieten diese DI-Container alle einen vergleichbaren Funktionsumfang. Im Detail unterscheiden sie sich etwas, abhängig davon, ob sie auf Performance oder auf funktionale Reichhaltigkeit getrimmt sind, aber einige Funktionalitäten bieten die meisten an:

  • Objekterzeugung mit Constructor- und Property-Injection
  • Irgendeine Form von Scopes
  • Möglichkeiten, um aspektorientierte Ansätze über Adapter bzw. Decorators zu realisieren.
  • Benannte Instanzen
  • Ausführliche Unterstützung beim Registrieren von Komponenten

Man kann sich in seiner Anwendung entscheiden, ob man alle Funktionalitäten eines DI-Containers konsequent ausnutzt, oder ob man sich auf grundlegende Dinge beschränkt. Das Ausnutzen erlaubt oft sehrt effiziente und elegante Lösungswege für Querschnittsthemen und andere Dinge, kommt aber in der Regel zum Preis der Abhängigkeit von diesem speziellen DI-Container.

… zu den Microsoft Extensions

Da der Einsatz von Dependency Injection nicht wirklich in Frage steht, stellte sich für Microsoft nur die Frage, welchen DI-Container sie einsetzen, oder ob sie einen eigenen neu schreiben. Letztendlich haben sie sich für den Mittelweg entschieden: Ein DI-Container, der genau das zur Verfügung stellt, was sie benötigen. Und die Bereitstellung über ein Provider-Konzept, so dass man bei Bedarf den DI-Container seiner Wahl einklinken kann.

Diese Selbstbeschränkung was den Funktionsumfang angeht bleibt nicht ohne Folgen. Auf der einen Seite ist der entstandene DI-Container sehr schnell. Auf der anderen Seite muss man aber mit einigen Einschränkungen leben:

  • Es wird nur Constructor Injection unterstützt, keine Property Injection.
  • Keine benannten Instanzen
  • Kein Interception-Mechanismus, damit keine Unterstützung von Adaptern oder Decorators.

Property Injection halte ich persönlich für ein Antipattern, insofern Applaus. Benannte Instanzen kommen in Sonderfällen oder beim Aufbau von Pipelines zum Einsatz, das lässt sich ggf. aber auch anders lösen. Am schwersten kommt sicher der letzte Punkt zum Tragen. Man braucht Interception nicht oft, aber wenn man es braucht, dann geht es meist um sehr grundlegende Dinge, die anders nur sehr umständlich oder mit mehr oder weniger viel Code zu lösen sind. Das Feature nicht verfügbar zu haben, kann durchaus schmerzlich sein.

Andere Dinge sind im ersten Schritt etwas aufwendiger als bei anderen DI-Containern, lassen sich aber lösen:

  • Registrierung aller Typen nach Assembly oder Interface.
  • Die Registrierung einer Klasse für zwei Interfaces.
  • Weitere Convenience-Features

Diese Einschränkungen sind insbesondere dann relevant, wenn man im Kontext einer wiederverwendbaren Bibliothek arbeitet. Sie definieren im Grunde die Schnittmenge der Funktionalitäten, die ein DI-Container im Umfeld von .NET Standard anbieten muss und worauf man sich verlassen kann. Sie legen aber eben auch fest, was nicht zwingend zur Verfügung steht – und somit als Beschränkung akzeptiert werden muss.

Bei der Entwicklung einer Anwendung kann man hingegen den DI-Container seiner Wahl nutzen und die Beschränkungen so umgehen. Microsoft verlinkt selbst auf einige Alternativen. Hier und hier.

Konventionen

Es gibt noch einige Konventionen, die man kennen und ggf. einhalten sollte.

Um das deutlicher zu machen, einfaches Beispiel, das den DI-Container aufsetzt und nutzt:

   1: class Program

   2: {

   3:     static void Main(string[] args)

   4:     {

   5:         var services = new ServiceCollection();

   6:

   7:         services.AddSingleton<MyApp>();

   8:         services.AddClassLibrary1();

   9:

  10:         var serviceProvider = services.BuildServiceProvider();

  11:         var app = serviceProvider.GetService<MyApp>();

  12:         app.Main();

  13:     }

  14: }

Der Aufbau des DI-Containers nutzt das Builder-Pattern, wie wir es auch schon bei der Konfiguration gesehen haben.

Der Code ist sehr gradlinig und sollte für jeden, der schon mal mit DI zu tun hatte, nachvollziehbar sein. Einzig der Aufruf von AddClassLbrary1() muss noch angesprochen werden.
Registrierung

Wir haben die gleiche Konvention, die uns schon bei Konfiguration begegnet ist: Jede Bibliothek, die Registrierungen für Dependency Injection vornehmen muss, stellt eine passende Erweiterungsmethode für IServiceCollection zur Verfügung. Und sie tut dies nicht im eigenen Namespace, sondern im Namespace Microsoft.Extensions.DependencyInjection, so dass die Methode ohne jedes weitere using verfügbar ist. Der Name der Klasse endet üblicherweise auf ServiceCollectionExtensions, sollte jedoch global eindeutig sein (andernfalls käme es zu Namenskollisionen).

Hier das passende Beispiel:

   1: namespace Microsoft.Extensions.DependencyInjection

   2: {

   3:     public static class ClassLibrary1ServiceCollectionExtensions

   4:     {

   5:         public static IServiceCollection AddClassLibrary1(this IServiceCollection services)

   6:         {

   7:             return services

   8:                 .AddSingleton<IFoo, FooService>()

   9:                 .AddTransient<IBar, BarService>();

  10:         }

  11:     }

  12: }

Konfiguration

Eine weitere Konvention gibt es im Zusammenhang mit der Konfiguration über Options. Hier sind Überladungen üblich, die einen Callback übernehmen, um die Options per Code zu setzen. Am Beispiel Caching/Memory aus den Microsoft Extensions (verkürzt):

   1: public static class MemoryCacheServiceCollectionExtensions

   2: {

   3:      public static IServiceCollection AddMemoryCache(this IServiceCollection services)

   4:      {

   5:          services.AddOptions();

   6:          services.TryAdd(ServiceDescriptor.Singleton<IMemoryCache, MemoryCache>());

   7:          return services;

   8:      }

   9:

  10:      public static IServiceCollection AddMemoryCache(this IServiceCollection services, Action<MemoryCacheOptions> setupAction)

  11:      {

  12:          services.AddMemoryCache();

  13:          services.Configure(setupAction);

  14:          return services;

  15:      }

Es lohnt sich, diese Konvention auch im eigenen Code einzuhalten, denn in UnitTests möchte man durchaus mal unterschiedliche Konfigurationen austesten.

Fazit

Das war’s im Grunde auch schon: die Microsoft Extensions bieten wenig Spezielles zum Thema Dependency Injection. Man muss sich der Einschränkungen bewusst sein, man sollte ein paar Konventionen einhalten – und man ist “good to go”.

Lohnt sich eine Umstellung, wenn man schon einen DI-Container im Einsatz hat? Wer sich von speziellen Features seines DI-Containers ferngehalten hat, kann den DI-Container einfach austauschen. Dinge wie Batch-Registrierungen, Mehrfachregistrierung oder benannte Registrierungen (z. Beispiel für Pipelines) lassen sich mit wenig Aufwand – wenn auch nicht immer ganz elegant – lösen. Einzig wer auf Interception angewiesen ist hat etwas mehr Arbeit vor sich.
Hier noch die relevanten Links zu den Quelltexten und zur Dokumentation:

autor Alexander Jung

Alexander Jung

Chief eXpert Alexander.Jung@sdx-ag.de