Serialisieren komplexer Objekte in JavaScript

Veröffentlicht: 2022-03-11

Website-Leistung und Daten-Caching

Moderne Websites rufen in der Regel Daten von verschiedenen Orten ab, darunter Datenbanken und APIs von Drittanbietern. Beispielsweise kann eine Website bei der Authentifizierung eines Benutzers den Benutzerdatensatz in der Datenbank nachschlagen und ihn dann über API-Aufrufe mit Daten einiger externer Dienste verschönern. Die Minimierung kostspieliger Aufrufe dieser Datenquellen, wie z. B. Festplattenzugriff für Datenbankabfragen und Internet-Roundtrips für API-Aufrufe, ist für die Aufrechterhaltung einer schnellen, reaktionsschnellen Website von entscheidender Bedeutung. Das Zwischenspeichern von Daten ist eine gängige Optimierungstechnik, die verwendet wird, um dies zu erreichen.

Prozesse speichern ihre Arbeitsdaten im Arbeitsspeicher. Wenn ein Webserver in einem einzelnen Prozess ausgeführt wird (z. B. Node.js/Express), können diese Daten problemlos mit einem Speichercache zwischengespeichert werden, der im selben Prozess ausgeführt wird. Webserver mit Lastenausgleich umfassen jedoch mehrere Prozesse, und selbst wenn Sie mit einem einzelnen Prozess arbeiten, möchten Sie möglicherweise, dass der Cache beim Neustart des Servers bestehen bleibt. Dies erfordert eine prozessexterne Caching-Lösung wie Redis, was bedeutet, dass die Daten irgendwie serialisiert und beim Lesen aus dem Cache deserialisiert werden müssen.

Serialisierung und Deserialisierung sind in statisch typisierten Sprachen wie C# relativ einfach zu erreichen. Die dynamische Natur von JavaScript macht das Problem jedoch etwas kniffliger. Während ECMAScript 6 (ES6) Klassen eingeführt hat, werden die Felder dieser Klassen (und ihre Typen) nicht definiert, bis sie initialisiert werden – was möglicherweise nicht der Fall ist, wenn die Klasse instanziiert wird – und die Rückgabetypen von Feldern und Funktionen werden nicht definiert überhaupt im Schema. Darüber hinaus kann die Struktur der Klasse zur Laufzeit leicht geändert werden – Felder können hinzugefügt oder entfernt, Typen geändert werden usw. Während dies mit Reflektion in C# möglich ist, stellt Reflektion die „dunklen Künste“ dieser Sprache dar, und Entwickler erwarten, dass die Funktionalität unterbrochen wird.

Ich wurde vor einigen Jahren bei der Arbeit mit diesem Problem konfrontiert, als ich im Kernteam von Toptal arbeitete. Wir bauten ein agiles Dashboard für unsere Teams, das schnell sein musste; Andernfalls würden Entwickler und Produktbesitzer es nicht verwenden. Wir haben Daten aus einer Reihe von Quellen gezogen: unserem Arbeitsverfolgungssystem, unserem Projektmanagement-Tool und einer Datenbank. Die Website wurde in Node.js/Express erstellt, und wir hatten einen Speichercache, um Aufrufe an diese Datenquellen zu minimieren. Unser schneller, iterativer Entwicklungsprozess bedeutete jedoch, dass wir mehrmals am Tag bereitgestellt (und daher neu gestartet) wurden, wodurch der Cache ungültig wurde und dadurch viele seiner Vorteile verloren gingen.

Eine naheliegende Lösung war ein Out-of-Process-Cache wie Redis. Nach einiger Recherche fand ich jedoch heraus, dass es keine gute Serialisierungsbibliothek für JavaScript gab. Die integrierten JSON.stringify/JSON.parse-Methoden geben Daten des Objekttyps zurück und verlieren alle Funktionen auf den Prototypen der ursprünglichen Klassen. Das bedeutete, dass die deserialisierten Objekte nicht einfach „an Ort und Stelle“ in unserer Anwendung verwendet werden konnten, was daher eine beträchtliche Umgestaltung erfordern würde, um mit einem alternativen Design zu arbeiten.

Anforderungen an die Bibliothek

Um die Serialisierung und Deserialisierung beliebiger Daten in JavaScript zu unterstützen, wobei die deserialisierten Darstellungen und Originale austauschbar sind, benötigten wir eine Serialisierungsbibliothek mit den folgenden Eigenschaften:

  • Die deserialisierten Repräsentationen müssen denselben Prototyp (Funktionen, Getter, Setter) wie die ursprünglichen Objekte haben.
  • Die Bibliothek sollte verschachtelte Komplexitätstypen (einschließlich Arrays und Maps) unterstützen, wobei die Prototypen der verschachtelten Objekte korrekt festgelegt sind.
  • Es sollte möglich sein, dieselben Objekte mehrmals zu serialisieren und zu deserialisieren – der Prozess sollte idempotent sein.
  • Das Serialisierungsformat sollte einfach über TCP übertragbar und mit Redis oder einem ähnlichen Dienst speicherbar sein.
  • Es sollten minimale Codeänderungen erforderlich sein, um eine Klasse als serialisierbar zu markieren.
  • Die Bibliotheksroutinen sollten schnell sein.
  • Idealerweise sollte es eine Möglichkeit geben, die Deserialisierung alter Versionen einer Klasse durch eine Art Mapping/Versionierung zu unterstützen.

Implementierung

Um diese Lücke zu schließen, habe ich mich entschieden, Tanagra.js zu schreiben, eine universelle Serialisierungsbibliothek für JavaScript. Der Name der Bibliothek bezieht sich auf eine meiner Lieblingsfolgen von Star Trek: The Next Generation , in der die Crew der Enterprise lernen muss, mit einer mysteriösen außerirdischen Rasse zu kommunizieren, deren Sprache unverständlich ist. Diese Serialisierungsbibliothek unterstützt gängige Datenformate, um solche Probleme zu vermeiden.

Tanagra.js ist einfach und leichtgewichtig und unterstützt derzeit Node.js (es wurde nicht im Browser getestet, sollte aber theoretisch funktionieren) und ES6-Klassen (einschließlich Maps). Die Hauptimplementierung unterstützt JSON und eine experimentelle Version unterstützt Google Protocol Buffers. Die Bibliothek erfordert nur Standard-JavaScript (derzeit getestet mit ES6 und Node.js), ohne Abhängigkeit von experimentellen Funktionen, Babel -Transpiling oder TypeScript .

Serialisierbare Klassen werden beim Export der Klasse mit einem Methodenaufruf als solche gekennzeichnet:

module.exports = serializable(Foo, myUniqueSerialisationKey)

Die Methode gibt einen Proxy an die Klasse zurück, der den Konstruktor abfängt und einen eindeutigen Bezeichner einfügt. (Wenn nicht angegeben, wird standardmäßig der Klassenname verwendet.) Dieser Schlüssel wird mit den restlichen Daten serialisiert, und die Klasse macht ihn auch als statisches Feld verfügbar. Wenn die Klasse verschachtelte Typen enthält (dh Mitglieder mit Typen, die serialisiert werden müssen), werden sie auch im Methodenaufruf angegeben:

module.exports = serializable(Foo, [Bar, Baz], myUniqueSerialisationKey)

(Verschachtelte Typen für frühere Versionen der Klasse können ebenfalls auf ähnliche Weise angegeben werden, sodass beispielsweise ein Foo1, wenn Sie es serialisieren, in ein Foo2 deserialisiert werden kann.)

Während der Serialisierung baut die Bibliothek rekursiv eine globale Zuordnung von Schlüsseln zu Klassen auf und verwendet diese während der Deserialisierung. (Denken Sie daran, dass der Schlüssel mit den restlichen Daten serialisiert wird.) Um den Typ der „obersten“ Klasse zu kennen, muss die Bibliothek dies im Deserialisierungsaufruf angeben:

const foo = decodeEntity(serializedFoo, Foo)

Eine experimentelle Auto-Mapping-Bibliothek geht durch den Modulbaum und generiert die Mappings aus den Klassennamen, aber das funktioniert nur für eindeutig benannte Klassen.

Projektlayout

Das Projekt gliedert sich in mehrere Module:

  • tanagra-core – allgemeine Funktionalität, die von den verschiedenen Serialisierungsformaten benötigt wird, einschließlich der Funktion zum Markieren von Klassen als serialisierbar
  • tanagra-json – serialisiert die Daten im JSON-Format
  • tanagra-protobuf - serialisiert die Daten in das Google-Protobuffer-Format (experimentell)
  • tanagra-protobuf-redis-cache – eine Hilfsbibliothek zum Speichern von serialisierten Protobufs in Redis
  • tanagra-auto-mapper – geht durch den Modulbaum in Node.js , um eine Karte von Klassen aufzubauen, was bedeutet, dass der Benutzer den Typ nicht angeben muss, auf den er deserialisiert werden soll (experimentell).

Beachten Sie, dass die Bibliothek die US-amerikanische Rechtschreibung verwendet.

Beispielnutzung

Das folgende Beispiel deklariert eine serialisierbare Klasse und verwendet das Modul tanagra-json, um sie zu serialisieren/deserialisieren:

 const serializable = require('tanagra-core').serializable class Foo { constructor(bar, baz1, baz2, fooBar1, fooBar2) { this.someNumber = 123 this.someString = 'hello, world!' this.bar = bar // a complex object with a prototype this.bazArray = [baz1, baz2] this.fooBarMap = new Map([ ['a', fooBar1], ['b', fooBar2] ]) } } // Mark class `Foo` as serializable and containing sub-types `Bar`, `Baz` and `FooBar` module.exports = serializable(Foo, [Bar, Baz, FooBar]) ... const json = require('tanagra-json') json.init() // or: // require('tanagra-protobuf') // await json.init() const foo = new Foo(bar, baz) const encoded = json.encodeEntity(foo) ... const decoded = json.decodeEntity(encoded, Foo)

Leistung

Ich habe die Leistung der beiden Serializer ( JSON -Serializer und experimenteller Protobufs- Serializer) mit einem Steuerelement (natives JSON.parse und JSON.stringify) verglichen. Ich habe insgesamt 10 Versuche mit jedem durchgeführt.

Ich habe dies auf meinem Dell XPS15- Laptop von 2017 mit 32 GB Speicher und Ubuntu 17.10 getestet.

Ich habe das folgende verschachtelte Objekt serialisiert:

 foo: { "string": "Hello foo", "number": 123123, "bars": [ { "string": "Complex Bar 1", "date": "2019-01-09T18:22:25.663Z", "baz": { "string": "Simple Baz", "number": 456456, "map": Map { 'a' => 1, 'b' => 2, 'c' => 2 } } }, { "string": "Complex Bar 2", "date": "2019-01-09T18:22:25.663Z", "baz": { "string": "Simple Baz", "number": 456456, "map": Map { 'a' => 1, 'b' => 2, 'c' => 2 } } } ], "bazs": Map { 'baz1' => Baz { string: 'baz1', number: 111, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } }, 'baz2' => Baz { string: 'baz2', number: 222, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } }, 'baz3' => Baz { string: 'baz3', number: 333, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } } }, }

Leistung schreiben

Serialisierungsmethode Allee Inc. erster Versuch (ms) StAbw. inkl. erster Versuch (ms) Allee ex. erster Versuch (ms) StAbw. Ex. erster Versuch (ms)
JSON 0,115 0,0903 0,0879 0,0256
Google Protobufs 2.00 2.748 1.13 0,278
Kontrollgruppe 0,0155 0,00726 0,0139 0,00570

Lesen

Serialisierungsmethode Allee Inc. erster Versuch (ms) StAbw. inkl. erster Versuch (ms) Allee ex. erster Versuch (ms) StAbw. Ex. erster Versuch (ms)
JSON 0,133 0,102 0,104 0,0429
Google Protobufs 2.62 1.12 2.28 0,364
Kontrollgruppe 0,0135 0,00729 0,0115 0,00390

Zusammenfassung

Der JSON -Serializer ist etwa 6-7 Mal langsamer als die native Serialisierung. Der experimentelle Protobufs- Serializer ist etwa 13-mal langsamer als der JSON -Serializer oder 100-mal langsamer als die native Serialisierung.

Darüber hinaus wirkt sich das interne Caching von Schema-/Strukturinformationen in jedem Serialisierer eindeutig auf die Leistung aus. Beim JSON-Serializer ist der erste Schreibvorgang etwa viermal langsamer als der Durchschnitt. Für den Protobuf-Serializer ist er neunmal langsamer. Das Schreiben von Objekten, deren Metadaten bereits zwischengespeichert wurden, ist also in beiden Bibliotheken viel schneller.

Der gleiche Effekt wurde für Lesevorgänge beobachtet. Bei der JSON-Bibliothek ist der erste Lesevorgang etwa viermal langsamer als der Durchschnitt, und bei der protobuf-Bibliothek ist er etwa zweieinhalbmal langsamer.

Aufgrund der Leistungsprobleme des protobuf-Serialisierungsprogramms befindet es sich noch im experimentellen Stadium, und ich würde es nur empfehlen, wenn Sie das Format aus irgendeinem Grund benötigen. Es lohnt sich jedoch, etwas Zeit zu investieren, da das Format viel knapper ist als JSON und daher besser für die Übertragung über das Kabel geeignet ist. Stack Exchange verwendet das Format für sein internes Caching.

Der JSON-Serializer ist deutlich performanter, aber immer noch deutlich langsamer als die native Implementierung. Für kleine Objektbäume ist dieser Unterschied nicht signifikant (ein paar Millisekunden zusätzlich zu einer 50-ms-Anforderung werden die Leistung Ihrer Website nicht beeinträchtigen), aber dies könnte bei extrem großen Objektbäumen zu einem Problem werden und ist eine meiner Entwicklungsprioritäten.

Fahrplan

Die Bibliothek befindet sich noch im Beta-Stadium. Der JSON-Serializer ist ziemlich gut getestet und stabil. Hier ist die Roadmap für die nächsten Monate:

  • Leistungsverbesserungen für beide Serialisierer
  • Bessere Unterstützung für JavaScript vor ES6
  • Unterstützung für ES-Next-Dekorateure

Mir ist keine andere JavaScript-Bibliothek bekannt, die das Serialisieren komplexer, verschachtelter Objektdaten und das Deserialisieren auf ihren ursprünglichen Typ unterstützt. Wenn Sie Funktionen implementieren, die von der Bibliothek profitieren würden, probieren Sie es bitte aus, melden Sie sich mit Ihrem Feedback und erwägen Sie, einen Beitrag zu leisten.

Projekthomepage
GitHub-Repository