Bidirektionale Beziehungsunterstützung in JSON
Veröffentlicht: 2022-03-11Haben Sie jemals versucht, eine JSON-Datenstruktur zu erstellen, die Entitäten enthält, die eine bidirektionale Beziehung haben (dh Zirkelverweis)? Wenn ja, haben Sie wahrscheinlich einen JavaScript-Fehler im Sinne von „Uncaught TypeError: Converting Circular Structure to JSON“ gesehen. Oder wenn Sie ein Java-Entwickler sind, der die Jackson-Bibliothek verwendet, sind Sie möglicherweise auf „Could not write JSON: Infinite recursion (StackOverflowError) with root cause java.lang.StackOverflowError“ gestoßen.
Dieser Artikel bietet einen robusten Arbeitsansatz zum Erstellen von JSON-Strukturen, die eine bidirektionale Beziehung enthalten, ohne dass diese Fehler auftreten.
Oft beinhalten die Lösungen, die für dieses Problem präsentiert werden, Problemumgehungen, die das Problem im Grunde umgehen, aber nicht wirklich angehen. Beispiele sind die Verwendung von Jackson-Annotationstypen wie @JsonManagedReference
und @JsonBackReference
(wobei die Rückreferenz einfach bei der Serialisierung weggelassen wird) oder die Verwendung von @JsonIgnore
, um einfach eine der Seiten der Beziehung zu ignorieren. Alternativ kann man benutzerdefinierten Serialisierungscode entwickeln, der solche bidirektionalen Beziehungen oder zirkulären Abhängigkeiten in den Daten ignoriert.
Aber wir wollen keine Seite der bidirektionalen Beziehung ignorieren oder auslassen. Wir wollen es in beiden Richtungen erhalten, ohne Fehler zu erzeugen. Eine echte Lösung sollte zirkuläre Abhängigkeiten in JSON zulassen und es dem Entwickler ermöglichen, nicht mehr darüber nachzudenken, ohne zusätzliche Maßnahmen zu ergreifen, um sie zu beheben. Dieser Artikel bietet eine praktische und unkomplizierte Technik dafür, die als nützliche Ergänzung zu jedem Standardsatz von Tipps und Praktiken für den Front-End-Entwickler von heute dienen kann.
Ein einfaches Beispiel für eine bidirektionale Beziehung
Ein häufiger Fall, in dem dieses Problem der bidirektionalen Beziehung (auch bekannt als zirkuläre Abhängigkeit) auftritt, ist, wenn es ein übergeordnetes Objekt gibt, das untergeordnete Objekte hat (auf die es verweist) und diese untergeordneten Objekte wiederum Verweise auf ihre übergeordneten Objekte beibehalten möchten. Hier ist ein einfaches Beispiel:
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]
Wenn Sie versuchen, das obige parent
Objekt in JSON zu konvertieren (z. B. mithilfe der stringify
Methode, wie in var parentJson = JSON.stringify(parent);
), wird die Ausnahme Uncaught TypeError: Converting Circular Structure to JSON ausgelöst.
Wir könnten zwar eine der oben besprochenen Techniken verwenden (z. B. die Verwendung von Anmerkungen wie @JsonIgnore
) oder wir könnten einfach die obigen Verweise auf die Eltern von den Kindern entfernen, aber dies sind Möglichkeiten, das Problem zu vermeiden, anstatt es zu lösen . Was wir wirklich wollen, ist eine resultierende JSON-Struktur, die jede bidirektionale Beziehung aufrechterhält und die wir in JSON konvertieren können, ohne Ausnahmen auszulösen.
Auf dem Weg zu einer Lösung
Ein möglicherweise offensichtlicher Schritt in Richtung einer Lösung besteht darin, jedem Objekt eine Art Objekt-ID hinzuzufügen und dann die Verweise der untergeordneten Elemente auf das übergeordnete Objekt durch Verweise auf die id des übergeordneten Objekts zu ersetzen. Zum Beispiel:
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 } ]
Dieser Ansatz wird sicherlich alle Ausnahmen vermeiden, die sich aus einer bidirektionalen Beziehung oder einem Zirkelverweis ergeben. Aber es gibt immer noch ein Problem, und dieses Problem wird deutlich, wenn wir darüber nachdenken, wie wir diese Referenzen serialisieren und deserialisieren würden.
Das Problem ist, dass wir anhand des obigen Beispiels wissen müssten, dass sich jede Referenz auf den Wert „100“ auf das übergeordnete Objekt bezieht (da dies seine id
ist). Das wird im obigen Beispiel gut funktionieren, wo die einzige Eigenschaft, die den Wert „100“ hat, die parent
Eigenschaft ist. Was aber, wenn wir eine weitere Eigenschaft mit dem Wert „100“ hinzufügen? Zum Beispiel:
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 } ]
Wenn wir davon ausgehen, dass ein Verweis auf den Wert „100“ auf ein Objekt verweist, gibt es für unseren Serialisierungs-/Deserialisierungscode keine Möglichkeit zu wissen, dass, wenn parent
auf den Wert „100“ verweist, dies auf die id
des übergeordneten Objekts verweist, aber wann priority
verweist auf den Wert „100“, der NICHT auf die id
des übergeordneten Objekts verweist (und da es davon ausgeht, dass priority
auch auf die id
des übergeordneten Objekts verweist, wird es seinen Wert fälschlicherweise durch eine Referenz auf das übergeordnete Objekt ersetzen).
Sie könnten an dieser Stelle fragen: „Warte, dir entgeht eine offensichtliche Lösung. Anstatt den Eigenschaftswert zu verwenden, um festzustellen, dass es sich um eine Objekt-ID handelt, warum verwenden Sie nicht einfach den Eigenschaftsnamen?“ Das ist zwar eine Option, aber eine sehr einschränkende. Das bedeutet, dass wir eine Liste mit „reservierten“ Eigenschaftsnamen vorgeben müssen, von denen immer angenommen wird, dass sie auf andere Objekte verweisen (Namen wie „übergeordnet“, „untergeordnet“, „nächster“ usw.). Dies bedeutet dann, dass nur diese Eigenschaftsnamen für Verweise auf andere Objekte verwendet werden können und dass diese Eigenschaftsnamen immer als Verweise auf andere Objekte behandelt werden. Dies ist daher in den meisten Situationen keine praktikable Alternative.
Es sieht also so aus, als müssten wir bei der Erkennung von Eigenschaftswerten als Objektreferenzen bleiben. Dies bedeutet jedoch, dass diese Werte garantiert eindeutig von allen anderen Eigenschaftswerten sein müssen. Wir können dem Bedarf an eindeutigen Werten gerecht werden, indem wir Globally Unique Identifiers (GUIDs) verwenden. Zum Beispiel:
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 } ]
Das sollte also funktionieren, oder?
Jawohl.
Aber…
Eine vollautomatische Lösung
Denken Sie an unsere ursprüngliche Herausforderung. Wir wollten in der Lage sein, Objekte zu serialisieren und zu deserialisieren, die eine bidirektionale Beziehung zu/von JSON haben, ohne Ausnahmen zu generieren. Während die obige Lösung dies erreicht, tut sie dies, indem sie uns dazu auffordert, (a) jedem Objekt eine Art eindeutiges ID-Feld hinzuzufügen und (b) jede Objektreferenz durch die entsprechende eindeutige ID zu ersetzen . Das wird funktionieren, aber wir würden eine Lösung bevorzugen, die einfach automatisch mit unseren vorhandenen Objektreferenzen funktioniert, ohne dass wir unsere Objekte auf diese Weise „manuell“ ändern müssen.
Im Idealfall möchten wir in der Lage sein, einen Satz von Objekten (die einen beliebigen Satz von Eigenschaften und Objektreferenzen enthalten) durch den Serializer und Deserializer zu leiten (ohne Ausnahmen basierend auf einer bidirektionalen Beziehung zu generieren) und die vom Deserializer generierten Objekte genau übereinstimmen zu lassen die Objekte, die in den Serializer eingegeben wurden.
Unser Ansatz besteht darin, dass unser Serializer automatisch eine eindeutige ID (unter Verwendung einer GUID) erstellt und jedem Objekt hinzufügt. Anschließend werden alle Objektverweise durch die GUID dieses Objekts ersetzt. (Beachten Sie, dass der Serialisierer auch für diese IDs einen eindeutigen Eigenschaftsnamen verwenden muss; in unserem Beispiel verwenden wir @id
, da es vermutlich ausreicht, dem Eigenschaftsnamen das „@“ voranzustellen, um sicherzustellen, dass er eindeutig ist.) Der Deserialisierer ersetzt dann jede GUID, die einer Objekt-ID entspricht, durch einen Verweis auf dieses Objekt (beachten Sie, dass der Deserialisierer auch die vom Serialisierer generierten GUIDs aus den deserialisierten Objekten entfernt und sie dadurch genau in ihren ursprünglichen Zustand zurückversetzt).
Um zu unserem Beispiel zurückzukehren, möchten wir den folgenden Satz von Objekten unverändert in unseren Serializer einspeisen:
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]
Wir würden dann erwarten, dass der Serialisierer eine JSON-Struktur ähnlich der folgenden generiert:

