---
URL: "/zh-CN/blog/2025-08/system_text_json.html"
LLMS_URL: "/zh-CN/blog/2025-08/system_text_json.md"
lastUpdated: true
commentabled: true
recommended: true
title: "2025年：是时候重新认识System.Text.Json了"
description: "2025年：是时候重新认识System.Text.Json了"
date:

pageClass: "blog-page-class"
cover: "/covers/dotnet.svg"
---

---
lastUpdated: true
commentabled: true
recommended: true
title: 2025年：是时候重新认识System.Text.Json了
description: 2025年：是时候重新认识System.Text.Json了
date: 2025-08-01 09:00:00 
pageClass: blog-page-class
cover: /covers/dotnet.svg
---

曾几何时，在.NET的世界里，`Newtonsoft.Json` 如同一位德高望重的王者，无人不晓。直到有一天，一位名叫 `System.Text.Json`（后文简称STJ）的新贵悄然登场。它出身名门（.NET官方），身怀绝技（号称性能超群），本应是明日之星，却被无数开发者贴上了“坑王”、“难用”、“反人类”的标签。

无数个深夜，开发者们为了解决一个看似简单的JSON序列化问题，从STJ切换回NSJ，嘴里念叨着：“STJ，劝退了。”然后默默地 `Install-Package Newtonsoft.Json`，仿佛这才是解决问题的唯一“骚操作”。

但是，这一切真的公平吗？时过境迁，如今.NET 10的预览版都已发布，STJ早已不是当年的吴下阿蒙。那些曾经让你抓狂的“坑”，有多少只是因为误解了它的设计哲学？有多少早已被新版本填平？

今天，就让我们一起为STJ来一场轰轰烈烈的“正名运动”，让你彻底告别因它而起的“996”！

## 告别加班：STJ与Newtonsoft行为对齐实战 ##

很多时候，我们觉得STJ“不好用”，仅仅是因为它的默认行为和牛顿不一样。STJ的设计哲学是：**性能优先、安全第一、严格遵守RFC 8259规范**。而牛顿则更倾向于**灵活方便、兼容并包**。下面我们就通过一个个小故事和代码示例，看看如何通过简单的配置，让STJ的行为像我们熟悉的老朋友牛顿一样。

### 大小写问题：前端传的name，我C#的Name怎么就收不到了？ ###

#### 背景 ####

小王刚接手一个前后端分离的项目，前端用JS，遵循驼峰命名（camelCase），传来一个JSON：`{"name": "张三", "age": 18}`。后端的C#模型用的是帕斯卡命名（PascalCase）：`public class User { public string Name { get; set; } public int Age { get; set; } }`。结果用STJ一反序列化，`user.Name` 和 `user.Age` 全都是null和0！小王抓耳挠腮，查了半天才发现是大小写匹配问题，差点就要加班调试一晚上了。

#### 骚操作揭秘 ####

STJ为了极致性能，默认是**区分大小写**的。而牛顿默认是不区分的。我们只需一个配置项就能解决问题。

```C#
using System.Text.Json;

var jsonFromJs = "{\"name\": \"张三\", \"age\": 18}";

// 默认行为，会匹配失败
var optionsDefault = new JsonSerializerOptions();
var userDefault = JsonSerializer.Deserialize<User>(jsonFromJs, optionsDefault);
Console.WriteLine($"默认行为: Name = {userDefault.Name}"); // 输出: 默认行为: Name = 

// 骚操作：开启不区分大小写匹配
var optionsInsensitive = new JsonSerializerOptions
{
    PropertyNameCaseInsensitive = true
};
var userInsensitive = JsonSerializer.Deserialize<User>(jsonFromJs, optionsInsensitive);
Console.WriteLine($"开启不区分大小写: Name = {userInsensitive.Name}"); // 输出: 开启不区分大小写: Name = 张三

public class User
{
    public string Name { get; set; }
    public int Age { get; set; }
}
```

