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.
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:
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:
Bei der Entwicklung ist hierbei vor allem darauf zu achten, dass jeder Prozessschritt in sich geschlossen ist. Aber welchen Vorteil bietet das jetzt genau?
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.
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.
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:
Nun können wir uns zusammen die Implementierung anschauen.
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.
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.
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.
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.