1. Wprowadzenie
Wyobraźcie sobie aplikację dla księgarni: obiektami tam powinny być nie tylko książki, ale też autorzy, którzy mają biografię, wydawnictwa wydające książki, pracownicy, sekcje... Gdyby serializacja obsługiwała tylko "płaskie" obiekty, nasza aplikacja ugrzęzłaby na poziomie notesu. W realnych projektach dane prawie zawsze są wielopoziomowe i zagnieżdżone. Dlatego umiejętność serializowania i deserializowania struktur hierarchicznych — to umiejętność, która odróżnia przeciętnego developera od prawdziwego magistra .NET serializacji.
Przeanalizujemy, jak współczesne serializery C# (na przykładzie System.Text.Json) pozwalają zapisywać nie tylko drzewa obiektów, ale całe "dżungle". I jak poprawnie projektować klasy do serializacji, żeby potem nie musieć ręcznie wyciągać danych kawałek po kawałku.
2. Modelowanie struktur hierarchicznych
Rozszerzmy nasz model. W poprzednich przykładach mieliśmy obiekty Book, Author i klasę Library, która trzyma listę książek. Teraz dodajmy jeszcze jeden poziom: niech każdy autor ma listę książek, które napisał (tak, tak, informacja się dubluje — później omówimy, do czego to może prowadzić!), a w bibliotece pojawi się też lista pracowników Employee.
Diagram klas
Tak mniej więcej będzie wyglądać nasza struktura obiektów:
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
Takie podejście pozwala zrealizować pełnoprawny system biblioteczny. Tak, istnieje ryzyko powstania „zamkniętych” referencji (na przykład autor ma książki, a książka — autora: nieskończony serializacyjny serial!). O tym opowiemy dalej.
3. Przykład implementacji klas
Najpierw opiszemy klasy C# dla naszego modelu. Przy okazji dodamy komentarze:
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; }
// WAŻNE! To pole jest potencjalnie "niebezpieczne": może stworzyć cykliczną referencję.
// Ale dla demonstracji uwzględniamy je.
public List<Book> Books { get; set; } = new();
}
public class Employee
{
public string Name { get; set; }
public string Position { get; set; }
}
4. Budowanie struktury: tworzymy bibliotekę
Teraz stwórzmy bibliotekę z książkami, autorami i pracownikami, żeby sprawdzić serializację.
// Tworzymy autorów
var author1 = new Author { Name = "Wiliam Golding", BirthYear = 1911 };
var author2 = new Author { Name = "John Updike", BirthYear = 1932 };
var author3 = new Author { Name = "Jerome Salinger", BirthYear = 1919 };
// Tworzymy książki
var book1 = new Book { Title = "Władca much", Year = 1954, Author = author1 };
var book2 = new Book { Title = "Centaur", Year = 1963, Author = author2 };
var book3 = new Book { Title = "Buszujący w zbożu", Year = 1951, Author = author3 };
// Dodajemy książki do autorów
author1.Books.Add(book1);
author2.Books.Add(book2);
author3.Books.Add(book3);
// Tworzymy pracowników
var emp1 = new Employee { Name = "Iwan Iwanow", Position = "Bibliotekarz" };
var emp2 = new Employee { Name = "Siergiej Siergiejew", Position = "Dyrektor" };
// Komponujemy bibliotekę
var library = new Library
{
Name = "Miejska biblioteka",
Books = new List<Book> { book1, book2, book3 },
Employees = new List<Employee> { emp1, emp2 }
};
Oczywiście w realnym kodzie automatyzowalibyście tworzenie obiektów i powiązań między nimi, żeby nie zarządzać ręcznie każdą książką przy autorze. Ale do naszego przykładu — wystarczy.
5. Serializacja do JSON
Użyjemy klasycznego podejścia za pomocą System.Text.Json:
using System.Text.Json;
// Serializujemy bibliotekę do JSON
var options = new JsonSerializerOptions
{
WriteIndented = true, // ładne formatowanie z wcięciami
ReferenceHandler = ReferenceHandler.IgnoreCycles // zapobiegamy zapętlaniu!
};
string json = JsonSerializer.Serialize(library, options);
// Wypisujemy wynik
Console.WriteLine(json);
Ciekawostka: Jeśli nie użyjecie specjalnej opcji ReferenceHandler.IgnoreCycles, serializacja się zapętli — bo autor ma listę książek, a książka — autora, w rezultacie serializator będzie nieskończenie "przechodził" tam i z powrotem, aż się zmęczy (i wyrzuci wyjątek). Opcja IgnoreCycles rozwiązuje problem: jeśli podczas przejścia struktury serializator widzi, że obiekt został już zserializowany wyżej w drzewie — po prostu zapisuje null zamiast powtórnej serializacji.
Jak będzie wyglądać JSON?
{
"Name": "Miejska biblioteka",
"Books": [
{
"Title": "Centaur",
"Year": 1963,
"Author": {
"Name": "John Updike",
"BirthYear": 1932,
"Books": [
{
"Title": "Centaur",
"Year": 1963,
"Author": null
},
{
"Title": "Istwaikski wiedmy",
"Year": 1984,
"Author": null
}
]
}
},
{
"Title": "Istwaikski wiedmy",
"Year": 1984,
"Author": {
"Name": "John Updike",
"BirthYear": 1932,
"Books": [
{
"Title": "Centaur",
"Year": 1963,
"Author": null
},
{
"Title": "Istwaikski wiedmy",
"Year": 1984,
"Author": null
}
]
}
}
],
"Employees": [
{
"Name": "Iwan Iwanow",
"Position": "Bibliotekarz"
},
{
"Name": "Siergiej Siergiejew",
"Position": "Dyrektor"
}
]
}
Zwróć uwagę: wewnątrz tablicy Books u autora książki nie ma już informacji o autorze — zamiast tego pole Author: null. To właśnie sposób na przerwanie cyklu przy serializacji.
6. Deserializacja z powrotem do obiektów
A teraz zdeserializujemy dane z powrotem:
// Odtwarzamy obiekt z JSON
var libraryCopy = JsonSerializer.Deserialize<Library>(json, options);
Console.WriteLine(libraryCopy.Name); // "Miejska biblioteka"
Console.WriteLine($"Książek: {libraryCopy.Books.Count}");
Console.WriteLine($"Pracowników: {libraryCopy.Employees.Count}");
Ale! Odtwarzanie cyklicznych referencji tutaj już nie działa w 100%: u zagnieżdżonych książek na liście autora pole Author będzie równe null, ponieważ serializator obciął łańcuch, żeby zapobiec nieskończonej zagnieżdżalności.
Ważna uwaga: Serializacja skomplikowanych wzajemnych powiązań (np. rodzic — dzieci — rodzic) przez standardowy serializer zawsze wymaga kompromisu: albo tracimy część powiązań, albo musimy ręcznie je odtwarzać po deserializacji.
7. Cyclical references (zagnieżdżenie vs cykle)
Jeśli w strukturze występują przypadki, gdy obiekt odwołuje się sam do siebie przez łańcuch innych obiektów (cykliczna referencja) — standardowe serializery, takie jak System.Text.Json i Newtonsoft.Json, zwłaszcza w trybie silnego typowania, reagują na to różnie. Przed pojawieniem się opcji ReferenceHandler.IgnoreCycles serializacja kończyła się wyjątkiem "ReferenceLoopHandling detected". Teraz po prostu wpisuje null zamiast powtarzającej się referencji.
Na co uważać?
Plus: twój kod nie pada z błędem.
Minus: po deserializacji trzeba ręcznie odtwarzać część powiązań. Na przykład, jeśli zserializowany graf użytkowników odwołuje się wzajemnie (np. pracownik i jego przełożony) — po deserializacji któraś z referencji może być pusta.
8. Jak projektować skomplikowane struktury pod serializację
Jeśli z góry wiesz, że twoje obiekty tworzą cykle, albo zależy ci, żeby po odtworzeniu struktury wszystkie powiązania pozostały jak w oryginale — lepiej przechowywać nie same obiekty, a ich identyfikatory.
Przykład: przechowywać identyfikatory zamiast referencji
Zmieńmy klasę Book, żeby referencja na autora była przez identyfikator:
public class Book
{
public string Title { get; set; }
public int Year { get; set; }
public int AuthorId { get; set; }
}
Zamiast listy książek u autora — lista identyfikatorów książek. Aby odtworzyć powiązania po deserializacji trzeba będzie "dopasować" po id, ale nie będzie ryzyka niebezpiecznych cykli.
Dlaczego to ważne w realnych projektach?
- Bazy danych prawie zawsze używają identyfikatorów, bo z nimi łatwiej pracować przy eksporcie/importie.
- REST API też wymieniają się id-ami, a nie zagnieżdżonymi, skomplikowanymi strukturami.
9. Zagnieżdżone kolekcje: serializacja drzew
Częsty przypadek — hierarchie dowolnego poziomu zagnieżdżenia: drzewo folderów, struktura menu, katalog produktów z podkategoriami.
Przykład klasy dla "drzewa":
public class Folder
{
public string Name { get; set; }
public List<Folder> Children { get; set; } = new();
}
Tworzymy drzewo:
var root = new Folder
{
Name = "Root",
Children = new List<Folder>
{
new Folder { Name = "Sub1", Children = { new Folder { Name = "Sub1-1" } } },
new Folder { Name = "Sub2" }
}
};
Serializujemy i wypisujemy:
string jsonTree = JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(jsonTree);
Wygląd JSON dla takiego drzewa — czytelna hierarchia ze zagnieżdżonymi tablicami.
10. Specyfika serializacji tablic i list
Jeśli jakieś właściwość to tablica (T[]) lub kolekcja (List<T>), serializacja zamieni ją na zwykłą tablicę JSON.
public class Shop
{
public string Name { get; set; }
public string[] Departments { get; set; }
}
var shop = new Shop
{
Name = "Supermarket",
Departments = new[] { "Warzywa", "Owoce", "Mięso" }
};
string jsonShop = JsonSerializer.Serialize(shop, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(jsonShop);
JSON będzie mniej więcej taki:
{
"Name": "Supermarket",
"Departments": [
"Warzywa",
"Owoce",
"Mięso"
]
}
11. Wpływ atrybutów na zagnieżdżone obiekty
Jeśli użyjecie [JsonIgnore] dla właściwości w zagnieżdżonych obiektach, one również nie trafią do finalnego JSON niezależnie od poziomu zagnieżdżenia.
public class SecretBook : Book
{
[JsonIgnore]
public string SecretCode { get; set; }
}
Takie podejście często stosuje się do ochrony prywatnych informacji: jeśli nie chcesz serializować jakichś wewnętrznych danych obiektów, po prostu dodajesz atrybut — i zapomnienie gwarantowane.
12. Praktyczne wskazówki
- Na rozmowach często pytają: „Jak zserializować drzewo (Tree)?” i „Co robić z cyklicznymi referencjami?”. Przygotuj przykłady z ReferenceHandler.IgnoreCycles i przechowywaniem identyfikatorów.
- W projektach komercyjnych serializuje się zamówienia, faktury, użytkowników, katalogi produktów, skomplikowane raporty. Zagnieżdżenie występuje praktycznie wszędzie.
- Jeśli pracujesz z grafami lub drzewami, staraj się unikać cykli albo używaj id-ek.
- Jeśli używasz zewnętrznego API, dogaduj format zagnieżdżonych struktur z góry, żeby uniknąć niespodzianek przy parsowaniu JSON.
GO TO FULL VERSION