> 小贴士： 在ASP.NET Core的Web API项目中，默认已经帮你开启了 `PropertyNameCaseInsensitive = true`，所以你可能根本没遇到过这个问题，但如果你手动调用 `JsonSerializer`，就需要注意了。

### 命名策略：我的UserName怎么就不能变成userName？ ###

#### 背景 ####

小李的后端API返回的JSON字段都是Pascal风格，比如`{"UserName": "Lisi", "IsEnabled": true}`。前端小伙伴抱怨说这不符合JS社区的规范，希望能统一用驼峰命名`{"userName": "Lisi", "isEnabled": true}`。小李心想，难道要把所有C#属性名都改成小写开头？这也太不优雅了！

#### 骚操作揭秘 ####

当然不用！STJ提供了命名策略（Naming Policy），让你轻松转换。

```C#
using System.Text.Json;

var user = new User { UserName = "Lisi", IsEnabled = true };

// 默认行为，Pascal风格
var optionsDefault = new JsonSerializerOptions { WriteIndented = true };
var jsonDefault = JsonSerializer.Serialize(user, optionsDefault);
Console.WriteLine("默认输出:\n" + jsonDefault);
// 默认输出:
// {
//   "UserName": "Lisi",
//   "IsEnabled": true
// }

// 骚操作：指定驼峰命名策略
var optionsCamelCase = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true
};
var jsonCamelCase = JsonSerializer.Serialize(user, optionsCamelCase);
Console.WriteLine("\n驼峰输出:\n" + jsonCamelCase);
// 驼峰输出:
// {
//   "userName": "Lisi",
//   "isEnabled": true
// }

public class User
{
    public string UserName { get; set; }
    public bool IsEnabled { get; set; }
}
```

> 小贴士： 同样，在ASP.NET Core中，默认也帮你配置了驼峰命名策略。这就是为什么你的API天生就符合前端规范。

### 注释和尾随逗号：这JSON怎么就不“合法”了？ ###

#### 背景 ####

老张需要处理一批由其他系统生成的JSON配置文件，这些文件里竟然带了注释，而且数组末尾还可能有个多余的逗号，比如 `[1, 2, 3, /*这是注释*/]`。`Newtonsoft.Json` 处理这些文件毫无压力，但 `System.Text.Json` 一上来就抛出 `JsonException`，直接罢工。

#### 骚操作揭秘 ####

STJ严格遵守RFC 8259规范，该规范不允许注释和尾随逗号。但为了兼容性，它也提供了开关。

```C#
using System.Text.Json;

// 注意3后面有一个尾随逗号
var nonStandardJson = @"{
    ""name"": ""带注释的JSON"",
    ""data"": [
        1,
        2,
        3,
    ]
}";

// 默认行为，直接抛异常
try
{
    JsonSerializer.Deserialize<object>(nonStandardJson);
}
catch (JsonException ex)
{
    // The JSON array contains a trailing comma at the end which is not supported in this mode. Change the reader options. Path: $ | LineNumber: 6 | BytePositionInLine: 4.
    Console.WriteLine("默认行为，果然报错了: " + ex.Message);
}

// 骚操作：允许注释和尾随逗号
var tolerantOptions = new JsonSerializerOptions
{
    ReadCommentHandling = JsonCommentHandling.Skip, // 跳过注释
    AllowTrailingCommas = true // 允许尾随逗号
};
var deserializedObject = JsonSerializer.Deserialize<object>(nonStandardJson, tolerantOptions);
Console.WriteLine("\n开启兼容模式后，成功解析！");
```

### null值的处理：满屏的null看着好烦！ ###

#### 背景故事 ####

小赵的API返回的用户信息里，有些字段是可选的，比如 `MiddleName`。当这些字段没有值时，序列化出的JSON里会包含 `"middleName": null`。这不仅增加了网络传输的数据量，前端同学也觉得处理起来很麻烦，他们希望 `null` 值的字段干脆就不要出现在 `JSON` 里。

#### 骚操作揭秘 ####

牛顿通过 `NullValueHandling.Ignore` 可以轻松实现，STJ同样可以。

```C#
using System.Text.Json;
using System.Text.Json.Serialization;

var user = new User { FirstName = "San", LastName = "Zhang", MiddleName = null };

// 默认行为，包含null值
var optionsDefault = new JsonSerializerOptions { WriteIndented = true };
var jsonDefault = JsonSerializer.Serialize(user, optionsDefault);
Console.WriteLine("默认输出:\n" + jsonDefault);

// 骚操作：序列化时忽略null值
var optionsIgnoreNull = new JsonSerializerOptions
{
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    WriteIndented = true
};
var jsonIgnoreNull = JsonSerializer.Serialize(user, optionsIgnoreNull);
Console.WriteLine("\n忽略null值输出:\n" + jsonIgnoreNull);

public class User
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string MiddleName { get; set; }
}
```

### 带引号的数字：`"age": "30"` 也能是数字？ ###

#### 背景 ####

小孙在对接一个非常“古老”的第三方API，返回的JSON里，所有的数字都是用字符串表示的，例如 `{"age": "30"}`。STJ在反序列化到 `int Age` 属性时直接抛出异常，因为它认为"30"是字符串，不是数字。难道还得先反序列化成string再手动 `int.Parse`？

#### 骚操作揭秘 ####

不用那么麻烦，STJ早就想到了这种不规范但常见的情况。

```C#
using System.Text.Json;
using System.Text.Json.Serialization;

var jsonWithQuotedNumber = @"{""Age"": ""30""}";

// 默认行为，抛出异常
try
{
    JsonSerializer.Deserialize<User>(jsonWithQuotedNumber);
}
catch (JsonException ex)
{
    // The JSON value could not be converted to System.Int32. Path: $.Age | LineNumber: 0 | BytePositionInLine: 12.
    Console.WriteLine("默认行为，报错了: " + ex.Message);
}

// 骚操作：允许从字符串读取数字
var optionsAllowQuotedNumbers = new JsonSerializerOptions
{
    NumberHandling = JsonNumberHandling.AllowReadingFromString
};
var user = JsonSerializer.Deserialize<User>(jsonWithQuotedNumber, optionsAllowQuotedNumbers);
Console.WriteLine($"\n开启兼容模式后，Age = {user.Age}");

public class User
{
    public int Age { get; set; }
}
```

### 循环引用：我和我的老板，谁先序列化？ ###

#### 背景 ####

小钱在使用 `Entity Framework`时，遇到了经典难题：`Employee` 对象有个 `Manager` 属性，`Manager` 对象又有个 `DirectReports` 列表包含了这个 `Employee`。一序列化，就陷入了“*你中有我，我中有你*”的无限循环，最终 `JsonException` 爆栈。

#### 骚操作揭秘 ####

这是STJ在 `.NET 5` 和 `.NET 6` 中重点解决的问题。现在我们有两种选择。

```C#
using System.Text.Json;
using System.Text.Json.Serialization;

var manager = new Employee { Name = "老板" };
var employee = new Employee { Name = "小钱", Manager = manager };
manager.DirectReports = new List<Employee> { employee };

// 默认行为，抛出循环引用异常
try
{
    JsonSerializer.Serialize(employee);
}
catch (JsonException ex)
{
    // 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 64. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. 
    // Path: $.Manager.DirectReports.Manager.DirectReports.Manager.DirectReports.……
    Console.WriteLine("默认行为，循环引用报错: " + ex.Message);
}

// 骚操作：忽略循环引用点（推荐用于API）
var optionsIgnoreCycles = new JsonSerializerOptions
{
    ReferenceHandler = ReferenceHandler.IgnoreCycles,
    WriteIndented = true
};
var jsonIgnoreCycles = JsonSerializer.Serialize(employee, optionsIgnoreCycles);
Console.WriteLine("\n忽略循环引用输出:\n" + jsonIgnoreCycles);
// 输出中，老板的DirectReports里的小钱的Manager属性会是null

public class Employee
{
    public string Name { get; set; }
    public Employee Manager { get; set; }
    public List<Employee> DirectReports { get; set; }
}
```

> 小贴士： 还有一个 `ReferenceHandler.Preserve` 选项，它会通过 `$id` 和 `$ref` 元数据来完整保留对象图，适合需要完美往返（round-trip）序列化的场景，但生成的JSON通用性较差。对于Web API，`IgnoreCycles` 通常是更好的选择。

### 枚举变字符串：别再给我返回0和1了！ ###

#### 背景 ####

小周的API里有个 `Gender` 枚举，序列化后默认变成了数字0或1。前端每次都要查文档才知道0是Male，1是Female。这沟通成本也太高了！

#### 骚操作揭秘 ####

一个转换器就能搞定，让你的枚举变得可读。

```C#
using System.Text.Json;
using System.Text.Json.Serialization;

var user = new User { Gender = Gender.Male };

// 默认行为，序列化为数字
var optionsDefault = new JsonSerializerOptions { WriteIndented = true };
var jsonDefault = JsonSerializer.Serialize(user, optionsDefault);
Console.WriteLine("默认输出:\n" + jsonDefault);
// {
//   "Gender": 0
// }

// 骚操作：添加枚举字符串转换器
var optionsEnumAsString = new JsonSerializerOptions
{
    Converters = { new JsonStringEnumConverter() },
    WriteIndented = true
};
var jsonEnumAsString = JsonSerializer.Serialize(user, optionsEnumAsString);
Console.WriteLine("\n枚举转字符串输出:\n" + jsonEnumAsString);
// 枚举转字符串输出:
// {
//   "Gender": "Male"
// }

public class User
{
    public Gender Gender { get; set; }
}

public enum Gender { Male, Female }
```

### 让JSON回归人类可读：与中文和AI友好相处 ###

#### 背景故事 ####

我兴冲冲地序列化了一个包含中文的对象，准备发给新接入的AI大模型。结果一看日志，"`骚操作`" 变成了 "`\u9A9A\u64CD\u4F5C`"！我当时就懵了，这不仅我看着费劲，AI能看懂吗？Token数暴增暂且不说，理解上出现偏差怎么办？难道又要退回Newtonsoft？

#### 骚操作揭秘 ####

这可能是对STJ误解最深的一点。STJ默认这样做，是出于极致的安全考虑。它的默认编码器 `JavaScriptEncoder.Default` 会转义所有 `非ASCII` 字符以及HTML敏感字符（如`<` , `>` , `&`），这是为了防止当你的JSON被不当地嵌入到HTML `<script>`标签中时，引发XSS（跨站脚本）攻击。它遵循的是“默认安全”的最高原则。

然而，在如今API交互的时代，我们通常通过 `Content-Type: application/json` 来通信，数据并不会直接嵌入HTML。特别是在与大模型（LLM）交互时，保持中文字符的原样不仅能让我们人类更容易阅读和调试，更能让AI准确无误地理解语义，同时显著减少Token消耗。这时，我们可以明确地告诉STJ：“我清楚我的使用环境是安全的，请别转义！”

```C#
// 默认行为：输出Unicode转义字符，安全至上
var escapedJson = JsonSerializer.Serialize("骚操作");
// escapedJson -> "\u9A9A\u64CD\u4F5C"

// 骚操作配置：在安全的环境下，让JSON对人类和AI更友好
var options = new JsonSerializerOptions()
{
    Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var readableJson = JsonSerializer.Serialize("骚操作", options);
// readableJson -> "骚操作"
```

你看，STJ不是“坑”，它只是个安全感爆棚、需要你明确指令的“直男”而已。

### JsonDocument 难用？你可能找错了“对标对象”！ ###

#### 背景 ####

小郑需要动态地构建一个复杂的JSON对象，或者在反序列化后对JSON结构进行一些增删改查。他怀念着Newtonsoft.Json里JObject、JArray的灵活与强大，于是他找到了STJ里的JsonDocument。结果他惊奇地发现，这玩意儿居然是只读的！“这怎么用？连个属性都不能改，简直是反人类设计！” 小郑抱怨道，差点就把STJ拉进了黑名单。

骚操作揭秘： 这是一个经典的“指鹿为马”式误解。JsonDocument的设计目标是对标高性能的只读文档模型，它的核心优势是低内存占用和极速查询，它通过Utf8JsonReader直接操作原始的UTF-8字节流，避免了将整个JSON字符串物化为.NET对象，因此性能极佳。但它的使命是“读”，而不是“写”。

真正的JObject/JArray对标物，是.NET 6中隆重推出的**JsonNode及其派生类JsonObject、JsonArray！这是一个可变的、功能完善的文档对象模型（DOM）**，它提供了你所期望的一切动态操作能力。

```C#
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Encodings.Web;

// 骚操作：使用JsonNode动态构建和修改JSON
Console.WriteLine("--- 使用JsonNode ---");

// 1. 创建一个JsonObject，就像 new JObject()
var rootNode = new JsonObject();

// 2. 像字典一样轻松添加属性
rootNode["message"] = "Hello, JsonNode!";
rootNode["user"] = new JsonObject
{
    ["name"] = "小郑",
    ["isActive"] = true
};

// 3. 添加一个JsonArray
var scores = new JsonArray(88, 95, 100);
rootNode["scores"] = scores;
Console.WriteLine("添加属性后的JSON:\n" + rootNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));

// 4. 轻松修改属性值
rootNode["user"]["isActive"] = false;
Console.WriteLine("\n修改属性后的JSON:\n" + rootNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));

// 5. 序列化为字符串，完美兼容中文
var finalOptions = new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
var finalJson = rootNode.ToJsonString(finalOptions);
Console.WriteLine("\n最终输出（兼容中文）:\n" + finalJson);
```

所以，下次当你想动态操作JSON时，请记住口诀：要读就用 `JsonDocument`，要写就用 `JsonNode`！

## 快速参考：行为对齐配置映射表 ##

为了方便大家快速查找，我把上面的骚操作整理成了一个表格：


