曾几何时,在.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}");
var optionsInsensitive = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
var userInsensitive = JsonSerializer.Deserialize<User>(jsonFromJs, optionsInsensitive);
Console.WriteLine($"开启不区分大小写: Name = {userInsensitive.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 };
var optionsDefault = new JsonSerializerOptions { WriteIndented = true };
var jsonDefault = JsonSerializer.Serialize(user, optionsDefault);
Console.WriteLine("默认输出:\n" + jsonDefault);
var optionsCamelCase = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
var jsonCamelCase = JsonSerializer.Serialize(user, optionsCamelCase);
Console.WriteLine("\n驼峰输出:\n" + jsonCamelCase);
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;
var nonStandardJson = @"{
""name"": ""带注释的JSON"",
""data"": [
1,
2,
3,
]
}";
try
{
JsonSerializer.Deserialize<object>(nonStandardJson);
}
catch (JsonException ex)
{
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 };
var optionsDefault = new JsonSerializerOptions { WriteIndented = true };
var jsonDefault = JsonSerializer.Serialize(user, optionsDefault);
Console.WriteLine("默认输出:\n" + jsonDefault);
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)
{
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)
{
Console.WriteLine("默认行为,循环引用报错: " + ex.Message);
}
var optionsIgnoreCycles = new JsonSerializerOptions
{
ReferenceHandler = ReferenceHandler.IgnoreCycles,
WriteIndented = true
};
var jsonIgnoreCycles = JsonSerializer.Serialize(employee, optionsIgnoreCycles);
Console.WriteLine("\n忽略循环引用输出:\n" + jsonIgnoreCycles);
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);
var optionsEnumAsString = new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() },
WriteIndented = true
};
var jsonEnumAsString = JsonSerializer.Serialize(user, optionsEnumAsString);
Console.WriteLine("\n枚举转字符串输出:\n" + jsonEnumAsString);
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:“我清楚我的使用环境是安全的,请别转义!”
var escapedJson = JsonSerializer.Serialize("骚操作");
var options = new JsonSerializerOptions()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var readableJson = JsonSerializer.Serialize("骚操作", options);
你看,STJ不是“坑”,它只是个安全感爆棚、需要你明确指令的“直男”而已。
9. 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;
Console.WriteLine("--- 使用JsonNode ---");
var rootNode = new JsonObject();
rootNode["message"] = "Hello, JsonNode!";
rootNode["user"] = new JsonObject
{
["name"] = "小郑",
["isActive"] = true
};
var scores = new JsonArray(88, 95, 100);
rootNode["scores"] = scores;
Console.WriteLine("添加属性后的JSON:\n" + rootNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
rootNode["user"]["isActive"] = false;
Console.WriteLine("\n修改属性后的JSON:\n" + rootNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
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 (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
的进化之路,是一部清晰的从满足基本需求,到追求极致性能,再到实现功能完备,并最终引领生态发展的成长史。那些关于它的陈旧“坑论”,早已被滚滚向前的版本车轮碾得粉碎。
转自https://www.cnblogs.com/sdcb/p/19010852
该文章在 2025/8/1 8:50:40 编辑过