1. Введение
Циклическая (или круговая) ссылка возникает, когда один объект прямо или косвенно содержит ссылку на другой объект, который в итоге ссылается обратно на первый.
Реальный пример
Давайте попробуем сделать жизненный пример для нашей книжной библиотеки. Пусть у нас есть класс Book, у которого есть свойство Author, а у класса Author есть свойство Books типа List<Book>, чтобы он помнил все свои книги.
public class Author
{
public string Name { get; set; }
public int BirthYear { get; set; }
public List<Book> Books { get; set; } = new List<Book>();
}
public class Book
{
public string Title { get; set; }
public Author Author { get; set; }
}
Теперь, если мы создадим одного автора и одну книгу, установим связи, получим "замкнутый круг":
var author = new Author { Name = "Марсель Пруст", BirthYear = 1871 };
var book = new Book { Title = "В сторону Свана", Author = author };
author.Books.Add(book);
// Всё, теперь author ссылается на book, а book ссылается на author!
Почему это проблема?
Когда вы сериализуете такой объект в JSON, сериализатор начинает идти по свойствам. Он видит, что у автора есть книги, внутри которых снова автор... который снова содержит книги... которые снова содержат авторов... и так до бесконечности.
author -> books[] -> author -> books[] ...
Это как смотреть в зеркало, стоя перед другим зеркалом — отражения уходят в бесконечность. Только вместо красивых отражений — переполнение стека (StackOverflowException).
2. Как сериализатор реагирует на циклические ссылки?
Ошибка сериализации
По умолчанию System.Text.Json не умеет обрабатывать циклические ссылки. Если вы попробуете сериализовать такую структуру, получите исключение JsonException: "A possible object cycle was detected".
Пример, который вызовет ошибку:
string json = JsonSerializer.Serialize(author); // БАЦ! JsonException
Визуализация:
graph TD;
Author --> Book;
Book --> Author;
3. Как решать проблему циклических ссылок?
Рассмотрим несколько реальных подходов, каждый со своими плюсами и минусами. Как вы уже догадались, универсального "волшебного флага" нет (а жаль).
Удалять циклы перед сериализацией
Самое простое – не делать так. Перед сериализацией обнулять (или игнорировать) ссылки, ведущие к циклу.
Как это выглядит на практике:
// Временно уберём ссылку у автора на книги
var authorToSerialize = new Author
{
Name = author.Name,
BirthYear = author.BirthYear,
Books = null // или можно вообще не включать свойство
};
string json = JsonSerializer.Serialize(authorToSerialize);
// Теперь всё сериализовалось без проблем!
Плюсы: просто, быстро, понятно.
Минусы: вы теряете часть данных (после десериализации не узнаете обратные связи).
Использовать атрибут [JsonIgnore]
Можно пометить свойство, участвующее в круговой ссылке, как игнорируемое:
public class Author
{
public string Name { get; set; }
public int BirthYear { get; set; }
[JsonIgnore]
public List<Book> Books { get; set; }
}
Теперь при сериализации автора его книги сохраняться не будут. Это похоже на способ выше, но декларативно и без ручной "чистки".
Плюсы: проще, меньше риска забыть очистить ссылку.
Минусы: информация о книгах автора потеряна в JSON.
Использовать идентификаторы вместо вложенных объектов
Если важно сохранить обе стороны связи (и авторов, и книги), но не хочется круговых ссылок, используйте уникальные идентификаторы вместо вложенных объектов:
public class Book
{
public string Title { get; set; }
public int AuthorId { get; set; } // вместо Author
}
public class Author
{
public int AuthorId { get; set; }
public string Name { get; set; }
// не храним книги или храним список их Id
}
В JSON теперь не объекты, а только идентификаторы. Это распространённый подход в БД, REST API и системах с однозначными ссылками.
Плюсы: нет циклов, JSON компактный, ссылки можно восстановить по Id.
Минусы: ломается привычная объектная модель, на десериализации нужен поиск по Id.
Мини-таблица сравнения:
| Подход | Проблема циклов решена? | Потеря данных? | Применимость |
|---|---|---|---|
| [JsonIgnore] | Да | Да | Когда вложенность не критична |
| Удалять ссылку вручную | Да | Да | Быстро перед сериализацией |
| Хранить Id вместо объекта | Да | Нет* | REST, базы, сложные системы |
* Данные не потеряны, но не сразу доступны (нужен поиск по Id).
4. Как научить System.Text.Json сериализовывать циклические ссылки?
Начиная с .NET 5 у JsonSerializerOptions появился режим ссылок: options.ReferenceHandler = ReferenceHandler.Preserve.
Этот режим использует специальные поля $id и $ref для повторных объектов.
Пример
var options = new JsonSerializerOptions
{
WriteIndented = true,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve
};
string json = JsonSerializer.Serialize(author, options);
Console.WriteLine(json);
Получившийся JSON будет выглядеть так:
{
"$id": "1",
"Name": "Марсель Пруст",
"BirthYear": 1871,
"Books": {
"$id": "2",
"$values": [
{
"$id": "3",
"Title": "В сторону Свана",
"Author": {
"$ref": "1"
}
}
]
}
}
- $id — уникальный идентификатор объекта в JSON
- $ref — ссылка на уже сериализованный объект
На десериализации все восстановится корректно (без бесконечных циклов и ошибок стека).
Особенности и ограничения
- Такой JSON необычен для фронтенда: большинство JS-клиентов не понимают $id/$ref без доп. логики.
- Размер JSON больше, сложнее дебажить глазами.
- Работает только при явном включении ReferenceHandler.Preserve.
- Не относится к типам-значениям (циклов там быть не может).
Как десериализовать такой JSON?
Точно так же, как и обычный, но используйте те же JsonSerializerOptions:
var deserializedAuthor = JsonSerializer.Deserialize<Author>(json, options);
5. А что с Newtonsoft.Json (Json.NET)?
Исторически Newtonsoft.Json умел работать с циклами раньше System.Text.Json. Для этого есть атрибут [JsonObject(IsReference = true)] и глобальные настройки сериализации.
Атрибуты для ссылок
[JsonObject(IsReference = true)]
public class Author
{
public string Name { get; set; }
public List<Book> Books { get; set; }
}
[JsonObject(IsReference = true)]
public class Book
{
public string Title { get; set; }
public Author Author { get; set; }
}
Далее сериализуем так:
var settings = new JsonSerializerSettings
{
PreserveReferencesHandling = PreserveReferencesHandling.Objects,
Formatting = Formatting.Indented
};
string json = JsonConvert.SerializeObject(author, settings);
В итоге получим JSON с $id и $ref, аналогично режиму ReferenceHandler.Preserve.
Быстрый вывод
- Если обмен между .NET-приложениями — включайте ссылочную сериализацию (ReferenceHandler.Preserve или PreserveReferencesHandling).
- Если данные идут в JavaScript/иные клиенты — разрывайте циклы: [JsonIgnore], очистка ссылок или переход на Id.
6. Как избежать ошибок и головной боли
Очень часто новички (и даже опытные) сталкиваются с падением сериализатора из‑за циклов. Помните: если коллекции/свойства указывают друг на друга — проверьте архитектуру модели.
Не стесняйтесь использовать [JsonIgnore] для свойств, которые не нужны для внешнего обмена.
Классическая ловушка — сериализация связей «многие ко многим» (например, студенты ↔ курсы). Без разрыва циклов или ссылочной сериализации это не работает.
В REST API чаще отправляют объект «в одну сторону»: например, книга знает автора, а автор — только Id книг (или вовсе не знает о книгах в этом контракте).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