Prolog
Derzeit entwickle ich einen Javascript-Schema-Editor. Während dieser Arbeit bin ich auf ein Problem gestoßen, auf das sich dieser Artikel konzentrieren wird, nämlich die Serialisierung und Deserialisierung komplexer Datenobjekte.
Ohne auf Details des Projekts einzugehen, stelle ich fest, dass das Schema meiner Idee nach ein Array von Elementen (Eckpunkten) ist, die von der Basisklasse geerbt wurden. Dementsprechend implementiert jede untergeordnete Klasse ihre eigene Logik. Darüber hinaus enthalten die Eckpunkte Verknüpfungen miteinander (Pfeile), die ebenfalls erhalten bleiben müssen. Theoretisch können sich Eckpunkte direkt oder durch andere Eckpunkte auf sich selbst beziehen. Standard JSON.stringify kann ein solches Array nicht serialisieren, daher habe ich beschlossen, einen eigenen Serializer zu erstellen, der die beiden beschriebenen Probleme löst:
- Möglichkeit, Klasseninformationen während der Serialisierung zu speichern und während der Deserialisierung wiederherzustellen.
- Die Möglichkeit, Links zu Objekten zu speichern und wiederherzustellen, einschließlich zyklisch.
Lesen Sie mehr über die Erklärung des Problems und seine Lösung unter dem Schnitt.
Github Serializer Projekt
Link zum Github-Projekt: Link .
Komplexe Beispiele befinden sich auch im Ordner test-src .
Rekursiver Serializer: Link .
Flat Serializer: Link .
Erklärung des Problems
Wie bereits erwähnt, besteht die anfängliche Aufgabe darin, beliebige Schaltkreise für den Editor zu serialisieren. Um keine Zeit mit der Beschreibung des Editors zu verschwenden, setzen wir die Aufgabe einfacher. Angenommen, wir möchten eine formale Beschreibung eines einfachen Algorithmusschemas unter Verwendung von ES6-Javascript-Klassen erstellen und dieses Schema dann serialisieren und deserialisieren.
Im Internet habe ich ein geeignetes Bild des einfachsten Algorithmus zur Bestimmung des Maximums von zwei Werten gefunden:

