CodeGym /Kursy /C# SELF /Problem cyklicznych referencji

Problem cyklicznych referencji

C# SELF
Poziom 46 , Lekcja 3
Dostępny

1. Wprowadzenie

Cykliczna (lub obiegowa) referencja pojawia się, gdy jeden obiekt bezpośrednio lub pośrednio zawiera referencję do innego obiektu, który ostatecznie odnosi się z powrotem do pierwszego.

Przykład z życia

Zróbmy realistyczny przykład dla naszej biblioteki. Załóżmy, że mamy klasę Book, która ma właściwość Author, a klasa Author ma właściwość Books typu List<Book>, żeby przechowywać wszystkie swoje książki.


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; }
}

Teraz, jeśli stworzymy jednego autora i jedną książkę, ustawimy powiązania, otrzymamy "zamknięte koło":


var author = new Author { Name = "Marsel Prust", BirthYear = 1871 };
var book = new Book { Title = "W stronę Swana", Author = author };

author.Books.Add(book);

// Gotowe, teraz author odnosi się do book, a book odnosi się do author!

Dlaczego to problem?

Kiedy serializujesz taki obiekt do JSON, serializer zaczyna przechodzić po właściwościach. Widzisz, że autor ma książki, w środku których znowu jest autor... który znowu zawiera książki... które znowu zawierają autorów... i tak w nieskończoność.

author -> books[] -> author -> books[] ...

To jak patrzenie w lustro stojąc naprzeciw drugiego lustra — odbicia idą w nieskończoność. Tylko zamiast ładnych odbić — przepełnienie stosu (StackOverflowException).

2. Jak serializer reaguje na cykliczne referencje?

Błąd serializacji

Domyślnie System.Text.Json nie potrafi obsłużyć cyklicznych referencji. Jeśli spróbujesz serializować taką strukturę, otrzymasz wyjątek JsonException: "A possible object cycle was detected".

Przykład, który spowoduje błąd:


string json = JsonSerializer.Serialize(author); // BAM! JsonException

Wizualizacja:

graph TD;
    Author --> Book;
    Book --> Author;

3. Jak rozwiązać problem cyklicznych referencji?

Rozważmy kilka realnych podejść, każde z własnymi plusami i minusami. Jak się pewnie spodziewasz, uniwersalnego "magicznego przełącznika" nie ma (co szkoda).

Usuwać cykle przed serializacją

Najprościej — tego nie robić. Przed serializacją ustawić na null (lub pominąć) referencje prowadzące do cyklu.

Jak to wygląda w praktyce:


// Tymczasowo usuniemy referencję autora do książek
var authorToSerialize = new Author
{
    Name = author.Name,
    BirthYear = author.BirthYear,
    Books = null // albo można w ogóle nie uwzględniać właściwości
};

string json = JsonSerializer.Serialize(authorToSerialize);
// Teraz wszystko się zserializowało bez problemu!

Zalety: proste, szybkie, czytelne.
Wady: tracisz część danych (po deserializacji nie odtworzysz powiązań wstecznych).

Użyć atrybutu [JsonIgnore]

Można oznaczyć właściwość biorącą udział w cyklu jako ignorowaną:


public class Author
{
    public string Name { get; set; }
    public int BirthYear { get; set; }
    
    [JsonIgnore] 
    public List<Book> Books { get; set; }
}

Teraz podczas serializacji autor nie będzie zawierał swoich książek. To przypomina sposób powyżej, ale deklaratywnie i bez ręcznego "czyszczenia".

Zalety: prostsze, mniejsze ryzyko zapomnienia o czyszczeniu referencji.
Wady: informacja o książkach autora jest utracona w JSON.

Użyć identyfikatorów zamiast zagnieżdżonych obiektów

Jeśli ważne jest zachowanie obu stron powiązania (i autorów, i książek), ale nie chcesz cyklicznych referencji, użyj unikalnych identyfikatorów zamiast zagnieżdżonych obiektów:


public class Book
{
    public string Title { get; set; }
    public int AuthorId { get; set; } // zamiast Author
}
public class Author 
{
    public int AuthorId { get; set; }
    public string Name { get; set; }
    // nie przechowujemy książek lub przechowujemy listę ich Id
}

W JSON teraz będą nie obiekty, a tylko identyfikatory. To powszechne podejście w bazach danych, REST API i systemach z jednoznacznymi referencjami.

Zalety: brak cykli, JSON bardziej kompaktowy, referencje można odtworzyć po Id.
Wady: łamie wygodny model obiektowy, na deserializacji potrzebne jest wyszukiwanie po Id.

Mini-tabela porównawcza:

Podejście Czy problem cykli rozwiązany? Utrata danych? Zastosowanie
[JsonIgnore] Tak Tak Kiedy zagnieżdżenie nie jest krytyczne
Usuwanie referencji ręcznie Tak Tak Szybko przed serializacją
Przechowywać Id zamiast obiektu Tak Nie* REST, bazy, złożone systemy

* Dane nie są utracone, ale nie są od razu dostępne (wymagane wyszukanie po Id).

4. Jak nauczyć System.Text.Json serializować cykliczne referencje?

Począwszy od .NET 5, JsonSerializerOptions dostał tryb referencji: options.ReferenceHandler = ReferenceHandler.Preserve.

Ten tryb używa specjalnych pól $id i $ref dla powtarzających się obiektów.

Przykład


var options = new JsonSerializerOptions
{
    WriteIndented = true,
    ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve
};

string json = JsonSerializer.Serialize(author, options);
Console.WriteLine(json);

Uzyskany JSON będzie wyglądał tak:

{
  "$id": "1",
  "Name": "Marsel Prust",
  "BirthYear": 1871,
  "Books": {
    "$id": "2",
    "$values": [
      {
        "$id": "3",
        "Title": "W stronę Swana",
        "Author": {
          "$ref": "1"
        }
      }
    ]
  }
}
  • $id — unikalny identyfikator obiektu w JSON
  • $ref — referencja do już zserializowanego obiektu

Podczas deserializacji wszystko zostanie poprawnie odtworzone (bez nieskończonych cykli i błędów stosu).

Cechy i ograniczenia

  • Taki JSON jest nietypowy dla frontendu: większość klientów JS nie rozumie $id/$ref bez dodatkowej logiki.
  • Rozmiar JSON rośnie, trudniej debugować "na oko".
  • Działa tylko po jawym włączeniu ReferenceHandler.Preserve.
  • Nie dotyczy typów wartościowych (w nich nie ma cykli).

Jak deserializować taki JSON?

Dokładnie tak samo jak zwykły, ale użyj tych samych JsonSerializerOptions:


var deserializedAuthor = JsonSerializer.Deserialize<Author>(json, options);

5. A co z Newtonsoft.Json (Json.NET)?

Historycznie Newtonsoft.Json potrafił obsługiwać cykle przed System.Text.Json. Ma do tego atrybut [JsonObject(IsReference = true)] i globalne ustawienia serializacji.

Atrybuty do referencji


[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; }
}

Następnie serializujemy tak:


var settings = new JsonSerializerSettings
{
    PreserveReferencesHandling = PreserveReferencesHandling.Objects,
    Formatting = Formatting.Indented
};

string json = JsonConvert.SerializeObject(author, settings);

W efekcie dostaniemy JSON z $id i $ref, podobnie jak w trybie ReferenceHandler.Preserve.

Szybkie wnioski

  • Jeśli wymiana jest między aplikacjami .NET — włącz referencyjną serializację (ReferenceHandler.Preserve lub PreserveReferencesHandling).
  • Jeśli dane idą do JavaScript/innych klientów — przerwij cykle: [JsonIgnore], czyszczenie referencji lub przejście na Id.

6. Jak uniknąć błędów i bólu głowy

Bardzo często początkujący (i nawet doświadczeni) napotykają na awarie serializatora przez cykle. Pamiętaj: jeśli kolekcje/właściwości wskazują na siebie nawzajem — przejrzyj architekturę modelu.

Nie bój się używać [JsonIgnore] dla właściwości, które nie są potrzebne do wymiany zewnętrznej.

Klasowa pułapka — serializacja relacji „wiele do wielu” (np. studenci ↔ kursy). Bez przerwania cykli lub referencyjnej serializacji to się nie uda.

W REST API częściej wysyła się obiekt "w jedną stronę": na przykład książka zna autora, a autor — tylko Id książek (albo w ogóle nie zna książek w tym kontrakcie).

Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION