Ihr eigener JSON-Konverter oder etwas mehr über ExpressionTrees



Serialisierung und Deserialisierung sind typische Vorgänge, die der moderne Entwickler als trivial betrachtet. Wir kommunizieren mit Datenbanken, generieren HTTP-Anforderungen, empfangen Daten über die REST-API und denken oft nicht einmal darüber nach, wie dies funktioniert. Heute schlage ich vor, meinen Serializer und Deserializer für JSON zu schreiben, um herauszufinden, was sich unter der Haube befindet.

Haftungsausschluss


Wie beim letzten Mal werde ich feststellen: Wir werden einen primitiven Serializer schreiben, man könnte sagen, ein Fahrrad. Wenn Sie eine schlüsselfertige Lösung benötigen, verwenden Sie Json.NET . Diese Jungs haben ein wundervolles Produkt veröffentlicht, das hochgradig anpassbar ist, viel kann und bereits Probleme löst , die bei der Arbeit mit JSON auftreten. Die Verwendung Ihrer eigenen Lösung ist wirklich cool, aber nur, wenn Sie maximale Leistung, spezielle Anpassungen benötigen oder Fahrräder so mögen, wie ich sie mag.

Themenbereich


Der Dienst zum Konvertieren von JSON in eine Objektdarstellung besteht aus mindestens zwei Subsystemen. Deserializer ist ein Subsystem, das gültigen JSON (Text) in eine Objektdarstellung in unserem Programm verwandelt. Die Deserialisierung umfasst die Tokenisierung, dh das Parsen von JSON in logische Elemente. Serializer ist ein Subsystem, das die inverse Aufgabe ausführt: Verwandelt die Objektdarstellung von Daten in JSON.

Der Verbraucher sieht am häufigsten die folgende Schnittstelle. Ich habe es bewusst vereinfacht, um die wichtigsten Methoden hervorzuheben, die am häufigsten verwendet werden.

public interface IJsonConverter { T Deserialize<T>(string json); string Serialize(object source); } 

Unter der Haube umfasst die Deserialisierung die Tokenisierung (Parsen eines JSON-Textes) und das Erstellen einiger Grundelemente, die das spätere Erstellen einer Objektdarstellung erleichtern. Zu Schulungszwecken überspringen wir die Konstruktion von Zwischenprimitiven (z. B. JObject, JProperty von Json.NET) und schreiben sofort Daten in das Objekt. Dies ist ein Minus, da es die Anpassungsoptionen reduziert, es jedoch unmöglich ist, eine gesamte Bibliothek im Rahmen eines Artikels zu erstellen.

Tokenisierung


Ich möchte Sie daran erinnern, dass der Prozess der Tokenisierung oder lexikalischen Analyse eine Analyse des Textes ist, um eine andere, strengere Darstellung der darin enthaltenen Daten zu erhalten. In der Regel wird diese Darstellung als Token oder Token bezeichnet. Zum Parsen von JSON müssen die Eigenschaften, ihre Werte, die Symbole für den Anfang und das Ende von Strukturen hervorgehoben werden, dh Token, die im Code als JsonToken dargestellt werden können.

JsonToken ist eine Struktur, die einen Wert (Text) sowie eine Art Token enthält. JSON ist eine strikte Notation, sodass alle Arten von Token auf die nächste Aufzählung reduziert werden können. Natürlich wäre es schön, dem Token seine Koordinaten in den eingehenden Daten (Zeile und Spalte) hinzuzufügen, aber das Debuggen geht über den Rahmen der Implementierung hinaus, was bedeutet, dass JsonToken diese Daten nicht enthält.

