Suporte de relacionamento bidirecional em JSON
Publicados: 2022-03-11Já tentou criar uma estrutura de dados JSON que inclua entidades que tenham um relacionamento bidirecional (ou seja, referência circular)? Se sim, você provavelmente já viu um erro de JavaScript ao longo das linhas de “Uncaught TypeError: Converting circular structure to JSON” . Ou se você é um desenvolvedor Java que usa a biblioteca Jackson, pode ter encontrado “Could not write JSON: Infinite recursion (StackOverflowError) with root cause java.lang.StackOverflowError” .
Este artigo fornece uma abordagem de trabalho robusta para criar estruturas JSON que incluem um relacionamento bidirecional sem resultar nesses erros.
Muitas vezes, as soluções apresentadas para esse problema envolvem soluções alternativas que basicamente evitam, mas não tratam realmente do problema. Os exemplos incluem o uso de tipos de anotação de Jackson como @JsonManagedReference
e @JsonBackReference
(que simplesmente omite a referência inversa da serialização) ou o uso de @JsonIgnore
para simplesmente ignorar um dos lados do relacionamento. Como alternativa, pode-se desenvolver um código de serialização personalizado que ignore qualquer relacionamento bidirecional ou dependência circular nos dados.
Mas não queremos ignorar ou omitir nenhum dos lados do relacionamento bidirecional. Queremos preservá-lo, em ambas as direções, sem gerar erros. Uma solução real deve permitir dependências circulares em JSON e permitir que o desenvolvedor pare de pensar nelas sem realizar ações adicionais para corrigi-las. Este artigo fornece uma técnica prática e direta para fazer isso, que pode servir como um complemento útil para qualquer conjunto padrão de dicas e práticas para o desenvolvedor front-end de hoje.
Um exemplo simples de relacionamento bidirecional
Um caso comum em que esse problema de relacionamento bidirecional (também conhecido como dependência circular) surge é quando há um objeto pai que tem filhos (aos quais ele faz referência) e esses objetos filho, por sua vez, desejam manter referências ao pai. Aqui está um exemplo simples:
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]
Se você tentar converter o objeto parent
acima em JSON (por exemplo, usando o método stringify
, como em var parentJson = JSON.stringify(parent);
), a exceção Uncaught TypeError: Converting circular structure to JSON será lançada.
Embora pudéssemos usar uma das técnicas discutidas acima (como usar anotações como @JsonIgnore
), ou poderíamos simplesmente remover as referências acima ao pai dos filhos, essas são maneiras de evitar em vez de resolver o problema. O que realmente queremos é uma estrutura JSON resultante que mantenha cada relacionamento bidirecional e que possamos converter em JSON sem lançar nenhuma exceção.
Caminhando em direção a uma solução
Uma etapa potencialmente óbvia em direção a uma solução é adicionar alguma forma de ID de objeto a cada objeto e, em seguida, substituir as referências dos filhos ao objeto pai por referências ao id do objeto pai. Por exemplo:
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 } ]
Essa abordagem certamente evitará quaisquer exceções que resultem de um relacionamento bidirecional ou referência circular. Mas ainda há um problema, e esse problema se torna aparente quando pensamos em como faríamos para serializar e desserializar essas referências.
A questão é que precisaríamos saber, usando o exemplo acima, que toda referência ao valor “100” se refere ao objeto pai (já que esse é o seu id
). Isso funcionará bem no exemplo acima, onde a única propriedade que tem o valor “100” é a propriedade parent
. Mas e se adicionarmos outra propriedade com o valor “100”? Por exemplo:
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 } ]
Se assumirmos que qualquer referência ao valor “100” está referenciando um objeto, não haverá como nosso código de serialização/desserialização saber que quando o parent
referencia o valor “100”, isso está referenciando o id
do objeto pai, mas quando priority
referencia o valor “100”, que NÃO está referenciando o id
do objeto pai (e como ele pensará que priority
também está referenciando o id
do objeto pai, ele substituirá incorretamente o seu valor por uma referência ao objeto pai).
Você pode perguntar neste momento: “Espere, você está perdendo uma solução óbvia. Em vez de usar o valor da propriedade para determinar que está referenciando um id de objeto, por que você não usa apenas o nome da propriedade?” Na verdade, essa é uma opção, mas muito limitante. Isso significa que precisaremos pré-designar uma lista de nomes de propriedades “reservados” que sempre são considerados como referência a outros objetos (nomes como “pai”, “filho”, “próximo”, etc.). Isso significa que apenas esses nomes de propriedade podem ser usados para referências a outros objetos e também significa que esses nomes de propriedade sempre serão tratados como referências a outros objetos. Esta não é, portanto, uma alternativa viável na maioria das situações.
Portanto, parece que precisamos continuar reconhecendo valores de propriedade como referências de objeto. Mas isso significa que precisaremos garantir que esses valores sejam exclusivos de todos os outros valores de propriedade. Podemos abordar a necessidade de valores exclusivos usando identificadores exclusivos globalmente (GUIDs). Por exemplo:
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 } ]
Então isso deve funcionar, certo?
sim.
Mas…
Uma solução totalmente automatizada
Lembre-se do nosso desafio original. Queríamos poder serializar e desserializar objetos que têm um relacionamento bidirecional de/para JSON sem gerar exceções. Embora a solução acima faça isso, ela exige que (a) adicionemos alguma forma de campo de ID exclusivo a cada objeto e (b) substitua cada referência de objeto pelo ID exclusivo correspondente. Isso funcionará, mas preferimos uma solução que funcione automaticamente com nossas referências de objeto existentes sem exigir que modifiquemos “manualmente” nossos objetos dessa maneira.
Idealmente, queremos ser capazes de passar um conjunto de objetos (contendo qualquer conjunto arbitrário de propriedades e referências de objeto) através do serializador e desserializador (sem gerar nenhuma exceção com base em um relacionamento bidirecional) e ter os objetos gerados pelo desserializador correspondam precisamente os objetos que foram alimentados no serializador.
Nossa abordagem é fazer com que nosso serializador crie e adicione automaticamente um ID exclusivo (usando um GUID) a cada objeto. Em seguida, ele substitui qualquer referência de objeto pelo GUID desse objeto. (Observe que o serializador também precisará usar algum nome de propriedade exclusivo para esses IDs; em nosso exemplo, usamos @id
, pois presumivelmente o prefixo “@” ao nome da propriedade é adequado para garantir que ele seja exclusivo.) O desserializador substituirá qualquer GUID que corresponda a uma ID de objeto por uma referência a esse objeto (observe que o desserializador também removerá os GUIDs gerados pelo serializador dos objetos desserializados, retornando-os precisamente ao seu estado inicial).
Então, voltando ao nosso exemplo, queremos alimentar o seguinte conjunto de objetos como está para o nosso serializador:
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]
Esperaríamos então que o serializador gerasse uma estrutura JSON semelhante à seguinte:
{ "@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" }, ] }
(Você pode usar uma ferramenta de formatação JSON para embelezar qualquer objeto JSON.)

