دعم العلاقة ثنائي الاتجاه في JSON
نشرت: 2022-03-11هل حاولت يومًا إنشاء بنية بيانات JSON تتضمن كيانات لها علاقة ثنائية الاتجاه (أي مرجع دائري)؟ إذا كان لديك ، فمن المحتمل أنك رأيت خطأ جافا سكريبت على غرار "خطأ نوع غير معلوم: تحويل البنية الدائرية إلى JSON" . أو إذا كنت مطور Java يستخدم مكتبة Jackson ، فربما تكون قد واجهت "تعذر كتابة JSON: العودية اللانهائية (StackOverflowError) مع السبب الجذري java.lang.StackOverflowError" .
توفر هذه المقالة طريقة عمل قوية لإنشاء هياكل JSON التي تتضمن علاقة ثنائية الاتجاه دون التسبب في هذه الأخطاء.
في كثير من الأحيان ، تستلزم الحلول التي يتم تقديمها لهذه المشكلة حلولاً تكون في الأساس خطوة جانبية ، ولكنها لا تعالج المشكلة حقًا. تتضمن الأمثلة استخدام أنواع تعليقات جاكسون التوضيحية مثل @JsonManagedReference
و @JsonBackReference
(التي تحذف ببساطة المرجع الخلفي من التسلسل) أو استخدام @JsonIgnore
لتجاهل أحد جوانب العلاقة ببساطة. بدلاً من ذلك ، يمكن للمرء تطوير رمز تسلسل مخصص يتجاهل أي علاقة ثنائية الاتجاه أو تبعية دائرية في البيانات.
لكننا لا نريد تجاهل أو حذف أي من جانبي العلاقة ثنائية الاتجاه. نريد الحفاظ عليه ، في كلا الاتجاهين ، دون التسبب في أي أخطاء. يجب أن يسمح الحل الحقيقي بالتبعية الدائرية في JSON ويسمح للمطور بالتوقف عن التفكير فيها دون اتخاذ إجراءات إضافية لإصلاحها. توفر هذه المقالة تقنية عملية ومباشرة للقيام بذلك ، والتي يمكن أن تكون بمثابة إضافة مفيدة إلى أي مجموعة قياسية من النصائح والممارسات لمطور الواجهة الأمامية اليوم.
مثال بسيط على علاقة ثنائية الاتجاه
من الحالات الشائعة التي تنشأ فيها هذه العلاقة ثنائية الاتجاه (ويعرف أيضًا باسم التبعية الدائرية) عندما يكون هناك كائن أصلي له أطفال (والتي تشير إليه) وتلك الكائنات الفرعية ، بدورها ، تريد الاحتفاظ بالإشارات إلى والدها. إليك مثال بسيط:
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 دون طرح أي استثناءات.
التحرك نحو الحل
تتمثل إحدى الخطوات الواضحة نحو الحل في إضافة شكل من أشكال معرف الكائن إلى كل كائن ثم استبدال مراجع الأطفال إلى الكائن الأصل بمراجع إلى معرف الكائن الأصل. علي سبيل المثال:
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" تشير إلى كائن ، فلن تكون هناك طريقة لكود التسلسل / إلغاء التسلسل الخاص بنا لمعرفة أنه عندما يشير parent
إلى القيمة "100" ، فإن هذا يشير إلى id
الكائن الأصلي ، ولكن متى تشير priority
إلى القيمة "100" ، التي لا تشير إلى id
الكائن الأصلي (وبما أنها ستعتقد أن priority
تشير أيضًا إلى id
الكائن الأصلي ، فإنها ستحل محل قيمتها بشكل غير صحيح بمرجع إلى الكائن الأصلي).
قد تسأل في هذه المرحلة ، "انتظر ، أنت تفتقد حلاً واضحًا. بدلاً من استخدام قيمة الخاصية لتحديد أنها تشير إلى معرف كائن ، لماذا لا تستخدم اسم الخاصية فقط؟ " في الواقع ، هذا خيار ، لكنه محدود للغاية. سيعني ذلك أننا سنحتاج إلى التحديد المسبق لقائمة بأسماء الخصائص "المحجوزة" التي يُفترض دائمًا أنها تشير إلى كائنات أخرى (أسماء مثل "الأصل" ، "الطفل" ، "التالي" ، إلخ.). سيعني هذا بعد ذلك أنه يمكن استخدام أسماء الخصائص هذه فقط للإشارة إلى كائنات أخرى وسيعني أيضًا أن أسماء الخصائص هذه ستعامل دائمًا كمراجع لكائنات أخرى. لذلك ، هذا ليس بديلاً قابلاً للتطبيق في معظم الحالات.
لذلك يبدو أننا بحاجة إلى التمسك بالتعرف على قيم الخصائص كمراجع للكائنات. لكن هذا يعني أننا سنحتاج إلى ضمان أن تكون هذه القيم فريدة من جميع قيم الممتلكات الأخرى. يمكننا تلبية الحاجة إلى قيم فريدة باستخدام المعرفات الفريدة عالميًا (GUIDs). علي سبيل المثال:
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 دون إنشاء أي استثناءات. في حين أن الحل أعلاه يحقق ذلك ، فإنه يقوم بذلك عن طريق مطالبتنا بما يلي (أ) إضافة شكل من أشكال حقل المعرف الفريد لكل كائن و (ب) استبدال كل مرجع كائن بالمعرف الفريد المقابل. سيعمل هذا ، لكننا نفضل كثيرًا حلاً يعمل تلقائيًا مع مراجع الكائنات الموجودة لدينا دون مطالبتنا بتعديل كائناتنا "يدويًا" بهذه الطريقة.
من الناحية المثالية ، نريد أن نكون قادرين على تمرير مجموعة من الكائنات (تحتوي على أي مجموعة عشوائية من الخصائص ومراجع الكائنات) من خلال جهاز التسلسل وإلغاء التسلسل (دون إنشاء أي استثناءات بناءً على علاقة ثنائية الاتجاه) وجعل الكائنات التي تم إنشاؤها بواسطة برنامج إلغاء التسلسل متطابقة بدقة الكائنات التي تم إدخالها في المسلسل.
نهجنا هو جعل جهاز التسلسل الخاص بنا ينشئ تلقائيًا ويضيف معرفًا فريدًا (باستخدام GUID) لكل كائن. ثم يستبدل أي مرجع كائن بـ GUID لهذا الكائن. (لاحظ أن المُسلسل سيحتاج إلى استخدام اسم خاصية فريد لهذه المعرفات أيضًا ؛ في مثالنا ، نستخدم @id
نظرًا لأنه من المفترض أن يكون إدخال العلامة "@" مسبقًا في اسم الخاصية مناسبًا للتأكد من أنه فريد.) سيحل بعد ذلك محل أي GUID يتوافق مع معرف الكائن بمرجع إلى هذا الكائن (لاحظ أن أداة إلغاء التسلسل ستزيل أيضًا GUIDs التي تم إنشاؤها بواسطة جهاز التسلسل من الكائنات التي تم إلغاء تسلسلها ، وبالتالي إعادتها إلى حالتها الأولية بدقة).
لذا بالعودة إلى مثالنا ، نريد تغذية المجموعة التالية من الكائنات كما هي للمسلسل الخاص بنا:
var obj = { "name": "I'm parent" } obj.children = [ { "name": "I'm first child", "parent": obj }, { "name": "I'm second child", "parent": obj } ]
نتوقع بعد ذلك أن يقوم المسلسل بإنشاء بنية 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 أعلاه إلى جهاز إلغاء التسلسل إلى إنشاء المجموعة الأصلية من الكائنات (على سبيل المثال ، الكائن الرئيسي وطفليهما ، مع الإشارة إلى بعضهما البعض بشكل صحيح).
والآن بعد أن عرفنا ما نريد القيام به وكيف نريد القيام به ، فلننفذ ذلك.
تنفيذ المسلسل في JavaScript
يوجد أدناه نموذج لتطبيق JavaScript يعمل لمسلسل يتعامل بشكل صحيح مع علاقة ثنائية الاتجاه دون طرح أي استثناءات.
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"); }
أشياء = []؛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.
@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 and 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": "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 ، فيمكنك تنفيذ الحل الخاص بك بناءً على المثال المقدم. أتمنى أن تجده مفيد.