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. 如何解决循环引用问题?
我们来看几种常见做法,各有利弊。正如你可能猜到的,没有万能的“魔法开关”(可惜)。
在序列化前删除循环
最简单的办法就是别让它成环。序列化前把会导致循环的引用置空(或忽略)。
实际做法示例:
// 暂时移除作者指向书的引用
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; }
}
这样序列化作者时就不会包含它的书了。和上面的方法类似,但这是声明式的,不需要手动清理。
优点:更简单,减少忘记清理的风险。
缺点:作者的书信息在 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 中多数情况下会选择单向返回对象:例如书里包含作者,而作者在这个契约里只包含书的 Id(或者根本不包含书的信息)。
GO TO FULL VERSION