Em seguida, alimentar o JSON acima para o desserializador geraria o conjunto original de objetos (ou seja, o objeto pai e seus dois filhos, referenciando um ao outro corretamente).
Agora que sabemos o que queremos fazer e como queremos fazer, vamos implementá-lo.
Implementando o serializador em JavaScript
Abaixo está um exemplo de implementação JavaScript funcional de um serializador que manipulará adequadamente um relacionamento bidirecional sem gerar exceções.
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 o desserializador em JavaScript
Abaixo está um exemplo de implementação JavaScript funcional de um desserializador que manipulará adequadamente um relacionamento bidirecional sem gerar exceções.
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"); }
Passar um conjunto de objetos (incluindo aqueles que têm um relacionamento bidirecional) por meio desses dois métodos é essencialmente uma função de identidade; ou seja, convertToObject(convertToJson(obj)) === obj
é avaliado como verdadeiro.
Exemplo Java/Jackson
Agora vamos ver como essa abordagem é suportada em bibliotecas externas populares. Por exemplo, vamos ver como isso é tratado em Java usando a 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; } }
Essas duas classes java Parent e Child representam a mesma estrutura do exemplo JavaScript no início deste artigo. O ponto principal aqui é usar a anotação @JsonIdentityInfo
que dirá a Jackson como serializar/desserializar esses objetos.
Vejamos um exemplo:
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 da serialização da instância pai para JSON, a mesma estrutura JSON será retornada como no exemplo 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" }, ] }
Outra vantagem
A abordagem descrita para lidar com um relacionamento bidirecional em JSON também pode ser aproveitada para ajudar a reduzir o tamanho de um arquivo JSON, pois permite que você faça referência a objetos simplesmente por seu ID exclusivo, em vez de precisar incluir cópias redundantes do mesmo objeto.
Considere o seguinte exemplo:
{ "@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" ] }
Conforme mostrado no array filteredChildren
, podemos simplesmente incluir referências de objeto em nosso JSON em vez de réplicas dos objetos referenciados e seu conteúdo.
Embrulhar
Com esta solução, você pode eliminar exceções relacionadas a referências circulares ao serializar arquivos JSON de forma a minimizar quaisquer restrições em seus objetos e dados. Se nenhuma solução desse tipo já estiver disponível nas bibliotecas que você está usando para lidar com a serialização de arquivos JSON, você pode implementar sua própria solução com base no exemplo de implementação fornecido. Espero que você ache isto útil.