曾几何时,在.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.Name
和user.Age
全都是null
和0
!小王抓耳挠腮,查了半天才发现是大小写匹配问题,差点就要加班调试一晚上了。 骚操作揭秘:
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. 枚举变字符串:别再给我返回0
和1
了!
背景故事:
小周的API里有个
Gender
枚举,序列化后默认变成了数字0
或1
。前端每次都要查文档才知道0
是Male
,1
是Female
。这沟通成本也太高了! 骚操作揭秘:
一个转换器就能搞定,让你的枚举变得可读。
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
难用?你可能找错了“对标对象”!
JsonDocument
难用?你可能找错了“对标对象”!背景故事:
小郑需要动态地构建一个复杂的JSON对象,或者在反序列化后对JSON结构进行一些增删改查。他怀念着Newtonsoft.Json
里JObject
、JArray
的灵活与强大,于是他找到了STJ里的JsonDocument
。结果他惊奇地发现,这玩意儿居然是只读的
!“这怎么用?连个属性都不能改,简直是反人类设计!” 小郑抱怨道,差点就把STJ拉进了黑名单。骚操作揭秘:
这是一个经典的“指鹿为马”式误解。JsonDocument
的设计目标是对标高性能的只读文档模型
,它的核心优势是低内存占用
和极速查询
,它通过Utf8JsonReader
直接操作原始的UTF-8字节流,避免了将整个JSON字符串物化为.NET对象,因此性能极佳。但它的使命是“读”,而不是“写”。 真正的JObject
/JArray
对标物,是.NET 6中隆重推出的**JsonNode
及其派生类JsonObject
、JsonArray
!这是一个
可变的、功能完善的文档对象模型(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 = 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 ( | JsonDocument (只读) | 使用 / | .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(预编译)时代打下坚实基础。
源码生成器 (
这是STJ的“核武器”!通过在编译时生成无反射的序列化代码,极大地提升了性能、降低了内存占用,并成为Blazor Wasm AOT和Native AOT等场景下的唯一选择。JsonSerializerContext
):可变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导出器 (
这是一项里程碑式的更新!现在可以直接从你的C#类型生成标准的JSON Schema文档。这意味着与OpenAPI、Swagger等API工具链的集成将变得空前简单,自动化客户端生成、API文档和数据验证都将因此受益。JsonSchemaExporter
):更灵活的格式化选项:
允许自定义缩进字符和大小,满足各种代码风格和显示需求。更强的类型安全:
默认将更严格地遵守C#的可空引用类型注解,进一步减少运行时错误。
综上所述,System.Text.Json
的进化之路,是一部清晰的从满足基本需求,到追求极致性能,再到实现功能完备,并最终引领生态发展的成长史。那些关于它的陈旧“坑论”,早已被滚滚向前的版本车轮碾得粉碎。
感谢阅读到这里,如果感觉本文对您有帮助,请不吝
评论
和点赞
,这也是我持续创作的动力!也欢迎加入我的
这一切,似未曾拥有