1. 引言
想象一个书店/图书馆的应用:对象不仅有书,还有作者(有传记)、出版社、员工、书籍分类……如果序列化只支持“平面”对象,我们的应用就停留在记事本级别。在真实项目里数据几乎总是多层嵌套的。因此会序列化/反序列化分层结构是把普通开发者和真正懂 .NET 序列化的高手区分开的技能。
我们会看现在 C# 的序列化器(以 System.Text.Json 为例)如何保存不仅是对象树,还有更复杂的“丛林”。同时讲如何设计类以便序列化时不需要手动一点点拆数据。
2. 对分层结构建模
把模型扩展一下。之前示例里有 Book、Author,还有保存书列表的 Library。现在再加一层:每个作者有个他们写的书的列表(是的,会有重复信息——稍后会讨论这会引发什么问题!),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": "馆长"
}
]
}
注意:在作者的书数组里,书的 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,因为序列化器为防止无限嵌套把链断掉了。
重要提示:用标准序列化器保存复杂互相引用的关系(比如父<->子<->父)总要做权衡:要么丢失部分引用,要么在反序列化后手动恢复关系。
7. 循环引用(嵌套 vs 环)
如果结构里对象通过一系列引用回到自己(循环引用)——标准序列化器像 System.Text.Json 和 Newtonsoft.Json 在严格类型模式下的表现不同。在 ReferenceHandler.IgnoreCycles 出现之前,序列化通常会抛出 "ReferenceLoopHandling detected" 异常。现在它会用 null 替代重复引用。
这里的陷阱是什么?
优点:代码不会因为异常而崩溃。
缺点:反序列化后需要手动恢复部分引用。例如,序列化后的用户图如果彼此引用(例如员工与上司)——反序列化后某些引用可能为空。
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. 属性对嵌套对象序列化的影响
如果在嵌套对象的属性上使用 [JsonIgnore],这些属性将不会出现在最终的 JSON 中,无论嵌套层级有多深。
public class SecretBook : Book
{
[JsonIgnore]
public string SecretCode { get; set; }
}
这个方法常用于保护敏感信息:如果某些内部数据不需要序列化,直接加上属性,就能保证它们不会被写进 JSON。
12. 实用建议
- 面试时常问:「如何序列化树(Tree)?」和「如何处理循环引用?」准备好用 ReferenceHandler.IgnoreCycles 的例子以及用 id 保存引用的方案。
- 在商业项目里会序列化订单、发票、用户、商品目录、复杂报表。嵌套非常常见。
- 处理图或树时,尽量避免循环,或者采用 id 方案。
- 如果与外部 API 交互,提前约定好嵌套结构的格式,避免解析 JSON 时出现惊喜。
GO TO FULL VERSION