Der einfachste Weg, Text in Token zu analysieren, besteht darin, jedes Zeichen nacheinander zu lesen und mit Mustern zu vergleichen. Wir müssen verstehen, was dieses oder jenes Symbol bedeutet. Es ist möglich, dass das Schlüsselwort (wahr, falsch, null) mit diesem Zeichen beginnt, dass dies möglicherweise der Anfang der Zeile ist (Anführungszeichen), oder dass dieses Zeichen selbst ein Token ist ([,], {,}). Die allgemeine Idee sieht so aus:

 var tokens = new List<JsonToken>(); for (int i = 0; i < json.Length; i++) { char ch = json[i]; switch (ch) { case '[': tokens.Add(new JsonToken(JsonTokenType.ArrayStart)); break; case ']': tokens.Add(new JsonToken(JsonTokenType.ArrayEnd)); break; case '"': string stringValue = ReadString(); tokens.Add(new JsonToken(JsonTokenType.String, stringValue); break; ... } } 

Wenn Sie sich den Code ansehen, scheinen Sie die gelesenen Daten lesen und sofort etwas damit anfangen zu können. Sie müssen nicht gelagert werden, sondern müssen sofort an den Verbraucher gesendet werden. Daher bittet ein bestimmter IEnumerator, der den Text in Stücke zerlegt. Erstens wird dadurch die Zuordnung verringert, da keine Zwischenergebnisse (ein Array von Token) gespeichert werden müssen. Zweitens werden wir die Arbeitsgeschwindigkeit erhöhen - ja, in unserem Beispiel ist die Eingabe eine Zeichenfolge, aber in einer realen Situation wird sie durch Stream (aus einer Datei oder einem Netzwerk) ersetzt, den wir nacheinander lesen.

Ich habe den JsonTokenizer- Code vorbereitet, den Sie hier finden . Die Idee ist dieselbe - der Tokenizer geht nacheinander entlang der Linie und versucht festzustellen, worauf sich das Symbol oder seine Reihenfolge bezieht. Wenn sich herausstellt, dass es verstanden wird, erstellen wir ein Token und übertragen die Kontrolle an den Verbraucher. Wenn es noch nicht klar ist, lesen Sie weiter.

Vorbereiten der Deserialisierung von Objekten


In den meisten Fällen handelt es sich bei einer Anforderung zum Konvertieren von Daten aus JSON um einen Aufruf der generischen Deserialize-Methode, wobei TOut der Datentyp ist, mit dem JSON-Token zugeordnet werden sollen. Wo Typ ist : Es ist Zeit, Reflection und ExpressionTrees anzuwenden. Die Grundlagen der Arbeit mit ExpressionTrees sowie die Gründe, warum kompilierte Ausdrücke besser sind als "nackte" Reflexion, habe ich in einem früheren Artikel beschrieben, wie Sie Ihren AutoMapper erstellen . Wenn Sie nichts über Expression.Labmda.Compile () wissen, empfehle ich, es zu lesen. Am Beispiel des Mappers scheint es mir verständlich geworden zu sein.

Der Plan zum Erstellen eines Objektdeserialisierers basiert also auf dem Wissen, dass wir jederzeit Eigenschaftstypen aus dem TOut-Typ abrufen können, dh aus der PropertyInfo- Auflistung. Gleichzeitig werden Eigenschaftstypen durch die JSON-Notation begrenzt: Zahlen, Zeichenfolgen, Arrays und Objekte. Auch wenn wir Null nicht vergessen, ist dies nicht so sehr, wie es auf den ersten Blick scheinen mag. Und wenn wir für jeden primitiven Typ gezwungen sind, einen separaten Deserializer zu erstellen, können wir für Arrays und Objekte generische Klassen erstellen. Wenn Sie ein wenig nachdenken, können alle Serializer-Deserializer (oder Konverter ) auf die folgende Schnittstelle reduziert werden:

 public interface IJsonConverter<T> { T Deserialize(JsonTokenizer tokenizer); void Serialize(T value, StringBuilder builder); } 

Der Code eines stark typisierten Konverters primitiver Typen ist so einfach wie möglich: Wir extrahieren das aktuelle JsonToken aus dem Tokenizer und wandeln es durch Parsen in einen Wert um. Zum Beispiel float.Parse (currentToken.Value). Schauen Sie sich BoolConverter oder FloatConverter an - nichts Kompliziertes. Als nächstes, wenn Sie einen Deserializer für Bool benötigen? oder float? kann es auch hinzugefügt werden.

Array-Deserialisierung


Der generische Klassencode zum Konvertieren eines Arrays aus JSON ist ebenfalls relativ einfach. Es wird durch den Elementtyp parametrisiert, den wir extrahieren können. TypE . GetElementType () . Das Bestimmen, dass ein Typ ein Array ist, ist ebenfalls einfach: Type.IsArray . Bei der Array-Deserialisierung wird tokenizer.MoveNext () angezeigt, bis ein Token vom Typ ArrayEnd erreicht ist. Die Deserialisierung von Array-Elementen ist die Deserialisierung des Array-Elementtyps. Daher wird beim Erstellen eines ArrayConverter der Element-Deserializer an diesen übergeben.

Manchmal gibt es Schwierigkeiten bei der Instanziierung generischer Implementierungen, daher werde ich Ihnen sofort erklären, wie es geht. Mit Reflection können Sie generische Typen in Echtzeit erstellen. Dies bedeutet, dass wir den erstellten Typ als Argument für Activator.CreateInstance verwenden können. Nutzen Sie dies:

 Type elementType = arrayType.GetElementType(); Type converterType = typeof(ArrayConverter<>).MakeGenericType(elementType); var converterInstance = Activator.CreateInstance(converterType, object[] args); 

Wenn Sie die Vorbereitungen zum Erstellen eines Deserialisierers für Objekte abgeschlossen haben, können Sie den gesamten Infrastrukturcode für die Erstellung und Speicherung von Deserialisierern in die Fassade von JConverter einfügen . Er wird für alle JSON-Serialisierungs- und Deserialisierungsvorgänge verantwortlich sein und steht den Verbrauchern als Service zur Verfügung.

Objektdeserialisierung


Ich möchte Sie daran erinnern, dass Sie alle Eigenschaften des Typs T wie folgt erhalten können: typeof (T) .GetProperties (). Für jede Eigenschaft können Sie PropertyInfo.PropertyType extrahieren, wodurch wir die Möglichkeit haben, einen typisierten IJsonConverter zum Serialisieren und Deserialisieren von Daten eines bestimmten Typs zu erstellen. Wenn der Typ der Eigenschaft ein Array ist, instanziieren wir den ArrayConverter oder finden einen geeigneten unter den vorhandenen. Wenn der Eigenschaftstyp ein primitiver Typ ist, werden im JConverter-Konstruktor bereits Deserialisierer (Konverter) für sie erstellt.

Der resultierende Code kann in der generischen Klasse ObjectConverter angezeigt werden. In seinem Konstruktor wird ein Aktivator erstellt, Eigenschaften werden aus einem speziell vorbereiteten Wörterbuch extrahiert und für jedes von ihnen wird eine Deserialisierungsmethode erstellt - Aktion <TObject, JsonTokenizer>. Dies ist zum einen erforderlich, um den IJsonConverter sofort mit der gewünschten Eigenschaft zu verknüpfen, und zum anderen, um beim Extrahieren und Schreiben primitiver Typen ein Boxen zu vermeiden. Jede Deserialisierungsmethode weiß, welche Eigenschaft des ausgehenden Objekts aufgezeichnet wird, der Deserialisierer des Werts wird streng typisiert und gibt den Wert genau in der Form zurück, in der er benötigt wird.

Die Bindung eines IJsonConverter an eine Eigenschaft lautet wie folgt:

 Type converterType = propertyValueConverter.GetType(); ConstantExpression Expression.Constant(propertyValueConverter, converterType); MethodInfo deserializeMethod = converterType.GetMethod("Deserialize"); var value = Expression.Call(converter, deserializeMethod, tokenizer); 

Die Expression.Constant- Konstante wird direkt im Ausdruck erstellt, in dem ein Verweis auf die Deserializer-Instanz für den Eigenschaftswert gespeichert ist. Dies ist nicht genau die Konstante, die wir in "reguläres C #" schreiben, da sie einen Referenztyp speichern kann. Als nächstes wird die Deserialize-Methode vom Deserializer-Typ abgerufen, der den Wert des gewünschten Typs zurückgibt, und dann heißt sie - Expression.Call . So erhalten wir eine Methode, die genau weiß, wo und was zu schreiben ist. Es bleibt, es in das Wörterbuch aufzunehmen und es aufzurufen, wenn ein Token vom Typ Eigenschaft mit dem gewünschten Namen vom Tokenizer "kommt". Ein weiteres Plus ist, dass alles sehr schnell funktioniert.

Wie schnell


Fahrräder, wie eingangs erwähnt, ist es in mehreren Fällen sinnvoll zu schreiben: Wenn dies ein Versuch ist, die Funktionsweise der Technologie zu verstehen, oder wenn Sie einige spezielle Ergebnisse erzielen müssen. Zum Beispiel Geschwindigkeit. Sie können sicherstellen, dass der Deserializer mit den vorbereiteten Tests wirklich deserialisiert (ich verwende AutoFixture , um Testdaten abzurufen ). Übrigens haben Sie wahrscheinlich bemerkt, dass ich auch die Serialisierung von Objekten geschrieben habe. Da sich der Artikel jedoch als ziemlich umfangreich herausstellte, werde ich ihn nicht beschreiben, sondern nur Benchmarks geben. Ja, genau wie im vorherigen Artikel habe ich Benchmarks mit der BenchmarkDotNet- Bibliothek geschrieben.

Natürlich habe ich die Deserialisierungsgeschwindigkeit mit Newtonsoft (Json.NET) verglichen, der häufigsten und empfohlenen Lösung für die Arbeit mit JSON. Darüber hinaus steht direkt auf ihrer Website: 50% schneller als DataContractJsonSerializer und 250% schneller als JavaScriptSerializer. Kurz gesagt, ich wollte wissen, wie viel mein Code verlieren würde. Die Ergebnisse haben mich überrascht: Beachten Sie, dass die Datenzuweisung fast dreimal geringer ist und die Deserialisierungsrate etwa zweimal schneller ist.
MethodeMittelwertFehlerStddevVerhältnisZugewiesen
Newtonsoft75,39 ms0,3027 ms0,2364 ms1,0035,47 MB
Velo31,78 ms0,1135 ms0,1062 ms0,4212,36 MB

Der Vergleich von Geschwindigkeit und Zuordnung während der Datenserialisierung ergab noch interessantere Ergebnisse. Es stellt sich heraus, dass der Bike-Serializer fast fünfmal weniger zugewiesen hat und fast dreimal schneller gearbeitet hat. Wenn mich die Geschwindigkeit wirklich stören würde (wirklich sehr), wäre das ein klarer Erfolg.
MethodeMittelwertFehlerStddevVerhältnisZugewiesen
Newtonsoft54,83 ms0,5582 ms0,5222 ms1,0025,44 MB
Velo20,66 ms0,0484 ms0,0429 ms0,385,93 MB

Ja, bei der Geschwindigkeitsmessung habe ich die auf der Json.NET-Website veröffentlichten Tipps zur Steigerung der Produktivität nicht verwendet. Ich habe Messungen sofort durchgeführt, dh gemäß dem am häufigsten verwendeten Szenario: JsonConvert.DeserializeObject. Es gibt möglicherweise andere Möglichkeiten, die Leistung zu verbessern, aber ich weiß nichts darüber.

Schlussfolgerungen


Trotz der relativ hohen Geschwindigkeit der Serialisierung und Deserialisierung würde ich nicht empfehlen, Json.NET zugunsten meiner eigenen Lösung aufzugeben. Der Geschwindigkeitsgewinn wird in Millisekunden berechnet und sie "ertrinken" leicht in Netzwerkverzögerungen, Festplatten oder Code, die sich hierarchisch über dem Ort befinden, an dem die Serialisierung angewendet wird. Solche proprietären Lösungen zu unterstützen ist die Hölle, wo nur Entwickler zugelassen werden können, die sich mit dem Thema auskennen.

Der Umfang solcher Fahrräder sind Anwendungen, die vollständig im Hinblick auf hohe Leistung entwickelt wurden, oder Haustierprojekte, bei denen Sie verstehen, wie diese oder jene Technologie funktioniert. Ich hoffe, ich habe dir dabei ein bisschen geholfen.

Source: https://habr.com/ru/post/de464525/


All Articles