Chain of Responsibility per DI

Solid-Reihe

Stefan Jeglorz | Softwareentwickler | 19.04.2024

Einleitung

In der Softwareentwicklung stehen wir bei der Implementierung von Prozessen oft vor der Problematik wie diese sauber im Sourcecode implementiert werden können.
Je nach Ansatz der Implementierung entsteht aber dadurch Code der sehr schwer zu pflegen ist oder der so einen Zustand erreicht hat das man ihn nicht mehr ändern möchte bzw. kann.
Dafür gibt es Implementierungen die darauf ausgelegt sind die Komplexität eines Prozesses zu verteilen und somit die Größe der Komplexität besser zu überblicken.

Den jeder sollet wissen: Komplexität kann man nicht reduzieren, aber verlagern und somit überschaubarer machen

Durchführung

Jetzt besteht ja nur noch die Frage wie können wir mit dieser Problematik umgehen?

Eine Antwort und worum auch dieser Artikel handelt ist das Behavior Pattern:

 

Chain of Responsiblity

Das Pattern erlaubt uns unseren Prozess in eine Kette von kleinen und überschaubaren Methoden zu zerteilen und diese zu verketten.
Dadurch ist die Implementierung von neuer Logik innerhalb eines Prozesses wesentlich einfacher wie vorher.
Auch Änderungen von bestehenden Prozessen können wesentlich sicherer implementiert werden.

 

Schauen wir uns das ganze anhand eines Schemas an.

 

 

 

Das Interface was oben zu sehen ist beinhaltet 2 Informationen.

 

  1. Die private Membervariable „next“ steht für den nachfolgenden Prozessschritt und somit für das nächste Mitglied der Prozesskette.
  2. Die Funktion „HandleAsync“ übernimmt hierbei die Implementierung und ist dafür Zuständig den nächsten Handler aufzurufen.

 

Bei der Entwicklung ist hierbei vor allem zu Achten das jeder in Prozessschritt in sich geschlossen ist.
Aber welchen Vorteil bietet das jetzt genau?

  1. Der erste Vorteil ist die Testbarkeit von solchen Code Fragmenten. Es ist viel leichter die Funktonalität einen kleinen Teils des Prozesses im Unittest zu belegen als den Kompletten Prozess an sich.
  2. Dadurch das die einzelnen Implementierungen in sich geschlossen sind, sind die Implementierungen nicht mehr von Serviceimplementierungen überlaben wie dies sonst der Fall ist.

 

Hört sich ja bis jetzt wohl alles ziemlich gut an oder?

 

Kommen wir doch mal zur eigentlichen Implementierung.

 

public interface IChainHandler

{

public Task HandleAsync(int id);

}

 

Hier sehen wir das Interface. Was hier besonders auffällt ist das ich hier nicht die private Membervariable wiederfinden lässt. Dazu kommen wir gleich bei der Implementierung des Interfaces zurück.

internal class ChainHandlerBase(IChainHandler next) : IChainHandler

 {

     public virtual async Task HandleAsync(int id)

     {

         if (next is not null)

         {

             await next.HandleAsync(id);

         }

     }

 }

 

Hier haben wir eine Basisimplementierung für unser „IHandler“ Interface welche als Vorlage für weitere Handler benutzt werden kann.
Was hierbei besonders auffällt ist die Implementierung des Primären Konstruktors.

Dadurch sparen wir uns ein paar Zeilen Code und die Deklaration der privaten Membervariable „next“.

Diese Implementierung des Konstruktors ist ein Feature das mit C# 12 gekommen ist.

Auch der Zugriff auf Datenbanken ist damit kein Problem. Die Implementierung ist so flexibel das alles per DI injected werden kann.

 

Soweit so gut. Nun haben wir uns das Interface und eine Implementierung angeschaut. Da ein Kette aus vielen kleinen Teilen besteht müssen diese auch mit einander verkettet werden.

 

Schauen wir uns das ganz einmal an.

public interface IChainHandlerConfigurator<T> where T : class
{
   IChainHandlerConfigurator<T> Add<TImplementation>() where TImplementation : T;
   void Configure();
}

public static IChainHandlerConfigurator<T> Chain<T>(this IServiceCollection services, object? key) where T : class
{
   return new ChainConfiguratorImpl<T>(services, key);
}

Hier haben wir ein Interface IChainHandlerConfigurator<T> was die Methoden für die Implementierung unseres Prozessketten-Konfigurator vorgibt. Das Interface gibt folgende Methoden vor.

 

  1. Dort haben wir einmal die Methode „Add“ welche zum Hinzufügen von Kettenmitgliedern dient.
  2. Anschließend haben wir noch die Methode „Configure“ welche die Anmeldung der Konfiguration in unserer ServiceCollection vornimmt.

 

Nun können wir uns zusammen die Implementierung anschauen.

private class ChainConfiguratorImpl<T>(IServiceCollection services, object? key) : IChainHandlerConfigurator<T> where T : class
{
   private List<Type> types = new List<Type>();
   private Type interfaceType = typeof(T);

public IChainHandlerConfigurator<T> Add<TImplementation>() where        TImplementation : T
   {
      var type = typeof(TImplementation);
      types.Add(type);
      return this;
   }

