Erstellen von Filter-Pipelines in C#

Erfahren Sie, wie Sie Ihre eigene Version der AspNet.Core Middleware-Pipeline erstellen und wie Sie diese für erweiterbaren und konfigurierbaren Code nutzen können.

von Frank Wagner am 12.05.2021

Einleitung

Während der Entwicklung unserer .NET Core Generic Host Erweiterung Hosuto hatten wir die Anforderung, sie erweiterbar und konfigurierbar zu machen.
Also habe ich darüber nachgedacht, wie man eine Kette von Filtern implementieren kann, mit der man neue Funktionen hinzufügen kann, ohne die Hauptimplementierung zu ändern.

Die grundlegenden Teile dieser Lösung sind hier beschrieben.

Wenn Sie sehen möchten, wie es in der realen Welt verwendet wird, sehen Sie sich Hosuto' s GitHub Repository an. Die Implementierung funktioniert wie die AspNet.Core Middleware-Pipeline - die ist nämlich auch nichts anderes als eine Filter-Pipeline.

Bitte beachten Sie, dass dies keine Umsetzung des Pipe and Filters Pattern ist, da die hier erstellte Pipeline nicht das Ergebnis der Verarbeitung zurückgibt.
Abgesehen vom Namen 'Pipeline' ist die AspNet.Core Middleware-Pipeline eigentlich eher eine Chain of Responsibility Design-Pattern-Implementierung und das ist es auch, was wir hier erstellen werden.


Filtere es!

Wir beginnen mit der Visual Studio-Vorlage für eine Konsolenanwendung.
Sie wissen schon: Hello world!

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Hello World!");
    }
}

Als nächstes deklarieren wir eine Schnittstelle für Filter:
public interface IFilter<T1>
{
    Action<T1> Invoke(Action<T1> next);

}

Die Eingabe und das Ergebnis der Methode "Invoke" ist eine Action, die einen generischen Wert als Eingabe hat.
Wird die Methode aufgerufen, kann sie den in der zurückgegebenen Action den Wert verarbeiten. Solange der Typ T unveränderlich ist, kann sie den Wert aber nicht ändern, sondern nur ersetzen.

Außerdem kann sie entscheiden, ob sie die Verarbeitung anhält oder die nächste Aktion mit dem Wert aufruft, um die Pipeline fortzusetzen.

Hier zwei einfache Filter, die als string Filter verwendet werden können:

public class ToUpperFilter : IFilter<string>
{
    public Action<string> Invoke(Action<string> next)
    {
        return stringValue =>
        {
            next(stringValue.ToUpper());
        };
    }
}

public class StopHelloWorldFilter : IFilter<string>
{
    public Action<string> Invoke(Action<string> next)
    {
        return stringValue =>
        {
            if (stringValue != "Hello world!")
                next(stringValue);
        };
    }
}

Bauen einer Pipeline

Um eine Pipeline zu erstellen, müssen die einzelnen Filter nacheinander aufgerufen werden, wobei die Eingabe des nächsten Filters das Aktionsergebnis des vorherigen Filters sein wird.

Aber wie kodiert man nun eine Pipeline dieser Filter?

Hier kommt es:

public static class Filters
{

    public static Action<T1> BuildFilterPipeline<T1>(
        IEnumerable<IFilter<T1>> filters,
        Action<T1> filteredDelegate)
    {
        if (filteredDelegate == null) throw 
            new ArgumentNullException(nameof(filteredDelegate));

        return (p1 =>
        {
            var filterList = filters.Reverse().ToList();
            if (filterList.Count == 0)
            {
                filteredDelegate(p1);
                return;
            }

            var pipeline = filterList.Aggregate(
                filteredDelegate, (current, filter) 
                    => filter.Invoke(current));

            pipeline(p1);

        });
    }
}

Diese Methode dreht zunächste die Reihenfolge der Filter und konvertiert sie in eine Liste (siehe unten, warum wir die Reihenfolge umkehren). Wenn keine Filter vorhanden sind, wird nur der Delegate aufgerufen. Wenn nicht, werden die Filter mit IEnumerable.Aggregate verkettet.

Anstelle der Aggregate Methode könnten Sie auch eine foreach-Schleife verwenden:


            var pipeline = filteredDelegate;
            foreach (var filter in filterList)
            {
                pipeline = filter.Invoke(pipeline);
            }

            pipeline(p1);


Hello world 2.0

Sie werden vermutlich bereits ahnen, was als nächstes kommt:
Wir werden die Filter verwenden, um zu beeinflussen, wie das Programm "Hello world!" ausgibt!

static void Main(string[] args)
{
    var filters = new IFilter<string>[]
    {
        new ToUpperFilter(),
        new StopHelloWorldFilter()				
    };

    Filters.BuildFilterPipeline(filters, Console.WriteLine)
        ("Hello world!");
    Filters.BuildFilterPipeline(filters, Console.WriteLine)
        ("Some new greeting!");
}		

Wenn Sie dieses Programm ausführen, sieht die Ausgabe wie folgt aus:

HELLO WORLD!
SOME NEW GREETING!

Ohh, was ist denn hier passiert?
Der erste Filter hat die Zeichenkette in Großbuchstaben geändert, und da der zweite Filter Groß- und Kleinschreibung berücksichtigt...

Die Ausgabe wird sich ändern, wenn wir die Reihenfolge der Filter ändern (StopHelloWorldFilter, ToUpperFilter):

SOME NEW GREETING!

Die Reihenfolge der Filter ist also wichtig!
Sie werden in der Pipeline vom letzten zum ersten verkettet. Das bedeutet, dass der erste zuerst aufgerufen wird, dann der zweite und so weiter.
Wenn wir die Reihenfolge der Filter in der Methode BuildFilterPipeline nicht umkehren würden, würde der letzte Filter zuerst aufgerufen werden.