{ "@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" }, ] }
(Sie können ein JSON-Formatierungstool verwenden, um jedes JSON-Objekt zu verschönern.)
Dann würde das Einspeisen des obigen JSON in den Deserialisierer den ursprünglichen Satz von Objekten generieren (dh das übergeordnete Objekt und seine zwei untergeordneten Objekte, die sich ordnungsgemäß referenzieren).
Nun, da wir wissen, was wir tun wollen und wie wir es tun wollen, lassen Sie es uns umsetzen.
Implementieren des Serializers in JavaScript
Unten sehen Sie eine funktionierende JavaScript-Beispielimplementierung eines Serializers, der eine bidirektionale Beziehung ordnungsgemäß verarbeitet, ohne Ausnahmen auszulösen.
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"); }
Implementierung des Deserializers in JavaScript
Unten finden Sie ein Beispiel für eine funktionierende JavaScript-Implementierung eines Deserialisierers, der eine bidirektionale Beziehung ordnungsgemäß verarbeitet, ohne Ausnahmen auszulösen.
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"); }
Das Übergeben einer Reihe von Objekten (einschließlich derjenigen, die eine bidirektionale Beziehung haben) durch diese beiden Methoden ist im Wesentlichen eine Identitätsfunktion; dh convertToObject(convertToJson(obj)) === obj
als wahr ausgewertet.
Java/Jackson-Beispiel
Sehen wir uns nun an, wie dieser Ansatz in gängigen externen Bibliotheken unterstützt wird. Sehen wir uns zum Beispiel an, wie es in Java mit der Jackson-Bibliothek gehandhabt wird.
@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; } }
Diese beiden Java-Klassen Parent und Child stellen die gleiche Struktur dar wie im JavaScript-Beispiel am Anfang dieses Artikels. Der Hauptpunkt hier ist die Verwendung der Annotation @JsonIdentityInfo
, die Jackson mitteilt, wie diese Objekte serialisiert/deserialisiert werden.
Sehen wir uns ein Beispiel an:
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));
Als Ergebnis der Serialisierung der übergeordneten Instanz in JSON wird dieselbe JSON-Struktur wie im JavaScript-Beispiel zurückgegeben.
{ "@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" }, ] }
Ein weiterer Vorteil
Der beschriebene Ansatz zur Handhabung einer bidirektionalen Beziehung in JSON kann auch genutzt werden, um die Größe einer JSON-Datei zu reduzieren, da Sie Objekte einfach anhand ihrer eindeutigen ID referenzieren können, anstatt redundante Kopien desselben Objekts einschließen zu müssen.
Betrachten Sie das folgende Beispiel:
{ "@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" ] }
Wie im Array „ filteredChildren
“ gezeigt, können wir einfach Objektreferenzen in unseren JSON-Code aufnehmen, anstatt Replikate der referenzierten Objekte und ihres Inhalts.
Einpacken
Mit dieser Lösung können Sie Ausnahmen im Zusammenhang mit Zirkelverweisen eliminieren und gleichzeitig JSON-Dateien so serialisieren, dass Einschränkungen für Ihre Objekte und Daten minimiert werden. Wenn in den Bibliotheken, die Sie für die Handhabung der Serialisierung von JSON-Dateien verwenden, noch keine solche Lösung verfügbar ist, können Sie Ihre eigene Lösung basierend auf der bereitgestellten Beispielimplementierung implementieren. Ich hoffe, Sie finden das hilfreich.