CodeGym /課程 /C# SELF /序列化嵌套與階層物件

序列化嵌套與階層物件

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

1. 介紹

想像一個書店/圖書館的應用:物件不只會有書,還有作者(有簡介)、出版社、員工、分類……如果序列化只支援「平面」物件,我們的應用就只能做到類似通訊錄那種程度。在真實專案中,資料幾乎總是多層且嵌套的。所以會序列化與反序列化階層結構的能力,是把一般程式設計師和真正熟悉 .NET 序列化的人區分開來的技能。

我們會看目前 C# 的序列化器(以 System.Text.Json 為例)如何既能保存樹狀結構,也能處理更複雜的「叢林」。還會講怎麼設計類別以便序列化,避免日後得手動一段段撈資料。

2. 模擬階層結構

把我們的模型擴充一下。之前範例有 Book、Author,以及存書列表的 Library。現在多加一層:讓每個作者有一個他寫過的書列表(是的,資料會重複 — 稍後會討論這會帶來什麼問題!),而圖書館還會有員工清單 Employee。

類別結構圖

大概會長這樣:

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

這種設計讓我們能做出完整的圖書系統。但要注意可能會出現「環狀」引用(例如作者有書,書又指回作者:序列化會無限遞迴)。這個問題稍後會再講。

3. 類別實作範例

先把 C# 類別定義寫出來,順便加上註解:

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; }
    
    // 重要!這個欄位可能很「危險」:它可能會製造循環引用。
    // 但為了示範我們還是把它保留。
    public List<Book> Books { get; set; } = new();
}

public class Employee
{
    public string Name { get; set; }
    public string Position { get; set; }
}

4. 組裝結構:建立圖書館

現在來建立一個有書、作者和員工的圖書館,驗證序列化效果。

// 建立作者
var author1 = new Author { Name = "威廉·高爾丁", BirthYear = 1911 };
var author2 = new Author { Name = "約翰·厄普代克", BirthYear = 1932 };
var author3 = new Author { Name = "傑羅姆·塞林格", BirthYear = 1919 };

// 建立書籍
var book1 = new Book { Title = "蒼蠅王", Year = 1954, Author = author1 };
var book2 = new Book { Title = "半人馬", Year = 1963, Author = author2 };
var book3 = new Book { Title = "麥田裡的守望者", Year = 1951, Author = author3 };

// 加入作者的書單
author1.Books.Add(book1);
author2.Books.Add(book2);
author3.Books.Add(book3);

// 建立員工
var emp1 = new Employee { Name = "伊萬·伊萬諾夫", Position = "圖書管理員" };
var emp2 = new Employee { Name = "謝爾蓋·謝爾蓋夫", Position = "館長" };

// 組成圖書館
var library = new Library
{
    Name = "市立圖書館",
    Books = new List<Book> { book1, book2, book3 },
    Employees = new List<Employee> { emp1, emp2 }
};

當然,真實專案會把物件和關聯的建立自動化,避免手動一筆一筆綁定。但這個範例就足夠示範序列化的行為。

5. 序列化成 JSON

用常見的做法搭配 System.Text.Json:

using System.Text.Json;

// 將圖書館序列化為 JSON
var options = new JsonSerializerOptions
{
    WriteIndented = true, // 美觀的縮排格式
    ReferenceHandler = ReferenceHandler.IgnoreCycles // 避免循環!
};

string json = JsonSerializer.Serialize(library, options);

// 輸出結果到畫面
Console.WriteLine(json);

有趣的地方:如果不使用 ReferenceHandler.IgnoreCycles 這種選項,序列化會陷入迴圈——因為作者有書,書又有作者,序列化器會一直在兩者之間來回遞迴,最後可能丟出例外。IgnoreCycles 的做法是:當序列化器發現某個物件在樹的上層已經被序列化過,它就用 null 取代重複序列化。

JSON 長什麼樣子?

{
  "Name": "市立圖書館",
  "Books": [
    {
      "Title": "半人馬",
      "Year": 1963,
      "Author": {
        "Name": "約翰·厄普代克",
        "BirthYear": 1932,
        "Books": [
          {
            "Title": "半人馬",
            "Year": 1963,
            "Author": null
          },
          {
            "Title": "伊斯威克的女巫",
            "Year": 1984,
            "Author": null
          }
        ]
      }
    },
    {
      "Title": "伊斯威克的女巫",
      "Year": 1984,
      "Author": {
        "Name": "約翰·厄普代克",
        "BirthYear": 1932,
        "Books": [
          {
            "Title": "半人馬",
            "Year": 1963,
            "Author": null
          },
          {
            "Title": "伊斯威克的女巫",
            "Year": 1984,
            "Author": null
          }
        ]
      }
    }
  ],
  "Employees": [
    {
      "Name": "伊萬·伊萬諾夫",
      "Position": "圖書管理員"
    },
    {
      "Name": "謝爾蓋·謝爾蓋夫",
      "Position": "館長"
    }
  ]
}

