Prise en charge des relations bidirectionnelles dans JSON
Publié: 2022-03-11Avez-vous déjà essayé de créer une structure de données JSON qui inclut des entités ayant une relation bidirectionnelle (c'est-à-dire une référence circulaire) ? Si c'est le cas, vous avez probablement vu une erreur JavaScript du type "Uncaught TypeError : Conversion de la structure circulaire en JSON" . Ou si vous êtes un développeur Java qui utilise la bibliothèque Jackson, vous avez peut-être rencontré "Could not write JSON: Infinite recursion (StackOverflowError) with root cause java.lang.StackOverflowError" .
Cet article fournit une approche de travail robuste pour créer des structures JSON qui incluent une relation bidirectionnelle sans entraîner ces erreurs.
Souvent, les solutions présentées à ce problème impliquent des solutions de contournement qui contournent essentiellement le problème, mais ne le résolvent pas vraiment. Les exemples incluent l'utilisation de types d'annotation Jackson tels que @JsonManagedReference
et @JsonBackReference
(qui omettent simplement la référence arrière de la sérialisation) ou l'utilisation de @JsonIgnore
pour ignorer simplement l'un des côtés de la relation. Alternativement, on peut développer un code de sérialisation personnalisé qui ignore toute relation bidirectionnelle ou dépendance circulaire dans les données.
Mais nous ne voulons pas ignorer ou omettre l'un ou l'autre côté de la relation bidirectionnelle. Nous voulons le conserver, dans les deux sens, sans générer d'erreurs. Une véritable solution devrait autoriser les dépendances circulaires dans JSON et permettre au développeur d'arrêter d'y penser sans prendre de mesures supplémentaires pour les corriger. Cet article fournit une technique pratique et simple pour le faire, qui peut servir de complément utile à tout ensemble standard de conseils et de pratiques pour le développeur frontal d'aujourd'hui.
Un exemple simple de relation bidirectionnelle
Un cas courant où ce problème de relation bidirectionnelle (c'est-à-dire de dépendance circulaire) se pose est lorsqu'un objet parent a des enfants (auxquels il fait référence) et que ces objets enfants, à leur tour, souhaitent conserver des références à leur parent. Voici un exemple simple :
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]
Si vous essayez de convertir l'objet parent
ci-dessus en JSON (par exemple, en utilisant la méthode stringify
, comme dans var parentJson = JSON.stringify(parent);
), l'exception Uncaught TypeError: Converting circular structure to JSON sera levée.
Bien que nous puissions utiliser l'une des techniques décrites ci-dessus (comme l'utilisation d'annotations comme @JsonIgnore
), ou nous pourrions simplement supprimer les références ci-dessus au parent des enfants, ce sont des moyens d' éviter plutôt que de résoudre le problème. Ce que nous voulons vraiment, c'est une structure JSON résultante qui maintient chaque relation bidirectionnelle et que nous pouvons convertir en JSON sans lever d'exceptions.
Vers une solution
Une étape potentiellement évidente vers une solution consiste à ajouter une forme d'ID d'objet à chaque objet, puis à remplacer les références des enfants à l' objet parent par des références à l' id de l'objet parent. Par exemple:
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 } ]
Cette approche évitera certainement toute exception résultant d'une relation bidirectionnelle ou d'une référence circulaire. Mais il y a toujours un problème, et ce problème devient apparent lorsque nous réfléchissons à la manière dont nous procéderions pour sérialiser et désérialiser ces références.
Le problème est que nous aurions besoin de savoir, en utilisant l'exemple ci-dessus, que chaque référence à la valeur "100" fait référence à l'objet parent (puisque c'est son id
). Cela fonctionnera très bien dans l'exemple ci-dessus où la seule propriété qui a la valeur "100" est la propriété parent
. Mais que se passe-t-il si nous ajoutons une autre propriété avec la valeur « 100 » ? Par exemple:
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 nous supposons que toute référence à la valeur "100" fait référence à un objet, il n'y aura aucun moyen pour notre code de sérialisation/désérialisation de savoir que lorsque parent
référence la valeur "100", cela fait référence à l'objet parent id
, mais quand priority
fait référence à la valeur "100", qui ne fait PAS référence à l' id
de l'objet parent (et puisqu'il pensera que priority
fait également référence à l' id
de l'objet parent, il remplacera incorrectement sa valeur par une référence à l'objet parent).
Vous pouvez demander à ce stade : « Attendez, il vous manque une solution évidente. Au lieu d'utiliser la valeur de la propriété pour déterminer qu'elle fait référence à un ID d'objet, pourquoi n'utilisez-vous pas simplement le nom de la propriété ? » En effet, c'est une option, mais très limitative. Cela signifie que nous devrons pré-désigner une liste de noms de propriétés "réservées" qui sont toujours supposées faire référence à d'autres objets (des noms comme "parent", "enfant", "suivant", etc.). Cela signifie alors que seuls ces noms de propriété peuvent être utilisés pour des références à d'autres objets et signifie également que ces noms de propriété seront toujours traités comme des références à d'autres objets. Ce n'est donc pas une alternative viable dans la plupart des situations.
Il semble donc que nous devions nous en tenir à la reconnaissance des valeurs de propriété en tant que références d'objet. Mais cela signifie que nous aurons besoin que ces valeurs soient garanties uniques par rapport à toutes les autres valeurs de propriété. Nous pouvons répondre au besoin de valeurs uniques en utilisant des identificateurs globaux uniques (GUID). Par exemple:
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 } ]
Donc ça devrait marcher, non ?
Oui.
Mais…
Une solution entièrement automatisée
Rappelez-vous notre défi initial. Nous voulions pouvoir sérialiser et désérialiser des objets qui ont une relation bidirectionnelle vers/depuis JSON sans générer d'exceptions. Bien que la solution ci-dessus accomplisse cela, elle le fait en nous obligeant à (a) ajouter une forme de champ d'ID unique à chaque objet et (b) remplacer chaque référence d'objet par l'ID unique correspondant. Cela fonctionnera, mais nous préférerions de loin une solution qui fonctionnerait automatiquement avec nos références d'objet existantes sans nous obliger à modifier « manuellement » nos objets de cette façon.
Idéalement, nous voulons être en mesure de passer un ensemble d'objets (contenant n'importe quel ensemble arbitraire de propriétés et de références d'objets) à travers le sérialiseur et le désérialiseur (sans générer d'exceptions basées sur une relation bidirectionnelle) et que les objets générés par le désérialiseur correspondent précisément les objets qui ont été introduits dans le sérialiseur.
Notre approche consiste à faire en sorte que notre sérialiseur crée et ajoute automatiquement un ID unique (à l'aide d'un GUID) à chaque objet. Il remplace ensuite toute référence d'objet par le GUID de cet objet. (Notez que le sérialiseur devra également utiliser un nom de propriété unique pour ces identifiants ; dans notre exemple, nous utilisons @id
, car il est probable que l'ajout du « @ » au nom de la propriété soit suffisant pour garantir qu'il est unique.) Le désérialiseur remplacera alors tout GUID correspondant à un ID d'objet par une référence à cet objet (notez que le désérialiseur supprimera également les GUID générés par le sérialiseur des objets désérialisés, les ramenant ainsi précisément à leur état initial).
Donc, revenant à notre exemple, nous voulons alimenter l'ensemble d'objets suivant tel quel dans notre sérialiseur :
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]
Nous nous attendrions alors à ce que le sérialiseur génère une structure JSON similaire à la suivante :

{ "@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" }, ] }
(Vous pouvez utiliser un outil de formatage JSON pour embellir n'importe quel objet JSON.)
Ensuite, envoyer le JSON ci-dessus au désérialiseur générerait l'ensemble d'objets d'origine (c'est-à-dire l'objet parent et ses deux enfants, se référençant correctement).
Alors maintenant que nous savons ce que nous voulons faire et comment nous voulons le faire, mettons-le en œuvre.
Implémentation du sérialiseur en JavaScript
Vous trouverez ci-dessous un exemple d'implémentation JavaScript fonctionnelle d'un sérialiseur qui gérera correctement une relation bidirectionnelle sans lever d'exceptions.
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"); }
Implémentation du désérialiseur en JavaScript
Vous trouverez ci-dessous un exemple d'implémentation JavaScript fonctionnelle d'un désérialiseur qui gérera correctement une relation bidirectionnelle sans lever d'exceptions.
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"); }
Passer un ensemble d'objets (y compris ceux qui ont une relation bidirectionnelle) à travers ces deux méthodes est essentiellement une fonction d'identité ; c'est-à-dire, convertToObject(convertToJson(obj)) === obj
évalué à vrai.
Exemple Java/Jackson
Voyons maintenant comment cette approche est prise en charge dans les bibliothèques externes populaires. Par exemple, voyons comment cela est géré en Java à l'aide de la bibliothèque 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; } }
Ces deux classes Java Parent et Child représentent la même structure que dans l'exemple JavaScript au début de cet article. Le point principal ici est d'utiliser l'annotation @JsonIdentityInfo
qui indiquera à Jackson comment sérialiser/désérialiser ces objets.
Voyons un exemple :
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));
Suite à la sérialisation de l'instance parente en JSON, la même structure JSON sera renvoyée que dans l'exemple 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 autre avantage
L'approche décrite pour gérer une relation bidirectionnelle dans JSON peut également être exploitée pour aider à réduire la taille d'un fichier JSON, car elle vous permet de référencer des objets simplement par leur ID unique, plutôt que d'avoir à inclure des copies redondantes du même objet.
Considérez l'exemple suivant :
{ "@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" ] }
Comme indiqué dans le tableau filteredChildren
, nous pouvons simplement inclure des références d'objets dans notre JSON plutôt que des répliques des objets référencés et de leur contenu.
Emballer
Avec cette solution, vous pouvez éliminer les exceptions liées aux références circulaires tout en sérialisant les fichiers JSON de manière à minimiser les contraintes sur vos objets et vos données. Si aucune solution de ce type n'est déjà disponible dans les bibliothèques que vous utilisez pour gérer la sérialisation des fichiers JSON, vous pouvez implémenter votre propre solution en vous basant sur l'exemple d'implémentation fourni. J'espère que ceci vous aidera.