JavaRush /Курси /C# SELF /Серіалізація вкладених та ієрархічних об’єктів

Серіалізація вкладених та ієрархічних об’єктів

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

1. Вступ

Уявімо застосунок для книжкового магазину: у ньому об’єктами будуть не лише книги, а й автори з біографіями, видавництва, що випускають книги, співробітники, розділи… Якби серіалізація підтримувала лише «пласкі» об’єкти, наш застосунок застряг би на рівні записника. У реальних проєктах дані майже завжди багаторівневі й вкладені. Тому вміння серіалізувати й десеріалізувати ієрархічні структури — навичка, яка відрізняє пересічного розробника від справжнього майстра .NET‑серіалізації.

Розглянемо, як сучасні серіалізатори C# (на прикладі System.Text.Json) дають змогу зберігати не лише дерева об’єктів, а й цілі «джунглі». Також обговоримо, як правильно проєктувати класи під серіалізацію, щоб потім не доводилося вручну витягувати дані по шматочках.

2. Моделювання ієрархічних структур

Розширімо нашу модель. У попередніх прикладах ми мали об’єкти Book, Author і клас Library, що зберігає список книг. Тепер додамо ще один рівень: нехай у кожного автора буде список книг, які він написав (інформація дублюється — далі обговоримо, до чого це може призвести), а в бібліотеки з’явиться також список співробітників Employee.

Схема класів

Приблизно так виглядатиме наша структура об’єктів:

classDiagram
    class Library {
        List~Book~ Books
        List~Employee~ Employees
        string Name
    }
    class Book {
        string Title
        Author Author
        int Year
    }
    class Author {
        string Name
        int BirthYear
        List~Book~ Books
    }
    class Employee {
        string Name
        string Position
    }

    Library "1" -- "many" Book
    Library "1" -- "many" Employee
    Book "1" -- "1" Author
    Author "1" -- "many" Book

Такий підхід дає змогу зібрати повноцінну бібліотечну систему. Так, є ризик появи «закільцьованих» посилань (наприклад, у автора є книги, а в книги — автор: безкінечний серіалізаційний цикл). Про це поговоримо трохи далі.

3. Приклад реалізації класів

Спочатку опишемо C#‑класи для нашої моделі. Водночас додамо коментарі:

using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;

public class Library
{
    public string Name { get; set; }
    public List<Book> Books { get; set; } = new();
    public List<Employee> Employees { get; set; } = new();
}

public class Book
{
    public string Title { get; set; }
    public int Year { get; set; }

    public Author Author { get; set; }
}

public class Author
{
    public string Name { get; set; }
    public int BirthYear { get; set; }
    
    // ВАЖЛИВО! Це поле потенційно «небезпечне»: воно може створити циклічне посилання.
    // Але для демонстрації залишаємо його.
    public List<Book> Books { get; set; } = new();
}

public class Employee
{
    public string Name { get; set; }
    public string Position { get; set; }
}

4. Збірка структури: створюємо бібліотеку

Створімо тепер бібліотеку з книгами, авторами й співробітниками, щоб перевірити серіалізацію.

// Створюємо авторів
var author1 = new Author { Name = "Вільям Ґолдінґ", BirthYear = 1911 };
var author2 = new Author { Name = "Джон Апдайк", BirthYear = 1932 };
var author3 = new Author { Name = "Джером Селінджер", BirthYear = 1919 };

// Створюємо книги
var book1 = new Book { Title = "Володар мух", Year = 1954, Author = author1 };
var book2 = new Book { Title = "Кентавр", Year = 1963, Author = author2 };
var book3 = new Book { Title = "Ловець у житі", Year = 1951, Author = author3 };

// Додаємо книги авторам
author1.Books.Add(book1);
author2.Books.Add(book2);
author3.Books.Add(book3);

// Створюємо співробітників
var emp1 = new Employee { Name = "Іван Іванов", Position = "Бібліотекар" };
var emp2 = new Employee { Name = "Сергій Сергієв", Position = "Директор" };

// Складаємо бібліотеку
var library = new Library
{
    Name = "Міська бібліотека",
    Books = new List<Book> { book1, book2, book3 },
    Employees = new List<Employee> { emp1, emp2 }
};

Звісно, у реальному коді ви б автоматизували створення об’єктів і зв’язків між ними, щоб не доводилося вручну вести облік кожної книги для автора. Але для нашого прикладу — згодиться.

5. Серіалізація в JSON

Використаємо класичний підхід за допомогою System.Text.Json:

using System.Text.Json;

// Серіалізуємо бібліотеку в JSON
var options = new JsonSerializerOptions
{
    WriteIndented = true, // Зручне форматування з відступами
    ReferenceHandler = ReferenceHandler.IgnoreCycles // Запобігаємо зацикленню!
};

string json = JsonSerializer.Serialize(library, options);

// Виведемо на екран результат
Console.WriteLine(json);

Цікавий момент: якщо не використовувати спеціальну опцію ReferenceHandler.IgnoreCycles, серіалізація зациклиться — адже в автора є список книг, а в книги — автор; у підсумку серіалізатор ходитиме туди‑сюди, поки не згенерує виняток. Опція IgnoreCycles вирішує проблему: якщо під час обходу структури серіалізатор бачить, що об’єкт уже серіалізувався вище по дереву, він просто записує null замість повторної серіалізації.

Як виглядатиме JSON?

{
  "Name": "Міська бібліотека",
  "Books": [
    {
      "Title": "Кентавр",
      "Year": 1963,
      "Author": {
        "Name": "Джон Апдайк",
        "BirthYear": 1932,
        "Books": [
          {
            "Title": "Кентавр",
            "Year": 1963,
            "Author": null
          },
          {
            "Title": "Відьми Іствіка",
            "Year": 1984,
            "Author": null
          }
        ]
      }
    },
    {
      "Title": "Відьми Іствіка",
      "Year": 1984,
      "Author": {
        "Name": "Джон Апдайк",
        "BirthYear": 1932,
        "Books": [
          {
            "Title": "Кентавр",
            "Year": 1963,
            "Author": null
          },
          {
            "Title": "Відьми Іствіка",
            "Year": 1984,
            "Author": null
          }
        ]
      }
    }
  ],
  "Employees": [
    {
      "Name": "Іван Іванов",
      "Position": "Бібліотекар"
    },
    {
      "Name": "Сергій Сергієв",
      "Position": "Директор"
    }
  ]
}

Зверніть увагу: всередині масиву Books у автора в книгах уже немає інформації про автора — замість цього там Author: null. Це і є спосіб розірвати цикл під час серіалізації.

6. Десеріалізація назад в об’єкти

А тепер десеріалізуємо дані назад:

// Відновлюємо об’єкт з JSON
var libraryCopy = JsonSerializer.Deserialize<Library>(json, options);

Console.WriteLine(libraryCopy.Name); // "Міська бібліотека"
Console.WriteLine($"Книжок: {libraryCopy.Books.Count}");
Console.WriteLine($"Співробітників: {libraryCopy.Employees.Count}");

Але! Відновлення циклічних посилань тут уже не працює на 100%: у вкладених книг у списку автора поле Author дорівнюватиме null, бо серіалізатор обрізав ланцюжок, щоб запобігти безкінечній вкладеності.

Важливий момент: серіалізація складних взаємних зв’язків (наприклад, батько — діти — батько) через стандартний серіалізатор завжди вимагає компромісу: або губиться частина зв’язків, або доводиться вручну відновлювати їх після десеріалізації.

7. Циклічні посилання (вкладеність vs цикли)

Якщо у вашій структурі є випадки, коли об’єкт опосередковано посилається сам на себе (циклічне посилання), — стандартні серіалізатори на кшталт System.Text.Json і Newtonsoft.Json, особливо в суворо типізованому режимі, реагують на це по‑різному. До появи опції ReferenceHandler.IgnoreCycles серіалізація зупинялася винятком "ReferenceLoopHandling detected". Тепер же він просто ставить null замість повторного посилання.

У чому тут підступ?

Плюс: ваш код не падає з помилкою.

Мінус: після десеріалізації доводиться вручну відновлювати частину зв’язків. Наприклад, якщо серіалізований граф користувачів посилається на об’єкти одне одного (скажімо, співробітник і його керівник), після десеріалізації якась із посилань може виявитися порожньою.

8. Як проєктувати складні структури під серіалізацію

Якщо ви заздалегідь знаєте, що ваші об’єкти утворюють цикли, або вам важливо, щоб після відновлення структури всі зв’язки лишилися як у початковому стані — краще зберігати не самі об’єкти, а їхні ідентифікатори.

Приклад: зберігати ідентифікатори замість посилань

Змінимо клас Book, щоб посилання на автора було через ідентифікатор:

public class Book
{
    public string Title { get; set; }
    public int Year { get; set; }

    public int AuthorId { get; set; }
}

Замість списку книг у автора — список ідентифікаторів книг. Для відновлення зв’язків після десеріалізації доведеться «зводити» по id, зате не буде небезпечних циклів.

Чому це важливо для реальних проєктів?

  • Бази даних майже завжди використовують ідентифікатори, бо з ними простіше працювати під час експорту/імпорту.
  • REST API теж обмінюються ідентифікаторами (ID), а не вкладеними надскладними структурами.

9. Вкладені колекції: серіалізація дерев

Поширений випадок — ієрархії довільної вкладеності: дерево папок, структура меню, каталог товарів із підкатегоріями.

Приклад класу для «дерева»:

public class Folder
{
    public string Name { get; set; }
    public List<Folder> Children { get; set; } = new();
}

Створюємо дерево:

var root = new Folder
{
    Name = "Root",
    Children = new List<Folder>
    {
        new Folder { Name = "Sub1", Children = { new Folder { Name = "Sub1-1" } } },
        new Folder { Name = "Sub2" }
    }
};

Серіалізуємо й виводимо:

string jsonTree = JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(jsonTree);

Вигляд JSON для такого дерева — наочна ієрархія з вкладеними масивами.

10. Особливості серіалізації масивів і списків

Якщо якась властивість — масив (T[]) або колекція (List<T>), серіалізація перетворить її на звичайний JSON‑масив.

public class Shop
{
    public string Name { get; set; }
    public string[] Departments { get; set; }
}
var shop = new Shop
{
    Name = "Універсам",
    Departments = new[] { "Овочі", "Фрукти", "Мʼясо" }
};

string jsonShop = JsonSerializer.Serialize(shop, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(jsonShop);

JSON буде приблизно таким:

{
  "Name": "Універсам",
  "Departments": [
    "Овочі",
    "Фрукти",
    "Мʼясо"
  ]
}

11. Вплив атрибутів на вкладені об’єкти

Якщо використовувати [JsonIgnore] для властивостей у вкладених об’єктах, вони також не потраплять у підсумковий JSON незалежно від рівня вкладеності.

public class SecretBook : Book
{
    [JsonIgnore]
    public string SecretCode { get; set; }
}

Такий підхід часто використовують для захисту приватної інформації: якщо вам не потрібно серіалізувати якісь внутрішні дані об’єктів — просто додайте атрибут, і вони не потраплять до JSON.

12. Практичні поради

  • На співбесідах часто питають: «Як серіалізувати дерево (Tree)?» і «Що робити з циклічними посиланнями?». Підготуйте приклади з ReferenceHandler.IgnoreCycles і зі зберіганням ідентифікаторів.
  • У комерційних проєктах серіалізують замовлення, рахунки, користувачів, каталоги товарів, складні звіти. Вкладеність трапляється всюди.
  • Якщо працюєте з графами чи деревами — намагайтеся уникати циклів або використовуйте ідентифікатори.
  • Якщо використовується зовнішній API — узгоджуйте формат вкладених структур заздалегідь, щоб уникнути сюрпризів під час розбору JSON.
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