Skip to content

在 .NET 中处理 JSON 的循环引用

Published: at 12:00 AM

摘要

解决 .NET 中与 JSON 相关的循环引用问题,这些问题发生在对象之间直接或间接循环引用时。

原文 Handling Circular References When Working With JSON in .NET 由 Georgios Panagopoulos 撰写。


在 .NET 应用中处理 JSON (JavaScript对象表示法)时,开发者经常遇到循环引用的挑战,即对象相互之间形成循环引用。

在本文中,我们将探索在 .NET 应用中涉及循环引用的常见场景,并讨论有效处理这些场景的技术。我们将使用 System.Text.Json 进行序列化和反序列化,这是最受欢迎的 NuGet 库之一。

要下载本文的源代码,你可以访问我们的 GitHub 仓库

理解循环引用

循环引用 发生在一个对象通过直接或间接的引用回到自身。 在 JSON 序列化的背景下,当序列化复杂的对象图时,对象之间循环引用就会出现。

作为循环引用的一个例子,让我们定义一个类并引用同一类型的属性:

public class Employee
{
    public required string Name { get; set; }
    public required string Surname { get; set; }
    public required string Title { get; set; }
    public Employee? Manager { get; set; }
    public Collection<Employee> DirectReports { get; set; } = new Collection<Employee>();
}

在这里,我们定义了 Employee 类,带有一个属性 Employee? Manager,该属性持有对员工直接上司的引用。另外,我们还定义了一个 Collection<Employee> DirectReports 属性,保存员工直接下属的集合。

这两个属性形成了一个循环引用:

员工对象之间显示的循环引用

接下来的部分将讨论将 Employee 类的 JSON 负载返回意味着什么。

默认的 JSON 序列化行为

首先,让我们看看对于序列化 Employee 类时 .NET System.Text.Json 的默认行为。

让我们创建一个 ASP.NET Core Web API 项目并带有 Controllers。我们将使用 Services 层将数据返回给 Controller 来实现 单一职责原则

现在让我们创建一个 EmployeeService 类:

public class EmployeeService : IEmployeeService
{
    public IReadOnlyCollection<Employee> GetEmployees()
    {
        var manager = new Employee() { Name = "Kate", Surname = "Wilson", Title = "Development Manager" };
        var engineer = new Employee()
        {
           Name = "Adam", Surname = "Smith", Title = "Software Engineer", Manager = manager
        };
        manager.DirectReports.Add(engineer);
        return new[] { manager, engineer}.AsReadOnly();
    }
}

在这里,我们定义了 EmployeeService 类并实现了 GetEmployees() 方法。

在我们的方法中,我们创建了两个 Employee 对象的数组并将其作为 ReadOnlyCollection 返回。在集合中定义的 managerengineer 员工形成了循环依赖。

现在让我们创建我们的 EmployeeController

[Route("api/[controller]")]
[ApiController]
public class EmployeeController(IEmployeeService employeeService) : ControllerBase
{
    [HttpGet]
    [ProducesResponseType(StatusCodes.Status200OK)]
    public IActionResult GetEmployees() => Ok(employeeService.GetEmployees());
}

在这里,我们使用 依赖注入 方式将我们的 IEmployeeService 接口传递给 EmployeeController 类。同时,我们定义了 GetEmployees() 方法,该方法监听 HttpGet 网络调用并返回包含 IEmployeeService  GetEmployees() 方法结果的 OkObjectResult

Program 类中,让我们注册 IEmployeeServiceEmployeeService 之间的依赖关系:

builder.Services.AddScoped<IEmployeeService, EmployeeService>();

现在,让我们构建并运行我们的项目,并向我们的 /api/employee 端点执行一个 GET 请求:

Status: 500 Internal Server Error
System.Text.Json.JsonException: A possible object cycle was detected.
This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32.
Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles.
Path: $.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.Name.
   at System.Text.Json.ThrowHelper.ThrowJsonException_SerializerCycleDetected(Int32 maxDepth)
...

我们得到了一个 JsonException 错误 检测到可能的对象循环。这可能是由于循环或对象深度大于允许的最大深度32

另外,我们看到错误路径 $.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.Manager.Name...,指示导致错误的属性。

此外,框架提出了解决错误的方法 考虑在 JsonSerializerOptions 上使用 ReferenceHandler.Preserve 以支持循环,接下来让我们看看如何解决它。

处理循环引用

现在,让我们看看我们有哪些解决由循环引用引起的问题的选项。

System.Text.Json 库允许我们配置 C# 对象序列化为 JSON 的某些方面。我们可以用它来配置格式化、编码、末尾逗号,以及库将如何处理循环引用的方式。

通过 ReferenceHandler 配置选项处理循环引用,它可以取 ReferenceHandler.IgnoreCyclesReferenceHandler.Preserve 两个值。

让我们从 IgnoreCycles 选项开始尝试。

ReferenceHandler.IgnoreCycles

在我们的 Program 类中,让我们添加 IgnoreCycles

builder.Services.AddControllers().AddJsonOptions(options =>
   options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles);

.NET 框架将通过打破循环引用并返回 null 来尝试解决错误:

[
  {
    "name": "Kate",
    "surname": "Wilson",
    "title": "Development Manager",
    "manager": null,
    "directReports": [
      {
        "name": "Adam",
        "surname": "Smith",
        "title": "Software Engineer",
        "manager": null,
        "directReports": []
      }
    ]
  },
  {
    "name": "Adam",
    "surname": "Smith",
    "title": "Software Engineer",
    "manager": {
      "name": "Kate",
      "surname": "Wilson",
      "title": "Development Manager",
      "manager": null,
      "directReports": [null]
    },
    "directReports": []
  }
]

"manager":{...}"directReports":[...] 属性被替换为 null,这打破了循环引用,因为对象不再引用回自身。

这种方法很有效,并且我们可以看到输出以简单的格式。然而,这种解决方案的缺点是通过查看 JSON 结果很难理解关系。

现在让我们尝试 ReferenceHandler.Preserve 选项。

ReferenceHandler.Preserve

在我们的 Program 类中,让我们更改配置代码:

builder.Services.AddControllers().AddJsonOptions(options =>
   options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve);

通过这种方式,我们尝试用 Preserve 处理器解决我们的问题。这个处理器引入了两个新字段,$id$ref,用于在打破循环引用的同时保留对象之间的关系:

{
  "$id": "1",
  "$values": [
    {
      "$id": "2",
      "name": "Kate",
      "surname": "Wilson",
      "title": "Development Manager",
      "manager": null,
      "directReports": {
        "$id": "3",
        "$values": [
          {
            "$id": "4",
            "name": "Adam",
            "surname": "Smith",
            "title": "Software Engineer",
            "manager": {
              "$ref": "2"
            },
            "directReports": {
              "$id": "5",
              "$values": []
            }
          }
        ]
      }
    },
    {
      "$ref": "4"
    }
  ]
}

现在每个对象被声明一次并分配了一个 $id,这个 $id 被用于在 JSON 的其他地方通过 $ref 字段引用同一个对象。

这个机制解决了循环引用问题,并且还保留了关系。然而,它的缺点是使得 JSON 输出有些复杂,难以阅读和反序列化。

结论

在本文中,我们已经看到了如何使用 System.Text.Json 配置 JSON 序列化以在 C# 和 . NET 中处理循环引用。我们讨论了什么是循环引用并观察了在处理循环引用时 System.Text.Json 库的默认行为。最后,我们还讨论了解决循环引用的两种不同选项,IgnoreCyclesPreserve