注意:在作者的 Books 陣列裡,書物的 Author 欄位已經沒有作者物件了——那裡的 Author 變成了 null。這就是序列化時打斷循環的方式。

6. 反序列化回物件

現在把資料反序列化回物件:

// 從 JSON 還原物件
var libraryCopy = JsonSerializer.Deserialize<Library>(json, options);

Console.WriteLine(libraryCopy.Name); // "市立圖書館"
Console.WriteLine($"書籍數: {libraryCopy.Books.Count}");
Console.WriteLine($"員工數: {libraryCopy.Employees.Count}");

但要注意!循環引用的完整還原在這種做法下不一定會 100% 恢復:因為在作者的書列表中,內部書物的 Author 欄位會是 null——序列化時為了避免無限嵌套把鏈路切掉了。

重點:用標準序列化器去處理複雜相互關聯的資料(像是 parent-children-parent)時通常需要妥協:要嘛丟失部分關聯,要嘛在反序列化後手動重建關係。

7. 循環引用(嵌套 vs 迴圈)

當你的資料結構中存在物件透過其他物件回到自己的情況(循環引用)——像是 System.Text.Json 跟 Newtonsoft.Json 這類序列化器在面對它們時會有不同的反應。早期沒有 ReferenceHandler.IgnoreCycles 前,序列化會拋出類似 "ReferenceLoopHandling detected" 的例外。現在則是把重複的引用替換成 null。

這裡的陷阱是什麼?

優點:你的程式不會因為這種循環而直接炸掉。

缺點:反序列化之後你可能得手動恢復部分關聯。例如使用者圖(users)互相參照(比如員工和他的主管),反序列化後某些引用可能變成空的,需要額外邏輯重建。

8. 如何為序列化設計複雜結構

如果你事先知道物件會形成環,或你需要反序列化後保持所有關聯不變——比較好的做法是保存物件的識別碼(id),而不是直接序列化整個物件參考。

範例:用 id 儲存引用

把 Book 改成用作者 id 來表示:

public class Book
{
    public string Title { get; set; }
    public int Year { get; set; }

    public int AuthorId { get; set; }
}

作者那邊的書單也改成存書的 id。反序列化後你要根據 id 去「配對」物件,但這樣就不會有危險的循環了。

為什麼這在真實專案很重要?

  • 資料庫幾乎都用 id,因為匯出/匯入時比較好處理。
  • REST API 通常也交換 id 而不是極度嵌套的物件。

9. 嵌套集合:序列化樹狀結構

常見情境是任意深度的階層:資料夾樹、選單結構、有子分類的商品目錄。

範例:樹的類別

public class Folder
{
    public string Name { get; set; }
    public List<Folder> Children { get; set; } = new();
}

建立一棵樹:

var root = new Folder
{
    Name = "Root",
    Children = new List<Folder>
    {
        new Folder { Name = "Sub1", Children = { new Folder { Name = "Sub1-1" } } },
        new Folder { Name = "Sub2" }
    }
};

序列化並輸出:

string jsonTree = JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(jsonTree);

這樣的樹在 JSON 裡會顯示出清楚的階層,用巢狀陣列表示子節點。

10. 陣列和列表序列化的特性

如果某個屬性是陣列(T[])或集合(List<T>),序列化會把它轉成一般的 JSON 陣列。

public class Shop
{
    public string Name { get; set; }
    public string[] Departments { get; set; }
}
var shop = new Shop
{
    Name = "賣場",
    Departments = new[] { "蔬菜", "水果", "肉類" }
};

string jsonShop = JsonSerializer.Serialize(shop, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(jsonShop);

JSON 大概會是這樣:

{
  "Name": "賣場",
  "Departments": [
    "蔬菜",
    "水果",
    "肉類"
  ]
}

11. 屬性(Attributes)對嵌套物件的影響

如果在某些屬性上使用 [JsonIgnore],那麼嵌套物件中的那些屬性也不會出現在最後的 JSON,無論它們在多深的層級。

public class SecretBook : Book
{
    [JsonIgnore]
    public string SecretCode { get; set; }
}

這種做法常用來保護敏感資料:不想序列化的內部欄位直接加屬性,就能保證它不會被輸出。

12. 實務建議

  • 面試常會問:「怎麼序列化樹(Tree)?」或「遇到循環引用怎麼辦?」準備好 ReferenceHandler.IgnoreCycles 和用 id 的範例。
  • 商業專案會序列化訂單、發票、使用者、商品目錄、複雜報表。嵌套是日常。
  • 處理圖或樹時,盡量避免循環,或直接使用 id。
  • 與外部 API 合作時,事先協商好嵌套結構的格式,避免在解析 JSON 時吃到驚喜。
2
任務
C# SELF, 等級 46, 課堂 2
上鎖
物件序列化含有集合
物件序列化含有集合
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION