Serializando objetos complexos em JavaScript
Publicados: 2022-03-11Desempenho do site e cache de dados
Sites modernos normalmente recuperam dados de vários locais diferentes, incluindo bancos de dados e APIs de terceiros. Por exemplo, ao autenticar um usuário, um site pode pesquisar o registro do usuário no banco de dados e embelezá-lo com dados de alguns serviços externos por meio de chamadas de API. Minimizar chamadas caras para essas fontes de dados, como acesso ao disco para consultas de banco de dados e viagens de ida e volta à Internet para chamadas de API, é essencial para manter um site rápido e responsivo. O cache de dados é uma técnica de otimização comum usada para conseguir isso.
Os processos armazenam seus dados de trabalho na memória. Se um servidor da Web for executado em um único processo (como Node.js/Express), esses dados poderão ser facilmente armazenados em cache usando um cache de memória em execução no mesmo processo. No entanto, os servidores Web com balanceamento de carga abrangem vários processos e, mesmo ao trabalhar com um único processo, talvez você queira que o cache persista quando o servidor for reiniciado. Isso requer uma solução de cache fora do processo, como o Redis, o que significa que os dados precisam ser serializados de alguma forma e desserializados quando lidos do cache.
A serialização e a desserialização são relativamente simples de alcançar em linguagens de tipagem estática, como C#. No entanto, a natureza dinâmica do JavaScript torna o problema um pouco mais complicado. Embora o ECMAScript 6 (ES6) tenha introduzido classes, os campos dessas classes (e seus tipos) não são definidos até que sejam inicializados - o que pode não ser quando a classe é instanciada - e os tipos de retorno de campos e funções não são definidos nada no esquema. Além disso, a estrutura da classe pode ser facilmente alterada em tempo de execução - campos podem ser adicionados ou removidos, tipos podem ser alterados etc. os desenvolvedores esperam que ele quebre a funcionalidade.
Fui apresentado a esse problema no trabalho alguns anos atrás, quando trabalhava na equipe principal da Toptal. Estávamos construindo um dashboard ágil para nossas equipes, que precisava ser rápido; caso contrário, desenvolvedores e proprietários de produtos não o usariam. Extraímos dados de várias fontes: nosso sistema de rastreamento de trabalho, nossa ferramenta de gerenciamento de projetos e um banco de dados. O site foi construído em Node.js/Express, e tínhamos um cache de memória para minimizar as chamadas para essas fontes de dados. No entanto, nosso processo de desenvolvimento rápido e iterativo significou que implantamos (e, portanto, reiniciamos) várias vezes ao dia, invalidando o cache e, assim, perdendo muitos de seus benefícios.
Uma solução óbvia era um cache fora do processo, como o Redis. No entanto, após algumas pesquisas, descobri que não existia uma boa biblioteca de serialização para JavaScript. Os métodos JSON.stringify/JSON.parse integrados retornam dados do tipo de objeto, perdendo quaisquer funções nos protótipos das classes originais. Isso significava que os objetos desserializados não poderiam simplesmente ser usados “in-place” em nosso aplicativo, o que exigiria uma refatoração considerável para trabalhar com um design alternativo.
Requisitos para a Biblioteca
Para dar suporte à serialização e desserialização de dados arbitrários em JavaScript, com as representações desserializadas e originais utilizáveis de forma intercambiável, precisávamos de uma biblioteca de serialização com as seguintes propriedades:
- As representações desserializadas devem ter o mesmo protótipo (funções, getters, setters) dos objetos originais.
- A biblioteca deve suportar tipos de complexidade aninhados (incluindo arrays e mapas), com os protótipos dos objetos aninhados configurados corretamente.
- Deve ser possível serializar e desserializar os mesmos objetos várias vezes — o processo deve ser idempotente.
- O formato de serialização deve ser facilmente transmissível por TCP e armazenável usando Redis ou um serviço similar.
- Mudanças mínimas de código devem ser necessárias para marcar uma classe como serializável.
- As rotinas da biblioteca devem ser rápidas.
- Idealmente, deve haver alguma maneira de dar suporte à desserialização de versões antigas de uma classe, por meio de algum tipo de mapeamento/versão.
Implementação
Para preencher essa lacuna, decidi escrever Tanagra.js , uma biblioteca de serialização de uso geral para JavaScript. O nome da biblioteca é uma referência a um dos meus episódios favoritos de Star Trek: The Next Generation , onde a tripulação da Enterprise deve aprender a se comunicar com uma misteriosa raça alienígena cuja linguagem é ininteligível. Essa biblioteca de serialização oferece suporte a formatos de dados comuns para evitar esses problemas.
Tanagra.js foi projetado para ser simples e leve, e atualmente suporta Node.js (não foi testado no navegador, mas em teoria deve funcionar) e classes ES6 (incluindo Maps). A implementação principal suporta JSON e uma versão experimental suporta Google Protocol Buffers. A biblioteca requer apenas JavaScript padrão (atualmente testado com ES6 e Node.js), sem dependência de recursos experimentais, transpilação Babel ou TypeScript .
As classes serializáveis são marcadas como tal com uma chamada de método quando a classe é exportada:
module.exports = serializable(Foo, myUniqueSerialisationKey)
O método retorna um proxy para a classe, que intercepta o construtor e injeta um identificador exclusivo. (Se não for especificado, o padrão é o nome da classe.) Essa chave é serializada com o restante dos dados e a classe também a expõe como um campo estático. Se a classe contiver quaisquer tipos aninhados (ou seja, membros com tipos que precisam de serialização), eles também serão especificados na chamada de método:
module.exports = serializable(Foo, [Bar, Baz], myUniqueSerialisationKey)
(Tipos aninhados para versões anteriores da classe também podem ser especificados de maneira semelhante, para que, por exemplo, se você serializar um Foo1, ele possa ser desserializado em um Foo2.)
Durante a serialização, a biblioteca cria recursivamente um mapa global de chaves para classes e usa isso durante a desserialização. (Lembre-se, a chave é serializada com o restante dos dados.) Para saber o tipo da classe de “nível superior”, a biblioteca exige que isso seja especificado na chamada de desserialização:
const foo = decodeEntity(serializedFoo, Foo)
Uma biblioteca de mapeamento automático experimental percorre a árvore do módulo e gera os mapeamentos dos nomes das classes, mas isso só funciona para classes com nomes exclusivos.

