CodeGym /Kursy /C# SELF /Serializacja zagnieżdżonych i hierarchicznych obiektów

Serializacja zagnieżdżonych i hierarchicznych obiektów

C# SELF
Poziom 46 , Lekcja 2
Dostępny

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.
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION