JSON 中的双向关系支持
已发表: 2022-03-11曾经尝试过创建包含具有双向关系(即循环引用)的实体的 JSON 数据结构吗? 如果您有,您可能已经看到类似“未捕获的类型错误:将循环结构转换为 JSON”的 JavaScript 错误。 或者,如果您是使用 Jackson 库的 Java 开发人员,您可能遇到过“Could not write JSON: Infinite recursion (StackOverflowError) with root cause java.lang.StackOverflowError” 。
本文提供了一种强大的工作方法来创建包含双向关系的 JSON 结构,而不会导致这些错误。
通常,针对这个问题提出的解决方案需要一些变通方法,这些变通方法基本上是回避但并没有真正解决这个问题。 示例包括使用诸如@JsonManagedReference和@JsonBackReference类的 Jackson 注释类型(它只是从序列化中省略反向引用)或使用@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: Converting circular structure to 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”的任何引用都在引用一个对象,那么我们的序列化/反序列化代码将无法知道当parent对象引用值“100”时,即是在引用父对象的id ,但是当priority引用值“100”,即不引用父对象的id (并且由于它会认为priority也引用父对象的id ,因此它会错误地将其值替换为对父对象的引用)。
此时你可能会问: “等等,你错过了一个明显的解决方案。 与其使用属性值来确定它引用的是对象 id,不如直接使用属性名称?” 事实上,这是一种选择,但非常有限。 这意味着我们需要预先指定一个“保留”属性名称列表,这些属性名称总是假定引用其他对象(名称如“父”、“子”、“下一个”等)。 这意味着只有那些属性名称可以用于引用其他对象,也意味着这些属性名称将始终被视为对其他对象的引用。 因此,在大多数情况下,这不是一个可行的替代方案。
所以看起来我们需要坚持将属性值识别为对象引用。 但这意味着我们需要保证这些值与所有其他属性值不同。 我们可以通过使用全局唯一标识符 (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替换每个对象引用。 这会起作用,但我们更喜欢一种解决方案,它可以自动使用我们现有的对象引用,而不需要我们以这种方式“手动”修改我们的对象。
理想情况下,我们希望能够通过序列化器和反序列化器传递一组对象(包含任意一组属性和对象引用)(不会基于双向关系生成任何异常)并让反序列化器生成的对象精确匹配输入序列化程序的对象。
我们的方法是让我们的序列化程序自动创建并向每个对象添加唯一 ID(使用 GUID)。 然后它将任何对象引用替换为该对象的 GUID。 (请注意,序列化程序也需要为这些 ID 使用一些唯一的属性名称;在我们的示例中,我们使用@id ,因为大概在属性名称前面加上“@”足以确保它是唯一的。)反序列化程序然后将使用对该对象的引用替换与对象 ID 对应的任何 GUID(请注意,反序列化器还将从反序列化的对象中删除序列化器生成的 GUID,从而将它们精确地返回到它们的初始状态)。
所以回到我们的例子,我们想将以下对象集原样提供给我们的序列化器:
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"); }在 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 中处理它。
@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 的结果,将返回与 JavaScript 示例中相同的 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 文件的大小,因为它使您能够简单地通过对象的唯一 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 文件序列化的库中还没有此类解决方案,您可以根据提供的示例实现来实现您自己的解决方案。 希望你觉得这很有帮助。
