Prologo
Actualmente, estoy desarrollando un editor de esquemas Javascript, y en el proceso de este trabajo, encontré un problema en el que este artículo se centrará, a saber, la serialización y deserialización de objetos de datos complejos.
Sin entrar en detalles del proyecto, noto que según mi idea, el esquema es una matriz de elementos (vértices) heredados de la clase base. En consecuencia, cada clase secundaria implementa su propia lógica. Además, los vértices contienen enlaces entre sí (flechas), que también deben conservarse. Teóricamente, los vértices pueden referirse a sí mismos directamente oa través de otros vértices. JSON.stringify estándar no puede serializar dicha matriz, por lo que decidí hacer mi propio serializador que resuelva los dos problemas descritos:
- Posibilidad de guardar información de clase durante la serialización y restaurarla durante la deserialización.
- La capacidad de guardar y restaurar enlaces a objetos, incluidos cíclico
Lea más sobre el enunciado del problema y su solución bajo el corte.
Proyecto serializador de Github
Enlace al proyecto github: enlace .
Ejemplos complejos también se encuentran en la carpeta test-src .
Serializador recursivo: enlace .
Serializador plano: enlace .
Declaración del problema.
Como ya señalé, la tarea inicial es serializar circuitos arbitrarios para el editor. Para no perder el tiempo describiendo el editor, configuramos la tarea más fácilmente. Supongamos que queremos hacer una descripción formal de un esquema de algoritmo simple usando clases ES6 Javascript, y luego serializar y deserializar este esquema.
En Internet, encontré una imagen adecuada del algoritmo más simple para determinar el máximo de dos valores:

