การสนับสนุนความสัมพันธ์แบบสองทิศทางใน JSON
เผยแพร่แล้ว: 2022-03-11เคยพยายามสร้างโครงสร้างข้อมูล JSON ที่รวมเอนทิตีที่มีความสัมพันธ์แบบสองทิศทาง (เช่น การอ้างอิงแบบวงกลม) หรือไม่ หากคุณมี คุณอาจเห็นข้อผิดพลาด JavaScript ตามบรรทัดของ “Uncaught TypeError: การแปลงโครงสร้างแบบวงกลมเป็น JSON” หรือหากคุณเป็นนักพัฒนา Java ที่ใช้ไลบรารี่ Jackson คุณอาจเคยพบ “ไม่สามารถเขียน JSON: Infinite recursion (StackOverflowError) ด้วยสาเหตุราก java.lang.StackOverflowError”
บทความนี้มีแนวทางการทำงานที่มีประสิทธิภาพในการสร้างโครงสร้าง JSON ที่มีความสัมพันธ์แบบสองทิศทางโดยไม่ทำให้เกิดข้อผิดพลาดเหล่านี้
บ่อยครั้ง แนวทางแก้ไขที่นำเสนอในปัญหานี้ทำให้เกิดวิธีแก้ไขปัญหาชั่วคราวซึ่งโดยทั่วไปแล้วจะเป็นขั้นตอนข้างเคียง แต่ไม่ได้แก้ไขปัญหาจริงๆ ตัวอย่างรวมถึงการใช้ประเภทคำอธิบายประกอบของ Jackson เช่น @JsonManagedReference
และ @JsonBackReference
(ซึ่งเพียงแค่ละเว้นการอ้างอิงด้านหลังจากการทำให้เป็นอันดับ) หรือใช้ @JsonIgnore
เพื่อละเว้นด้านใดด้านหนึ่งของความสัมพันธ์ อีกทางหนึ่งสามารถพัฒนารหัสซีเรียลไลซ์เซชั่นแบบกำหนดเองที่ไม่สนใจความสัมพันธ์แบบสองทิศทางหรือการพึ่งพาแบบวงกลมในข้อมูล
แต่เราไม่ต้องการเพิกเฉยหรือละเว้นความสัมพันธ์แบบสองทิศทางด้านใดด้านหนึ่ง เราต้องการรักษาไว้ทั้งสองทิศทางโดยไม่ให้เกิดข้อผิดพลาดใดๆ โซลูชัน ที่แท้จริงควรอนุญาตให้มีการพึ่งพาแบบวงกลมใน JSON และอนุญาตให้นักพัฒนาหยุดคิดถึงสิ่งเหล่านี้โดยไม่ต้องดำเนินการเพิ่มเติมเพื่อแก้ไข บทความนี้นำเสนอเทคนิคที่ใช้งานได้จริงและตรงไปตรงมา ซึ่งสามารถเป็นส่วนเสริมที่เป็นประโยชน์สำหรับชุดเคล็ดลับและแนวทางปฏิบัติมาตรฐานสำหรับนักพัฒนาส่วนหน้าในปัจจุบัน
ตัวอย่างความสัมพันธ์แบบสองทิศทางอย่างง่าย
กรณีทั่วไปที่ปัญหาความสัมพันธ์แบบสองทิศทาง (aka การพึ่งพาแบบวงกลม) เกิดขึ้นคือเมื่อมีอ็อบเจ็กต์หลักที่มีลูก (ซึ่งอ้างอิงถึง) และในทางกลับกัน อ็อบเจกต์ย่อยเหล่านั้นต้องการรักษาการอ้างอิงถึงพาเรนต์ นี่เป็นตัวอย่างง่ายๆ:
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]
หากคุณพยายามแปลงวัตถุ parent
ด้านบนเป็น JSON (ตัวอย่างเช่น โดยใช้เมธอด stringify
เช่นใน var parentJson = JSON.stringify(parent);
) ข้อยกเว้น Uncaught TypeError: การแปลงโครงสร้างแบบวงกลมเป็น JSON จะถูกส่งออกไป
แม้ว่าเราจะสามารถใช้เทคนิคใดเทคนิคหนึ่งที่กล่าวถึงข้างต้น (เช่น การใช้คำอธิบายประกอบ เช่น @JsonIgnore
) หรือเราอาจเพียงแค่ลบการอ้างอิงด้านบนถึงผู้ปกครองออกจากกลุ่มย่อย สิ่งเหล่านี้เป็นวิธีการ หลีกเลี่ยง มากกว่า การแก้ ปัญหา สิ่งที่เราต้องการจริงๆ คือโครงสร้าง JSON ที่เป็นผลลัพธ์ซึ่งรักษาความสัมพันธ์แบบสองทิศทางแต่ละรายการ และเราสามารถแปลงเป็น JSON ได้โดยไม่ต้องมีข้อยกเว้นใดๆ
ก้าวไปสู่การแก้ปัญหา
ขั้นตอนหนึ่งที่อาจชัดเจนในการแก้ปัญหาคือการเพิ่มรูปแบบ ID อ็อบเจ็กต์ให้กับแต่ละอ็อบเจ็กต์ จากนั้นแทนที่การอ้างอิงของลูกไปยัง อ็อบเจกต์ พาเรนต์ด้วยการอ้างอิงถึง id ของอ็อบเจ็กต์พาเรนต์ ตัวอย่างเช่น:
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 } ]
แนวทางนี้จะหลีกเลี่ยงข้อยกเว้นที่เกิดจากความสัมพันธ์แบบสองทิศทางหรือการอ้างอิงแบบวงกลมอย่างแน่นอน แต่ยังมีปัญหาอยู่ และปัญหานั้นก็ชัดเจนเมื่อเราคิดว่าเราจะดำเนินการจัดลำดับและดีซีเรียลไลซ์ข้อมูลอ้างอิงเหล่านี้ได้อย่างไร
ปัญหาคือเราจำเป็นต้องรู้ โดยใช้ตัวอย่างข้างต้น ทุกการอ้างอิงถึงค่า “100” หมายถึงอ็อบเจกต์หลัก (เนื่องจากนั่นคือ id
) ซึ่งจะใช้ได้ดีในตัวอย่างข้างต้นโดยที่คุณสมบัติเดียวที่มีค่า "100" คือคุณสมบัติ parent
แต่ถ้าเราเพิ่มคุณสมบัติอื่นที่มีค่า “100” ล่ะ? ตัวอย่างเช่น:
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 } ]
หากเราคิดว่าการอ้างอิงถึงค่า "100" ใด ๆ กำลังอ้างอิงวัตถุ จะไม่มีทางใดที่รหัสการทำให้เป็นอนุกรม/ดีซีเรียลไลเซชันของเราจะรู้ว่าเมื่อพาเรนต์อ้างอิงค่า "100" นั้น IS อ้างอิงถึง id
ของอ็อบเจ็กต์ parent
แต่เมื่อ priority
อ้างอิงค่า "100" ซึ่งไม่ได้อ้างอิง id
ของวัตถุหลัก (และเนื่องจากจะคิดว่า priority
นั้นอ้างอิงถึง id
ของวัตถุหลักด้วย มันจะแทนที่ค่าด้วยการอ้างอิงไปยังวัตถุหลักอย่างไม่ถูกต้อง)
คุณอาจถาม ณ จุดนี้ว่า “เดี๋ยวก่อน คุณไม่มีวิธีแก้ปัญหาที่ชัดเจน แทนที่จะใช้ค่าคุณสมบัติเพื่อกำหนดว่าอ้างอิงถึง id อ็อบเจ็กต์ ทำไมคุณไม่ใช้ชื่อคุณสมบัติเพียงอย่างเดียวล่ะ” อันที่จริงนั่นก็เป็นทางเลือกหนึ่ง แต่มีข้อ จำกัด อย่างมาก หมายความว่าเราจะต้องกำหนดรายการชื่อคุณสมบัติ "ที่สงวนไว้" ไว้ล่วงหน้า ซึ่งมักสันนิษฐานว่าอ้างอิงวัตถุอื่นๆ (ชื่อเช่น "พาเรนต์" "ลูก" "ถัดไป" เป็นต้น) จากนั้นจะหมายความว่า เฉพาะ ชื่อคุณสมบัติเหล่านั้นเท่านั้นที่สามารถใช้สำหรับการอ้างอิงไปยังอ็อบเจ็กต์อื่น และยังหมายความว่าชื่อคุณสมบัติเหล่านั้น จะ ถือว่าเป็นการอ้างอิงไปยังอ็อบเจ็กต์อื่นเสมอ ดังนั้นจึงไม่ใช่ทางเลือกที่เป็นไปได้ในสถานการณ์ส่วนใหญ่
ดูเหมือนว่าเราต้องยึดติดกับการรับรู้ค่าคุณสมบัติเป็นการอ้างอิงวัตถุ แต่นี่หมายความว่าเราต้องการให้ค่าเหล่านี้ได้รับ การประกัน ว่าไม่ซ้ำกันจากค่าคุณสมบัติอื่นๆ ทั้งหมด เราสามารถตอบสนองความต้องการค่าที่ไม่ซ้ำกันได้โดยใช้ Globally Unique Identifiers (GUID) ตัวอย่างเช่น:
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 } ]
มันควรจะได้ผลใช่มั้ย?
ใช่.
แต่…
โซลูชันอัตโนมัติเต็มรูปแบบ
จำความท้าทายเดิมของเรา เราต้องการทำให้เป็นอนุกรมและดีซีเรียลไลซ์ออบเจ็กต์ที่มีความสัมพันธ์แบบสองทิศทางไปยัง/จาก JSON โดยไม่สร้างข้อยกเว้นใดๆ ในขณะที่วิธีแก้ปัญหาข้างต้นทำได้สำเร็จ โดยกำหนดให้เรา (a) เพิ่มฟิลด์ ID เฉพาะบางรูปแบบให้กับแต่ละอ็อบเจ็กต์ และ (b) แทนที่ การอ้างอิงอ็อบเจ็กต์แต่ละรายการด้วย ID ที่ไม่ซ้ำที่สอดคล้องกัน สิ่งนี้จะได้ผล แต่เราต้องการโซลูชันที่จะทำงานกับการอ้างอิงอ็อบเจ็กต์ที่มีอยู่ของเราโดยอัตโนมัติ โดยไม่ต้องให้เรา "ด้วยตนเอง" แก้ไขออบเจ็กต์ด้วยวิธีนี้
ตามหลักการแล้ว เราต้องการส่งชุดของอ็อบเจ็กต์ (ประกอบด้วยชุดคุณสมบัติและการอ้างอิงอ็อบเจ็กต์ตามอำเภอใจ) ผ่าน serializer และ deserializer (โดยไม่สร้างข้อยกเว้นใดๆ ตามความสัมพันธ์แบบสองทิศทาง) และให้วัตถุที่สร้างโดย deserializer ตรงกันอย่างแม่นยำ วัตถุที่ถูกป้อนเข้าไปในซีเรียลไลเซอร์
แนวทางของเราคือให้ serializer ของเราสร้างและเพิ่ม ID เฉพาะ (โดยใช้ GUID) ให้กับแต่ละอ็อบเจ็กต์โดยอัตโนมัติ จากนั้นจะแทนที่การอ้างอิงวัตถุด้วย GUID ของวัตถุนั้น (โปรดทราบว่าเครื่องซีเรียลไลเซอร์จะต้องใช้ชื่อคุณสมบัติเฉพาะบาง ชื่อ สำหรับ ID เหล่านี้เช่นกัน ในตัวอย่างของเรา เราใช้ @id
เนื่องจากสันนิษฐานว่าการเติม “@” ข้างหน้าชื่อคุณสมบัตินั้นเพียงพอเพื่อให้แน่ใจว่าจะไม่ซ้ำกัน) จากนั้นจะแทนที่ GUID ใดๆ ที่สอดคล้องกับ ID อ็อบเจ็กต์ด้วยการอ้างอิงไปยังอ็อบเจ็กต์นั้น (โปรดทราบว่าดีซีเรียลไลเซอร์จะลบ GUID ที่สร้างซีเรียลไลเซอร์ออกจากออบเจกต์ดีซีเรียลไลซ์ด้วย ซึ่งจะทำให้พวกมันกลับสู่สถานะเริ่มต้นได้อย่างแม่นยำ)
กลับมาที่ตัวอย่างของเรา เราต้องการป้อนชุดของอ็อบเจ็กต์ต่อไปนี้ เช่นเดียว กับซีเรียลไลเซอร์ของเรา:
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]
จากนั้นเราคาดว่า serializer จะสร้างโครงสร้าง JSON ที่คล้ายกับต่อไปนี้:
{ "@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" }, ] }
(คุณสามารถใช้เครื่องมือจัดรูปแบบ JSON เพื่อทำให้วัตถุ JSON สวยงาม)

จากนั้นป้อน JSON ด้านบนไปยังตัวดีซีเรียลไลเซอร์จะสร้างชุดออบเจ็กต์ดั้งเดิม (เช่น อ็อบเจ็กต์หลักและลูกทั้งสอง อ้างอิงถึงกันอย่างเหมาะสม)
ตอนนี้เรารู้แล้วว่าเราต้องการทำอะไรและต้องการทำอะไร มาลงมือทำกันเลย
การใช้ Serializer ใน JavaScript
ด้านล่างนี้คือตัวอย่างการใช้งาน JavaScript ของ Serializer ที่จะจัดการความสัมพันธ์แบบสองทิศทางได้อย่างถูกต้องโดยไม่มีข้อยกเว้นใดๆ
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"); }
การนำ Deserializer ไปใช้ใน JavaScript
ด้านล่างนี้คือตัวอย่างการใช้งาน JavaScript ของดีซีเรียลไลเซอร์ที่จะจัดการความสัมพันธ์แบบสองทิศทางอย่างถูกต้องโดยไม่มีข้อยกเว้นใดๆ
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"); }
การส่งชุดของอ็อบเจ็กต์ (รวมถึงวัตถุที่มีความสัมพันธ์แบบสองทิศทาง) ผ่านสองวิธีนี้ถือเป็นฟังก์ชันเอกลักษณ์ เช่น convertToObject(convertToJson(obj)) === obj
ประเมินว่าเป็นจริง
ตัวอย่าง Java/Jackson
ตอนนี้เรามาดูกันว่าวิธีนี้ได้รับการสนับสนุนอย่างไรในไลบรารีภายนอกยอดนิยม ตัวอย่างเช่น มาดูวิธีการจัดการกับ Java โดยใช้ไลบรารี 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; } }
คลาส Java ทั้งสองคลาส Parent และ Child แสดงถึงโครงสร้างเดียวกับในตัวอย่าง JavaScript ในตอนต้นของบทความนี้ ประเด็นหลักอยู่ที่การใช้คำอธิบายประกอบ @JsonIdentityInfo
ซึ่งจะบอกให้แจ็คสันทราบถึงวิธีการทำให้เป็นอนุกรม/ดีซีเรียลไลซ์วัตถุเหล่านี้
มาดูตัวอย่างกัน:
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));
จากผลของการทำให้เป็นอนุกรมอินสแตนซ์หลักเป็น JSON โครงสร้าง JSON เดียวกันจะถูกส่งคืนดังในตัวอย่าง 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" }, ] }
ข้อดีอีกอย่าง
แนวทางที่อธิบายไว้ในการจัดการความสัมพันธ์แบบสองทิศทางใน JSON ยังสามารถนำมาใช้เพื่อช่วยลดขนาดของไฟล์ JSON เนื่องจากช่วยให้คุณสามารถอ้างอิงวัตถุได้ง่ายๆ โดยใช้ ID เฉพาะ แทนที่จะต้องรวมสำเนาซ้ำซ้อนของวัตถุเดียวกัน
พิจารณาตัวอย่างต่อไปนี้:
{ "@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" ] }
ตามที่แสดงในอาร์เรย์ filteredChildren
เราสามารถรวมการอ้างอิงออบเจ็กต์ใน JSON ของเราแทนการจำลองของออบเจ็กต์ที่อ้างอิงและเนื้อหาได้
สรุป
ด้วยโซลูชันนี้ คุณสามารถกำจัดข้อยกเว้นที่เกี่ยวข้องกับการอ้างอิงแบบวงกลมในขณะที่ทำให้ไฟล์ JSON เป็นอนุกรมในลักษณะที่จะลดข้อจำกัดใดๆ บนวัตถุและข้อมูลของคุณ หากไม่มีโซลูชันดังกล่าวในไลบรารีที่คุณใช้สำหรับจัดการการทำให้เป็นอนุกรมของไฟล์ JSON คุณสามารถใช้โซลูชันของคุณเองตามตัวอย่างการใช้งานที่ให้มา หวังว่าคุณจะพบว่าสิ่งนี้มีประโยชน์