Chain of Responsibility per DI

Stefan J. | Softwareentwickler
19/4/2024
Blog

Implementierung des Chain of Responsibility Patterns

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 kann dabei Code entstehen, der sehr schwer zu pflegen ist oder der einen Zustand erreicht hat, in dem 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.

Denn jeder sollte wissen: Komplexität kann man nicht reduzieren, aber verlagern und somit überschaubarer machen.

Durchführung

Jetzt besteht 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 Responsibility

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

Schauen wir uns das Ganze anhand eines Schemas an.

Das Interface, das oben zu sehen ist, beinhaltet zwei Informationen:

  1. Die private Member-Variable „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 Schritt der Kette aufzurufen.

Vorteile des Patterns

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

  1. Der erste Vorteil ist die Testbarkeit solcher Code-Fragmente. Es ist viel leichter, die Funktionalität eines kleinen Teils des Prozesses im Unittest zu belegen als den kompletten Prozess an sich.
  2. Dadurch, dass die einzelnen Implementierungen in sich geschlossen sind, sind die Implementierungen nicht mehr durch Serviceimplementierungen überladen, wie dies sonst der Fall ist.

Implementierung des Chain of Responsibility Patterns

1public interface IChainHandler
2{
3    public Task HandleAsync(int id);
4}

Hier sehen wir das Interface. Was hier besonders auffällt, ist, dass ich hier die private Member-Variable nicht wiederfinde. Dazu kommen wir gleich bei der Implementierung des Interfaces zurück.

Basisimplementierung des Chain-Handlers

1internal class ChainHandlerBase : IChainHandler
2{
3    private readonly IChainHandler next;
4
5    public ChainHandlerBase(IChainHandler next)
6    {
7        this.next = next;
8    }
9
10    public virtual async Task HandleAsync(int id)
11    {
12        if (next is not null)
13        {
14            await next.HandleAsync(id);
15        }
16    }
17}

Hier haben wir eine Basisimplementierung für unser „IHandler“-Interface, die 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 eingeführt wurde. Auch der Zugriff auf Datenbanken ist damit kein Problem. Die Implementierung ist so flexibel, dass alles per DI injiziert werden kann.

Soweit so gut. Nun haben wir uns das Interface und eine Implementierung angeschaut. Da eine Kette aus vielen kleinen Teilen besteht, müssen diese auch miteinander verknüpft werden.

Schauen wir uns das Ganze einmal an.

Konfigurieren der Prozesskette

1public interface IChainHandlerConfigurator<T> where T : class
2{
3    IChainHandlerConfigurator<T> Add<TImplementation>() where TImplementation : T;
4    void Configure();
5}
6
7public static IChainHandlerConfigurator<T> Chain<T>(this IServiceCollection services, object? key) where T : class
8{
9    return new ChainConfiguratorImpl<T>(services, key);
10}

Hier haben wir das Interface IChainHandlerConfigurator<T>, das die Methoden für die Implementierung unseres Prozessketten-Konfigurators vorgibt. Das Interface gibt folgende Methoden vor:

  1. Die Methode „Add“, die zum Hinzufügen von Kettenmitgliedern dient.
  2. Die Methode „Configure“, die die Anmeldung der Konfiguration in unserer ServiceCollection vornimmt.

Nun können wir uns zusammen die Implementierung anschauen.

Verkettung der Prozessschritte

1private class ChainConfiguratorImpl<T> : IChainHandlerConfigurator<T> where T : class
2{
3    private readonly IServiceCollection services;
4    private readonly object? key;
5    private readonly List<Type> types = new List<Type>();
6    private readonly Type interfaceType = typeof(T);
7
8    public ChainConfiguratorImpl(IServiceCollection services, object? key)
9    {
10        this.services = services;
11        this.key = key;
12    }
13
14    public IChainHandlerConfigurator<T> Add<TImplementation>() where TImplementation : T
15    {
16        var type = typeof(TImplementation);
17        types.Add(type);
18        return this;
19    }
20
21    public void Configure()
22    {
23        if (types.Count == 0)
24            throw new InvalidOperationException($"No implementation defined for {interfaceType.Name}");
25
26        foreach (var type in types)
27        {
28            ConfigureType(type);
29        }
30    }
31
32    private void ConfigureType(Type currentType)
33    {
34        // Ermittelt den nächsten Handler in der Kette
35        var nextType = types.SkipWhile(x => x != currentType).SkipWhile(x => x == currentType).FirstOrDefault();
36
37        // Erzeugt den Parameter x vom Typ IServiceProvider
38        var parameter = Expression.Parameter(typeof(IServiceProvider), "x");
39
40        // Ermittelt den Konstruktor mit den meisten Parametern
41        var ctor = currentType.GetConstructors().OrderByDescending(x => x.GetParameters().Count()).First();
42
43        var ctorParameters = ctor.GetParameters().Select(p =>
44        {
45            // Prüft, ob der Parameter vom Typ IChainHandler ist
46            if (interfaceType.IsAssignableFrom(p.ParameterType))
47            {
48                if (nextType is null)
49                {
50                    // Übergibt null als Parameter, sollten wir am Ende der Kette sein
51                    return Expression.Constant(null, interfaceType);
52                }
53                else
54                {
55                    // Ruft die Methode GetRequiredService auf und übergibt den nächsten Kettenmitglied-Typ als Parameter
56                    return Expression.Call(typeof(ServiceProviderKeyedServiceExtensions), "GetRequiredKeyedService", new[] { nextType }, parameter, Expression.Constant(key));
57                }
58            }
59            // Löst alle weiteren Parameter per DI auf
60            return (Expression)Expression.Call(typeof(ServiceProviderServiceExtensions), "GetRequiredService", new[] { p.ParameterType }, parameter);
61        });
62
63        // Die Expression New erzeugt eine neue Instanz des Typs
64        var body = Expression.New(ctor, ctorParameters.ToArray());
65
66        // Sind wir am Anfang der Kette, wird der Typ IChainHandler zurückgegeben, sonst der Typ der aktuellen Implementierung
67        var first = types[0] == currentType;
68        var resolveType = first ? interfaceType : currentType;
69        var expressionType = Expression.GetFuncType(typeof(IServiceProvider), resolveType);
70
71        // Anschließend wird die Lambda Expression erzeugt und kompiliert
72        var expression = Expression.Lambda(expressionType, body, parameter);
73        var compiledExpression = (Func<IServiceProvider, object>)expression.Compile();
74        // Zum Schluss melden wir unseren Handler an
75        services.AddKeyedScoped(resolveType, key, (IServiceProvider provider, object? key) => compiledExpression.Invoke(provider));
76    }
77}

Die Methode sorgt dafür, dass die Kettenmitglieder per AddKeyedScoped in unserer ServiceCollection angemeldet werden. Dies ist seit einer Neuerung, die mit .NET 8 kam, möglich und erlaubt es, ein Interface mit mehreren Implementierungen anzumelden. Ich empfehle, jeder Prozesskette einen Namen zu geben, der dann über alle Ketten hinweg einmalig ist.

Was nun noch fehlt, ist das eigentliche Verketten.

Verwendung der Prozesskette

1services.Chain<IChainHandler>("Chain1")
2    .Add<Handler1>()
3    .Add<Handler2>()
4    .Configure();

Nun haben wir eine vollständige Prozesskette implementiert. Die Anwendung dieser Prozesskette könnte nun gar nicht mehr einfacher sein. Dafür injiziert 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.

Aufruf der Kette im API-Service

1internal class API
2{
3    public API([FromKeyedServices("Chain1")] IChainHandler chain)
4    {
5        // ...
6    }
7}

Dabei ist nur zu beachten, sich den Service mit dem Attribut „FromKeyedService“ zu injizieren.

Abschluss / Zusammenfassung

Meines Erachtens ist die Implementierung von Prozessen auf den ersten Blick ziemlich „simpel“. 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 der API aus dem .NET-Bereich und wird hoffentlich auch bald vom Framework übernommen, da dies meines Erachtens nach dort als Basisfunktionalität implementiert sein sollte.

Stefan J. | Softwareentwickler
Zurück zur Übersicht

Gemeinsam Großes schaffen

Wir freuen uns auf ein kostenloses Erstgespräch mit Ihnen!
Unser Geschäftsführer Tibor Csizmadia und unser Kundenbetreuer Jens Walter stehen Ihnen persönlich zur Verfügung. Profitieren Sie von unserer langjährigen Erfahrung und erhalten Sie eine kompetente Erstberatung in einem unverbindlichen Austausch.
Foto von Tibor

Tibor Csizmadia

Geschäftsführer
Foto von Jens

Jens Walter

Projektmanager
Devware GmbH verpflichtet sich, Ihre Privatsphäre zu schützen. Wir benötigen Ihre Kontaktinformationen, um Sie bezüglich unserer Produkte und Dienstleistungen zu kontaktieren. Mit Klick auf Absenden geben Sie sich damit einverstanden. Weitere Informationen finden Sie unter Datenschutz.
Vielen Dank für Ihre Nachricht!

Wir haben Ihre Anfrage erhalten und melden uns in Kürze bei Ihnen.

Falls Sie in der Zwischenzeit Fragen haben, können Sie uns jederzeit unter [email protected] erreichen.

Wir freuen uns auf die Zusammenarbeit!
Oops! Something went wrong while submitting the form.
KontaktImpressumDatenschutz