CodeGym /课程 /C# SELF /嵌套与分层对象的序列化

嵌套与分层对象的序列化

C# SELF
第 46 级 , 课程 2
可用

1. 引言

想象一个书店/图书馆的应用:对象不仅有书,还有作者(有传记)、出版社、员工、书籍分类……如果序列化只支持“平面”对象,我们的应用就停留在记事本级别。在真实项目里数据几乎总是多层嵌套的。因此会序列化/反序列化分层结构是把普通开发者和真正懂 .NET 序列化的高手区分开的技能。

我们会看现在 C# 的序列化器(以 System.Text.Json 为例)如何保存不仅是对象树,还有更复杂的“丛林”。同时讲如何设计类以便序列化时不需要手动一点点拆数据。

2. 对分层结构建模

把模型扩展一下。之前示例里有 BookAuthor,还有保存书列表的 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.JsonNewtonsoft.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 时出现惊喜。
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION