Supporto delle relazioni bidirezionali in JSON

Pubblicato: 2022-03-11

Hai mai provato a creare una struttura dati JSON che includa entità che hanno una relazione bidirezionale (ad esempio, riferimento circolare)? In tal caso, probabilmente hai visto un errore JavaScript sulla falsariga di "Uncaught TypeError: Conversione di una struttura circolare in JSON" . Oppure, se sei uno sviluppatore Java che utilizza la libreria Jackson, potresti aver riscontrato "Impossibile scrivere JSON: ricorsione infinita (StackOverflowError) con causa principale java.lang.StackOverflowError" .

Sfida di relazione bidirezionale JSON

Questo articolo fornisce un approccio di lavoro solido alla creazione di strutture JSON che includono una relazione bidirezionale senza causare questi errori.

Spesso, le soluzioni presentate a questo problema comportano soluzioni alternative che sostanzialmente eludono, ma non affrontano realmente il problema. Gli esempi includono l'utilizzo di tipi di annotazione Jackson come @JsonManagedReference e @JsonBackReference (che semplicemente omette il riferimento indietro dalla serializzazione) o l'utilizzo di @JsonIgnore per ignorare semplicemente uno dei lati della relazione. In alternativa, è possibile sviluppare codice di serializzazione personalizzato che ignori qualsiasi relazione bidirezionale o dipendenza circolare nei dati.

Ma non vogliamo ignorare o omettere nessuno dei due lati della relazione bidirezionale. Vogliamo preservarlo, in entrambe le direzioni, senza generare errori. Una vera soluzione dovrebbe consentire dipendenze circolari in JSON e consentire allo sviluppatore di smettere di pensarci senza intraprendere ulteriori azioni per risolverle. Questo articolo fornisce una tecnica pratica e diretta per farlo, che può fungere da utile aggiunta a qualsiasi insieme standard di suggerimenti e pratiche per lo sviluppatore front-end di oggi.

Un semplice esempio di relazione bidirezionale

Un caso comune in cui si verifica questo problema di relazione bidirezionale (nota anche come dipendenza circolare) è quando c'è un oggetto genitore che ha figli (a cui fa riferimento) e quegli oggetti figli, a loro volta, vogliono mantenere i riferimenti al loro genitore. Ecco un semplice esempio:

 var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]

Se si tenta di convertire l'oggetto parent sopra in JSON (ad esempio, utilizzando il metodo stringify , come in var parentJson = JSON.stringify(parent); ), verrà generata l'eccezione Uncaught TypeError: Conversione della struttura circolare in JSON .

Mentre potremmo usare una delle tecniche discusse sopra (come usare annotazioni come @JsonIgnore ), o potremmo semplicemente rimuovere i riferimenti precedenti al genitore dai bambini, questi sono modi per evitare piuttosto che risolvere il problema. Quello che vogliamo veramente è una struttura JSON risultante che mantenga ogni relazione bidirezionale e che possiamo convertire in JSON senza generare eccezioni.

Muoversi verso una soluzione

Un passaggio potenzialmente ovvio verso una soluzione consiste nell'aggiungere una qualche forma di ID oggetto a ciascun oggetto e quindi sostituire i riferimenti dei figli all'oggetto padre con i riferimenti all'id dell'oggetto padre. Per esempio:

 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 } ]

Questo approccio eviterà sicuramente qualsiasi eccezione che derivi da una relazione bidirezionale o da un riferimento circolare. Ma c'è ancora un problema, e quel problema diventa evidente quando pensiamo a come faremmo per serializzare e deserializzare questi riferimenti.

Il problema è che dovremmo sapere, usando l'esempio sopra, che ogni riferimento al valore "100" si riferisce all'oggetto genitore (poiché quello è il suo id ). Funzionerà perfettamente nell'esempio precedente in cui l'unica proprietà che ha il valore "100" è la proprietà parent . Ma cosa succede se aggiungiamo un'altra proprietà con il valore "100"? Per esempio:

 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 assumiamo che qualsiasi riferimento al valore "100" faccia riferimento a un oggetto, non ci sarà modo per il nostro codice di serializzazione/deserializzazione di sapere che quando parent fa riferimento al valore "100", sta facendo riferimento id dell'oggetto genitore, ma quando priority fa riferimento al valore "100", che NON fa riferimento id dell'oggetto padre (e poiché penserà che priority faccia riferimento anche id dell'oggetto padre, sostituirà erroneamente il suo valore con un riferimento all'oggetto padre).

A questo punto potresti chiedere: "Aspetta, ti manca una soluzione ovvia. Invece di utilizzare il valore della proprietà per determinare che fa riferimento a un ID oggetto, perché non usi semplicemente il nome della proprietà?" In effetti, questa è un'opzione, ma molto limitante. Significa che dovremo predesignare un elenco di nomi di proprietà "riservati" che si presume facciano sempre riferimento ad altri oggetti (nomi come "genitore", "figlio", "successivo", ecc.). Ciò significherà quindi che solo quei nomi di proprietà possono essere utilizzati per i riferimenti ad altri oggetti e significherà anche che quei nomi di proprietà saranno sempre trattati come riferimenti ad altri oggetti. Questa non è quindi un'alternativa praticabile nella maggior parte delle situazioni.

