LOGO OA教程 ERP教程 模切知识交流 PMS教程 CRM教程 开发文档 其他文档  
 
网站管理员

2025年:是时候重新认识System.Text.Json了

freeflydom
2025年8月1日 8:50 本文热度 82

曾几何时,在.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的行为像我们熟悉的老朋友牛顿一样。

1. 大小写问题:前端传的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.Nameuser.Age全都是null0!小王抓耳挠腮,查了半天才发现是大小写匹配问题,差点就要加班调试一晚上了。

骚操作揭秘:
STJ为了极致性能,默认是区分大小写的。而牛顿默认是不区分的。我们只需一个配置项就能解决问题。

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,就需要注意了。

2. 命名策略:我的UserName怎么就不能变成userName

背景故事:
小李的后端API返回的JSON字段都是Pascal风格,比如{"UserName": "Lisi", "IsEnabled": true}。前端小伙伴抱怨说这不符合JS社区的规范,希望能统一用驼峰命名{"userName": "Lisi", "isEnabled": true}。小李心想,难道要把所有C#属性名都改成小写开头?这也太不优雅了!

骚操作揭秘:
当然不用!STJ提供了命名策略(Naming Policy),让你轻松转换。

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天生就符合前端规范。

3. 注释和尾随逗号:这JSON怎么就不“合法”了?

背景故事:
老张需要处理一批由其他系统生成的JSON配置文件,这些文件里竟然带了注释,而且数组末尾还可能有个多余的逗号,比如 [1, 2, 3, /*这是注释*/]Newtonsoft.Json处理这些文件毫无压力,但System.Text.Json一上来就抛出JsonException,直接罢工。

骚操作揭秘:
STJ严格遵守RFC 8259规范,该规范不允许注释和尾随逗号。但为了兼容性,它也提供了开关。

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开启兼容模式后,成功解析!");

4. null值的处理:满屏的null看着好烦!

背景故事:
小赵的API返回的用户信息里,有些字段是可选的,比如MiddleName。当这些字段没有值时,序列化出的JSON里会包含"middleName": null。这不仅增加了网络传输的数据量,前端同学也觉得处理起来很麻烦,他们希望null值的字段干脆就不要出现在JSON里。

骚操作揭秘:
牛顿通过NullValueHandling.Ignore可以轻松实现,STJ同样可以。

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; }
}

5. 带引号的数字:"age": "30" 也能是数字?

背景故事:
小孙在对接一个非常“古老”的第三方API,返回的JSON里,所有的数字都是用字符串表示的,例如{"age": "30"}。STJ在反序列化到int Age属性时直接抛出异常,因为它认为"30"是字符串,不是数字。难道还得先反序列化成string再手动int.Parse

骚操作揭秘:
不用那么麻烦,STJ早就想到了这种不规范但常见的情况。

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; }
}

6. 循环引用:我和我的老板,谁先序列化?

背景故事:
小钱在使用Entity Framework时,遇到了经典难题:Employee对象有个Manager属性,Manager对象又有个DirectReports列表包含了这个Employee。一序列化,就陷入了“你中有我,我中有你”的无限循环,最终JsonException爆栈。

骚操作揭秘:
这是STJ在.NET 5和.NET 6中重点解决的问题。现在我们有两种选择。

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通常是更好的选择。

7. 枚举变字符串:别再给我返回01了!

背景故事:
小周的API里有个Gender枚举,序列化后默认变成了数字01。前端每次都要查文档才知道0Male1Female。这沟通成本也太高了!

骚操作揭秘:
一个转换器就能搞定,让你的枚举变得可读。

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 }

8. 让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:“我清楚我的使用环境是安全的,请别转义!”

// 默认行为:输出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不是“坑”,它只是个安全感爆棚、需要你明确指令的“直男”而已。

9. JsonDocument 难用?你可能找错了“对标对象”!

背景故事: 小郑需要动态地构建一个复杂的JSON对象,或者在反序列化后对JSON结构进行一些增删改查。他怀念着Newtonsoft.JsonJObjectJArray的灵活与强大,于是他找到了STJ里的JsonDocument。结果他惊奇地发现,这玩意儿居然是只读的!“这怎么用?连个属性都不能改,简直是反人类设计!” 小郑抱怨道,差点就把STJ拉进了黑名单。

骚操作揭秘: 这是一个经典的“指鹿为马”式误解。JsonDocument的设计目标是对标高性能的只读文档模型,它的核心优势是低内存占用极速查询,它通过Utf8JsonReader直接操作原始的UTF-8字节流,避免了将整个JSON字符串物化为.NET对象,因此性能极佳。但它的使命是“读”,而不是“写”。

真正的JObject/JArray对标物,是.NET 6中隆重推出的**JsonNode及其派生类JsonObjectJsonArray!这是一个可变的、功能完善的文档对象模型(DOM)**,它提供了你所期望的一切动态操作能力。

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 默认行为System.Text.Json 配置 (JsonSerializerOptions)备注
反序列化大小写不敏感区分大小写PropertyNameCaseInsensitive = trueASP.NET Core默认已开启此选项。
驼峰命名策略PascalCase (与C#属性名一致)PropertyNamingPolicy = JsonNamingPolicy.CamelCaseASP.NET Core默认已开启此选项。
忽略JSON注释抛出异常ReadCommentHandling = JsonCommentHandling.Skip
忽略尾随逗号抛出异常AllowTrailingCommas = true
序列化时忽略null值包含null值DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
反序列化带引号的数字抛出异常NumberHandling = JsonNumberHandling.AllowReadingFromString例如,将"123"反序列化为int类型。
处理循环引用抛出异常ReferenceHandler = ReferenceHandler.IgnoreCyclesIgnoreCycles将循环点置为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.JsonTypeNameHandling的安全隐患。
  • 终极定制武器 (IJsonTypeInfoResolver): 引入了对标IContractResolver的接口,允许开发者在运行时动态修改类型的序列化“契约”,实现了诸如动态添加/删除属性、实现复杂条件序列化等高级“骚操作”。

.NET 8 - 精益求精,优化开发体验

在功能已经非常完善的基础上,.NET 8更侧重于对现有功能的打磨和对开发者体验的优化。

  • 源码生成器增强: 完美支持C# 11的requiredinit属性,让不可变模型和源码生成器能更好地协同工作。
  • 内置更多常用类型支持: 如HalfInt128UInt128等,并改进了对接口层次结构的支持。

.NET 9 - 生态集成,引领行业标准

从.NET 9开始,我们能清晰地看到STJ已经不再满足于追赶,而是开始作为.NET生态的核心组件,主动引领和定义标准。

  • JSON Schema导出器 (JsonSchemaExporter): 这是一项里程碑式的更新!现在可以直接从你的C#类型生成标准的JSON Schema文档。这意味着与OpenAPI、Swagger等API工具链的集成将变得空前简单,自动化客户端生成、API文档和数据验证都将因此受益。
  • 更灵活的格式化选项: 允许自定义缩进字符和大小,满足各种代码风格和显示需求。
  • 更强的类型安全: 默认将更严格地遵守C#的可空引用类型注解,进一步减少运行时错误。

综上所述,System.Text.Json的进化之路,是一部清晰的从满足基本需求,到追求极致性能,再到实现功能完备,并最终引领生态发展的成长史。那些关于它的陈旧“坑论”,早已被滚滚向前的版本车轮碾得粉碎。

​转自https://www.cnblogs.com/sdcb/p/19010852


该文章在 2025/8/1 8:50:40 编辑过
关键字查询
相关文章
正在查询...
点晴ERP是一款针对中小制造业的专业生产管理软件系统,系统成熟度和易用性得到了国内大量中小企业的青睐。
点晴PMS码头管理系统主要针对港口码头集装箱与散货日常运作、调度、堆场、车队、财务费用、相关报表等业务管理,结合码头的业务特点,围绕调度、堆场作业而开发的。集技术的先进性、管理的有效性于一体,是物流码头及其他港口类企业的高效ERP管理信息系统。
点晴WMS仓储管理系统提供了货物产品管理,销售管理,采购管理,仓储管理,仓库管理,保质期管理,货位管理,库位管理,生产管理,WMS管理系统,标签打印,条形码,二维码管理,批号管理软件。
点晴免费OA是一款软件和通用服务都免费,不限功能、不限时间、不限用户的免费OA协同办公管理系统。
Copyright 2010-2025 ClickSun All Rights Reserved