CodeGym /課程 /C# SELF /循環引用問題

循環引用問題

C# SELF
等級 46 , 課堂 3
開放

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.JsonSystem.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.PreservePreserveReferencesHandling)。
  • 如果資料要給 JavaScript/其他客戶端使用 — 建議打斷循環:用 [JsonIgnore]、清理引用,或改用 Id。

6. 如何避免錯誤和頭痛

新手(甚至有經驗的人)經常因為循環引用讓序列化器崩潰。記得:如果集合或屬性互相指向對方 — 檢查你的模型設計。

別害羞,對於不需要對外暴露的屬性使用 [JsonIgnore]

經典陷阱是「多對多」關聯(例如學生 ↔ 課程)。如果不斷開循環或不使用參照序列化,這類關係會出問題。

在 REST API 中通常會採用單向傳遞:比如書會知道作者,而作者在 API 合約中只帶書的 Id(或乾脆不包含書的資訊)。

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