Quindi sembra che dobbiamo continuare a riconoscere i valori delle proprietà come riferimenti a oggetti. Ma questo significa che avremo bisogno che questi valori siano garantiti per essere unici rispetto a tutti gli altri valori di proprietà. Possiamo soddisfare la necessità di valori univoci utilizzando gli identificatori univoci globali (GUID). Per esempio:

 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 } ]

Quindi dovrebbe funzionare, giusto?

Sì.

Ma…

Una soluzione completamente automatizzata

Ricorda la nostra sfida originale. Volevamo essere in grado di serializzare e deserializzare oggetti che hanno una relazione bidirezionale da/verso JSON senza generare eccezioni. Sebbene la soluzione di cui sopra ottenga ciò, lo fa richiedendoci di (a) aggiungere una forma di campo ID univoco a ciascun oggetto e (b) sostituire ogni riferimento all'oggetto con l'ID univoco corrispondente. Funzionerà, ma preferiremmo di gran lunga una soluzione che funzioni automaticamente con i nostri riferimenti agli oggetti esistenti senza richiederci di modificare "manualmente" i nostri oggetti in questo modo.

Idealmente, vogliamo essere in grado di passare un insieme di oggetti (contenente qualsiasi insieme arbitrario di proprietà e riferimenti a oggetti) attraverso il serializzatore e il deserializzatore (senza generare eccezioni basate su una relazione bidirezionale) e fare in modo che gli oggetti generati dal deserializzatore corrispondano esattamente gli oggetti che sono stati inseriti nel serializzatore.

Il nostro approccio consiste nel fare in modo che il nostro serializzatore crei e aggiunga automaticamente un ID univoco (utilizzando un GUID) a ciascun oggetto. Sostituisce quindi qualsiasi riferimento a un oggetto con il GUID di quell'oggetto. (Si noti che il serializzatore dovrà utilizzare anche un nome di proprietà univoco per questi ID; nel nostro esempio, utilizziamo @id poiché presumibilmente è sufficiente anteporre la "@" al nome della proprietà per garantire che sia univoco.) Il deserializzatore sostituirà quindi qualsiasi GUID che corrisponde a un ID oggetto con un riferimento a tale oggetto (si noti che il deserializzatore rimuoverà anche i GUID generati dal serializzatore dagli oggetti deserializzati, riportandoli così esattamente al loro stato iniziale).

Quindi, tornando al nostro esempio, vogliamo alimentare il seguente insieme di oggetti così com'è al nostro serializzatore:

 var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]

Ci si aspetterebbe quindi che il serializzatore generi una struttura JSON simile alla seguente:

 { "@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" }, ] }

(Puoi utilizzare uno strumento di formattazione JSON per abbellire qualsiasi oggetto JSON.)

Quindi, l'alimentazione del JSON sopra al deserializzatore genererebbe l'insieme originale di oggetti (ad esempio, l'oggetto genitore e i suoi due figli, che si riferiscono correttamente l'uno all'altro).

Quindi ora che sappiamo cosa vogliamo fare e come vogliamo farlo, implementiamolo.

Implementazione del serializzatore in JavaScript

Di seguito è riportato un esempio di implementazione JavaScript funzionante di un serializzatore che gestirà correttamente una relazione bidirezionale senza generare eccezioni.

 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"); }

Implementazione del deserializzatore in JavaScript

Di seguito è riportato un esempio di implementazione JavaScript funzionante di un deserializzatore che gestirà correttamente una relazione bidirezionale senza generare eccezioni.

 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"); }

Il passaggio di un insieme di oggetti (inclusi quelli che hanno una relazione bidirezionale) attraverso questi due metodi è essenzialmente una funzione di identità; cioè convertToObject(convertToJson(obj)) === obj true.

Esempio Java/Jackson

Ora diamo un'occhiata a come questo approccio è supportato nelle librerie esterne più diffuse. Ad esempio, vediamo come viene gestito in Java utilizzando la libreria 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; } }

Queste due classi Java Parent e Child rappresentano la stessa struttura dell'esempio JavaScript all'inizio di questo articolo. Il punto principale qui è usare l'annotazione @JsonIdentityInfo che dirà a Jackson come serializzare/deserializzare questi oggetti.

Vediamo un esempio:

 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));

Come risultato della serializzazione dell'istanza padre in JSON, verrà restituita la stessa struttura JSON dell'esempio 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" }, ] }

Un altro vantaggio

L'approccio descritto alla gestione di una relazione bidirezionale in JSON può essere sfruttato anche per ridurre le dimensioni di un file JSON, poiché consente di fare riferimento agli oggetti semplicemente tramite il loro ID univoco, anziché dover includere copie ridondanti dello stesso oggetto.

Considera il seguente esempio:

 { "@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" ] }

Come mostrato nell'array filteredChildren , possiamo semplicemente includere riferimenti a oggetti nel nostro JSON anziché repliche degli oggetti referenziati e del loro contenuto.

Incartare

Con questa soluzione, puoi eliminare le eccezioni relative ai riferimenti circolari serializzando i file JSON in modo da ridurre al minimo qualsiasi vincolo sui tuoi oggetti e dati. Se nessuna soluzione di questo tipo è già disponibile nelle librerie che stai utilizzando per la gestione della serializzazione dei file JSON, puoi implementare la tua soluzione in base all'implementazione di esempio fornita. Spero che ti sia d'aiuto.