Sérialisation d'objets complexes en JavaScript

Publié: 2022-03-11

Performances du site Web et mise en cache des données

Les sites Web modernes récupèrent généralement des données à partir d'un certain nombre d'emplacements différents, y compris des bases de données et des API tierces. Par exemple, lors de l'authentification d'un utilisateur, un site Web peut rechercher l'enregistrement de l'utilisateur dans la base de données, puis l'embellir avec des données provenant de certains services externes via des appels d'API. Minimiser les appels coûteux vers ces sources de données, tels que l'accès au disque pour les requêtes de base de données et les allers-retours Internet pour les appels d'API, est essentiel pour maintenir un site rapide et réactif. La mise en cache des données est une technique d'optimisation courante utilisée pour y parvenir.

Les processus stockent leurs données de travail en mémoire. Si un serveur Web s'exécute dans un seul processus (tel que Node.js/Express), ces données peuvent facilement être mises en cache à l'aide d'un cache mémoire s'exécutant dans le même processus. Cependant, les serveurs Web à charge équilibrée s'étendent sur plusieurs processus, et même lorsque vous travaillez avec un seul processus, vous souhaiterez peut-être que le cache persiste lorsque le serveur est redémarré. Cela nécessite une solution de mise en cache hors processus telle que Redis, ce qui signifie que les données doivent être sérialisées d'une manière ou d'une autre et désérialisées lorsqu'elles sont lues à partir du cache.

La sérialisation et la désérialisation sont relativement simples à réaliser dans des langages à typage statique tels que C#. Cependant, la nature dynamique de JavaScript rend le problème un peu plus délicat. Bien qu'ECMAScript 6 (ES6) ait introduit des classes, les champs de ces classes (et leurs types) ne sont pas définis tant qu'ils ne sont pas initialisés (ce qui peut ne pas être le cas lorsque la classe est instanciée) et les types de retour des champs et des fonctions ne sont pas définis. du tout dans le schéma. De plus, la structure de la classe peut facilement être modifiée au moment de l'exécution : des champs peuvent être ajoutés ou supprimés, des types peuvent être modifiés, etc. Bien que cela soit possible en utilisant la réflexion en C#, la réflexion représente les « arts sombres » de ce langage, et les développeurs s'attendent à ce qu'il casse la fonctionnalité.

On m'a présenté ce problème au travail il y a quelques années lorsque je travaillais dans l'équipe principale de Toptal. Nous étions en train de construire un tableau de bord agile pour nos équipes, qui devait être rapide ; sinon, les développeurs et les propriétaires de produits ne l'utiliseraient pas. Nous avons extrait des données de plusieurs sources : notre système de suivi des travaux, notre outil de gestion de projet et une base de données. Le site a été construit en Node.js/Express, et nous avions un cache mémoire pour minimiser les appels à ces sources de données. Cependant, notre processus de développement rapide et itératif nous a obligés à déployer (et donc à redémarrer) plusieurs fois par jour, invalidant le cache et perdant ainsi bon nombre de ses avantages.

Une solution évidente était un cache hors processus tel que Redis. Cependant, après quelques recherches, j'ai découvert qu'aucune bonne bibliothèque de sérialisation n'existait pour JavaScript. Les méthodes intégrées JSON.stringify/JSON.parse renvoient les données du type d'objet, perdant toutes les fonctions sur les prototypes des classes d'origine. Cela signifiait que les objets désérialisés ne pouvaient pas simplement être utilisés "sur place" dans notre application, ce qui nécessiterait donc une refactorisation considérable pour fonctionner avec une conception alternative.

Exigences pour la bibliothèque