Hier muss ich sagen, dass ich kein Javascript-Entwickler bin und meine "Muttersprache" C # ist. Der Ansatz zur Lösung des Problems wird daher durch die Erfahrung der objektorientierten Entwicklung in C # bestimmt. In diesem Diagramm sehe ich die Eckpunkte der folgenden Typen (bedingte Namen und spezielle Rollen spielen keine Rolle):
- Scheitelpunkt starten (Start)
- Letzter Höhepunkt (Ziel)
- Team Top (Befehl)
- Zuweisungsscheitelpunkt (Let)
- Verifikation Verifikation oben (wenn)
Diese Scheitelpunkte unterscheiden sich in ihrem Datensatz oder ihrer Semantik voneinander, werden jedoch alle vom Basisscheitelpunkt (Knoten) geerbt. An derselben Stelle wird in der Node-Klasse das Verknüpfungsfeld beschrieben, das Verknüpfungen zu anderen Scheitelpunkten enthält, und die addLink-Methode ermöglicht das Hinzufügen dieser Verknüpfungen. Den vollständigen Code aller Klassen finden Sie hier .
Schreiben wir den Code, der die Schaltung aus dem Bild sammelt, und versuchen wir, das Ergebnis zu serialisieren.
Wenn wir dieses Schema mit JSON.stringify serialisieren, erhalten wir etwas Schreckliches. Ich werde die ersten Zeilen des Ergebnisses geben, in denen ich meine Kommentare hinzugefügt habe:
JSON.stringify Ergebnis [ { "id": "d9c8ab69-e4fa-4433-80bb-1cc7173024d6", "name": "Start", "links": { "2e3d482b-187f-4c96-95cd-b3cde9e55a43": { "id": "2e3d482b-187f-4c96-95cd-b3cde9e55a43", "target": { "id": "f87a3913-84b0-4b70-8927-6111c6628a1f", "name": "Command", "links": { "4f623116-1b70-42bf-8a47-da1e9be5e4b2": { "id": "4f623116-1b70-42bf-8a47-da1e9be5e4b2", "target": { "id": "94a47403-13ab-4c83-98fe-3b201744c8f2", "name": "If", "links": { ...
Weil Der erste Scheitelpunkt enthielt eine Verknüpfung zum zweiten und zu den nachfolgenden. Als Ergebnis seiner Serialisierung wurde die gesamte Schaltung serialisiert. Dann wurde der zweite Peak serialisiert und alles, was davon abhing, und so weiter. Sie können die ursprünglichen Links aus diesem Hash nur durch Bezeichner wiederherstellen. Sie helfen jedoch nicht, wenn einer der Scheitelpunkte direkt oder über andere Scheitelpunkte auf sich selbst verweist. In diesem Fall löst der Serializer einen nicht erfassten TypeError aus: Konvertieren einer kreisförmigen Struktur in einen JSON- Fehler. Wenn es nicht klar ist, ist hier das einfachste Beispiel, das diesen Fehler generiert: https://jsfiddle.net/L4guo86w/ .
Darüber hinaus enthält JSON keine Informationen zu den Quellklassen, sodass nicht verstanden werden kann, welcher Typ jeder Scheitelpunkt vor der Serialisierung war.
Als ich diese Probleme erkannte, ging ich online und suchte nach vorgefertigten Lösungen. Es gab viele, aber die meisten waren sehr sperrig oder erforderten eine spezielle Beschreibung der serialisierbaren Klassen. Daher wurde beschlossen, ein eigenes Fahrrad herzustellen. Und ja, ich liebe Fahrräder.
Serializer-Konzept
Dieser Abschnitt richtet sich an diejenigen, die mit mir an der Erstellung eines Serialisierungsalgorithmus teilnehmen möchten, wenn auch virtuell.
Eines der Probleme mit Javascript ist das Fehlen von Metadaten, die in Sprachen wie C # oder Java (Attribute und Reflexion) Wunder wirken können. Andererseits brauche ich keine superkomplexe Serialisierung mit der Fähigkeit, eine Liste von serialisierbaren Feldern, Validierungen und anderen Chips zu definieren. Daher besteht die Hauptidee darin, dem Objekt Informationen zu seinem Typ hinzuzufügen und es mit normalem JSON.stringify zu serialisieren.
Bei der Suche nach Lösungen stieß ich auf einen interessanten Artikel, dessen Titel „6 falsche Möglichkeiten zum Hinzufügen von Typinformationen in JSON“ bedeutet . Tatsächlich sind die Methoden sehr gut, und ich habe die unter Nummer 5 gewählt. Wenn Sie zu faul sind, um den Artikel zu lesen, ich dies jedoch sehr empfehle, werde ich diese Methode kurz beschreiben: Wenn Sie ein Objekt serialisieren, verpacken wir es in ein anderes Objekt mit dem einzigen Ein Feld, dessen Name das Format "@<type>"
hat und dessen Wert die Daten des Objekts sind. Während der Deserialisierung extrahieren wir den Typnamen, erstellen das Objekt aus dem Konstruktor neu und lesen die Daten seiner Felder.
Wenn wir Links aus unserem obigen Beispiel entfernen, serialisiert Standard JSON.stringify Daten wie folgt:
JSON.stringify [ { "id": "d04d6a58-7215-4102-aed0-32122e331cf4", "name": "Start", "links": {} }, { "id": "5c58c3fc-8ce1-45a5-9e44-90d5cebe11d3", "name": "Command", "links": {}, "command": " A, B" }, ... }
Und unser Serializer wird es so verpacken:
Serialisierungsergebnis [ { "@Schema.Start": { "id": "d04d6a58-7215-4102-aed0-32122e331cf4", "name": "Start", "links": {} } }, { "@Schema.Command": { "id": "5c58c3fc-8ce1-45a5-9e44-90d5cebe11d3", "name": "Command", "links": {}, "command": " A, B" } }, ... }
Natürlich gibt es einen Nachteil: Der Serializer muss wissen, welche Typen er serialisieren kann, und die Objekte selbst sollten keine Felder enthalten, deren Name mit einem Hund beginnt. Das zweite Problem wird jedoch durch Vereinbarung mit den Entwicklern oder durch Ersetzen des Hundesymbols durch etwas anderes gelöst, und das erste Problem wird in einer Codezeile gelöst (siehe unten ein Beispiel). Wir wissen genau, was wir serialisieren werden, oder?
Lösen des Linkproblems
Es ist noch einfacher in Bezug auf den Algorithmus, aber schwieriger zu implementieren.
Wenn Sie Instanzen von Klassen serialisieren, die im Serializer registriert sind, speichern wir sie im Cache und weisen ihnen eine Seriennummer zu. Wenn wir diese Instanz in Zukunft erneut treffen, fügen wir in der ersten Definition diese Nummer hinzu (der Feldname hat die Form "@<type>|<index>"
) und fügen am Ort der Serialisierung den Link in Form eines Objekts ein
{ "@<type>": <index> }
Während der Deserialisierung betrachten wir also, was genau der Wert des Feldes ist. Wenn dies eine Zahl ist, extrahieren wir das Objekt anhand dieser Zahl aus dem Cache. Ansonsten ist dies seine erste Definition.
Lassen Sie uns den Link vom ersten Anfang des Schemas zum zweiten zurückführen und das Ergebnis betrachten:
Serialisierungsergebnis [ { "@Schema.Start": { "id": "a26a3a29-9462-4c92-8d24-6a93dd5c819a", "name": "Start", "links": { "25fa2c44-0446-4471-a013-8b24ffb33bac": { "@Schema.Link": { "id": "25fa2c44-0446-4471-a013-8b24ffb33bac", "target": { "@Schema.Command|1": { "id": "4f4f5521-a2ee-4576-8aec-f61a08ed38dc", "name": "Command", "links": {}, "command": " A, B" } } } } } } }, { "@Schema.Command": 1 }, ... }
Es sieht auf den ersten Blick nicht sehr klar aus, weil Der zweite Scheitelpunkt wird zuerst im ersten im Link-Kommunikationsobjekt definiert, aber es ist wichtig, dass dieser Ansatz funktioniert. Außerdem habe ich die zweite Version des Serializers erstellt, die den Baum nicht in der Tiefe, sondern in der Breite umgeht, wodurch solche "Leitern" vermieden werden.
Serializer erstellen
Dieser Abschnitt richtet sich an diejenigen, die an der Umsetzung der oben beschriebenen Ideen interessiert sind.
Serializer leer
Wie jeder andere Serializer verfügt auch unser Serializer über zwei Hauptmethoden: Serialisieren und Deserialisieren. Darüber hinaus benötigen wir eine Methode, die dem Serializer mitteilt, welche Klassen serialisiert (registriert) und welche nicht (ignoriert) werden sollen. Letzteres ist erforderlich, um DOM-Elemente, JQuery-Objekte oder andere Datentypen, die nicht serialisiert werden können oder die nicht serialisiert werden müssen, nicht zu serialisieren. In meinem Editor speichere ich beispielsweise ein visuelles Element, das einem Scheitelpunkt oder Link entspricht. Es wird während der Initialisierung erstellt und sollte natürlich nicht in die Datenbank fallen.
Serializer-Shell-Code export default class Serializer { constructor() { this._nameToCtor = [];
Erklärungen
Um eine Klasse zu registrieren, müssen Sie ihren Konstruktor auf zwei Arten an die Registrierungsmethode übergeben:
- registrieren (MyClass)
- registrieren ('MyNamespace.MyClass', MyClass)
Im ersten Fall wird der Klassenname aus dem Namen der Konstruktorfunktion extrahiert (im IE nicht unterstützt), im zweiten Fall geben Sie den Namen selbst an. Die zweite Methode ist vorzuziehen, weil Ermöglicht die Verwendung von Namespaces. Der erste dient dazu, integrierte Javascript-Typen mit neu definierter Serialisierungslogik zu registrieren.
In unserem Beispiel lautet die Initialisierung des Serializers wie folgt:
import Schema from './schema'; ...
Das Schema-Objekt enthält Beschreibungen aller Vertex-Klassen, sodass der Klassenregistrierungscode in eine Zeile passt.
Der Kontext von Serialisierung und Deserialisierung
Möglicherweise haben Sie die kryptischen Klassen SerializationContext und DeserializationContext bemerkt. Sie erledigen die ganze Arbeit und werden in erster Linie benötigt, um die Daten von verschiedenen Serialisierungs- / Deserialisierungsprozessen zu trennen, weil Für jeden Anruf müssen Zwischeninformationen gespeichert werden - ein Cache mit serialisierten Objekten und eine Seriennummer für die Verbindung.
SerializationContext
Ich werde im Detail nur den rekursiven Serializer analysieren, weil ihr "flaches" Gegenstück ist etwas komplizierter und unterscheidet sich nur in seiner Herangehensweise an die Verarbeitung eines Baumes von Objekten.
Beginnen wir mit dem Konstruktor:
constructor(ser) { this.__proto__.__proto__ = ser; this.cache = [];
Ich this.__proto__.__proto__ = ser;
, die mysteriöse Linie zu erklären this.__proto__.__proto__ = ser;
Bei der Eingabe des Konstruktors akzeptieren wir das Objekt des Serializers selbst, und diese Zeile erbt unsere Klasse davon. Dies ermöglicht den Zugriff auf Serializer-Daten.
Zum Beispiel bezieht sich this._ignore
auf eine Liste ignorierter Klassen des Serializers selbst (die "schwarze Liste"), was sehr nützlich ist. Andernfalls müssten wir so etwas this._serializer._ignore
.
Hauptserialisierungsmethode:
serialize(val) { if (Array.isArray(val)) {
Es ist zu beachten, dass wir drei grundlegende Datentypen verarbeiten: Arrays, Objekte und einfache Werte. Befindet sich der Konstruktor eines Objekts in der "schwarzen Liste", wird dieses Objekt nicht serialisiert.
Array-Serialisierung:
serializeArray(val) { let res = []; for (let item of val) { let e = this.serialize(item); if (typeof e !== 'undefined') res.push(e); } return res; }
Sie können über die Karte kürzer schreiben, dies ist jedoch nicht kritisch. Nur eines ist wichtig - den Wert auf undefiniert prüfen. Wenn das Array eine nicht serialisierbare Klasse enthält, fällt sie ohne diese Prüfung als undefiniert in das Array, was nicht sehr gut ist. Auch in meiner Implementierung werden Arrays ohne Schlüssel serialisiert. Theoretisch können Sie den Algorithmus zum Serialisieren von assoziativen Arrays verfeinern, aber für diese Zwecke bevorzuge ich die Verwendung von Objekten. Darüber hinaus mag JSON.stringify auch keine assoziativen Arrays.
Objektserialisierung:
Code serializeObject(val) { let name = this._ctorToName[val.constructor]; if (name) {
Offensichtlich ist dies der schwierigste Teil des Serialisierers, sein Herz. Nehmen wir es auseinander.
Zunächst prüfen wir, ob der Klassenkonstruktor im Serializer registriert ist. Wenn nicht, ist dies ein einfaches Objekt, für das die Dienstprogrammmethode serializeObjectInner
aufgerufen wird.
Andernfalls prüfen wir, ob dem Objekt eine eindeutige Kennung __uuid zugewiesen wurde . Dies ist eine einfache Zählervariable, die allen Serialisierern gemeinsam ist und verwendet wird, um den Verweis auf die Klasseninstanz im Cache zu behalten. Sie könnten darauf verzichten und die Instanz selbst ohne Schlüssel im Cache speichern. Um dann zu überprüfen, ob das Objekt im Cache gespeichert ist, müssten Sie den gesamten Cache durchgehen, und hier reicht es aus, den Schlüssel zu überprüfen. Ich denke, dies ist schneller in Bezug auf die interne Implementierung von Objekten in Browsern. Außerdem serialisiere ich absichtlich keine Felder, die mit zwei Unterstrichen beginnen, damit das Feld __uuid nicht wie andere private Klassenfelder in den resultierenden json fällt. Wenn dies für Ihre Aufgabe nicht akzeptabel ist, können Sie diese Logik ändern.
Als nächstes suchen wir anhand des Werts von __uuid nach einem Objekt, das die Instanz der Klasse im Cache beschreibt ( zwischengespeichert ).
Wenn ein solches Objekt vorhanden ist, wurde der Wert bereits früher serialisiert. In diesem Fall weisen wir dem Objekt eine Seriennummer zu, falls dies noch nicht geschehen ist:
if (!cached.index) {
Der Code sieht verwirrend aus und kann vereinfacht werden, indem allen von uns serialisierten Klassen eine Nummer zugewiesen wird. Zum Debuggen und Wahrnehmen des Ergebnisses ist es jedoch besser, wenn die Nummer nur den Klassen zugewiesen wird, zu denen in Zukunft Verknüpfungen bestehen.
Wenn die Nummer vergeben ist, geben wir den Link gemäß dem Algorithmus zurück:
Wenn das Objekt zum ersten Mal serialisiert wird, erstellen wir eine Instanz seines Caches:
let res; let cached = { ref: { [`@${name}`]: {} } }; this.cache[val.__uuid] = cached;
Und dann serialisieren Sie es:
if (typeof val.serialize === 'function') {
Es gibt eine Überprüfung der Implementierung der Serialisierungsschnittstelle durch die Klasse (die später erläutert wird) sowie die Konstruktion von Object.keys(cached.ref)[0]
. Tatsache ist, dass cached.ref einen Link zum Wrapper-Objekt { "@<type>[|<index>]": <> }
, aber der Name des Objektfelds ist uns unbekannt, weil Zu diesem Zeitpunkt wissen wir noch nicht, ob der Name die Objektnummer (Index) enthalten wird. Dieses Konstrukt extrahiert einfach das erste und einzige Feld des Objekts.
Schließlich die Dienstprogrammmethode zum Serialisieren von Objektinternalen:
serializeObjectInner(val) { let res = {}; for (let key of Object.getOwnPropertyNames(val)) { if (!(isString(key) && key.startsWith('__'))) {
Wir erstellen ein neues Objekt und kopieren die Felder aus dem alten hinein.
DeserializationContext
Der Deserialisierungsprozess funktioniert in umgekehrter Reihenfolge und erfordert keine besonderen Kommentare.
Code /** * */ class DeserializationContext { /** * * @param {Serializer} ser */ constructor(ser) { this.__proto__.__proto__ = ser; this.cache = []; // } /** * json * @param {any} val json * @returns {any} */ deserialize(val) { if (Array.isArray(val)) { // return this.deserializeArray(val); } else if (isObject(val)) { // return this.deserializeObject(val); } else { // return val; } } /** * * @param {Object} val * @returns {Object} */ deserializeArray(val) { return val.map(item => this.deserialize(item)); } /** * * @param {Array} val * @returns {Array} */ deserializeObject(val) { let res = {}; for (let key of Object.getOwnPropertyNames(val)) { let data = val[key]; if (isString(key) && key.startsWith('@')) { // if (isInteger(data)) { // res = this.cache[data]; if (res) { return res; } else { console.error(` ${data}`); return data; } } else { // let [name, id] = key.substr(1).split('|'); let ctor = this._nameToCtor[name]; if (ctor) { // res = new ctor(); // , if (id) this.cache[id] = res; if (typeof res.deserialize === 'function') { // res.deserialize(data); } else { // for (let key of Object.getOwnPropertyNames(data)) { res[key] = this.deserialize(data[key]); } } return res; } else { // console.error(` "${name}" .`); return val[key]; } } } else { // res[key] = this.deserialize(val[key]); } } return res; } }
Zusätzliche Funktionen
Serialisierungsschnittstelle
In Javascript gibt es keine Schnittstellenunterstützung, aber wir können uns darauf einigen, dass diese Methoden für die Serialisierung bzw. Deserialisierung verwendet werden, wenn die Klasse die Serialisierungs- und Deserialisierungsmethoden implementiert.
Darüber hinaus können Sie mit Javascript diese Methoden für integrierte Typen implementieren, z. B. für Datum:
Datum im ISO-Format serialisieren Date.prototype.serialize = function () { return this.toISOString(); }; Date.prototype.deserialize = function (val) { let date = new Date(val); this.setDate(date.getDate()); this.setTime(date.getTime()); };
Die Hauptsache ist, sich daran zu erinnern, den Datumstyp zu registrieren: serializer.register(Date);
.
Ergebnis:
{ "@Date": "2018-06-02T20:41:06.861Z" }
Einzige Einschränkung: Das Ergebnis der Serialisierung sollte keine Ganzzahl sein, weil In diesem Fall wird es als Referenz auf das Objekt interpretiert.
Ebenso können Sie einfache Klassen in Zeichenfolgen serialisieren. Ein Beispiel für die Serialisierung der Color-Klasse, die die Farbe beschreibt, in die Zeile #rrggbb
befindet sich auf github .
Flacher Serializer
Speziell für Sie, liebe Leser, habe ich die zweite Version des Serializers geschrieben , die den Baum von Objekten nicht rekursiv in der Tiefe, sondern iterativ in der Breite mithilfe einer Warteschlange durchläuft.
Zum Vergleich werde ich ein Beispiel für die Serialisierung der ersten beiden Eckpunkte unseres Schemas in beiden Fällen geben.
Rekursiver Serializer (Serialisierung in der Tiefe) [ { "@Schema.Start": { "id": "5ec74f26-9515-4789-b852-12feeb258949", "name": "Start", "links": { "102c3dca-8e08-4389-bc7f-68862f2061ef": { "@Schema.Link": { "id": "102c3dca-8e08-4389-bc7f-68862f2061ef", "target": { "@Schema.Command|1": { "id": "447f6299-4bd4-48e4-b271-016a0d47fc0e", "name": "Command", "links": {}, "command": " A, B" } } } } } } }, { "@Schema.Command": 1 } ]
Flat Serializer (Serialisierung weit) [ { "@Schema.Start": { "id": "1412603f-24c2-4513-836e-f2b0c0392483", "name": "Start", "links": { "b94ac7e5-d75f-44c1-960f-a02f52c994da": { "@Schema.Link": { "id": "b94ac7e5-d75f-44c1-960f-a02f52c994da", "target": { "@Schema.Command": 1 } } } } } }, { "@Schema.Command|1": { "id": "a93e452e-4276-4d6a-86a1-0681226d79f0", "name": "Command", "links": {}, "command": " A, B" } } ]
Persönlich mag ich die zweite Option noch mehr als die erste, aber es sollte beachtet werden, dass Sie bei Auswahl einer der Optionen die andere nicht verwenden können. Es geht nur um die Links. Beachten Sie, dass im Flat Serializer eine Verknüpfung zum zweiten Scheitelpunkt vor seiner Beschreibung steht.
Vor- und Nachteile des Serializers
Vorteile:
- Der Serializer-Code ist recht einfach und kompakt (etwa 300 Zeilen, von denen die Hälfte Kommentare sind).
- Der Serializer ist einfach zu bedienen und erfordert keine Bibliotheken von Drittanbietern.
- Die Serialisierungsschnittstelle für die willkürliche Serialisierung von Klassen ist integriert.
- Das Ergebnis ist angenehm für das Auge (IMHO).
- Die Entwicklung eines ähnlichen Serializers / Deserializers in anderen Sprachen ist kein Problem. Dies kann erforderlich sein, wenn das Ergebnis der Serialisierung auf der Rückseite verarbeitet wird.
Nachteile:
- Der Serializer erfordert die Registrierung von Klassen, die serialisiert werden können.
- Die Feldnamen von Objekten unterliegen geringfügigen Einschränkungen.
- Der Serializer ist noob in Javascript geschrieben, daher kann er Fehler und Fehler enthalten.
- Die Leistung bei großen Datenmengen kann darunter leiden.
Ein Minus ist auch, dass der Code in ES6 geschrieben ist. Natürlich ist es möglich, auf frühere Versionen von Javascript zu konvertieren, aber ich habe die Kompatibilität des resultierenden Codes mit verschiedenen Browsern nicht überprüft.
Meine anderen Veröffentlichungen
- Lokalisierung von Projekten in .NET mit einem Funktionsinterpreter
- Füllen von Textvorlagen mit modellbasierten Daten. .NET-Implementierung mit IL-Funktionen (Dynamic Bytecode)