Serializzazione di oggetti complessi in JavaScript
Pubblicato: 2022-03-11Prestazioni del sito Web e memorizzazione nella cache dei dati
I siti Web moderni in genere recuperano i dati da diverse posizioni, inclusi database e API di terze parti. Ad esempio, durante l'autenticazione di un utente, un sito Web potrebbe cercare il record dell'utente dal database, quindi abbellirlo con i dati di alcuni servizi esterni tramite chiamate API. Per mantenere un sito veloce e reattivo, è essenziale ridurre al minimo le chiamate costose a queste origini dati, come l'accesso al disco per le query del database e i roundtrip Internet per le chiamate API. La memorizzazione nella cache dei dati è una tecnica di ottimizzazione comune utilizzata per raggiungere questo obiettivo.
I processi memorizzano i propri dati di lavoro in memoria. Se un server Web viene eseguito in un unico processo (come Node.js/Express), questi dati possono essere facilmente memorizzati nella cache utilizzando una cache di memoria in esecuzione nello stesso processo. Tuttavia, i server Web con bilanciamento del carico si estendono su più processi e, anche quando si lavora con un singolo processo, è possibile che la cache persista al riavvio del server. Ciò richiede una soluzione di memorizzazione nella cache fuori processo come Redis, il che significa che i dati devono essere serializzati in qualche modo e deserializzati quando letti dalla cache.
La serializzazione e la deserializzazione sono relativamente semplici da ottenere in linguaggi tipizzati statici come C#. Tuttavia, la natura dinamica di JavaScript rende il problema un po' più complicato. Sebbene ECMAScript 6 (ES6) abbia introdotto le classi, i campi su queste classi (e sui relativi tipi) non vengono definiti fino a quando non vengono inizializzati, cosa che potrebbe non avvenire quando viene istanziata la classe, e i tipi restituiti di campi e funzioni non sono definiti affatto nello schema. Inoltre, la struttura della classe può essere facilmente modificata in fase di esecuzione: i campi possono essere aggiunti o rimossi, i tipi possono essere modificati, ecc. Sebbene ciò sia possibile utilizzando la riflessione in C#, la riflessione rappresenta le "arti oscure" di quel linguaggio e gli sviluppatori si aspettano che interrompa la funzionalità.
Mi è stato presentato questo problema al lavoro alcuni anni fa quando lavoravo nel core team di Toptal. Stavamo costruendo una dashboard agile per i nostri team, che doveva essere veloce; in caso contrario, sviluppatori e proprietari di prodotti non lo userebbero. Abbiamo estratto i dati da una serie di fonti: il nostro sistema di monitoraggio del lavoro, il nostro strumento di gestione dei progetti e un database. Il sito è stato creato in Node.js/Express e avevamo una cache di memoria per ridurre al minimo le chiamate a queste origini dati. Tuttavia, il nostro processo di sviluppo rapido e iterativo ha comportato l'implementazione (e quindi il riavvio) più volte al giorno, invalidando la cache e perdendo così molti dei suoi vantaggi.
Una soluzione ovvia era una cache fuori processo come Redis. Tuttavia, dopo alcune ricerche, ho scoperto che non esisteva una buona libreria di serializzazione per JavaScript. I metodi JSON.stringify/JSON.parse integrati restituiscono dati del tipo di oggetto, perdendo qualsiasi funzione sui prototipi delle classi originali. Ciò significava che gli oggetti deserializzati non potevano essere semplicemente utilizzati "sul posto" all'interno della nostra applicazione, il che richiederebbe quindi un notevole refactoring per funzionare con un design alternativo.
Requisiti per la Biblioteca
Per supportare la serializzazione e la deserializzazione di dati arbitrari in JavaScript, con le rappresentazioni deserializzate e gli originali utilizzabili in modo intercambiabile, avevamo bisogno di una libreria di serializzazione con le seguenti proprietà:
- Le rappresentazioni deserializzate devono avere lo stesso prototipo (funzioni, getter, setter) degli oggetti originali.
- La libreria dovrebbe supportare tipi di complessità nidificati (inclusi array e mappe), con i prototipi degli oggetti nidificati impostati correttamente.
- Dovrebbe essere possibile serializzare e deserializzare gli stessi oggetti più volte: il processo dovrebbe essere idempotente.
- Il formato di serializzazione dovrebbe essere facilmente trasmissibile su TCP e memorizzabile utilizzando Redis o un servizio simile.
- Dovrebbero essere necessarie modifiche minime al codice per contrassegnare una classe come serializzabile.
- Le routine della libreria dovrebbero essere veloci.
- Idealmente, dovrebbe esserci un modo per supportare la deserializzazione delle vecchie versioni di una classe, attraverso una sorta di mappatura/versione.
Implementazione
Per colmare questa lacuna, ho deciso di scrivere Tanagra.js , una libreria di serializzazione generica per JavaScript. Il nome della libreria è un riferimento a uno dei miei episodi preferiti di Star Trek: The Next Generation , dove l'equipaggio dell'Enterprise deve imparare a comunicare con una misteriosa razza aliena il cui linguaggio è incomprensibile. Questa libreria di serializzazione supporta formati di dati comuni per evitare tali problemi.
Tanagra.js è progettato per essere semplice e leggero e attualmente supporta Node.js (non è stato testato nel browser, ma in teoria dovrebbe funzionare) e le classi ES6 (incluse le mappe). L'implementazione principale supporta JSON e una versione sperimentale supporta i Google Protocol Buffers. La libreria richiede solo JavaScript standard (attualmente testato con ES6 e Node.js), senza alcuna dipendenza da funzionalità sperimentali, Babel transpiling o TypeScript .
Le classi serializzabili sono contrassegnate come tali con una chiamata al metodo quando la classe viene esportata:
module.exports = serializable(Foo, myUniqueSerialisationKey)
Il metodo restituisce un proxy alla classe, che intercetta il costruttore e inserisce un identificatore univoco. (Se non specificato, il valore predefinito è il nome della classe.) Questa chiave viene serializzata con il resto dei dati e la classe la espone anche come campo statico. Se la classe contiene tipi nidificati (ovvero, membri con tipi che necessitano di serializzazione), vengono specificati anche nella chiamata al metodo:
module.exports = serializable(Foo, [Bar, Baz], myUniqueSerialisationKey)
(Anche i tipi annidati per le versioni precedenti della classe possono essere specificati in modo simile, in modo che, ad esempio, se si serializza un Foo1, può essere deserializzato in un Foo2.)
Durante la serializzazione, la libreria crea ricorsivamente una mappa globale di chiavi per le classi e la usa durante la deserializzazione. (Ricorda, la chiave è serializzata con il resto dei dati.) Per conoscere il tipo della classe "di primo livello", la libreria richiede che questo sia specificato nella chiamata di deserializzazione:

