Serializarea obiectelor complexe în JavaScript

Publicat: 2022-03-11

Performanța site-ului și stocarea în cache a datelor

Site-urile web moderne preiau de obicei date dintr-un număr de locații diferite, inclusiv baze de date și API-uri terțe. De exemplu, atunci când se autentifică un utilizator, un site web poate căuta înregistrarea utilizatorului din baza de date, apoi ar putea să o înfrumusețeze cu date de la unele servicii externe prin apeluri API. Minimizarea apelurilor costisitoare către aceste surse de date, cum ar fi accesul la disc pentru interogări la baze de date și călătorii dus-întors pe internet pentru apelurile API, este esențială pentru menținerea unui site rapid și receptiv. Memorarea în cache a datelor este o tehnică obișnuită de optimizare utilizată pentru a realiza acest lucru.

Procesele își stochează datele de lucru în memorie. Dacă un server web rulează într-un singur proces (cum ar fi Node.js/Express), atunci aceste date pot fi stocate cu ușurință în cache folosind un cache de memorie care rulează în același proces. Cu toate acestea, serverele web cu încărcare echilibrată acoperă mai multe procese și, chiar și atunci când lucrați cu un singur proces, este posibil să doriți ca cache-ul să persistă atunci când serverul este repornit. Acest lucru necesită o soluție de stocare în cache în afara procesului, cum ar fi Redis, ceea ce înseamnă că datele trebuie serializate cumva și deserializate atunci când sunt citite din cache.

Serializarea și deserializarea sunt relativ simple de realizat în limbaje tipizate static, cum ar fi C#. Cu toate acestea, natura dinamică a JavaScript face ca problema să fie puțin mai complicată. În timp ce ECMAScript 6 (ES6) a introdus clase, câmpurile din aceste clase (și tipurile lor) nu sunt definite până când nu sunt inițializate - ceea ce poate să nu fie atunci când clasa este instanțiată - și tipurile de câmpuri și funcții returnate nu sunt definite deloc în schemă. În plus, structura clasei poate fi schimbată cu ușurință în timpul execuției - câmpurile pot fi adăugate sau eliminate, tipurile pot fi schimbate etc. Deși acest lucru este posibil folosind reflectarea în C#, reflectarea reprezintă „artele întunecate” ale acelui limbaj și dezvoltatorii se așteaptă ca acesta să întrerupă funcționalitatea.

Mi s-a prezentat această problemă la locul de muncă acum câțiva ani, când lucram în echipa de bază Toptal. Construim un tablou de bord agil pentru echipele noastre, care trebuia să fie rapid; altfel, dezvoltatorii și proprietarii de produse nu l-ar folosi. Am extras date dintr-o serie de surse: sistemul nostru de urmărire a muncii, instrumentul nostru de management al proiectelor și o bază de date. Site-ul a fost construit în Node.js/Express și am avut o memorie cache pentru a minimiza apelurile către aceste surse de date. Cu toate acestea, procesul nostru de dezvoltare rapidă și iterativă a însemnat că am implementat (și, prin urmare, am repornit) de mai multe ori pe zi, invalidând memoria cache și, prin urmare, pierzând multe dintre beneficiile sale.

O soluție evidentă a fost un cache în afara procesului, cum ar fi Redis. Cu toate acestea, după câteva cercetări, am descoperit că nu există nicio bibliotecă de serializare bună pentru JavaScript. Metodele JSON.stringify/JSON.parse încorporate returnează date de tipul obiectului, pierzând orice funcție de pe prototipurile claselor originale. Acest lucru a însemnat că obiectele deserializate nu ar putea fi utilizate pur și simplu „la loc” în cadrul aplicației noastre, ceea ce ar necesita, prin urmare, o refactorizare considerabilă pentru a funcționa cu un design alternativ.

Cerințe pentru bibliotecă

Pentru a sprijini serializarea și deserializarea datelor arbitrare în JavaScript, cu reprezentările deserializate și originalele utilizabile în mod interschimbabil, aveam nevoie de o bibliotecă de serializare cu următoarele proprietăți:

  • Reprezentările deserializate trebuie să aibă același prototip (funcții, getter, setter) ca și obiectele originale.
  • Biblioteca ar trebui să accepte tipuri de complexitate imbricate (inclusiv matrice și hărți), cu prototipurile obiectelor imbricate setate corect.
  • Ar trebui să fie posibilă serializarea și deserializarea acelorași obiecte de mai multe ori - procesul ar trebui să fie idempotent.
  • Formatul de serializare ar trebui să fie ușor de transmis prin TCP și stocat folosind Redis sau un serviciu similar.
  • Ar trebui să fie necesare modificări minime de cod pentru a marca o clasă ca serializabilă.
  • Rutinele bibliotecii ar trebui să fie rapide.
  • În mod ideal, ar trebui să existe o modalitate de a sprijini deserializarea versiunilor vechi ale unei clase, printr-un fel de mapare/versiune.

Implementarea

Pentru a acoperi acest decalaj, am decis să scriu Tanagra.js , o bibliotecă de serializare de uz general pentru JavaScript. Numele bibliotecii este o referire la unul dintre episoadele mele preferate din Star Trek: The Next Generation , unde echipajul Enterprise trebuie să învețe să comunice cu o misterioasă rasă extraterestră a cărei limbaj este de neinteligibil. Această bibliotecă de serializare acceptă formate de date comune pentru a evita astfel de probleme.

Tanagra.js este conceput pentru a fi simplu și ușor și în prezent acceptă Node.js (nu a fost testat în browser, dar, în teorie, ar trebui să funcționeze) și clasele ES6 (inclusiv Maps). Implementarea principală acceptă JSON, iar o versiune experimentală acceptă Google Protocol Buffers. Biblioteca necesită doar JavaScript standard (testat în prezent cu ES6 și Node.js), fără a depinde de caracteristicile experimentale, transpiling Babel sau TypeScript .

Clasele serializabile sunt marcate ca atare cu un apel de metodă atunci când clasa este exportată:

module.exports = serializable(Foo, myUniqueSerialisationKey)

Metoda returnează un proxy la clasă, care interceptează constructorul și injectează un identificator unic. (Dacă nu este specificat, acesta este implicit numele clasei.) Această cheie este serializată cu restul datelor, iar clasa o expune și ca un câmp static. Dacă clasa conține tipuri imbricate (adică, membri cu tipuri care necesită serializare), acestea sunt, de asemenea, specificate în apelul de metodă:

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

(Tipurile imbricate pentru versiunile anterioare ale clasei pot fi, de asemenea, specificate într-un mod similar, astfel încât, de exemplu, dacă serializați un Foo1, acesta poate fi deserializat într-un Foo2.)

În timpul serializării, biblioteca construiește recursiv o hartă globală a cheilor către clase și o folosește în timpul deserializării. (Rețineți, cheia este serializată cu restul datelor.) Pentru a cunoaște tipul clasei de „nivel superior”, biblioteca necesită ca acest lucru să fie specificat în apelul de deserializare:

const foo = decodeEntity(serializedFoo, Foo)

O bibliotecă experimentală de mapare automată parcurge arborele de module și generează mapările din numele claselor, dar aceasta funcționează numai pentru clasele cu nume unic.

Aspectul proiectului

Proiectul este împărțit în mai multe module:

  • tanagra-core - funcționalitate comună cerută de diferitele formate de serializare, inclusiv funcția de marcare a claselor ca serializabile
  • tanagra-json - serializează datele în format JSON
  • tanagra-protobuf - serializează datele în format Google protobuffers (experimental)
  • tanagra-protobuf-redis-cache - o bibliotecă de ajutor pentru stocarea protobuf-urilor serializate în Redis
  • tanagra-auto-mapper - parcurge arborele de module din Node.js pentru a construi o hartă a claselor, ceea ce înseamnă că utilizatorul nu trebuie să specifice tipul la care să deserializeze (experimental).

Rețineți că biblioteca folosește ortografia SUA.

Exemplu de utilizare

Următorul exemplu declară o clasă serializabilă și folosește modulul tanagra-json pentru a o serializa/deserializa:

 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)

Performanţă

Am comparat performanța celor două serializatoare (serializatorul JSON și serializatorul experimental protobufs ) cu un control (JSON.parse și JSON.stringify nativ). Am efectuat un total de 10 încercări cu fiecare.

Am testat acest lucru pe laptopul meu Dell XPS15 din 2017 cu memorie de 32 Gb, rulând Ubuntu 17.10.

Am serializat următorul obiect imbricat:

 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 } } }, }

Performanță scrisă

Metoda de serializare Ave. inc. prima încercare (ms) StDev. inc. prima încercare (ms) Ave. ex. prima încercare (ms) StDev. ex. prima încercare (ms)
JSON 0,115 0,0903 0,0879 0,0256
Google Protobufs 2.00 2.748 1.13 0,278
Grupul de control 0,0155 0,00726 0,0139 0,00570

Citit

Metoda de serializare Ave. inc. prima încercare (ms) StDev. inc. prima încercare (ms) Ave. ex. prima încercare (ms) StDev. ex. prima încercare (ms)
JSON 0,133 0,102 0,104 0,0429
Google Protobufs 2,62 1.12 2.28 0,364
Grupul de control 0,0135 0,00729 0,0115 0,00390

rezumat

Serializatorul JSON este de aproximativ 6-7 ori mai lent decât serializarea nativă. Serializatorul experimental protobufs este de aproximativ 13 ori mai lent decât serializatorul JSON sau de 100 de ori mai lent decât serializatorul nativ.

În plus, stocarea în cache internă a informațiilor de schemă/structură din fiecare serializator are în mod clar un efect asupra performanței. Pentru serializatorul JSON, prima scriere este de aproximativ patru ori mai lentă decât media. Pentru serializatorul protobuf, este de nouă ori mai lent. Deci, scrierea obiectelor ale căror metadate au fost deja stocate în cache este mult mai rapidă în oricare dintre biblioteci.

Același efect a fost observat pentru citiri. Pentru biblioteca JSON, prima citire este de aproximativ patru ori mai lentă decât media, iar pentru biblioteca protobuf, este de aproximativ două ori și jumătate mai lentă.

Problemele de performanță ale serializatorului protobuf înseamnă că este încă în stadiul experimental și l-aș recomanda doar dacă aveți nevoie de format dintr-un anumit motiv. Cu toate acestea, merită să investiți ceva timp, deoarece formatul este mult mai concis decât JSON și, prin urmare, mai bun pentru trimiterea prin cablu. Stack Exchange folosește formatul pentru memorarea în cache internă.

Serializatorul JSON este în mod clar mult mai performant, dar totuși semnificativ mai lent decât implementarea nativă. Pentru arborii de obiecte mici, această diferență nu este semnificativă (câteva milisecunde peste o solicitare de 50 ms nu vor distruge performanța site-ului dvs.), dar aceasta ar putea deveni o problemă pentru arborii de obiecte extrem de mari și este una dintre prioritățile mele de dezvoltare.

Foaia de parcurs

Biblioteca este încă în stadiul beta. Serializatorul JSON este destul de bine testat și stabil. Iată foaia de parcurs pentru următoarele câteva luni:

  • Îmbunătățiri de performanță pentru ambele serializatoare
  • Suport mai bun pentru JavaScript pre-ES6
  • Suport pentru decoratorii ES-Next

Nu știu nicio altă bibliotecă JavaScript care să accepte serializarea datelor obiect complexe, imbricate și deserializarea la tipul său original. Dacă implementați o funcționalitate care ar beneficia de bibliotecă, vă rugăm să o încercați, să luați legătura cu feedback-ul dvs. și să luați în considerare contribuția.

Pagina principală a proiectului
Depozitul GitHub