การสนับสนุนความสัมพันธ์แบบสองทิศทางใน JSON

เผยแพร่แล้ว: 2022-03-11

เคยพยายามสร้างโครงสร้างข้อมูล JSON ที่รวมเอนทิตีที่มีความสัมพันธ์แบบสองทิศทาง (เช่น การอ้างอิงแบบวงกลม) หรือไม่ หากคุณมี คุณอาจเห็นข้อผิดพลาด JavaScript ตามบรรทัดของ “Uncaught TypeError: การแปลงโครงสร้างแบบวงกลมเป็น JSON” หรือหากคุณเป็นนักพัฒนา Java ที่ใช้ไลบรารี่ Jackson คุณอาจเคยพบ “ไม่สามารถเขียน JSON: Infinite recursion (StackOverflowError) ด้วยสาเหตุราก java.lang.StackOverflowError”

JSON ความท้าทายความสัมพันธ์แบบสองทิศทาง

บทความนี้มีแนวทางการทำงานที่มีประสิทธิภาพในการสร้างโครงสร้าง 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 คุณสามารถใช้โซลูชันของคุณเองตามตัวอย่างการใช้งานที่ให้มา หวังว่าคุณจะพบว่าสิ่งนี้มีประโยชน์