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 時吃到驚喜。
GO TO FULL VERSION