Afin de prendre en charge la sérialisation et la désérialisation de données arbitraires en JavaScript, avec les représentations désérialisées et les originaux utilisables de manière interchangeable, nous avions besoin d'une bibliothèque de sérialisation avec les propriétés suivantes :

  • Les représentations désérialisées doivent avoir le même prototype (fonctions, getters, setters) que les objets d'origine.
  • La bibliothèque doit prendre en charge les types de complexité imbriqués (y compris les tableaux et les cartes), avec les prototypes des objets imbriqués définis correctement.
  • Il devrait être possible de sérialiser et de désérialiser les mêmes objets plusieurs fois - le processus devrait être idempotent.
  • Le format de sérialisation doit être facilement transmissible via TCP et stockable à l'aide de Redis ou d'un service similaire.
  • Des modifications minimales du code devraient être nécessaires pour marquer une classe comme sérialisable.
  • Les routines de la bibliothèque doivent être rapides.
  • Idéalement, il devrait y avoir un moyen de prendre en charge la désérialisation des anciennes versions d'une classe, via une sorte de mappage/version.

Mise en œuvre

Pour combler cette lacune, j'ai décidé d'écrire Tanagra.js , une bibliothèque de sérialisation à usage général pour JavaScript. Le nom de la bibliothèque fait référence à l'un de mes épisodes préférés de Star Trek : The Next Generation , où l'équipage de l' Enterprise doit apprendre à communiquer avec une mystérieuse race extraterrestre dont le langage est inintelligible. Cette bibliothèque de sérialisation prend en charge les formats de données courants pour éviter de tels problèmes.

