JSON-Serialisierung von LINQ-Expressions

Ich sehe hin und wieder C# Code, der via MongoDB.Driver Daten aus MongoDB-Collections aggregiert, aufgerufen über eine Web-API und eine vorgeschaltete Connector-Klasse mit statischen Wrapper-Methoden für die Bedienung der API-Endpunkte.

Wenn die Daten nicht nur paginiert sondern auch gefiltert werden sollen, stellt sich natürlich die Frage, wie man auf dem Hinweg das Filterkriterium kodiert und serialisiert. Bisher habe ich die folgende Antwort gesehen: ad-hoc ein DTO zusammenstricken, das jeweils eine Property enthält für die konkreten Filteroptionen, die jetzt (oder vielleicht später) benötigt werden. Ein Gedanke dabei ist sicherlich, dass man dieses Datentransferobjekt im Nachhinein erweitern kann sobald neue Filteranforderungen auftauchen.

Für die Posts in einem Blog könnte das so aussehen:

Class PostsRequest
{
    string? Author { get; set; }
    DateTime? MinCreated { get; set; }
    List<string>? Tags { get; set; }
}

Der Controller-Code nimmt dieses Objekt entgegen und konstruiert daraus einen Filter für die Aggregation der Daten:

  1. Erzeuge einen leeren Filter.
  2. Wenn r.Author is not null, füge x => x.Author == r.Author hinzu.
  3. Wenn r.MinCreated is not null, füge x => x.Created >= r.MinCreated hinzu.
  4. Wenn r.Tags.IsNotNullOrEmpty(), füge x.Tags.Contains(r.Tags) hinzu (oder so ähnlich).

Aber was ist wenn ich mehrere Autoren angeben möchte? Dann muss PostsRequest entsprechend angepasst und Author von string? auf List<string>? umgestellt werden. Der Server-Code, der den eigentlichen Filter konstruiert, muss dafür ebenfalls angepasst werden. Bekomme ich dann die Posts jeweils aller angegebenen Autoren? Oder nur die Posts, die von allen angegebenen Autoren gemeinsam verfasst wurden? Oder beides? Hoffentlich ist PostsRequest entsprechend dokumentiert. Hoffentlich passt die Dokumentation zur tatsächlichen Implementierung des Filters. Hoffentlich hat niemand im Nachhinein den Filter verändert und dabei vergessen, auch die Dokumentation anzupassen. Was ist wenn ich alle obigen Fälle benötige? Füge ich dann Properties für alle Fälle zu PostsRequest hinzu? Oder eine Art Steuer-Property, die festlegt, wie r.Author vom Server-Code zu interpretieren ist? Was ist wenn ich MaxCreated benötige? Oder MinCreated aber nicht inklusiv sondern exklusiv? Was ist wenn ich ein noch komplexeres Filterkriterium benötige?

Dieser Ansatz ist die reine Hölle. Er verkompliziert und verengt eine Sache, die eigentlich eine sehr simple und allgemeine Lösung haben sollte: ich gebe der Connector-Methode ein Prädikat Func<Post, bool> und sie holt mir Einträge vom Typ Post aus der Datenbank, die auf dieses Prädikat passen!

Damit verschwindet die ganze Sperrigkeit des PostsRequest Ansatzes, weil ich als Aufrufer der Connector-Methode selbst mein Filterprädikat angebe, und zwar in der allgemeinen Form Func<Post,bool>, ohne durch die unpassende Struktur und Semantik eines Request-Datentyps eingeschränkt zu sein; ohne raten zu müssen, was der eigentliche Filter-Code macht, der sich hinter der API auf dem Server versteckt; ohne dass der serverseitige Filter-Code immer wieder angepasst werden muss.

Die Sache hat natürlich einen Haken, der wahrscheinlich auch der Grund dafür sein wird, dass ich stattdessen Request-DTOs sehe: Wie serialisiert man eigentlich eine Func<Post, bool> um sie via HTTP an eine Web-API zu übertragen und auf der Gegenseite korrekt wieder zu deserialisieren? 🤔

Es gibt dafür tatsächlich eine Lösung. Die FilterDefinitionBuilder Klasse in MongoDB.Driver exponiert eine Where Methode:

public FilterDefinition<T> Where(Expression<Func<T, bool>> expression)

Damit können wir aus einer Expression<Func<Post, bool>> eine FilterDefinition<Post> erzeugen, die der MongoDB-Treiber in seiner Aggregation verwenden kann. Dabei repräsentiert Expression<Func<Post, bool>> einen AST für den unkompilierten Code einer Funktion Func<Post, bool>. Ein AST bringt uns der Serialisierung schon ein ganzes Stück näher. Allerdings kommt das ASP.NET Model Binding mit diesem Datentyp nicht zurecht. Wir müssen uns also selbst um die Serialisierung und Deserialisierung kümmern.

Immerhin müssen wir den Serializer nicht selbst schreiben. Sascha Kiefer war so freundlich, dafür das NuGet-Paket Serialize.Linq bereitzustellen. Wir müssen lediglich im Connector-Code unsere Expression serialisieren bevor wie sie übertragen, bzw. im Controller-Code händisch den Request.Body auslesen und deserialisieren:

// Connector

Expression<Func<T, bool>> expression = x => pred(x);

ExpressionSerializer expressionSerializer =
    new Serialize.Linq.Serializers.ExpressionSerializer(
        new Serialize.Linq.Serializers.JsonSerializer()
    );

var expressionPayload =
    expressionSerializer.SerializeText(expression);

// Controller

string expressionPayload =
    await new StreamReader(Request.Body).ReadToEndAsync();

ExpressionSerializer expressionSerializer =
    new Serialize.Linq.Serializers.ExpressionSerializer(
        new Serialize.Linq.Serializers.JsonSerializer()
    );

var expression =
    (Expression<Func<T, bool>>)
    expressionSerializer.DeserializeText(expressionPayload);

Damit spart man sich einen großen Haufen sperrigen Behelfs-Boilerplate-Code für DTOs und serverseitige Filterkonstruktion. Gleichzeitig behält man alle Freiheitsgrade eines allgemeinen logischen Prädikates.

Allerdings muss man dabei ein bisschen aufpassen: der MongoDB-Treiber unterstützt keine beliebigen Expressions. Er unterstützt alle Logik, die man braucht, um ein Filterprädikat zu formulieren, aber keinen beliebigen C#-Code, der sich prinzipiell nicht nach MongoDB übersetzen lässt, wie z.B. DateTime.Now oder Guid.NewGuid() oder Inline-Funktionen.