Suport pentru relații bidirecționale în JSON

Publicat: 2022-03-11

Ați încercat vreodată să creați o structură de date JSON care să includă entități care au o relație bidirecțională (adică, referință circulară)? Dacă ați făcut-o, probabil că ați văzut o eroare JavaScript de tipul „Uncaught TypeError: Converting circular structure to JSON” . Sau dacă sunteți un dezvoltator Java care folosește biblioteca Jackson, este posibil să fi întâlnit „Nu s-a putut scrie JSON: recursivitate infinită (StackOverflowError) cu cauza rădăcină java.lang.StackOverflowError” .

Provocarea relației bidirecționale JSON

Acest articol oferă o abordare de lucru robustă pentru crearea structurilor JSON care includ o relație bidirecțională fără a duce la aceste erori.

Adesea, soluțiile care sunt prezentate la această problemă implică soluții care practic ocolesc, dar nu abordează cu adevărat problema. Exemplele includ utilizarea unor tipuri de adnotări Jackson precum @JsonManagedReference și @JsonBackReference (care pur și simplu omite referința din spate din serializare) sau utilizarea @JsonIgnore pentru a ignora pur și simplu una dintre părțile relației. Alternativ, se poate dezvolta un cod de serializare personalizat care ignoră orice astfel de relație bidirecțională sau dependență circulară în date.

Dar nu vrem să ignorăm sau să omitem oricare dintre părțile relației bidirecționale. Dorim să-l păstrăm, în ambele direcții, fără a genera erori. O soluție reală ar trebui să permită dependențe circulare în JSON și să permită dezvoltatorului să nu se mai gândească la ele fără a întreprinde acțiuni suplimentare pentru a le remedia. Acest articol oferă o tehnică practică și simplă pentru a face acest lucru, care poate servi ca o completare utilă la orice set standard de sfaturi și practici pentru dezvoltatorul front-end de astăzi.

Un exemplu simplu de relație bidirecțională

Un caz obișnuit în care apare această problemă de relație bidirecțională (aka dependență circulară) este atunci când există un obiect părinte care are copii (la care face referire) și acele obiecte copil, la rândul lor, doresc să mențină referințe la părintele lor. Iată un exemplu simplu:

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

Dacă încercați să convertiți obiectul parent de mai sus în JSON (de exemplu, folosind metoda stringify , ca în var parentJson = JSON.stringify(parent); ), va fi aruncată excepția Uncaught TypeError: Converting circular structure to JSON .

Deși am putea folosi una dintre tehnicile discutate mai sus (cum ar fi utilizarea adnotărilor precum @JsonIgnore ), sau pur și simplu am putea elimina referințele de mai sus la părinte de la copii, acestea sunt modalități de a evita mai degrabă decât de a rezolva problema. Ceea ce ne dorim cu adevărat este o structură JSON rezultată care să mențină fiecare relație bidirecțională și pe care să o putem converti în JSON fără a arunca nicio excepție.

Îndreptarea către o soluție

Un pas potențial evident către o soluție este adăugarea unei forme de ID de obiect la fiecare obiect și apoi înlocuirea referințelor copiilor la obiectul părinte cu referințe la id -ul obiectului părinte. De exemplu:

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

Această abordare va evita cu siguranță orice excepții care rezultă dintr-o relație bidirecțională sau o referință circulară. Dar există încă o problemă, iar această problemă devine evidentă când ne gândim la modul în care am proceda pentru a serializa și a deserializa aceste referințe.

Problema este că ar trebui să știm, folosind exemplul de mai sus, că fiecare referință la valoarea „100” se referă la obiectul părinte (din moment ce acesta este id -ul său). Acest lucru va funcționa bine în exemplul de mai sus, unde singura proprietate care are valoarea „100” este proprietatea parent . Dar dacă adăugăm o altă proprietate cu valoarea „100”? De exemplu:

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

Dacă presupunem că orice referință la valoarea „100” face referire la un obiect, nu va exista nicio modalitate ca codul nostru de serializare/dezerializare să știe că atunci când parent face referire la valoarea „100”, acesta face referire la id -ul obiectului părinte, dar când priority face referire la valoarea „100”, care NU face referire la id -ul obiectului părinte (și, deoarece va crede că priority face referire și la id -ul obiectului părinte, va înlocui incorect valoarea acestuia cu o referință la obiectul părinte).

Puteți întreba în acest moment: „Stai, îți lipsește o soluție evidentă. În loc să utilizați valoarea proprietății pentru a determina că face referire la un ID de obiect, de ce nu folosiți pur și simplu numele proprietății?” Într-adevăr, aceasta este o opțiune, dar una foarte limitativă. Va însemna că va trebui să predesemnăm o listă de nume de proprietăți „rezervate” care se presupune că fac întotdeauna referire la alte obiecte (nume precum „părinte”, „copil”, „următorul”, etc.). Aceasta va însemna apoi că numai acele nume de proprietate pot fi folosite pentru referințe la alte obiecte și va însemna, de asemenea, că acele nume de proprietate vor fi întotdeauna tratate ca referințe la alte obiecte. Prin urmare, aceasta nu este o alternativă viabilă în majoritatea situațiilor.

Deci, se pare că trebuie să rămânem cu recunoașterea valorilor proprietăților ca referințe la obiect. Dar asta înseamnă că vom avea nevoie ca aceste valori să fie garantate a fi unice față de toate celelalte valori ale proprietății. Putem aborda nevoia de valori unice utilizând identificatori unici la nivel global (GUID). De exemplu:

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

Deci asta ar trebui să funcționeze, nu?

Da.

Dar…

O soluție complet automatizată

Amintiți-vă provocarea noastră inițială. Ne-am dorit să putem serializa și deserializa obiecte care au o relație bidirecțională către/de la JSON fără a genera excepții. În timp ce soluția de mai sus realizează acest lucru, face acest lucru solicitându-ne să (a) să adăugăm o formă de câmp ID unic fiecărui obiect și (b) să înlocuim fiecare referință de obiect cu ID-ul unic corespunzător. Acest lucru va funcționa, dar am prefera cu mult o soluție care să funcționeze automat cu referințele noastre de obiecte existente, fără a ne solicita să ne modificăm „manual” obiectele în acest fel.

În mod ideal, dorim să putem trece un set de obiecte (conținând orice set arbitrar de proprietăți și referințe la obiect) prin serializator și deserializator (fără a genera excepții bazate pe o relație bidirecțională) și ca obiectele generate de deserializator să se potrivească exact obiectele care au fost introduse în serializator.

Abordarea noastră este ca serializatorul nostru să creeze și să adauge automat un ID unic (folosind un GUID) fiecărui obiect. Apoi înlocuiește orice referință la obiect cu GUID-ul acelui obiect. (Rețineți că serializatorul va trebui să folosească un nume de proprietate unic și pentru aceste ID-uri; în exemplul nostru, folosim @id , deoarece se presupune că adăugarea „@” înaintea numelui proprietății este adecvată pentru a ne asigura că este unic.) Deserializatorul va înlocui apoi orice GUID care corespunde unui ID de obiect cu o referință la acel obiect (rețineți că deserializatorul va elimina, de asemenea, GUID-urile generate de serializator din obiectele deserializate, revenind astfel exact la starea lor inițială).

Așadar, revenind la exemplul nostru, dorim să alimentăm următorul set de obiecte așa cum este serializatorul nostru:

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

Ne-am aștepta apoi ca serializatorul să genereze o structură JSON similară cu următoarea:

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

(Puteți folosi un instrument de formatare JSON pentru a pretifica orice obiect JSON.)

Apoi, alimentarea JSON de mai sus la deserializator ar genera setul original de obiecte (adică, obiectul părinte și cei doi copii ai săi, făcând referire unul la celălalt în mod corespunzător).

Așa că acum că știm ce vrem să facem și cum vrem să facem, haideți să o implementăm.

Implementarea Serializatorului în JavaScript

Mai jos este un exemplu de implementare JavaScript funcțională a unui serializator care va gestiona corect o relație bidirecțională fără a arunca nicio excepție.

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

Implementarea deserializatorului în JavaScript

Mai jos este un exemplu de implementare JavaScript funcțională a unui deserializator care va gestiona corect o relație bidirecțională fără a arunca nicio excepție.

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

Trecerea unui set de obiecte (inclusiv cele care au o relație bidirecțională) prin aceste două metode este în esență o funcție de identitate; adică convertToObject(convertToJson(obj)) === obj se evaluează la adevărat.

Exemplu Java/Jackson

Acum să ne uităm la modul în care această abordare este acceptată în bibliotecile externe populare. De exemplu, să vedem cum se gestionează în Java folosind 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; } }

Aceste două clase Java Parent și Copil reprezintă aceeași structură ca în exemplul JavaScript de la începutul acestui articol. Ideea principală aici este utilizarea adnotării @JsonIdentityInfo care îi va spune lui Jackson cum să serializeze/deserializați aceste obiecte.

Să vedem un exemplu:

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

Ca rezultat al serializării instanței părinte la JSON, aceeași structură JSON va fi returnată ca în exemplul 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" }, ] }

Alt avantaj

Abordarea descrisă pentru gestionarea unei relații bidirecționale în JSON poate fi, de asemenea, valorificată pentru a ajuta la reducerea dimensiunii unui fișier JSON, deoarece vă permite să faceți referire la obiecte pur și simplu prin ID-ul lor unic, mai degrabă decât să fie nevoie să includeți copii redundante ale aceluiași obiect.

Luați în considerare următorul exemplu:

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

După cum se arată în matricea filteredChildren , putem include pur și simplu referințe la obiect în JSON-ul nostru, mai degrabă decât replici ale obiectelor la care se face referire și ale conținutului acestora.

Învelire

Cu această soluție, puteți elimina excepțiile legate de referințe circulare în timp ce serializați fișierele JSON într-un mod care minimizează orice constrângere asupra obiectelor și datelor dvs. Dacă nu este deja disponibilă o astfel de soluție în bibliotecile pe care le utilizați pentru gestionarea serializării fișierelor JSON, vă puteți implementa propria soluție pe baza exemplului de implementare furnizat. Sper că veți găsi acest lucru de ajutor.