Layout do projeto
O projeto está dividido em vários módulos:
- tanagra-core - funcionalidade comum exigida pelos diferentes formatos de serialização, incluindo a função para marcar classes como serializáveis
- tanagra-json - serializa os dados no formato JSON
- tanagra-protobuf - serializa os dados no formato de protobuffers do Google (experimental)
- tanagra-protobuf-redis-cache - uma biblioteca auxiliar para armazenar protobufs serializados no Redis
- tanagra-auto-mapper - percorre a árvore do módulo no Node.js para construir um mapa de classes, o que significa que o usuário não precisa especificar o tipo para desserializar (experimental).
Observe que a biblioteca usa a ortografia dos EUA.
Exemplo de uso
O exemplo a seguir declara uma classe serializável e usa o módulo tanagra-json para serializá-la/desserializá-la:
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)
atuação
Comparei o desempenho dos dois serializadores (o serializador JSON e o serializador protobufs experimental) com um controle (JSON.parse nativo e JSON.stringify). Realizei um total de 10 tentativas com cada um.
Eu testei isso no meu laptop Dell XPS15 2017 com 32 Gb de memória, executando o Ubuntu 17.10.
Serializei o seguinte objeto aninhado:
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 } } }, }
Desempenho de gravação
Método de serialização | Av. Inc. primeira tentativa (ms) | StDev. Inc. primeira tentativa (ms) | Av. ex. primeira tentativa (ms) | StDev. ex. primeira tentativa (ms) |
JSON | 0,115 | 0,0903 | 0,0879 | 0,0256 |
Google Protobufs | 2,00 | 2.748 | 1.13 | 0,278 |
Grupo de controle | 0,0155 | 0,00726 | 0,0139 | 0,00570 |
Leitura
Método de serialização | Av. Inc. primeira tentativa (ms) | StDev. Inc. primeira tentativa (ms) | Av. ex. primeira tentativa (ms) | StDev. ex. primeira tentativa (ms) |
JSON | 0,133 | 0,102 | 0,104 | 0,0429 |
Google Protobufs | 2,62 | 1.12 | 2,28 | 0,364 |
Grupo de controle | 0,0135 | 0,00729 | 0,0115 | 0,00390 |
Resumo
O serializador JSON é cerca de 6 a 7 vezes mais lento que a serialização nativa. O serializador protobufs experimental é cerca de 13 vezes mais lento que o serializador JSON ou 100 vezes mais lento que a serialização nativa.
Além disso, o cache interno de informações de esquema/estrutura dentro de cada serializador claramente afeta o desempenho. Para o serializador JSON, a primeira gravação é cerca de quatro vezes mais lenta que a média. Para o serializador protobuf, é nove vezes mais lento. Portanto, escrever objetos cujos metadados já foram armazenados em cache é muito mais rápido em qualquer uma das bibliotecas.
O mesmo efeito foi observado para leituras. Para a biblioteca JSON, a primeira leitura é cerca de quatro vezes mais lenta que a média e, para a biblioteca protobuf, é cerca de duas vezes e meia mais lenta.
Os problemas de desempenho do serializador protobuf significam que ele ainda está em fase experimental, e eu o recomendaria apenas se você precisar do formato por algum motivo. No entanto, vale a pena investir algum tempo, pois o formato é muito mais sucinto que o JSON e, portanto, melhor para envio por fio. O Stack Exchange usa o formato para seu cache interno.
O serializador JSON é claramente muito mais eficiente, mas ainda significativamente mais lento que a implementação nativa. Para árvores de objetos pequenas, essa diferença não é significativa (alguns milissegundos em cima de uma solicitação de 50 ms não destruirão o desempenho do seu site), mas isso pode se tornar um problema para árvores de objetos extremamente grandes e é uma das minhas prioridades de desenvolvimento.
Roteiro
A biblioteca ainda está em fase beta. O serializador JSON é razoavelmente bem testado e estável. Aqui está o roteiro para os próximos meses:
- Melhorias de desempenho para ambos os serializadores
- Melhor suporte para JavaScript pré-ES6
- Suporte para decoradores ES-Next
Não conheço nenhuma outra biblioteca JavaScript que suporte a serialização de dados de objetos complexos e aninhados e a desserialização para seu tipo original. Se você estiver implementando uma funcionalidade que se beneficiaria da biblioteca, experimente, entre em contato com seus comentários e considere contribuir.
Página inicial do projeto
Repositório do GitHub