Tanagra.js est conçu pour être simple et léger, et il prend actuellement en charge Node.js (il n'a pas été testé dans le navigateur, mais en théorie, cela devrait fonctionner) et les classes ES6 (y compris Maps). L'implémentation principale prend en charge JSON et une version expérimentale prend en charge Google Protocol Buffers. La bibliothèque ne nécessite que du JavaScript standard (actuellement testé avec ES6 et Node.js), sans aucune dépendance vis-à-vis des fonctionnalités expérimentales, de la transpilation Babel ou de TypeScript .

Les classes sérialisables sont marquées comme telles par un appel de méthode lorsque la classe est exportée :

module.exports = serializable(Foo, myUniqueSerialisationKey)

La méthode renvoie un proxy à la classe, qui intercepte le constructeur et injecte un identifiant unique. (Si elle n'est pas spécifiée, la valeur par défaut est le nom de la classe.) Cette clé est sérialisée avec le reste des données et la classe l'expose également en tant que champ statique. Si la classe contient des types imbriqués (c'est-à-dire des membres dont les types doivent être sérialisés), ils sont également spécifiés dans l'appel de méthode :

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

(Les types imbriqués pour les versions précédentes de la classe peuvent également être spécifiés de la même manière, de sorte que, par exemple, si vous sérialisez un Foo1, il peut être désérialisé en Foo2.)

Lors de la sérialisation, la bibliothèque construit de manière récursive une carte globale des clés des classes et l'utilise lors de la désérialisation. (Rappelez-vous que la clé est sérialisée avec le reste des données.) Afin de connaître le type de la classe "de niveau supérieur", la bibliothèque exige que cela soit spécifié dans l'appel de désérialisation :

const foo = decodeEntity(serializedFoo, Foo)

Une bibliothèque de mappage automatique expérimentale parcourt l'arborescence des modules et génère les mappages à partir des noms de classe, mais cela ne fonctionne que pour les classes nommées de manière unique.

Mise en page du projet

Le projet est divisé en plusieurs modules :

  • tanagra-core - fonctionnalité commune requise par les différents formats de sérialisation, y compris la fonction de marquage des classes comme sérialisables
  • tanagra-json - sérialise les données au format JSON
  • tanagra-protobuf - sérialise les données au format Google protobuffers (expérimental)
  • tanagra-protobuf-redis-cache - une bibliothèque d'aide pour stocker des protobufs sérialisés dans Redis
  • tanagra-auto-mapper - parcourt l'arborescence des modules dans Node.js pour créer une carte de classes, ce qui signifie que l'utilisateur n'a pas à spécifier le type à désérialiser (expérimental).

Notez que la bibliothèque utilise l'orthographe américaine.

Exemple d'utilisation

L'exemple suivant déclare une classe sérialisable et utilise le module tanagra-json pour la sérialiser/désérialiser :

 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)

Performance

J'ai comparé les performances des deux sérialiseurs (le sérialiseur JSON et le sérialiseur protobufs expérimental) avec un contrôle (JSON.parse natif et JSON.stringify). J'ai mené un total de 10 essais avec chacun.

J'ai testé cela sur mon ordinateur portable Dell XPS15 2017 avec 32 Go de mémoire, exécutant Ubuntu 17.10.

J'ai sérialisé l'objet imbriqué suivant :

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

Performances d'écriture

Méthode de sérialisation Av. inc. premier essai (ms) StDev. inc. premier essai (ms) Avenue ex. premier essai (ms) StDev. ex. premier essai (ms)
JSON 0,115 0,0903 0,0879 0,0256
Protobufs de Google 2,00 2.748 1.13 0,278
Groupe de contrôle 0,0155 0,00726 0,0139 0,00570

Lire

Méthode de sérialisation Av. inc. premier essai (ms) StDev. inc. premier essai (ms) Avenue ex. premier essai (ms) StDev. ex. premier essai (ms)
JSON 0,133 0,102 0,104 0,0429
Protobufs de Google 2.62 1.12 2.28 0,364
Groupe de contrôle 0,0135 0,00729 0,0115 0,00390

Sommaire

Le sérialiseur JSON est environ 6 à 7 fois plus lent que la sérialisation native. Le sérialiseur protobufs expérimental est environ 13 fois plus lent que le sérialiseur JSON , ou 100 fois plus lent que la sérialisation native.

De plus, la mise en cache interne des informations de schéma/structurelles dans chaque sérialiseur a clairement un effet sur les performances. Pour le sérialiseur JSON, la première écriture est environ quatre fois plus lente que la moyenne. Pour le sérialiseur protobuf, c'est neuf fois plus lent. Ainsi, l'écriture d'objets dont les métadonnées ont déjà été mises en cache est beaucoup plus rapide dans l'une ou l'autre bibliothèque.

Le même effet a été observé pour les lectures. Pour la bibliothèque JSON, la première lecture est environ quatre fois plus lente que la moyenne, et pour la bibliothèque protobuf, elle est environ deux fois et demie plus lente.

Les problèmes de performances du sérialiseur protobuf signifient qu'il est encore au stade expérimental, et je ne le recommanderais que si vous avez besoin du format pour une raison quelconque. Cependant, cela vaut la peine d'y investir du temps, car le format est beaucoup plus concis que JSON, et donc meilleur pour l'envoi sur le fil. Stack Exchange utilise le format pour sa mise en cache interne.

Le sérialiseur JSON est clairement beaucoup plus performant mais toujours nettement plus lent que l'implémentation native. Pour les petits arbres d'objets, cette différence n'est pas significative (quelques millisecondes en plus d'une requête de 50 ms ne détruiront pas les performances de votre site), mais cela pourrait devenir un problème pour les arbres d'objets extrêmement volumineux, et c'est l'une de mes priorités de développement.

Feuille de route

La bibliothèque est encore en phase bêta. Le sérialiseur JSON est raisonnablement bien testé et stable. Voici la feuille de route pour les prochains mois :

  • Améliorations des performances pour les deux sérialiseurs
  • Meilleure prise en charge du JavaScript pré-ES6
  • Prise en charge des décorateurs ES-Next

Je ne connais aucune autre bibliothèque JavaScript qui prend en charge la sérialisation de données d'objets complexes et imbriquées et la désérialisation vers son type d'origine. Si vous implémentez une fonctionnalité qui bénéficierait de la bibliothèque, essayez-la, contactez-nous avec vos commentaires et envisagez de contribuer.

Page d'accueil du projet
Référentiel GitHub