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. 如何解决循环引用问题?

我们来看几种常见做法,各有利弊。正如你可能猜到的,没有万能的“魔法开关”(可惜)。

在序列化前删除循环

最简单的办法就是别让它成环。序列化前把会导致循环的引用置空(或忽略)。

实际做法示例:


// 暂时移除作者指向书的引用
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.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 中多数情况下会选择单向返回对象:例如书里包含作者,而作者在这个契约里只包含书的 Id(或者根本不包含书的信息)。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION