Classe JavaScript Suporte à serialização de JavaScript

Prólogo


Atualmente, estou desenvolvendo um editor de esquema Javascript e, no processo deste trabalho, encontrei um problema no qual este artigo se concentrará, a saber, serialização e desserialização de objetos de dados complexos.


Sem entrar em detalhes do projeto, observo que, de acordo com minha ideia, o esquema é uma matriz de elementos (vértices) herdados da classe base. Consequentemente, cada classe filho implementa sua própria lógica. Além disso, os vértices contêm links entre si (setas), que também precisam ser preservados. Teoricamente, os vértices podem se referir a si mesmos diretamente ou através de outros vértices. O JSON.stringify padrão não pode serializar essa matriz, então decidi criar meu próprio serializador que resolve os dois problemas descritos:


  1. Capacidade de salvar informações de classe durante a serialização e restaurá-las durante a desserialização.
  2. A capacidade de salvar e restaurar links para objetos, incluindo cíclico.

Leia mais sobre a declaração do problema e sua solução sob o corte.


Projeto de serializador do Github


Link para o projeto github: link .
Exemplos complexos também estão na pasta test-src .


Serializador recursivo: link .
Serializador plano: link .


Declaração do problema


Como já observei, a tarefa inicial é serializar circuitos arbitrários para o editor. Para não perder tempo descrevendo o editor, facilitamos a tarefa. Suponha que desejemos fazer uma descrição formal de um esquema de algoritmo simples usando classes Javascript ES6 e depois serializar e desserializar esse esquema.


Na Internet, encontrei uma imagem adequada do algoritmo mais simples para determinar o máximo de dois valores:


imagem


Aqui devo dizer que não sou desenvolvedor de Javascript e minha linguagem "nativa" é C #, portanto a abordagem para resolver o problema é ditada pela experiência do desenvolvimento orientado a objetos em C #. Observando este diagrama, vejo os vértices dos seguintes tipos (nomes condicionais e funções especiais não desempenham):


  • Iniciar vértice (Iniciar)
  • Pico final (acabamento)
  • Equipe Top (Comando)
  • Vértice de atribuição (Let)
  • Verificação Verificação superior (se)

Esses vértices têm algumas diferenças entre si em seu conjunto de dados ou semântica, mas são todos herdados do vértice base (Nó). No mesmo local, na classe Node, é descrito o campo de links, que contém links para outros vértices, e o método addLink permite que esses links sejam adicionados. O código completo de todas as classes pode ser encontrado aqui .


Vamos escrever o código que coleta o circuito da imagem e tentar serializar o resultado.


Código de Design do Algoritmo
//   let start = new Schema.Start(); let input = new Schema.Command(' A, B'); let check = new Schema.If('A > B'); let maxIsA = new Schema.Let('Max', 'A'); let maxIsB = new Schema.Let('Max', 'B'); let output = new Schema.Command(' Max'); let finish = new Schema.Finish(); //   start.addLink(input); input.addLink(check); check.addLink(maxIsA, { condition: 'true' }); check.addLink(maxIsB, { condition: 'false' }); maxIsA.addLink(output); maxIsB.addLink(output); output.addLink(finish); //    ( ) let schema = [ start, input, check, maxIsA, maxIsB, output, finish ]; 

Se serializarmos esse esquema usando JSON.stringify, obteremos algo terrível. Vou dar as primeiras linhas do resultado, nas quais adicionei meus comentários:


