Dwukierunkowa obsługa relacji w JSON
Opublikowany: 2022-03-11Czy kiedykolwiek próbowałeś stworzyć strukturę danych JSON, która zawiera jednostki, które mają relację dwukierunkową (tj. odwołanie cykliczne)? Jeśli tak, prawdopodobnie zauważyłeś błąd JavaScript podobny do „Uncaught TypeError: Converting circular structure to JSON” . Lub jeśli jesteś programistą Java, który korzysta z biblioteki Jackson, mogłeś napotkać „Nie można napisać JSON: Infinite recursion (StackOverflowError) z główną przyczyną java.lang.StackOverflowError” .
Ten artykuł zapewnia niezawodne podejście do tworzenia struktur JSON, które zawierają dwukierunkową relację bez powodowania tych błędów.
Często przedstawione rozwiązania tego problemu obejmują obejścia, które w zasadzie omijają problem, ale nie rozwiązują go w rzeczywistości. Przykłady obejmują użycie typów adnotacji Jackson, takich jak @JsonManagedReference
i @JsonBackReference
(które po prostu pomijają odwołanie wsteczne z serializacji) lub użycie @JsonIgnore
, aby po prostu zignorować jedną ze stron relacji. Alternatywnie można opracować niestandardowy kod serializacji, który ignoruje wszelkie takie dwukierunkowe relacje lub cykliczne zależności w danych.
Ale nie chcemy ignorować ani pomijać żadnej ze stron dwukierunkowej relacji. Chcemy ją zachować w obu kierunkach, bez generowania błędów. Prawdziwe rozwiązanie powinno umożliwiać cykliczne zależności w JSON i pozwolić programiście przestać o nich myśleć bez podejmowania dodatkowych działań w celu ich naprawy. Ten artykuł zawiera praktyczną i prostą technikę wykonania tego zadania, która może służyć jako przydatny dodatek do dowolnego standardowego zestawu wskazówek i praktyk dla dzisiejszego programisty front-end.
Przykład prostego dwukierunkowego związku
Częstym przypadkiem, w którym pojawia się ta dwukierunkowa relacja (czyli zależność cykliczna), jest sytuacja, gdy istnieje obiekt nadrzędny, który ma dzieci (do których się odwołuje), a te obiekty podrzędne z kolei chcą zachować odniesienia do swojego rodzica. Oto prosty przykład:
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]
Jeśli spróbujesz przekonwertować powyższy obiekt parent
na JSON (na przykład przy użyciu metody stringify
, jak w var parentJson = JSON.stringify(parent);
), zostanie zgłoszony wyjątek Uncaught TypeError: Converting circular structure to JSON .
Chociaż moglibyśmy użyć jednej z omówionych powyżej technik (takich jak używanie adnotacji, takich jak @JsonIgnore
) lub moglibyśmy po prostu usunąć powyższe odniesienia do rodzica z dzieci, są to sposoby na uniknięcie problemu, a nie na jego rozwiązanie . To, czego naprawdę chcemy, to wynikowa struktura JSON, która utrzymuje każdą dwukierunkową relację i którą możemy przekonwertować na JSON bez zgłaszania żadnych wyjątków.
Dążenie do rozwiązania
Jednym z potencjalnie oczywistych kroków w kierunku rozwiązania jest dodanie jakiejś formy identyfikatora obiektu do każdego obiektu, a następnie zastąpienie odwołań dzieci do obiektu nadrzędnego odwołaniami do id obiektu nadrzędnego. Na przykład:
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 } ]
Takie podejście z pewnością pozwoli uniknąć wyjątków, które wynikają z relacji dwukierunkowej lub odwołania kołowego. Ale nadal istnieje problem, który staje się widoczny, gdy myślimy o tym, jak podejść do serializacji i deserializacji tych odwołań.
Problem w tym, że musielibyśmy wiedzieć, korzystając z powyższego przykładu, że każde odwołanie do wartości „100” odnosi się do obiektu nadrzędnego (bo to jest jego id
). To zadziała dobrze w powyższym przykładzie, w którym jedyną właściwością, która ma wartość „100”, jest właściwość parent
. A co jeśli dodamy kolejną właściwość o wartości „100”? Na przykład:
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 } ]
Jeśli założymy, że jakiekolwiek odwołanie do wartości „100” odwołuje się do obiektu, nasz kod serializacji/deserializacji nie będzie wiedział, że gdy parent
odwołuje się do wartości „100”, to odwołuje się do id
obiektu nadrzędnego, ale kiedy priority
odwołuje się do wartości „100”, czyli NIE odwołuje się do id
obiektu nadrzędnego (a ponieważ będzie myślał, że priority
odwołuje się również do id
obiektu nadrzędnego, błędnie zastąpi jego wartość odwołaniem do obiektu nadrzędnego).
W tym momencie możesz zapytać: „Czekaj, brakuje Ci oczywistego rozwiązania. Zamiast używać wartości właściwości do ustalenia, że odwołuje się ona do identyfikatora obiektu, dlaczego po prostu nie użyjesz nazwy właściwości?” Rzeczywiście, jest to opcja, ale bardzo ograniczająca. Oznacza to, że będziemy musieli wstępnie wyznaczyć listę „zarezerwowanych” nazw właściwości, które zawsze odnoszą się do innych obiektów (nazwy takie jak „rodzic”, „dziecko”, „następny” itp.). Będzie to oznaczać, że tylko te nazwy właściwości mogą być używane jako odniesienia do innych obiektów, a także, że te nazwy właściwości będą zawsze traktowane jako odniesienia do innych obiektów. Dlatego w większości sytuacji nie jest to realna alternatywa.
Wygląda więc na to, że musimy trzymać się rozpoznawania wartości właściwości jako odwołań do obiektów. Oznacza to jednak, że będziemy potrzebować gwarancji , że te wartości będą niepowtarzalne w stosunku do wszystkich innych wartości właściwości. Możemy zaspokoić potrzebę unikalnych wartości, używając globalnie unikalnych identyfikatorów (GUID). Na przykład:
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 } ]
Więc to powinno działać, prawda?
TAk.
Jednak…
W pełni zautomatyzowane rozwiązanie
Zapamiętaj nasze oryginalne wyzwanie. Chcieliśmy mieć możliwość serializacji i deserializacji obiektów, które mają dwukierunkową relację do/z JSON bez generowania żadnych wyjątków. Chociaż powyższe rozwiązanie osiąga to, wymaga od nas (a) dodania jakiejś formy pola unikalnego identyfikatora do każdego obiektu i (b) zastąpienia każdego odwołania do obiektu odpowiednim unikalnym identyfikatorem. To zadziała, ale zdecydowanie wolelibyśmy rozwiązanie, które po prostu działałoby automatycznie z naszymi istniejącymi referencjami do obiektów bez konieczności „ręcznego” modyfikowania naszych obiektów w ten sposób.
Idealnie, chcemy mieć możliwość przekazywania zestawu obiektów (zawierającego dowolny zestaw właściwości i referencji do obiektów) przez serializator i deserializator (bez generowania wyjątków na podstawie relacji dwukierunkowej) i dokładne dopasowanie obiektów generowanych przez deserializator obiekty, które zostały podane do serializatora.
Nasze podejście polega na tym, aby nasz serializator automatycznie tworzył i dodawał unikalny identyfikator (przy użyciu identyfikatora GUID) do każdego obiektu. Następnie zastępuje wszelkie odniesienia do obiektu identyfikatorem GUID tego obiektu. (Zauważ, że serializator będzie musiał również użyć unikalnej nazwy właściwości dla tych identyfikatorów; w naszym przykładzie używamy @id
, ponieważ przypuszczalnie poprzedzenie „@” przed nazwą właściwości jest wystarczające, aby zapewnić, że jest ona unikalna.) Deserializator następnie zastąpi dowolny identyfikator GUID, który odpowiada identyfikatorowi obiektu, odwołaniem do tego obiektu (należy zauważyć, że deserializator usunie również identyfikatory GUID wygenerowane przez serializator z deserializowanych obiektów, przywracając je w ten sposób dokładnie do ich stanu początkowego).
Wracając więc do naszego przykładu, chcemy wprowadzić następujący zestaw obiektów do naszego serializatora:
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]
Oczekiwalibyśmy wtedy, że serializator wygeneruje strukturę JSON podobną do następującej:
{ "@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" }, ] }
(Możesz użyć narzędzia do formatowania JSON, aby upiększyć dowolny obiekt JSON).

Następnie podanie powyższego kodu JSON do deserializatora wygeneruje oryginalny zestaw obiektów (tj. obiekt nadrzędny i jego dwoje dzieci, odwołujące się do siebie poprawnie).
Więc teraz, gdy wiemy, co i jak chcemy zrobić, zaimplementujmy to.
Implementacja serializatora w JavaScript
Poniżej znajduje się przykładowa działająca implementacja JavaScript serializatora, który prawidłowo obsłuży relację dwukierunkową bez zgłaszania wyjątków.
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"); }
Implementacja Deserializatora w JavaScript
Poniżej znajduje się przykładowa działająca implementacja JavaScript deserializatora, która prawidłowo obsłuży relację dwukierunkową bez zgłaszania żadnych wyjątków.
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"); }
Przekazywanie zbioru obiektów (w tym tych, które mają dwukierunkowy związek) za pomocą tych dwóch metod, jest zasadniczo funkcją tożsamości; tj. convertToObject(convertToJson(obj)) === obj
prawdę.
Przykład Java/Jackson
Przyjrzyjmy się teraz, jak to podejście jest obsługiwane w popularnych bibliotekach zewnętrznych. Na przykład zobaczmy, jak jest obsługiwane w Javie przy użyciu biblioteki 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; } }
Te dwie klasy Java Parent i Child reprezentują taką samą strukturę jak w przykładzie JavaScript na początku tego artykułu. Głównym punktem tutaj jest użycie adnotacji @JsonIdentityInfo
, która poinformuje Jacksona, jak serializować/deserializować te obiekty.
Zobaczmy przykład:
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));
W wyniku serializacji instancji nadrzędnej do formatu JSON zostanie zwrócona ta sama struktura JSON, co w przykładzie 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" }, ] }
Kolejna zaleta
Opisane podejście do obsługi relacji dwukierunkowych w formacie JSON można również wykorzystać do zmniejszenia rozmiaru pliku JSON, ponieważ umożliwia odwoływanie się do obiektów po prostu za pomocą ich unikalnego identyfikatora, bez konieczności dołączania nadmiarowych kopii tego samego obiektu.
Rozważmy następujący przykład:
{ "@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" ] }
Jak pokazano w tablicy filteredChildren
, możemy po prostu uwzględnić odwołania do obiektów w naszym JSON, a nie repliki obiektów, do których się odwołujemy i ich zawartości.
Zakończyć
Dzięki temu rozwiązaniu można wyeliminować wyjątki związane z odwołaniami cyklicznymi podczas serializacji plików JSON w sposób minimalizujący wszelkie ograniczenia dotyczące obiektów i danych. Jeśli żadne takie rozwiązanie nie jest już dostępne w bibliotekach używanych do obsługi serializacji plików JSON, możesz zaimplementować własne rozwiązanie na podstawie podanej przykładowej implementacji. Mam nadzieję, że okaże się to pomocne.