Поддержка двунаправленных отношений в JSON

Опубликовано: 2022-03-11

Вы когда-нибудь пытались создать структуру данных JSON, включающую сущности с двунаправленной связью (например, циклическую ссылку)? Если да, то вы, вероятно, видели ошибку JavaScript, похожую на «Uncaught TypeError: Преобразование циклической структуры в JSON» . Или, если вы Java-разработчик, использующий библиотеку Jackson, вы могли столкнуться с сообщением «Не удалось написать JSON: бесконечная рекурсия (StackOverflowError) с основной причиной java.lang.StackOverflowError» .

Задача двунаправленных отношений JSON

В этой статье представлен надежный рабочий подход к созданию структур 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», этот IS ссылается на id родительского объекта, но когда priority ссылается на значение «100», которое НЕ ссылается на id родительского объекта (и поскольку он будет думать, что priority также ссылается на 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 или из него, не создавая никаких исключений. Хотя приведенное выше решение достигает этого, оно требует от нас (а) добавления некоторого поля уникального идентификатора к каждому объекту и (б) замены каждой ссылки на объект соответствующим уникальным идентификатором. Это будет работать, но мы бы предпочли решение, которое просто автоматически работало бы с нашими существующими ссылками на объекты, не требуя от нас «ручного» изменения наших объектов таким образом.

В идеале мы хотим иметь возможность передавать набор объектов (содержащий любой произвольный набор свойств и ссылок на объекты) через сериализатор и десериализатор (без создания каких-либо исключений на основе двунаправленной связи) и чтобы объекты, сгенерированные десериализатором, точно соответствовали друг другу. объекты, которые были загружены в сериализатор.

Наш подход заключается в том, чтобы наш сериализатор автоматически создавал и добавлял уникальный идентификатор (используя GUID) к каждому объекту. Затем он заменяет любую ссылку на объект идентификатором GUID этого объекта. (Обратите внимание, что сериализатор также должен будет использовать какое-то уникальное имя свойства для этих идентификаторов; в нашем примере мы используем @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 оценивается как true.

Пример Java/Джексона

Теперь посмотрим, как этот подход поддерживается в популярных внешних библиотеках. Например, давайте посмотрим, как это обрабатывается в 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": "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, вы можете реализовать собственное решение на основе предоставленного примера реализации. Надеюсь, вы найдете это полезным.