| Newtonsoft.Json          |        System.Text.Json        |     JsonSerializerOptions  |             备注 |
| :----------------: | :--------------------: | :---------------------: | :---------------------: |
| 反序列化大小写不敏感 | 区分大小写 | `PropertyNameCaseInsensitive = true` | ASP.NET Core默认已开启此选项。 |
| 驼峰命名策略 | PascalCase (与C#属性名一致) | `PropertyNamingPolicy = JsonNamingPolicy.CamelCase` | ASP.NET Core默认已开启此选项。 |
| 忽略JSON注释 | 抛出异常 | `ReadCommentHandling = JsonCommentHandling.Skip` |  |
| 忽略尾随逗号 | 抛出异常 | AllowTrailingCommas = true |  |
| 序列化时忽略null值 | 包含null值 | `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull` |  |
| 反序列化带引号的数字 | 抛出异常 | `NumberHandling = JsonNumberHandling.AllowReadingFromString` | 例如，将"123"反序列化为int类型。 |
| 处理循环引用 | 抛出异常 | `ReferenceHandler = ReferenceHandler.IgnoreCycles` | IgnoreCycles将循环点置为null，是API场景的常用选择。 |
| 枚举序列化为字符串 | 序列化为数字 | 添加 `new JsonStringEnumConverter()` 到 `Converters` 集合 | Newtonsoft.Json中也有类似的StringEnumConverter。 |
| 中文字符转义 | 转义非ASCII字符 | `Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping` | 适用于API和AI交互，减少Token消耗。 |
| 动态操作JSON (`JObject`) | `JsonDocument` (只读) | 使用 `JsonNode` / `JsonObject` / `JsonArray` | .NET 6及以后版本可用，功能对标JObject。 |

## STJ的进化之路：从追赶者到引领者 ##

通过上面的例子，我们看到大部分所谓的“坑”其实都是可以通过 `JsonSerializerOptions` 轻松配置的。但我们也要承认，STJ在早期版本中确实存在一些功能上的“天坑”。幸运的是，微软的开发团队堪称“基建狂魔”，他们用一个个版本的迭代，不仅填平了所有坑，更铺就了一条通往未来的高速公路。

让我们沿着时间的脉络，回顾STJ这场精彩的逆袭之战：

### .NET 5 - 奠定基础，补齐核心短板 ###

这是STJ发布后的第一个“大招版本”，一口气解决了从 `Newtonsoft.Json` 迁移过来的最大痛点，让STJ在许多真实场景中变得“堪用”。

- **循环引用处理**： 引入 `ReferenceHandler.Preserve`，让处理 `EF Core` 等复杂对象图不再是噩梦。
- **非公共成员支持**： 带来了 `[JsonInclude]` 和 `[JsonConstructor]` 特性，终于可以序列化私有属性和使用私有构造函数了，解救了无数依赖封装性的开发者。
- **非字符串键字典**： 开始原生支持 `Dictionary<int, T>` 这类常见结构，不再抛出恼人的 `NotSupportedException`。

### .NET 6 - 性能飞跃，拥抱现代范式 ###

如果说.NET 5让STJ“能用”，那么.NET 6则让它真正“好用”和“快用”，并为未来的AOT（预编译）时代打下坚实基础。

- **源码生成器 (`JsonSerializerContext`)**： 这是STJ的“核武器”！通过在编译时生成无反射的序列化代码，极大地提升了性能、降低了内存占用，并成为Blazor Wasm AOT和Native AOT等场景下的唯一选择。
- **可变DOM (`JsonNode`)**： 提供了官方的`JObject`/`JArray`替代品，让动态解析和构建复杂的JSON对象变得简单而类型安全。

### .NET 7 - 功能完备，实现高级定制 ###

这个版本标志着STJ在功能上基本追平了 `Newtonsoft.Json`，尤其是在高级和复杂的定制场景下，给了开发者充足的信心。

- **安全的多态序列化**： 推出基于 `[JsonPolymorphic]` 和 `[JsonDerivedType]` 的白名单式多态支持，功能强大且从设计上根除了 `Newtonsoft.Json` 中 `TypeNameHandling` 的安全隐患。
- **终极定制武器 (`IJsonTypeInfoResolver`)**： 引入了对标 `IContractResolver` 的接口，允许开发者在运行时动态修改类型的序列化“契约”，实现了诸如动态添加/删除属性、实现复杂条件序列化等高级“骚操作”。

### .NET 8 - 精益求精，优化开发体验 ###

在功能已经非常完善的基础上，.NET 8更侧重于对现有功能的打磨和对开发者体验的优化。

- **源码生成器增强**： 完美支持 `C# 11` 的 `required` 和 `init` 属性，让不可变模型和源码生成器能更好地协同工作。
- **内置更多常用类型支持**： 如 `Half`, `Int128`, `UInt128` 等，并改进了对接口层次结构的支持。

### .NET 9 - 生态集成，引领行业标准 ###

从.NET 9开始，我们能清晰地看到STJ已经不再满足于追赶，而是开始作为.NET生态的核心组件，主动引领和定义标准。

- **JSON Schema导出器 (`JsonSchemaExporter`)**： 这是一项里程碑式的更新！现在可以直接从你的C#类型生成标准的JSON Schema文档。这意味着与OpenAPI、Swagger等API工具链的集成将变得空前简单，自动化客户端生成、API文档和数据验证都将因此受益。
- **更灵活的格式化选项**： 允许自定义缩进字符和大小，满足各种代码风格和显示需求。
- **更强的类型安全**： 默认将更严格地遵守C#的可空引用类型注解，进一步减少运行时错误。

综上所述，`System.Text.Json` 的进化之路，是一部清晰的从满足基本需求，到追求极致性能，再到实现功能完备，并最终引领生态发展的成长史。那些关于它的陈旧“坑论”，早已被滚滚向前的版本车轮碾得粉碎。
