Compatibilidad con relaciones bidireccionales en JSON
Publicado: 2022-03-11¿Alguna vez intentó crear una estructura de datos JSON que incluye entidades que tienen una relación bidireccional (es decir, referencia circular)? Si es así, es probable que haya visto un error de JavaScript similar a "Error de tipo no detectado: conversión de estructura circular a JSON" . O si es un desarrollador de Java que usa la biblioteca Jackson, es posible que haya encontrado "No se pudo escribir JSON: repetición infinita (StackOverflowError) con la causa raíz java.lang.StackOverflowError" .
Este artículo proporciona un enfoque de trabajo sólido para crear estructuras JSON que incluyan una relación bidireccional sin que se produzcan estos errores.
A menudo, las soluciones que se presentan a este problema implican alternativas que básicamente eluden el problema, pero en realidad no lo abordan. Los ejemplos incluyen el uso de tipos de anotación de Jackson como @JsonManagedReference y @JsonBackReference (que simplemente omite la referencia anterior de la serialización) o el uso de @JsonIgnore para simplemente ignorar uno de los lados de la relación. Alternativamente, se puede desarrollar un código de serialización personalizado que ignore cualquier relación bidireccional o dependencia circular en los datos.
Pero no queremos ignorar u omitir ningún lado de la relación bidireccional. Queremos preservarlo, en ambas direcciones, sin generar ningún error. Una solución real debería permitir dependencias circulares en JSON y permitir que el desarrollador deje de pensar en ellas sin tomar medidas adicionales para solucionarlas. Este artículo proporciona una técnica práctica y sencilla para hacerlo, que puede servir como una adición útil a cualquier conjunto estándar de consejos y prácticas para el desarrollador front-end de hoy.
Un ejemplo de relación bidireccional simple
Un caso común en el que surge este problema de relación bidireccional (también conocida como dependencia circular) es cuando hay un objeto principal que tiene elementos secundarios (a los que hace referencia) y esos objetos secundarios, a su vez, desean mantener referencias a su elemento principal. Aquí hay un ejemplo simple:
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ] Si intenta convertir el objeto principal anterior a JSON (por ejemplo, utilizando el método stringify , como en var parentJson = JSON.stringify(parent); parent , se lanzará la excepción Uncaught TypeError: Converting circular structure to JSON .
Si bien podríamos usar una de las técnicas discutidas anteriormente (como usar anotaciones como @JsonIgnore ), o simplemente podríamos eliminar las referencias anteriores al padre de los hijos, estas son formas de evitar en lugar de resolver el problema. Lo que realmente queremos es una estructura JSON resultante que mantenga cada relación bidireccional y que podamos convertir a JSON sin generar ninguna excepción.
Avanzando hacia una solución
Un paso potencialmente obvio hacia una solución es agregar alguna forma de ID de objeto a cada objeto y luego reemplazar las referencias de los hijos al objeto principal con referencias a la ID del objeto principal. Por ejemplo:
var obj = { "id": 100, "name": "I'm parent" } obj.children = [ { "id": 101, "name": "I'm first child", "parent": 100 }, { "id": 102, "name": "I'm second child", "parent": 100 } ]Este enfoque ciertamente evitará cualquier excepción que resulte de una relación bidireccional o una referencia circular. Pero todavía hay un problema, y ese problema se hace evidente cuando pensamos en cómo haríamos para serializar y deserializar estas referencias.
El problema es que necesitaríamos saber, utilizando el ejemplo anterior, que cada referencia al valor "100" se refiere al objeto principal (ya que ese es su id ). Eso funcionará bien en el ejemplo anterior donde la única propiedad que tiene el valor "100" es la parent . Pero, ¿y si añadimos otra propiedad con el valor “100”? Por ejemplo:
obj.children = [ { "id": 101, "name": "I'm first child", "priority": 100, // This is NOT referencing object ID "100" "parent": 100 // This IS referencing object ID "100" }, { "id": 102, "name": "I'm second child", "priority": 200, "parent": 100 } ] Si asumimos que cualquier referencia al valor "100" hace referencia a un objeto, no habrá forma de que nuestro código de serialización/deserialización sepa que cuando el parent hace referencia al valor "100", eso ESTÁ haciendo referencia a la id del objeto principal, pero cuando la priority hace referencia al valor "100", que NO hace referencia a la id del objeto principal (y dado que pensará que la priority también hace referencia a la id del objeto principal, reemplazará incorrectamente su valor con una referencia al objeto principal).
Puede preguntar en este punto: “Espera, te estás perdiendo una solución obvia. En lugar de usar el valor de la propiedad para determinar que hace referencia a una identificación de objeto, ¿por qué no usa simplemente el nombre de la propiedad? De hecho, esa es una opción, pero muy limitante. Significará que tendremos que predesignar una lista de nombres de propiedad "reservados" que siempre se supone que hacen referencia a otros objetos (nombres como "padre", "hijo", "siguiente", etc.). Esto significará que solo esos nombres de propiedad se pueden usar para hacer referencia a otros objetos y también significará que esos nombres de propiedad siempre se tratarán como referencias a otros objetos. Por lo tanto, esta no es una alternativa viable en la mayoría de las situaciones.
Por lo tanto, parece que debemos seguir reconociendo los valores de propiedad como referencias de objetos. Pero esto significa que necesitaremos que se garantice que estos valores sean únicos de todos los demás valores de propiedad. Podemos abordar la necesidad de valores únicos mediante el uso de identificadores únicos globales (GUID). Por ejemplo:
var obj = { "id": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc", "name": "I'm parent" } obj.children = [ { "id": "6616c598-0a0a-8263-7a56-fb0c0e16225a", "name": "I'm first child", "priority": 100, "parent": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc" // matches unique parent id }, { "id": "940e60e4-9497-7c0d-3467-297ff8bb9ef2", "name": "I'm second child", "priority": 200, "parent": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc" // matches unique parent id } ]Así que eso debería funcionar, ¿verdad?
Si.
Pero…
Una solución completamente automatizada
Recuerda nuestro desafío original. Queríamos poder serializar y deserializar objetos que tienen una relación bidireccional hacia/desde JSON sin generar excepciones. Si bien la solución anterior logra esto, lo hace requiriendo que (a) agreguemos alguna forma de campo de ID único a cada objeto y (b) reemplacemos cada referencia de objeto con el ID único correspondiente. Esto funcionará, pero preferimos una solución que simplemente funcione automáticamente con nuestras referencias de objetos existentes sin que tengamos que modificar "manualmente" nuestros objetos de esta manera.
Idealmente, queremos poder pasar un conjunto de objetos (que contengan cualquier conjunto arbitrario de propiedades y referencias de objetos) a través del serializador y el deserializador (sin generar ninguna excepción basada en una relación bidireccional) y que los objetos generados por el deserializador coincidan con precisión. los objetos que se alimentaron en el serializador.
Nuestro enfoque es hacer que nuestro serializador cree y agregue automáticamente una identificación única (usando un GUID) para cada objeto. Luego reemplaza cualquier referencia de objeto con el GUID de ese objeto. (Tenga en cuenta que el serializador también deberá usar algún nombre de propiedad único para estos ID; en nuestro ejemplo, usamos @id ya que presumiblemente anteponer la "@" al nombre de la propiedad es adecuado para garantizar que sea único). El deserializador luego reemplazará cualquier GUID que corresponda a un Id. de objeto con una referencia a ese objeto (tenga en cuenta que el deserializador también eliminará los GUID generados por el serializador de los objetos deserializados, devolviéndolos precisamente a su estado inicial).
Entonces, volviendo a nuestro ejemplo, queremos alimentar el siguiente conjunto de objetos a nuestro serializador:
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]Entonces esperaríamos que el serializador generara una estructura JSON similar a la siguiente:

{ "@id": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc", "name": "I'm parent", "children": [ { "@id": "6616c598-0a0a-8263-7a56-fb0c0e16225a", "name": "I'm first child", "parent": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc" }, { "@id": "940e60e4-9497-7c0d-3467-297ff8bb9ef2", "name": "I'm second child", "parent": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc" }, ] }(Puede usar una herramienta de formateo JSON para embellecer cualquier objeto JSON).
Luego, alimentar el JSON anterior al deserializador generaría el conjunto original de objetos (es decir, el objeto principal y sus dos elementos secundarios, haciendo referencia entre sí correctamente).
Así que ahora que sabemos lo que queremos hacer y cómo queremos hacerlo, vamos a implementarlo.
Implementando el Serializador en JavaScript
A continuación se muestra una implementación de JavaScript funcional de muestra de un serializador que manejará correctamente una relación bidireccional sin generar ninguna excepción.
var convertToJson = function(obj) { // Generate a random value structured as a GUID var guid = function() { function s4() { return Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); } return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); }; // Check if a value is an object var isObject = function(value) { return (typeof value === 'object'); } // Check if an object is an array var isArray = function(obj) { return (Object.prototype.toString.call(obj) === '[object Array]'); } var convertToJsonHelper = function(obj, key, objects) { // Initialize objects array and // put root object into if it exist if(!objects) { objects = []; if (isObject(obj) && (! isArray(obj))) { obj[key] = guid(); objects.push(obj); } } for (var i in obj) { // Skip methods if (!obj.hasOwnProperty(i)) { continue; } if (isObject(obj[i])) { var objIndex = objects.indexOf(obj[i]); if(objIndex === -1) { // Object has not been processed; generate key and continue // (but don't generate key for arrays!) if(! isArray(obj)) { obj[i][key] = guid(); objects.push(obj[i]); } // Process child properties // (note well: recursive call) convertToJsonHelper(obj[i], key, objects); } else { // Current object has already been processed; // replace it with existing reference obj[i] = objects[objIndex][key]; } } } return obj; } // As discussed above, the serializer needs to use some unique property name for // the IDs it generates. Here we use "@id" since presumably prepending the "@" to // the property name is adequate to ensure that it is unique. But any unique // property name can be used, as long as the same one is used by the serializer // and deserializer. // // Also note that we leave off the 3rd parameter in our call to // convertToJsonHelper since it will be initialized within that function if it // is not provided. return convertToJsonHelper(obj, "@id"); }Implementando el deserializador en JavaScript
A continuación se muestra una implementación de JavaScript funcional de muestra de un deserializador que manejará correctamente una relación bidireccional sin generar ninguna excepción.
var convertToObject = function(json) { // Check if an object is an array var isObject = function(value) { return (typeof value === 'object'); } // Iterate object properties and store all reference keys and references var getKeys = function(obj, key) { var keys = []; for (var i in obj) { // Skip methods if (!obj.hasOwnProperty(i)) { continue; } if (isObject(obj[i])) { keys = keys.concat(getKeys(obj[i], key)); } else if (i === key) { keys.push( { key: obj[key], obj: obj } ); } } return keys; }; var convertToObjectHelper = function(json, key, keys) { // Store all reference keys and references to object map if(!keys) { keys = getKeys(json, key); var convertedKeys = {}; for(var i = 0; i < keys.length; i++) { convertedKeys[keys[i].key] = keys[i].obj; } keys = convertedKeys; } var obj = json; // Iterate all object properties and object children // recursively and replace references with real objects for (var j in obj) { // Skip methods if (!obj.hasOwnProperty(j)) { continue; } if (isObject(obj[j])) { // Property is an object, so process its children // (note well: recursive call) convertToObjectHelper(obj[j], key, keys); } else if( j === key) { // Remove reference id delete obj[j]; } else if (keys[obj[j]]) { // Replace reference with real object obj[j] = keys[obj[j]]; } } return obj; }; // As discussed above, the serializer needs to use some unique property name for // the IDs it generates. Here we use "@id" since presumably prepending the "@" to // the property name is adequate to ensure that it is unique. But any unique // property name can be used, as long as the same one is used by the serializer // and deserializer. // // Also note that we leave off the 3rd parameter in our call to // convertToObjectHelper since it will be initialized within that function if it // is not provided. return convertToObjectHelper(json, "@id"); } Pasar un conjunto de objetos (incluidos aquellos que tienen una relación bidireccional) a través de estos dos métodos es esencialmente una función de identidad; es decir, convertToObject(convertToJson(obj)) === obj se evalúa como verdadero.
Ejemplo de Java/Jackson
Ahora veamos cómo este enfoque es compatible con bibliotecas externas populares. Por ejemplo, veamos cómo se maneja en Java usando la biblioteca Jackson.
@JsonIdentityInfo(generator=ObjectIdGenerators.UUIDGenerator.class, property="@id") public class Parent implements Serializable { private String name; private List<Child> children = new ArrayList<>(); public String getName() { return name; } public void setName(String name) { this.name = name; } public List<Child> getChildren() { return children; } public void setChildren(List<Child> children) { this.children = children; } } @JsonIdentityInfo(generator=ObjectIdGenerators.UUIDGenerator.class, property="@id") public class Child implements Serializable { private String name; private Parent parent; public String getName() { return name; } public void setName(String name) { this.name = name; } public Parent getParent() { return parent; } public void setParent(Parent parent) { this.parent = parent; } } Estas dos clases de Java, padre e hijo , representan la misma estructura que en el ejemplo de JavaScript al principio de este artículo. El punto principal aquí es usar la anotación @JsonIdentityInfo que le indicará a Jackson cómo serializar/deserializar estos objetos.
Veamos un ejemplo:
Parent parent = new Parent(); parent.setName("I'm parent") Child child1 = new Child(); child1.setName("I'm first child"); Child child2 = new Child(); child2.setName("I'm second child"); parent.setChildren(Arrays.asList(child1, child2));Como resultado de serializar la instancia principal en JSON, se devolverá la misma estructura JSON que en el ejemplo de JavaScript.
{ "@id": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc", "name": "I'm parent", "children": [ { "@id": "6616c598-0a0a-8263-7a56-fb0c0e16225a", "name": "I'm first child", "parent": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc" }, { "@id": "940e60e4-9497-7c0d-3467-297ff8bb9ef2", "name": "I'm second child", "parent": "28dddab1-4aa7-6e2b-b0b2-7ed9096aa9bc" }, ] }Otra ventaja
El enfoque descrito para manejar una relación bidireccional en JSON también se puede aprovechar para ayudar a reducir el tamaño de un archivo JSON, ya que le permite hacer referencia a objetos simplemente por su ID único, en lugar de tener que incluir copias redundantes del mismo objeto.
Considere el siguiente ejemplo:
{ "@id": "44f47be7-af77-9a5a-8606-a1e6df299ec9", "id": 1, "name": "I'm parent", "children": [ { "@id": "54f47be7-af77-9a5a-8606-a1e6df299eu8", "id": 10, "name": "I'm first child", "parent": "44f47be7-af77-9a5a-8606-a1e6df299ec9" }, { "@id": "98c47be7-af77-9a5a-8606-a1e6df299c7a", "id": 11, "name": "I'm second child", "parent": "44f47be7-af77-9a5a-8606-a1e6df299ec9" }, { "@id": "5jo47be7-af77-9a5a-8606-a1e6df2994g2", "id": 11, "name": "I'm third child", "parent": "44f47be7-af77-9a5a-8606-a1e6df299ec9" } ], "filteredChildren": [ "54f47be7-af77-9a5a-8606-a1e6df299eu8", "5jo47be7-af77-9a5a-8606-a1e6df2994g2" ] } Como se muestra en la matriz filteredChildren , podemos simplemente incluir referencias de objetos en nuestro JSON en lugar de réplicas de los objetos a los que se hace referencia y su contenido.
Envolver
Con esta solución, puede eliminar las excepciones relacionadas con la referencia circular mientras serializa los archivos JSON de una manera que minimiza cualquier restricción en sus objetos y datos. Si dicha solución ya no está disponible en las bibliotecas que usa para manejar la serialización de archivos JSON, puede implementar su propia solución según la implementación de ejemplo proporcionada. Espero que encuentres esto útil.