Use Erweiterungen

Das ist also schon ein recht gutes Ergebnis und funktioniert genau wie die AspNetCore Middleware.
Wir können es aber noch ein wenig verbessern.

Stellen Sie sich also vor, dass Sie einen Ausgabeprozessor für Strings bauen müssen. Er wird einfach einen String als Eingabe nehmen und ihn schreiben:

public class OutputProcessor
{
    public void WriteLine(string input)
    {
        Console.WriteLine(input);
    }
}

Natürlich könnte der Ausgabeprozessor noch etwas flexibler sein. Lassen Sie uns also unsere Filter verwenden, um die Ausgabe mit etwas fluent Code konfigurierbar zu machen:

public class OutputProcessor
{
    private readonly List<IFilter<string>> _filters = 
        new List<IFilter<string>>();
    private readonly List<IFilter<string>> _actions = 
        new List<IFilter<string>>();

    public OutputProcessor UseFilter(IFilter<string> filter)
    {
        _filters.Add(filter);
        return this;
    }

    public void WriteLine(string input)
    {
        Filters.BuildFilterPipeline(
            _filters,
            Console.WriteLine)(input);
    }
}

static void Main(string[] args)
{
    var outputProcessor = new OutputProcessor()
        .UseFilter(new StopHelloWorldFilter())
        .UseFilter(new ToUpperFilter()));

    outputProcessor.WriteLine("Hello world!");
    outputProcessor.WriteLine("Some new greeting!");
}

Use - Funktional

Ok, das wird auch ganz gut funktionieren.

Aber einige Tage später bittet Sie ein Kollege aus dem AspNet.Core-Entwicklungsteam, einen Filter wie die Methode IApplicationBuilder.Use hinzuzufügen - einen Filter, der ohne Typ, nur mit einer Funktion deklariert werden kann.

Sie stimmen zu - da ein funktionaler Programmierstil immer besser ist - also fügen Sie folgendes hinzu:


public class UseDelegateFilter<T> : IFilter<T>
{
    private readonly Func<Action<T>, Action<T>> _filterFunc;

    public UseDelegateFilter(
        Func<Action<T>, Action<T>> filterFunc)
    {
        _filterFunc = filterFunc;
    }

    public Action<T> Invoke(Action<T> next) 
        => _filterFunc(next);
}

public class OutputProcessor
{
    private readonly List<IFilter<string>> _filters = 
        new List<IFilter<string>>();
				
    public OutputProcessor UseFilter(IFilter<string> filter)
    {
        _filters.Add(filter);
        return this;
    }

    public OutputProcessor Use(
        Func<Action<string>, Action<string>> filter)
    {
        _filters.Add(new UseDelegateFilter<string>(filter));
        return this;
    }

    public void WriteLine(string input)
    {
        Filters.BuildFilterPipeline(
            _filters,
            Console.WriteLine)(input);
    }
}

// Main:

static void Main(string[] args)
{
    var outputProcessor = new OutputProcessor()
        .Use(next => s =>
        {
            if (s != "Hello world!")
                next(s);
        })
        .Use(next => s =>
        {
            next(s.ToUpper());
        });
				
        // ...

Wie Sie sehen können, müssen wir die Filtertypen nicht mehr verwenden. Stattdessen wird alles in Funktionen deklariert.


Mehr Funktionen!

Schließlich bittet Sie jemand, den Prozessor weiter zu erweitern:

  • Es sollte möglich sein, zusätzliche Ausgabeprozessoren hinzuzufügen.
  • Sie sollten nach der Filterung, aber vor dem Schreiben auf die Konsole verarbeitet werden.

Lassen Sie uns auch das noch implementieren:

public class OutputProcessor
{
    private readonly List<IFilter<string>> _filters = 
        new List<IFilter<string>>();
    private readonly List<IFilter<string>> _actions = 
        new List<IFilter<string>>();

    public OutputProcessor UseFilter(IFilter<string> filter)
    {
        _filters.Add(filter);
        return this;
    }

    public OutputProcessor Use(
        Func<Action<string>, Action<string>> filter)
    {
        _filters.Add(new UseDelegateFilter<string>(filter));
        return this;
    }

    public OutputProcessor With(Action<string> action)
    {
        _actions.Add(new UseDelegateFilter<string>(
            next =>
                s =>
                {
                    action(s);
                    next(s);
                }));

        return this;
    }

    public void WriteLine(string input)
    {
        Filters.BuildFilterPipeline(
            _filters.Concat(_actions),
            Console.WriteLine)(input);

    }
}


static void Main(string[] args)
{
     var outputProcessor = new OutputProcessor()
        .With(s =>
        {
            Console.WriteLine(
                $"I cannot prevent it, but I know you will write string '{s}'");
        })
        .Use(next => s =>
        {
            if (s != "Hello world!")
                next(s);
        })
        .Use(next => s =>
        {
            next(s.ToUpper());
        });

    outputProcessor.WriteLine("Hello world!");
    outputProcessor.WriteLine("Some new greeting!");
}

Wenn wir das Programm erneut ausgeführt wird, ist die Ausgabe nun:

I cannot prevent it, but I know you will write string 'some new greeting!'
some new greeting!



Fazit

Wie Sie sehen können, ist das Erstellen einer eigenen Middleware oder Filter-Pipeline nicht so kompliziert, wie es vielleicht scheint.

Sie können diese für Middlwares, erweiterbare Builder, Dependency Injection und vieles mehr verwenden. Alles, was Sie am Anfang brauchen, ist diese Schnittstelle:

public interface IFilter<T1>
{
    Action<T1> Invoke(Action<T1> next);
}
  • Autor:   Frank Wagner