const foo = decodeEntity(serializedFoo, Foo)
Una libreria sperimentale di mappatura automatica percorre l'albero dei moduli e genera le mappature dai nomi delle classi, ma funziona solo per classi con nome univoco.
Disposizione del progetto
Il progetto si articola in una serie di moduli:
- tanagra-core: funzionalità comune richiesta dai diversi formati di serializzazione, inclusa la funzione per contrassegnare le classi come serializzabili
- tanagra-json - serializza i dati in formato JSON
- tanagra-protobuf - serializza i dati nel formato protobuffer di Google (sperimentale)
- tanagra-protobuf-redis-cache - una libreria di supporto per la memorizzazione di protobuf serializzati in Redis
- tanagra-auto-mapper: percorre l'albero dei moduli in Node.js per creare una mappa di classi, il che significa che l'utente non deve specificare il tipo su cui deserializzare (sperimentale).
Si noti che la libreria utilizza l'ortografia statunitense.
Esempio di utilizzo
L'esempio seguente dichiara una classe serializzabile e usa il modulo tanagra-json per serializzarla/deserializzarla:
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)
Prestazione
Ho confrontato le prestazioni dei due serializzatori (il serializzatore JSON e il serializzatore protobufs sperimentale) con un controllo (nativo JSON.parse e JSON.stringify). Ho condotto un totale di 10 prove con ciascuno.
L'ho testato sul mio laptop Dell XPS15 del 2017 con 32 GB di memoria, con Ubuntu 17.10.
Ho serializzato il seguente oggetto annidato:
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 } } }, }
Scrivi prestazioni
Metodo di serializzazione | Ave. inc. prima prova (ms) | StDev. inc. prima prova (ms) | Viale es. prima prova (ms) | StDev. ex. prima prova (ms) |
JSON | 0,115 | 0,0903 | 0,0879 | 0,0256 |
Google Protobuff | 2.00 | 2.748 | 1.13 | 0,278 |
Gruppo di controllo | 0,0155 | 0,00726 | 0,0139 | 0,00570 |
Leggere
Metodo di serializzazione | Ave. inc. prima prova (ms) | StDev. inc. prima prova (ms) | Viale es. prima prova (ms) | StDev. ex. prima prova (ms) |
JSON | 0,133 | 0,102 | 0,104 | 0,0429 |
Google Protobuff | 2.62 | 1.12 | 2.28 | 0,364 |
Gruppo di controllo | 0,0135 | 0,00729 | 0,0115 | 0,00390 |
Sommario
Il serializzatore JSON è circa 6-7 volte più lento della serializzazione nativa. Il serializzatore sperimentale protobufs è circa 13 volte più lento del serializzatore JSON o 100 volte più lento della serializzazione nativa.
Inoltre, la memorizzazione nella cache interna di schema/informazioni strutturali all'interno di ogni serializzatore ha chiaramente un effetto sulle prestazioni. Per il serializzatore JSON, la prima scrittura è circa quattro volte più lenta della media. Per il serializzatore protobuf, è nove volte più lento. Quindi scrivere oggetti i cui metadati sono già stati memorizzati nella cache è molto più veloce in entrambe le librerie.
Lo stesso effetto è stato osservato per le letture. Per la libreria JSON, la prima lettura è circa quattro volte più lenta della media e per la libreria protobuf è circa due volte e mezzo più lenta.
I problemi di prestazioni del serializzatore protobuf significano che è ancora nella fase sperimentale e lo consiglierei solo se per qualche motivo hai bisogno del formato. Tuttavia, vale la pena investire un po' di tempo, poiché il formato è molto più conciso di JSON e quindi migliore per l'invio via cavo. Stack Exchange utilizza il formato per la memorizzazione nella cache interna.
Il serializzatore JSON è chiaramente molto più performante ma comunque significativamente più lento dell'implementazione nativa. Per alberi di oggetti piccoli, questa differenza non è significativa (pochi millisecondi sopra una richiesta di 50 ms non distruggeranno le prestazioni del tuo sito), ma questo potrebbe diventare un problema per alberi di oggetti estremamente grandi ed è una delle mie priorità di sviluppo.
Tabella di marcia
La libreria è ancora in fase beta. Il serializzatore JSON è ragionevolmente ben testato e stabile. Ecco la tabella di marcia per i prossimi mesi:
- Miglioramenti delle prestazioni per entrambi i serializzatori
- Migliore supporto per JavaScript pre-ES6
- Supporto per decoratori ES-Next
Non conosco nessun'altra libreria JavaScript che supporti la serializzazione di dati di oggetti nidificati complessi e la deserializzazione nel suo tipo originale. Se stai implementando funzionalità che potrebbero trarre vantaggio dalla libreria, provala, contattaci con il tuo feedback e valuta la possibilità di contribuire.
Pagina iniziale del progetto
Archivio GitHub