1. 介紹
循環(或環狀)引用發生在一個物件直接或間接包含對另一個物件的引用,而那個物件最終又指回第一個物件。
實際範例
我們用一個書籍圖書館的例子來說明。假設有一個類別 Book,它有一個屬性 Author;而類別 Author 有一個屬性 Books,型別是 List<Book>,用來記著所有它寫過的書。
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; }
}
現在,如果我們建立一位作者和一本書,並互相設置引用,就會得到「封閉的圈」:
var author = new Author { Name = "馬塞爾·普魯斯特", BirthYear = 1871 };
var book = new Book { Title = "走向斯萬", Author = author };
author.Books.Add(book);
// 好了,現在 author 參考 book,而 book 參考回 author!
為什麼這是個問題?
當你把這種物件序列化成 JSON 時,序列化器會遞迴遍歷屬性。它會看到作者有書,書裡面又有作者……然後又有書……無限下去。
author -> books[] -> author -> books[] ...
這就像兩面鏡子相對——反射無限延伸。不是美景而是 StackOverflowException(堆疊溢位)。
2. 序列化器如何對循環引用反應?
序列化錯誤
預設情況下 System.Text.Json 不會處理循環引用。如果你嘗試序列化這種結構,會拋出 JsonException:"A possible object cycle was detected"。
會觸發錯誤的範例如下:
string json = JsonSerializer.Serialize(author); // 啪!JsonException
視覺化:
graph TD;
Author --> Book;
Book --> Author;
3. 如何解決循環引用問題?
我們來看幾個常用的做法,每種都有優缺點。沒有萬能的「魔法開關」(可惜)。
在序列化前移除循環
最簡單的方式就是別讓循環存在。在序列化前把會造成循環的引用設為 null(或忽略)。
實作上長這樣:
// 臨時移除作者對書籍的引用
var authorToSerialize = new Author
{
Name = author.Name,
BirthYear = author.BirthYear,
Books = null // 或者乾脆不包含這個屬性
};
string json = JsonSerializer.Serialize(authorToSerialize);
// 現在序列化就沒問題了!
優點:簡單、快速、好理解。
缺點:你會失去部分資料(反序列化後無法還原反向關係)。
使用屬性 [JsonIgnore]
可以把參與循環的屬性標記為忽略:
public class Author
{
public string Name { get; set; }
public int BirthYear { get; set; }
[JsonIgnore]
public List<Book> Books { get; set; }
}
這樣序列化作者時就不會包含 Books。這和上面的方法類似,但更宣告式,不需要手動「清理」。
優點:更簡單,不容易忘記清理。
缺點:關於作者的書的資訊會從 JSON 中遺失。
使用識別符取代巢狀物件
如果你想保留雙向關係的資料,但不想產生循環,可以用唯一 Id 去代替巢狀物件:
public class Book
{
public string Title { get; set; }
public int AuthorId { get; set; } // 代替 Author
}
public class Author
{
public int AuthorId { get; set; }
public string Name { get; set; }
// 不儲存 Books 或只儲存它們的 Id 清單
}
在 JSON 裡會是一堆 Id 而不是物件嵌套。這是資料庫、REST API 和具明確關聯系統中常見的做法。
優點:沒有循環,JSON 更精簡,關聯可以透過 Id 還原。
缺點:破壞了原本的物件模型,反序列化時需要透過 Id 去查找關聯。
迷你比較表:
| 做法 | 解決循環問題? | 資料會遺失? | 適用情境 |
|---|---|---|---|
| [JsonIgnore] | 是 | 是 | 當巢狀資料不重要時 |
| 序列化前手動移除引用 | 是 | 是 | 序列化前快速處理 |
| 用 Id 代替物件 | 是 | 否* | REST、資料庫、複雜系統 |
* 資料沒有丟失,但不會立即可用(需要透過 Id 查找)。
4. 如何讓 System.Text.Json 序列化循環引用?
從 .NET 5 開始,JsonSerializerOptions 支援參照模式:options.ReferenceHandler = ReferenceHandler.Preserve。
這個模式會使用特殊欄位 $id 和 $ref 來表示重複的物件。
範例
var options = new JsonSerializerOptions
{
WriteIndented = true,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.Preserve
};
string json = JsonSerializer.Serialize(author, options);
Console.WriteLine(json);
產生的 JSON 會長這樣:
{
"$id": "1",
"Name": "馬塞爾·普魯斯特",
"BirthYear": 1871,
"Books": {
"$id": "2",
"$values": [
{
"$id": "3",
"Title": "走向斯萬",
"Author": {
"$ref": "1"
}
}
]
}
}
- $id — JSON 中物件的唯一識別符
- $ref — 指向已序列化物件的引用
反序列化時會正確還原(不會出現無限循環或堆疊錯誤)。
特性與限制
- 這種 JSON 對前端來說比較不常見:大部分的 JS 客戶端不會自動理解 $id/$ref,需要額外邏輯處理。
- JSON 體積會比較大,用肉眼除錯也較不直觀。
- 只有在明確啟用 ReferenceHandler.Preserve 時才會生效。
- 不適用於值類型(值類型不會形成循環)。
如何反序列化這種 JSON?
和一般反序列化一樣,但要使用相同的 JsonSerializerOptions:
var deserializedAuthor = JsonSerializer.Deserialize<Author>(json, options);
5. 那 Newtonsoft.Json (Json.NET) 呢?
歷史上 Newtonsoft.Json 比 System.Text.Json 早支援循環引用。可以使用屬性 [JsonObject(IsReference = true)] 或在序列化設定中啟用保留參照。
用於參照的屬性
[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; }
}
接著這樣序列化:
var settings = new JsonSerializerSettings
{
PreserveReferencesHandling = PreserveReferencesHandling.Objects,
Formatting = Formatting.Indented
};
string json = JsonConvert.SerializeObject(author, settings);
最終會得到帶有 $id 和 $ref 的 JSON,類似 ReferenceHandler.Preserve 的行為。
快速結論
- 如果兩個 .NET 應用程式之間交換資料 — 可以開啟參照序列化(ReferenceHandler.Preserve 或 PreserveReferencesHandling)。
- 如果資料要給 JavaScript/其他客戶端使用 — 建議打斷循環:用 [JsonIgnore]、清理引用,或改用 Id。
6. 如何避免錯誤和頭痛
新手(甚至有經驗的人)經常因為循環引用讓序列化器崩潰。記得:如果集合或屬性互相指向對方 — 檢查你的模型設計。
別害羞,對於不需要對外暴露的屬性使用 [JsonIgnore]。
經典陷阱是「多對多」關聯(例如學生 ↔ 課程)。如果不斷開循環或不使用參照序列化,這類關係會出問題。
在 REST API 中通常會採用單向傳遞:比如書會知道作者,而作者在 API 合約中只帶書的 Id(或乾脆不包含書的資訊)。
GO TO FULL VERSION