Serializar objetos complejos en JavaScript
Publicado: 2022-03-11Rendimiento del sitio web y almacenamiento en caché de datos
Los sitios web modernos suelen recuperar datos de varias ubicaciones diferentes, incluidas bases de datos y API de terceros. Por ejemplo, al autenticar a un usuario, un sitio web puede buscar el registro del usuario en la base de datos y luego embellecerlo con datos de algunos servicios externos a través de llamadas API. Minimizar las costosas llamadas a estas fuentes de datos, como el acceso al disco para las consultas de la base de datos y los viajes de ida y vuelta a Internet para las llamadas a la API, es esencial para mantener un sitio rápido y con capacidad de respuesta. El almacenamiento en caché de datos es una técnica de optimización común utilizada para lograr esto.
Los procesos almacenan sus datos de trabajo en la memoria. Si un servidor web se ejecuta en un solo proceso (como Node.js/Express), estos datos se pueden almacenar en caché fácilmente mediante una memoria caché que se ejecuta en el mismo proceso. Sin embargo, los servidores web con equilibrio de carga abarcan múltiples procesos, e incluso cuando se trabaja con un solo proceso, es posible que desee que la memoria caché persista cuando se reinicia el servidor. Esto requiere una solución de almacenamiento en caché fuera del proceso como Redis, lo que significa que los datos deben serializarse de alguna manera y deserializarse cuando se leen desde el caché.
La serialización y la deserialización son relativamente sencillas de lograr en lenguajes tipificados estáticamente como C#. Sin embargo, la naturaleza dinámica de JavaScript hace que el problema sea un poco más complicado. Si bien ECMAScript 6 (ES6) introdujo clases, los campos en estas clases (y sus tipos) no se definen hasta que se inicializan, lo que puede no ser cuando se crea una instancia de la clase, y los tipos de retorno de campos y funciones no están definidos en absoluto en el esquema. Además, la estructura de la clase se puede cambiar fácilmente en tiempo de ejecución: se pueden agregar o quitar campos, se pueden cambiar los tipos, etc. Si bien esto es posible usando la reflexión en C#, la reflexión representa las "artes oscuras" de ese lenguaje y los desarrolladores esperan que rompa la funcionalidad.
Me presentaron este problema en el trabajo hace unos años cuando trabajaba en el equipo central de Toptal. Estábamos creando un tablero ágil para nuestros equipos, que debía ser rápido; de lo contrario, los desarrolladores y propietarios de productos no lo utilizarían. Obtuvimos datos de varias fuentes: nuestro sistema de seguimiento del trabajo, nuestra herramienta de gestión de proyectos y una base de datos. El sitio se creó en Node.js/Express y teníamos un caché de memoria para minimizar las llamadas a estas fuentes de datos. Sin embargo, nuestro proceso de desarrollo rápido e iterativo significaba que implementábamos (y, por lo tanto, reiniciamos) varias veces al día, lo que invalidaba el caché y, por lo tanto, perdía muchos de sus beneficios.
Una solución obvia era un caché fuera de proceso como Redis. Sin embargo, después de algunas investigaciones, descubrí que no existía una buena biblioteca de serialización para JavaScript. Los métodos integrados JSON.stringify/JSON.parse devuelven datos del tipo de objeto, perdiendo cualquier función en los prototipos de las clases originales. Esto significaba que los objetos deserializados no podían simplemente usarse "in situ" dentro de nuestra aplicación, lo que requeriría una refactorización considerable para trabajar con un diseño alternativo.
Requisitos para la Biblioteca
Para admitir la serialización y deserialización de datos arbitrarios en JavaScript, con las representaciones deserializadas y los originales intercambiables, necesitábamos una biblioteca de serialización con las siguientes propiedades:
- Las representaciones deserializadas deben tener el mismo prototipo (funciones, getters, setters) que los objetos originales.
- La biblioteca debe admitir tipos de complejidad anidados (incluidos arreglos y mapas), con los prototipos de los objetos anidados configurados correctamente.
- Debería ser posible serializar y deserializar los mismos objetos varias veces; el proceso debería ser idempotente.
- El formato de serialización debe poder transmitirse fácilmente a través de TCP y almacenarse mediante Redis o un servicio similar.
- Se deben requerir cambios mínimos de código para marcar una clase como serializable.
- Las rutinas de la biblioteca deben ser rápidas.
- Idealmente, debería haber alguna forma de admitir la deserialización de versiones antiguas de una clase, a través de algún tipo de asignación/versión.
Implementación
Para llenar este vacío, decidí escribir Tanagra.js , una biblioteca de serialización de propósito general para JavaScript. El nombre de la biblioteca es una referencia a uno de mis episodios favoritos de Star Trek: The Next Generation , donde la tripulación del Enterprise debe aprender a comunicarse con una misteriosa raza alienígena cuyo idioma es ininteligible. Esta biblioteca de serialización admite formatos de datos comunes para evitar este tipo de problemas.
Tanagra.js está diseñado para ser simple y liviano, y actualmente es compatible con Node.js (no se ha probado en el navegador, pero en teoría, debería funcionar) y clases ES6 (incluido Maps). La implementación principal es compatible con JSON y una versión experimental es compatible con los búferes de protocolo de Google. La biblioteca requiere solo JavaScript estándar (actualmente probado con ES6 y Node.js), sin dependencia de funciones experimentales, transpilación de Babel o TypeScript .
Las clases serializables se marcan como tales con una llamada de método cuando se exporta la clase:
module.exports = serializable(Foo, myUniqueSerialisationKey)
El método devuelve un proxy a la clase, que intercepta al constructor e inyecta un identificador único. (Si no se especifica, el valor predeterminado es el nombre de la clase). Esta clave se serializa con el resto de los datos y la clase también la expone como un campo estático. Si la clase contiene tipos anidados (es decir, miembros con tipos que necesitan serialización), también se especifican en la llamada al método:
module.exports = serializable(Foo, [Bar, Baz], myUniqueSerialisationKey)
(Los tipos anidados para versiones anteriores de la clase también se pueden especificar de manera similar, de modo que, por ejemplo, si serializa un Foo1, se puede deserializar en un Foo2).
Durante la serialización, la biblioteca crea recursivamente un mapa global de claves para las clases y lo usa durante la deserialización. (Recuerde, la clave se serializa con el resto de los datos). Para conocer el tipo de clase de "nivel superior", la biblioteca requiere que se especifique en la llamada de deserialización:
const foo = decodeEntity(serializedFoo, Foo)

Una biblioteca experimental de mapeo automático recorre el árbol de módulos y genera los mapeos a partir de los nombres de las clases, pero esto solo funciona para clases con nombres únicos.
Diseño del proyecto
El proyecto se divide en varios módulos:
- tanagra-core: funcionalidad común requerida por los diferentes formatos de serialización, incluida la función para marcar clases como serializables
- tanagra-json: serializa los datos en formato JSON
- tanagra-protobuf: serializa los datos en formato de protobuffers de Google (experimental)
- tanagra-protobuf-redis-cache: una biblioteca auxiliar para almacenar protobufs serializados en Redis
- tanagra-auto-mapper: recorre el árbol de módulos en Node.js para crear un mapa de clases, lo que significa que el usuario no tiene que especificar el tipo para deserializar (experimental).
Tenga en cuenta que la biblioteca utiliza ortografía estadounidense.
Ejemplo de uso
El siguiente ejemplo declara una clase serializable y usa el módulo tanagra-json para serializarlo/deserializarlo:
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)
Rendimiento
Comparé el rendimiento de los dos serializadores (el serializador JSON y el serializador protobufs experimental) con un control (JSON.parse nativo y JSON.stringify). Realicé un total de 10 ensayos con cada uno.
Probé esto en mi computadora portátil Dell XPS15 2017 con memoria de 32 Gb, con Ubuntu 17.10.
Serialicé el siguiente objeto anidado:
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 } } }, }
Rendimiento de escritura
método de serialización | av. inc. primera prueba (ms) | desv.est. C ª. primera prueba (ms) | Ave. ej. primera prueba (ms) | desv.est. ex. primera prueba (ms) |
JSON | 0.115 | 0.0903 | 0.0879 | 0.0256 |
Protobufos de Google | 2.00 | 2.748 | 1.13 | 0.278 |
Grupo de control | 0.0155 | 0.00726 | 0.0139 | 0.00570 |
Leer
método de serialización | av. inc. primera prueba (ms) | desv.est. C ª. primera prueba (ms) | Ave. ej. primera prueba (ms) | desv.est. ex. primera prueba (ms) |
JSON | 0.133 | 0.102 | 0.104 | 0.0429 |
Protobufos de Google | 2.62 | 1.12 | 2.28 | 0.364 |
Grupo de control | 0.0135 | 0.00729 | 0.0115 | 0.00390 |
Resumen
El serializador JSON es entre 6 y 7 veces más lento que la serialización nativa. El serializador protobufs experimental es alrededor de 13 veces más lento que el serializador JSON , o 100 veces más lento que la serialización nativa.
Además, el almacenamiento en caché interno de la información estructural/de esquema dentro de cada serializador claramente tiene un efecto en el rendimiento. Para el serializador JSON, la primera escritura es aproximadamente cuatro veces más lenta que el promedio. Para el serializador protobuf, es nueve veces más lento. Por lo tanto, escribir objetos cuyos metadatos ya se hayan almacenado en caché es mucho más rápido en cualquiera de las dos bibliotecas.
El mismo efecto se observó para las lecturas. Para la biblioteca JSON, la primera lectura es aproximadamente cuatro veces más lenta que el promedio, y para la biblioteca protobuf, es aproximadamente dos veces y media más lenta.
Los problemas de rendimiento del serializador protobuf significan que todavía está en la etapa experimental y lo recomendaría solo si necesita el formato por algún motivo. Sin embargo, vale la pena invertir algo de tiempo, ya que el formato es mucho más breve que JSON y, por lo tanto, es mejor para enviar por cable. Stack Exchange usa el formato para su almacenamiento en caché interno.
El serializador JSON es claramente mucho más eficaz, pero sigue siendo significativamente más lento que la implementación nativa. Para árboles de objetos pequeños, esta diferencia no es significativa (unos pocos milisegundos además de una solicitud de 50 ms no destruirá el rendimiento de su sitio), pero esto podría convertirse en un problema para árboles de objetos extremadamente grandes y es una de mis prioridades de desarrollo.
Mapa vial
La biblioteca aún se encuentra en la etapa beta. El serializador JSON está razonablemente bien probado y es estable. Esta es la hoja de ruta para los próximos meses:
- Mejoras de rendimiento para ambos serializadores
- Mejor soporte para JavaScript anterior a ES6
- Soporte para decoradores ES-Next
No conozco ninguna otra biblioteca de JavaScript que admita la serialización de datos de objetos anidados complejos y la deserialización a su tipo original. Si está implementando una funcionalidad que se beneficiaría de la biblioteca, pruébela, envíenos sus comentarios y considere contribuir.
página de inicio del proyecto
repositorio GitHub