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).
GO TO FULL VERSION