Aquí debo decir que no soy un desarrollador de Javascript, y mi lenguaje "nativo" es C #, por lo que el enfoque para resolver el problema está dictado por la experiencia del desarrollo orientado a objetos en C #. Mirando este diagrama, veo los vértices de los siguientes tipos (los nombres condicionales y los roles especiales no juegan):
- Iniciar vértice (Inicio)
- Pico final (acabado)
- Team Top (Comando)
- Vértice de asignación (Let)
- Verificación Verificación Arriba (si)
Estos vértices tienen algunas diferencias entre sí en su conjunto de datos o semántica, pero todos se heredan del vértice base (Nodo). En el mismo lugar, en la clase Node, se describe el campo de enlaces, que contiene enlaces a otros vértices, y el método addLink permite agregar estos enlaces. El código completo de todas las clases se puede encontrar aquí .
Escribamos el código que recoge el circuito de la imagen e intente serializar el resultado.
Código de diseño de algoritmo Si serializamos este esquema usando JSON.stringify, obtenemos algo terrible. Daré las primeras líneas del resultado, en las que agregué mis comentarios:
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 el primer vértice contenía un enlace al segundo, y que a los siguientes, luego, como resultado de su serialización, se serializó todo el circuito. Luego se serializó el segundo pico y todo lo que dependía de él, y así sucesivamente. Puede restaurar los enlaces originales de este hash solo mediante identificadores, pero no ayudarán si alguno de los vértices se refiere a sí mismo directamente oa través de otros vértices. En este caso, el serializador arrojará un Error de tipo no capturado: convertir la estructura circular en un error JSON . Si no está claro, este es el ejemplo más simple que genera este error: https://jsfiddle.net/L4guo86w/ .
Además, JSON no contiene ninguna información sobre las clases de origen, por lo que no hay forma de comprender qué tipo era cada vértice antes de la serialización.
Al darme cuenta de estos problemas, me conecté a Internet y comencé a buscar soluciones listas para usar. Había muchos, pero la mayoría eran muy voluminosos o requerían una descripción especial de las clases serializables, por lo que se decidió hacer su propia bicicleta. Y sí, me encantan las bicicletas.
Concepto de serializador
Esta sección es para aquellos que desean participar en la creación de un algoritmo de serialización conmigo, aunque sea virtualmente.
Uno de los problemas con Javascript es la falta de metadatos que pueden hacer maravillas en lenguajes como C # o Java (atributos y reflexión). Por otro lado, no necesito una serialización súper compleja con la capacidad de definir una lista de campos serializables, validación y otros chips. Por lo tanto, la idea principal es agregar información sobre su tipo al objeto y serializarlo con JSON.stringify ordinario.
Mientras buscaba soluciones, me encontré con un artículo interesante cuyo título se traduce como "6 maneras incorrectas de agregar información de tipo en JSON" . De hecho, los métodos son muy buenos, y elegí el que está en el número 5. Si eres demasiado vago para leer el artículo, pero recomiendo hacerlo, describiré brevemente este método: cuando serializamos un objeto, lo envolvemos en otro objeto con el único un campo cuyo nombre tiene el formato "@<type>"
y el valor son los datos del objeto. Durante la deserialización, extraemos el nombre del tipo, recreamos el objeto del constructor y leemos los datos de sus campos.
Si eliminamos enlaces de nuestro ejemplo anterior, entonces JSON.stringify estándar serializa datos 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" }, ... }
Y nuestro serializador lo envolverá así:
Resultado de serialización [ { "@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" } }, ... }
Por supuesto, hay un inconveniente: el serializador debe conocer los tipos que puede serializar, y los objetos en sí no deben contener campos cuyo nombre comience con un perro. Sin embargo, el segundo problema se resuelve mediante un acuerdo con los desarrolladores o reemplazando el símbolo del perro con otra cosa, y el primer problema se resuelve en una línea de código (a continuación se mostrará un ejemplo). Sabemos exactamente lo que serializaremos, ¿verdad?
Resolviendo el problema del enlace
Todavía es más simple en términos de algoritmo, pero más difícil de implementar.
Al serializar instancias de clases registradas en el serializador, las almacenaremos en el caché y les asignaremos un número de serie. Si en el futuro volvemos a encontrarnos con esta instancia, en la primera definición agregaremos este número (el nombre del campo tomará la forma "@<type>|<index>"
), y en el lugar de la serialización insertaremos el enlace en forma de un objeto
{ "@<type>": <index> }
Por lo tanto, durante la deserialización, observamos cuál es exactamente el valor del campo. Si este es un número, entonces extraemos el objeto del caché por este número. De lo contrario, esta es su primera definición.
Regresemos el enlace de la primera parte superior del esquema a la segunda y veamos el resultado:
Resultado de serialización [ { "@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 }, ... }
A primera vista no parece muy claro, porque el segundo vértice se define por primera vez dentro del primero en el objeto de comunicación Enlace, pero es importante que este enfoque funcione. Además, creé la segunda versión del serializador, que evita el árbol no en profundidad, sino en ancho, lo que evita tales "escaleras".
Crear serializador
Esta sección está destinada a aquellos que estén interesados en implementar las ideas descritas anteriormente.
Serializador en blanco
Como cualquier otro, nuestro serializador tendrá dos métodos principales: serializar y deserializar. Además, necesitaremos un método que le informe al serializador sobre las clases que debe serializar (registrar) y las clases que no deben (ignorar). Esto último es necesario para no serializar elementos DOM, objetos JQuery o cualquier otro tipo de datos que no puedan ser serializados o que no sean necesarios para ser serializados. Por ejemplo, en mi editor almaceno un elemento visual correspondiente a un vértice o enlace. Se crea durante la inicialización y, por supuesto, no debe caer en la base de datos.
Código de shell del serializador export default class Serializer { constructor() { this._nameToCtor = [];
Explicaciones
Para registrar una clase, debe pasar su constructor al método de registro de una de dos maneras:
- registrarse (MyClass)
- registrarse ('MyNamespace.MyClass', MyClass)
En el primer caso, el nombre de la clase se extraerá del nombre de la función constructora (no es compatible con IE), en el segundo, usted mismo especifica el nombre. El segundo método es preferible, porque le permite usar espacios de nombres, y el primero, por diseño, está diseñado para registrar tipos de Javascript integrados con lógica de serialización redefinida.
Para nuestro ejemplo, la inicialización del serializador es la siguiente:
import Schema from './schema'; ...
El objeto Schema contiene descripciones de todas las clases de vértices, por lo que el código de registro de clase cabe en una línea.
El contexto de serialización y deserialización.
Es posible que haya notado las clases crípticas SerializationContext y DeserializationContext. Son ellos quienes hacen todo el trabajo, y son necesarios principalmente para separar los datos de diferentes procesos de serialización / deserialización, porque para cada llamada necesitan almacenar información intermedia: un caché de objetos serializados y un número de serie para el enlace.
SerializationContext
Analizaré en detalle solo el serializador recursivo, porque su contraparte "plana" es algo más complicada y solo difiere en su enfoque para procesar un árbol de objetos.
Comencemos con el constructor:
constructor(ser) { this.__proto__.__proto__ = ser; this.cache = [];
this.__proto__.__proto__ = ser;
de explicar la línea misteriosa this.__proto__.__proto__ = ser;
En la entrada del constructor, aceptamos el objeto del serializador en sí, y esta línea hereda nuestra clase de él. Esto permite el acceso a los datos del serializador a través de this
.
Por ejemplo, this._ignore
refiere a una lista de clases ignoradas del serializador (la "lista negra"), que es muy útil. De lo contrario, tendríamos que escribir algo como this._serializer._ignore
.
Método de serialización principal:
serialize(val) { if (Array.isArray(val)) {
Cabe señalar que hay tres tipos básicos de datos que procesamos: matrices, objetos y valores simples. Si el constructor de un objeto está en la "lista negra", entonces este objeto no está serializado.
Serialización de matriz:
serializeArray(val) { let res = []; for (let item of val) { let e = this.serialize(item); if (typeof e !== 'undefined') res.push(e); } return res; }
Puede escribir más corto a través del mapa, pero esto no es crítico. Solo una cosa es importante: verificar el valor de indefinido. Si hay una clase no serializable en la matriz, entonces, sin esta verificación, caerá en la matriz como indefinida, lo que no es muy bueno. También en mi implementación, las matrices se serializan sin claves. Teóricamente, puede refinar el algoritmo para serializar matrices asociativas, pero para estos propósitos prefiero usar objetos. Además, a JSON.stringify tampoco le gustan las matrices asociativas.
Serialización de objetos:
Código serializeObject(val) { let name = this._ctorToName[val.constructor]; if (name) {
Obviamente, esta es la parte más difícil del serializador, su corazón. Vamos a desarmarlo.
Para comenzar, verificamos si el constructor de la clase está registrado en el serializador. Si no, entonces este es un objeto simple para el cual se llama al método de utilidad serializeObjectInner
.
De lo contrario, verificamos si al objeto se le asigna un identificador único __uuid . Esta es una variable de contador simple que es común a todos los serializadores y se utiliza para mantener la referencia a la instancia de clase en la memoria caché. Podría prescindir de él y almacenar la instancia en sí sin una clave en el caché, pero luego para verificar si el objeto está almacenado en el caché, tendría que revisar todo el caché, y aquí es suficiente para verificar la clave. Creo que esto es más rápido en términos de implementación interna de objetos en los navegadores. Además, intencionalmente no serializo campos que comienzan con dos guiones bajos, por lo que el campo __uuid no caerá en el json resultante, como otros campos de clase privada. Si esto es inaceptable para su tarea, puede cambiar esta lógica.
Luego, por el valor de __uuid, buscamos un objeto que describa la instancia de la clase en el caché (en caché ).
Si tal objeto existe, entonces el valor ya se ha serializado anteriormente. En este caso, asignamos un número de serie al objeto, si esto no se ha hecho antes:
if (!cached.index) {
El código parece confuso y se puede simplificar asignando un número a todas las clases que serializamos. Pero para depurar y percibir el resultado, es mejor cuando el número se asigna solo a aquellas clases a las que hay enlaces en el futuro.
Cuando se asigna el número, devolvemos el enlace de acuerdo con el algoritmo:
Si el objeto se serializa por primera vez, creamos una instancia de su caché:
let res; let cached = { ref: { [`@${name}`]: {} } }; this.cache[val.__uuid] = cached;
Y luego serializarlo:
if (typeof val.serialize === 'function') {
Hay una verificación para la implementación de la interfaz de serialización por parte de la clase (que se discutirá más adelante), así como la construcción de Object.keys(cached.ref)[0]
. El hecho es que cached.ref almacena un enlace al objeto contenedor { "@<type>[|<index>]": <> }
, pero el nombre del campo de objeto es desconocido para nosotros, porque En esta etapa, aún no sabemos si el nombre contendrá el número de objeto (índice). Esta construcción simplemente extrae el primer y único campo del objeto.
Finalmente, el método de utilidad de serializar objetos internos:
serializeObjectInner(val) { let res = {}; for (let key of Object.getOwnPropertyNames(val)) { if (!(isString(key) && key.startsWith('__'))) {
Creamos un nuevo objeto y copiamos los campos del antiguo en él.
Deserialización Contexto
El proceso de deserialización funciona en orden inverso y no necesita comentarios especiales.
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; } }
Características adicionales
Interfaz de serialización
No hay soporte de interfaz en Javascript, pero podemos estar de acuerdo en que si la clase implementa los métodos de serialización y deserialización, estos métodos se utilizarán para la serialización / deserialización, respectivamente.
Además, Javascript le permite implementar estos métodos para los tipos integrados, por ejemplo, para Fecha:
Fecha de serialización al 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()); };
Lo principal es recordar registrar el tipo de fecha: serializer.register(Date);
.
Resultado:
{ "@Date": "2018-06-02T20:41:06.861Z" }
La única limitación: el resultado de la serialización no debe ser un número entero, porque en este caso, se interpretará como una referencia al objeto.
Del mismo modo, puede serializar clases simples en cadenas. Un ejemplo de serialización de la clase Color, que describe el color, a la línea #rrggbb
está en github .
Serializador plano
Especialmente para ustedes, queridos lectores, escribí la segunda versión del serializador , que atraviesa el árbol de objetos no recursivamente en profundidad, sino iterativamente en ancho usando una cola.
A modo de comparación, daré un ejemplo de serialización de los dos primeros vértices de nuestro esquema en ambos casos.
Serializador recursivo (serialización en profundidad) [ { "@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 (serialización amplia) [ { "@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" } } ]
Personalmente, me gusta la segunda opción incluso más que la primera, pero debe recordarse que al elegir una de las opciones, no se puede usar la otra. Se trata de los enlaces. Tenga en cuenta que en el serializador plano, un enlace al segundo vértice va antes de su descripción.
Pros y contras del serializador
Pros:
- El código del serializador es bastante simple y compacto (aproximadamente 300 líneas, la mitad de las cuales son comentarios).
- El serializador es fácil de usar y no requiere bibliotecas de terceros.
- Hay soporte incorporado para la interfaz de serialización para la serialización arbitraria de clases.
- El resultado es agradablemente agradable a la vista (en mi humilde opinión).
- Desarrollar un serializador / deserializador similar en otros idiomas no es un problema. Esto puede ser necesario si el resultado de la serialización se procesa en la parte posterior.
Contras:
- El serializador requiere el registro de clases que puede serializar.
- Existen ligeras restricciones en los nombres de campo de los objetos.
- El serializador está escrito noob en Javascript, por lo que puede contener errores y errores.
- El rendimiento en grandes cantidades de datos puede verse afectado.
También una desventaja es que el código está escrito en ES6. Por supuesto, es posible convertir a versiones anteriores de Javascript, pero no verifiqué la compatibilidad del código resultante con diferentes navegadores.
Mis otras publicaciones
- Localización de proyectos en .NET con un intérprete de funciones.
- Relleno de plantillas de texto con datos basados en modelos. Implementación de .NET usando funciones de bytecode dinámico (IL)