Resultado JSON.stringify
 [ /*    */ { "id": "d9c8ab69-e4fa-4433-80bb-1cc7173024d6", "name": "Start", "links": { "2e3d482b-187f-4c96-95cd-b3cde9e55a43": { "id": "2e3d482b-187f-4c96-95cd-b3cde9e55a43", "target": /*    */ { "id": "f87a3913-84b0-4b70-8927-6111c6628a1f", "name": "Command", "links": { "4f623116-1b70-42bf-8a47-da1e9be5e4b2": { "id": "4f623116-1b70-42bf-8a47-da1e9be5e4b2", "target": /*     */ { "id": "94a47403-13ab-4c83-98fe-3b201744c8f2", "name": "If", "links": { ... 

Porque o primeiro vértice continha um link para o segundo e, para os subsequentes, então, como resultado de sua serialização, todo o circuito foi serializado. Em seguida, o segundo pico foi serializado e tudo o que dependia dele, e assim por diante. Você pode restaurar os links originais desse hash apenas por identificadores, mas eles não ajudarão se algum dos vértices se referir a si próprio diretamente ou através de outros vértices. Nesse caso, o serializador lançará um erro TypeError Uncaught: Convertendo estrutura circular em erro JSON . Se não estiver claro, eis o exemplo mais simples que gera esse erro: https://jsfiddle.net/L4guo86w/ .


Além disso, o JSON não contém nenhuma informação sobre as classes de origem, portanto, não há como entender que tipo de vértice era antes da serialização.


Percebendo esses problemas, entrei na Internet e comecei a procurar soluções prontas. Havia muitos, mas a maioria era muito volumosa ou exigia uma descrição especial das classes serializáveis, por isso foi decidido fabricar sua própria bicicleta. E sim, eu amo bicicletas.


Conceito de serializador


Esta seção é para quem deseja participar da criação de um algoritmo de serialização comigo, embora virtualmente.


Salvando informações de tipo de dados


Um dos problemas com o Javascript é a falta de metadados que podem fazer maravilhas em linguagens como C # ou Java (atributos e reflexão). Por outro lado, não preciso de serialização super complexa com a capacidade de definir uma lista de campos serializáveis, validação e outros chips. Portanto, a idéia principal é adicionar informações sobre seu tipo ao objeto e serializá-lo com o JSON.stringify comum.


Enquanto procurava soluções, deparei-me com um artigo interessante cujo título se traduz em "6 maneiras erradas de adicionar informações de tipo no JSON" . De fato, os métodos são muito bons e eu escolhi o número 5. Se você estiver com preguiça de ler o artigo, mas eu recomendo fazê-lo, descreverei brevemente este método: ao serializar um objeto, envolvemos-o em outro objeto com o único um campo cujo nome está no formato "@<type>" e o valor são os dados do objeto. Durante a desserialização, extraímos o nome do tipo, recriamos o objeto do construtor e lemos os dados de seus campos.


Se removermos os links do nosso exemplo acima, o JSON.stringify padrão serializa dados como este:


JSON.stringify
 [ { "id": "d04d6a58-7215-4102-aed0-32122e331cf4", "name": "Start", "links": {} }, { "id": "5c58c3fc-8ce1-45a5-9e44-90d5cebe11d3", "name": "Command", "links": {}, "command": " A, B" }, ... } 

E o nosso serializador o envolverá assim:


Resultado de serialização
 [ { "@Schema.Start": { "id": "d04d6a58-7215-4102-aed0-32122e331cf4", "name": "Start", "links": {} } }, { "@Schema.Command": { "id": "5c58c3fc-8ce1-45a5-9e44-90d5cebe11d3", "name": "Command", "links": {}, "command": " A, B" } }, ... } 

Obviamente, há uma desvantagem: o serializador deve conhecer os tipos que ele pode serializar, e os próprios objetos não devem conter campos cujo nome começa com um cachorro. No entanto, o segundo problema é resolvido por acordo com os desenvolvedores ou substituindo o símbolo do cão por outra coisa, e o primeiro problema é resolvido em uma linha de código (abaixo será um exemplo). Sabemos exatamente o que serializaremos, certo?


Resolvendo o problema do link


Ainda é mais simples em termos de algoritmo, mas mais difícil de implementar.


Ao serializar instâncias de classes registradas no serializador, as armazenaremos no cache e atribuiremos um número de série. Se no futuro encontrarmos essa instância novamente, na primeira definição, adicionaremos esse número (o nome do campo assumirá o formato "@<type>|<index>" ) e, no local da serialização, inseriremos o link na forma de um objeto


  { "@<type>": <index> } 

Assim, durante a desserialização, observamos qual é exatamente o valor do campo. Se este for um número, extraímos o objeto do cache por esse número. Caso contrário, esta é sua primeira definição.


Vamos retornar o link da primeira parte superior do esquema para a segunda e ver o resultado:


Resultado de serialização
 [ { "@Schema.Start": { "id": "a26a3a29-9462-4c92-8d24-6a93dd5c819a", "name": "Start", "links": { "25fa2c44-0446-4471-a013-8b24ffb33bac": { "@Schema.Link": { "id": "25fa2c44-0446-4471-a013-8b24ffb33bac", "target": { "@Schema.Command|1": { "id": "4f4f5521-a2ee-4576-8aec-f61a08ed38dc", "name": "Command", "links": {}, "command": " A, B" } } } } } } }, { "@Schema.Command": 1 }, ... } 

Não parece muito claro à primeira vista, porque o segundo vértice é definido primeiro dentro do primeiro no objeto Link, mas é importante que essa abordagem funcione. Além disso, criei a segunda versão do serializador, que ignora a árvore não em profundidade, mas em largura, o que evita essas "escadas".


Criar serializador


Esta seção é direcionada para aqueles que estão interessados ​​em implementar as idéias descritas acima.


Serializador em branco


Como qualquer outro, nosso serializador terá dois métodos principais - serializar e desserializar. Além disso, precisaremos de um método que informe ao serializador sobre as classes que ele deve serializar (registrar) e classes que não devem (ignorar). O último é necessário para não serializar elementos DOM, objetos JQuery ou quaisquer outros tipos de dados que não possam ser serializados ou que não precisam ser serializados. Por exemplo, no meu editor, guardo um elemento visual correspondente a um vértice ou link. Ele é criado durante a inicialização e, é claro, não deve cair no banco de dados.


Código do shell do serializador
 /** *  */ export default class Serializer { /** *  */ constructor() { this._nameToCtor = []; //    this._ctorToName = []; //    this._ignore = [Element]; //    } /** *   * @param {string} alias  * @param {Function} ctor  */ register(alias, ctor) { if (typeof ctor === 'undefined' && typeof alias === 'function') { //    -  ctor = alias; alias = ctor.name; } this._nameToCtor[alias] = ctor; this._ctorToName[ctor] = alias; } /** *     * @param {Function} ctor  */ ignore(ctor) { if (this._ignore.indexOf(ctor) < 0) { this._ignore.push(ctor); } } /** *   * @param {any} val  * @param {Function} [replacer]       * @param {string} [space]   * @returns {string}  */ serialize(val, replacer, space) { return JSON.stringify(new SerializationContext(this).serialize(val), replacer, space); } /** *     json * @param {any} val    json * @returns {any}  */ deserialize(val) { //     if (isString(val)) val = JSON.parse(val); return new DeserializationContext(this).deserialize(val); } } 

Explicações


Para registrar uma classe, você deve passar seu construtor para o método register de uma das duas maneiras:


  1. registrar (MyClass)
  2. register ('MyNamespace.MyClass', MyClass)

No primeiro caso, o nome da classe será extraído do nome da função do construtor (não suportado no IE); no segundo, você mesmo especificará o nome. O segundo método é preferível, porque permite que você use espaços para nome, e o primeiro, por design, foi projetado para registrar tipos Javascript internos com lógica de serialização redefinida.


Para o nosso exemplo, a inicialização do serializador é a seguinte:


 import Schema from './schema'; ... //   let serializer = new Serializer(); //   Object.keys(Schema).forEach(key => serializer.register(`Schema.${key}`, Schema[key])); 

O objeto Schema contém descrições de todas as classes de vértice, portanto o código de registro de classe se encaixa em uma linha.


O contexto de serialização e desserialização


Você deve ter notado as classes enigmáticas SerializationContext e DeserializationContext. São eles que fazem todo o trabalho e são necessários principalmente para separar os dados de diferentes processos de serialização / desserialização, porque para cada chamada, eles precisam armazenar informações intermediárias - um cache de objetos serializados e um número de série para o link.


SerializationContext


Analisarei em detalhes apenas o serializador recursivo, porque sua contraparte "plana" é um pouco mais complicada e difere apenas em sua abordagem ao processamento de uma árvore de objetos.


Vamos começar com o construtor:


 /** *  * @param {Serializer} ser  */ constructor(ser) { this.__proto__.__proto__ = ser; this.cache = []; //    this.index = 0; //     } 

Vou this.__proto__.__proto__ = ser; explicar a linha misteriosa como this.__proto__.__proto__ = ser;
Na entrada do construtor, aceitamos o objeto do serializador em si, e essa linha herda nossa classe. Isso permite o acesso aos dados do serializador por meio this .
Por exemplo, this._ignore refere-se a uma lista de classes ignoradas do próprio serializador (a "lista negra"), o que é muito útil. Caso contrário, teríamos que escrever algo como this._serializer._ignore .


Principal método de serialização:


 /** *   * @param {any} val  * @returns {string}  */ serialize(val) { if (Array.isArray(val)) { //  return this.serializeArray(val); } else if (isObject(val)) { //  if (this._ignore.some(e => val instanceof e)) { //   return undefined; } else { return this.serializeObject(val); } } else { //   return val; } } 

Note-se que existem três tipos básicos de dados que processamos: matrizes, objetos e valores simples. Se o construtor de um objeto estiver na "lista negra", esse objeto não será serializado.


Serialização de matriz:


 /** *   * @param {Array} val  * @returns {Array}  */ serializeArray(val) { let res = []; for (let item of val) { let e = this.serialize(item); if (typeof e !== 'undefined') res.push(e); } return res; } 

Você pode escrever mais curto via mapa, mas isso não é crítico. Apenas uma coisa é importante - verificar o valor para indefinido. Se houver uma classe não serializável na matriz, sem essa verificação, ela cairá na matriz como indefinida, o que não é muito bom. Também na minha implementação, matrizes são serializadas sem chaves. Teoricamente, você pode refinar o algoritmo para serializar matrizes associativas, mas para esses fins, prefiro usar objetos. Além disso, o JSON.stringify também não gosta de matrizes associativas.


Serialização de objetos:


Código
 /** *   * @param {Object} val  * @returns {Object}  */ serializeObject(val) { let name = this._ctorToName[val.constructor]; if (name) { //     if (!val.__uuid) val.__uuid = ++uuid; let cached = this.cache[val.__uuid]; if (cached) { //     if (!cached.index) { //     cached.index = ++this.index; let key = Object.keys(cached.ref)[0]; let old = cached.ref[key]; cached.ref[`@${name}|${cached.index}`] = old; delete cached.ref[key]; } //     return { [`@${name}`]: cached.index }; } else { let res; let cached = { ref: { [`@${name}`]: {} } }; this.cache[val.__uuid] = cached; if (typeof val.serialize === 'function') { //     res = val.serialize(); } else { //   res = this.serializeObjectInner(val); } cached.ref[Object.keys(cached.ref)[0]] = res; return cached.ref; } } else { //   return this.serializeObjectInner(val); } } 

Obviamente, esta é a parte mais difícil do serializador, seu coração. Vamos desmontar.


Para começar, verificamos se o construtor da classe está registrado no serializador. Caso contrário, esse é um objeto simples para o qual o método utilitário serializeObjectInner é chamado.


Caso contrário, verificamos se o objeto recebe um identificador único __uuid . Essa é uma variável de contador simples que é comum a todos os serializadores e é usada para manter a referência à instância da classe no cache. Você poderia ficar sem ele e armazenar a instância em si sem uma chave no cache, mas para verificar se o objeto está armazenado no cache, você precisaria passar por todo o cache e aqui é suficiente verificar a chave. Eu acho que isso é mais rápido em termos de implementação interna de objetos nos navegadores. Além disso, intencionalmente não serializo campos começando com dois sublinhados, para que o campo __uuid não caia no json resultante, como outros campos de classe privada. Se isso for inaceitável para sua tarefa, você poderá alterar essa lógica.


Em seguida, pelo valor de __uuid, procuramos um objeto que descreva a instância da classe no cache (em cache ).


Se esse objeto existir, o valor já foi serializado anteriormente. Nesse caso, atribuímos um número de série ao objeto, se isso não tiver sido feito antes:


 if (!cached.index) { //     cached.index = ++this.index; let key = Object.keys(cached.ref)[0]; let old = cached.ref[key]; cached.ref[`@${name}|${cached.index}`] = old; delete cached.ref[key]; } 

O código parece confuso e pode ser simplificado atribuindo um número a todas as classes que serializamos. Mas para depurar e perceber o resultado, é melhor quando o número é atribuído apenas àquelas classes às quais existem links no futuro.


Quando o número é atribuído, retornamos o link de acordo com o algoritmo:


 //     return { [`@${name}`]: cached.index }; 

Se o objeto for serializado pela primeira vez, criamos uma instância de seu cache:


 let res; let cached = { ref: { [`@${name}`]: {} } }; this.cache[val.__uuid] = cached; 

E depois serialize-o:


 if (typeof val.serialize === 'function') { //     res = val.serialize(); } else { //   res = this.serializeObjectInner(val); } cached.ref[Object.keys(cached.ref)[0]] = res; 

Há uma verificação para a implementação da interface de serialização pela classe (que será discutida posteriormente), bem como a construção de Object.keys(cached.ref)[0] . O fato é que cached.ref armazena um link para o objeto wrapper { "@<type>[|<index>]": <> } , mas o nome do campo do objeto é desconhecido para nós, porque ainda não sabemos se o nome conterá o número do objeto (índice). Essa construção simplesmente extrai o primeiro e único campo do objeto.


Finalmente, o método utilitário de serializar objetos internos:


 /** *   * @param {Object} val  * @returns {Object}  */ serializeObjectInner(val) { let res = {}; for (let key of Object.getOwnPropertyNames(val)) { if (!(isString(key) && key.startsWith('__'))) { //  ,      res[key] = this.serialize(val[key]); } } return res; } 

Criamos um novo objeto e copiamos os campos do antigo para ele.


DeserializationContext


O processo de desserialização funciona na ordem inversa e não precisa de comentários especiais.


Código
 /** *   */ class DeserializationContext { /** *  * @param {Serializer} ser  */ constructor(ser) { this.__proto__.__proto__ = ser; this.cache = []; //    } /** *   json * @param {any} val  json * @returns {any}  */ deserialize(val) { if (Array.isArray(val)) { //  return this.deserializeArray(val); } else if (isObject(val)) { //  return this.deserializeObject(val); } else { //   return val; } } /** *   * @param {Object} val  * @returns {Object}  */ deserializeArray(val) { return val.map(item => this.deserialize(item)); } /** *   * @param {Array} val  * @returns {Array}  */ deserializeObject(val) { let res = {}; for (let key of Object.getOwnPropertyNames(val)) { let data = val[key]; if (isString(key) && key.startsWith('@')) { //   if (isInteger(data)) { //  res = this.cache[data]; if (res) { return res; } else { console.error(`     ${data}`); return data; } } else { //   let [name, id] = key.substr(1).split('|'); let ctor = this._nameToCtor[name]; if (ctor) { //     res = new ctor(); //   ,    if (id) this.cache[id] = res; if (typeof res.deserialize === 'function') { //     res.deserialize(data); } else { //    for (let key of Object.getOwnPropertyNames(data)) { res[key] = this.deserialize(data[key]); } } return res; } else { //    console.error(`  "${name}"  .`); return val[key]; } } } else { //   res[key] = this.deserialize(val[key]); } } return res; } } 

Recursos adicionais


Interface de serialização


Não há suporte para interface em Javascript, mas podemos concordar que, se a classe implementar os métodos serialize e desserialize, esses métodos serão usados ​​para serialização / desserialização, respectivamente.


Além disso, o Javascript permite implementar esses métodos para tipos internos, por exemplo, para Data:


Serializar data para o formato ISO
 Date.prototype.serialize = function () { return this.toISOString(); }; Date.prototype.deserialize = function (val) { let date = new Date(val); this.setDate(date.getDate()); this.setTime(date.getTime()); }; 

O principal é lembrar de registrar o tipo de data: serializer.register(Date); .


Resultado:


 { "@Date": "2018-06-02T20:41:06.861Z" } 

A única limitação: o resultado da serialização não deve ser um número inteiro, porque neste caso, será interpretado como uma referência ao objeto.


Da mesma forma, você pode serializar classes simples em strings. Um exemplo de serialização da classe Color, que descreve a cor, para a linha #rrggbb está no github .


Serializador plano


Especialmente para vocês, queridos leitores, escrevi a segunda versão do serializador , que atravessa a árvore de objetos não de forma recursiva em profundidade, mas iterativamente em largura usando uma fila.


Para comparação, darei um exemplo de serialização dos dois primeiros vértices do nosso esquema nos dois casos.


Serializador recursivo (serialização em profundidade)
 [ { "@Schema.Start": { "id": "5ec74f26-9515-4789-b852-12feeb258949", "name": "Start", "links": { "102c3dca-8e08-4389-bc7f-68862f2061ef": { "@Schema.Link": { "id": "102c3dca-8e08-4389-bc7f-68862f2061ef", "target": { "@Schema.Command|1": { "id": "447f6299-4bd4-48e4-b271-016a0d47fc0e", "name": "Command", "links": {}, "command": " A, B" } } } } } } }, { "@Schema.Command": 1 } ] 

Serializador plano (serialização ampla)
 [ { "@Schema.Start": { "id": "1412603f-24c2-4513-836e-f2b0c0392483", "name": "Start", "links": { "b94ac7e5-d75f-44c1-960f-a02f52c994da": { "@Schema.Link": { "id": "b94ac7e5-d75f-44c1-960f-a02f52c994da", "target": { "@Schema.Command": 1 } } } } } }, { "@Schema.Command|1": { "id": "a93e452e-4276-4d6a-86a1-0681226d79f0", "name": "Command", "links": {}, "command": " A, B" } } ] 

Pessoalmente, eu gosto da segunda opção ainda mais do que a primeira, mas deve-se lembrar que, escolhendo uma das opções, você não pode usar a outra. É tudo sobre os links. Observe que no serializador plano, um link para o segundo vértice é anterior à sua descrição.


Prós e contras do serializador


Prós:


  • O código do serializador é bastante simples e compacto (cerca de 300 linhas, metade das quais são comentários).
  • O serializador é fácil de usar e não requer bibliotecas de terceiros.
  • Há suporte interno para a interface de serialização para serialização arbitrária de classes.
  • O resultado é agradavelmente agradável aos olhos (IMHO).
  • Desenvolver um serializador / desserializador semelhante em outros idiomas não é um problema. Isso pode ser necessário se o resultado da serialização for processado na parte traseira.

Contras:


  • O serializador requer o registro de classes que ele pode serializar.
  • Existem pequenas restrições nos nomes de campo dos objetos.
  • O serializador está escrito noob em Javascript, portanto pode conter bugs e erros.
  • O desempenho em grandes quantidades de dados pode sofrer.

Também um sinal de menos é que o código está escrito no ES6. Obviamente, é possível converter para versões anteriores do Javascript, mas não verifiquei a compatibilidade do código resultante com diferentes navegadores.


Minhas outras publicações


  1. Localização de projetos no .NET com um intérprete de função
  2. Preenchendo modelos de texto com dados baseados em modelo. Implementação .NET usando funções dinâmicas de bytecode (IL)

Source: https://habr.com/ru/post/pt413113/


All Articles