JavaRush /Курси /C# SELF /Проблема циклічних посилань

Проблема циклічних посилань

C# SELF
Рівень 46 , Лекція 3
Відкрита

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 книжок (або взагалі не знає про книжки в цьому контракті).

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