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; }
}
Такой подход часто используют для защиты приватной информации: если вам не нужно сериализовать какие-то внутренние данные объектов, просто добавляете атрибут — и забвение обеспечено.
12. Практические советы
- На собеседованиях часто спрашивают: «Как сериализовать дерево (Tree)?» и «Что делать с циклическими ссылками?». Подготовьте примеры с ReferenceHandler.IgnoreCycles и хранением идентификаторов.
- В коммерческих проектах сериализуют заказы, счета, пользователей, каталоги товаров, сложные отчеты. Вложенность встречается повсеместно.
- Если работаете с графами или деревьями, старайтесь избегать циклов либо используйте id-шники.
- Если используется внешний API, согласовывайте формат вложенных структур заранее, чтобы избежать сюрпризов при парсинге JSON.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