   public void Configure()
   {
      if (types.Count == 0)
          throw new InvalidOperationException($"No implementation defined for {interfaceType.Name}");

      foreach (var type in types)
      {
         ConfigureType(type);
      }
   }
   // ...
}

 

Damit haben wir den ersten Teil der Implementierung einmal vor uns.

Dort sehen wir eine Liste types. Diese hält die Reihenfolge unsere Kettenmitglieder welche wir dann über die Methode „Configure“ verketten wollen.

Dazu halten wir noch den Typen unseres Interfaces „interfaceType“ innerhalb der Instanz des Konfigurators.

 

Was noch fehlt ist dann die Implementierung unsere Methode „Configure“.

private void ConfigureType(Type currentType)
{
   // Ermittelt den nächsten Händler in der Kette
   var nextType = types.SkipWhile(x => x != currentType).SkipWhile(x => x = currentType).FirstOrDefault();

   // Erzeugt den Parameter x vom Type IServiceProvider
   var parameter = Expression.Parameter(typeof(IServiceProvider), "x");

   // Ermittelt den Konstruktor mit den meisten Parametern
   var ctor = currentType.GetConstructors().OrderByDescending(x => x.GetParameters().Count()).First();

   var ctorParameters = ctor.GetParameters().Select(p =>
   {
      // Prüft ob der Parameter vom Typ IChainHandler ist
      if (interfaceType.IsAssignableFrom(p.ParameterType))
      {
          if (nextType is null)
          {
              // Übergibt null als Parameter sollten wir am Ende der Kette sein
              return Expression.Constant(null, interfaceType);
          }
          else
          {
              // Ruft die Methode GetRequiredService auf und übergibt den nächsten Kettenmitglied Typ als Parameter
             return Expression.Call(typeof(ServiceProviderKeyedServiceExtensions), "GetRequiredKeyedService", [nextType], parameter, Expression.Constant(key));
          }
      }
      // Löst alle weiteren Parameter per DI auf
      return (Expression)Expression.Call(typeof(ServiceProviderServiceExtensions), "GetRequiredService", [p.ParameterType], parameter);
   });

  // Die Expression New erzeugt eine neue Instanz des Typs
  var body = Expression.New(ctor, ctorParameters.ToArray());

  // Sind wir am Anfang der Kette wird der Typ IChainHandler zurückgegeben sont  der type der aktuellen Implementierung
  var first = types[0] == currentType;
  var resolveType = first ? interfaceType : currentType;
  var expressionType = Expression.GetFuncType(typeof(IServiceProvider), resolveType);

  // Anschließend wird die Lambda Expression erzeugt und kompiliert
  var expression = Expression.Lambda(expressionType, body, parameter);
  var compiledExpression = (Func<IServiceProvider, object>)expression.Compile();
  // Zum Schluss melden wir unseren Handler an
   services.AddKeyedScoped(resolveType, key, (IServiceProvider provider, object? key) => { return compiledExpression.Invoke(provider); });
}

 

Die Methode sorgt dafür das die Kettenmitglieder per AddKeyedScoped in unserer ServiceCollection angemeldet werden. Dies ist seit eine Neuerung die mit .NET8 kam und uns erlaubt ein Interface mit mehreren Implementierung anzumelden. Ich empfehle hierbei jeder Prozesskette einen Namen zu geben der dann über alle Ketten hinweg einmalig ist.

 

Was nun noch fehlt ist natürlich das eigentliche verketten.

services.Chain<IChainHandler>("Chain1")

         .Add<Handler1>()

         .Add<Handler2>()

         .Configure();

 

 

Nun haben wir eine Vollständige Prozesskette implementiert.

Die Anwendung dieser Prozesskette könnte nun gar nicht mehr einfacher sein.

Dafür injected man sich das IChainHandler Interface in seine Controller oder Serviceimplementierungen und ruft die Methode „HandleAsync“ auf und die Kette fängt an sich selbständig Stück für Stück abzuarbeiten.

internal class API([FromKeyedServices("Chain1")] IChainHandler chain)

 

Dabei ist nur zu beachten sich den Service mit den Attribute „FromKeyedService“ zu injecten.

 

Abschluss/ Zusammenfassung

Meines Erachtens ist die Implementierung von Prozessen so ziemlich „Simpel“ auf den ersten Blick.

Natürlich ist diese Implementierung nicht für jeden Anwendungsfall anwendbar oder muss spezifisch an das Projekt angepasst werden.
So wird die Notwendigkeit einer Id als Integer nicht für jeden erforderlich sein und dient hier nur zur Demonstration.

Diese Art von Implementierung ist Äquivalent zur Anmeldung von Middleware innerhalb API aus dem .NET Bereich und wird hoffentlich auch bald vom Framework übernommen da dies meines Erachtens nach raus dort als Basisfunktionalität implementiert sein sollte.

 

Stefan Jeglorz | Softwareentwickler | 19.04.2024

Sie suchen einen Software Anbieter?
Sprechen Sie uns an