
前阵子帮朋友做代码评审一个从 .NET Framework 4.7 硬扛到 .NET 8 的老项目我在里面同时见到了四种写 JSON 的方式老的 Web API controller 里是JsonConvert.SerializeObject某个遗留 ASMX 周边服务还在用JavaScriptSerializer一个 WCF 组件走的是DataContractJsonSerializer新加的 minimal API 则直接用System.Text.Json.JsonSerializer。一个项目四种姿势谁都不敢乱删。你大概率也有过类似的困惑.NET 对象转 JSON到底有几种方式哪种才是“正确”的这篇文章会把目前能跑的所有方案盘一遍讲清楚它们的出身、适用场景和坑最后给一张可以直接抄的选型决策表。无论你在维护老项目还是写 .NET 8/9 的新服务都能从中找到参考。1. 先盘家底.NET 生态里能数得上的 JSON 序列化方案1.1 为什么别的语言没这么乱.NET 却有一堆 JSON 库很多从 Java 或 Python 转过来的朋友会很不适应Java 圈基本就是 Jackson 和 Gson 二选一Python 直接用标准库json但 .NET 这边光“官方内置”就换过好几代。根源在于 .NET 平台的历史包袱太重。.NET Framework 时代WebForms、ASP.NET AJAX、WCF、Silverlight 各有一套自己的数据交换基础设施每个框架都顺手做了自己的 JSON 实现。后来 .NET Core 横空出世微软在 3.0 里重写了一套System.Text.Json想统一全局。与此同时社区里的性能派也没闲着用 IL 生成、内存池等黑科技做出一批“比官方快几倍”的库。于是新旧叠加就成了现在的局面。1.2 全家福清单与一句话定位先把目前能见到的方案列个表后面再逐个展开方案出身背景现状JavaScriptSerializer.NET Framework 时代 ASP.NET AJAX 的默认选择新平台基本没有官方移植只出现在老 WebForms/ASMX 维护现场DataContractJsonSerializerWCF/Silverlight 的契约序列化体系官方仍随 .NET 发布适合严格契约互操作Newtonsoft.Json社区大神 James Newton-King 开发事实标准大量第三方 SDK 间接依赖维护积极System.Text.Json微软官方随 .NET Core 3.0 推出新平台默认内置持续迭代Jil前 Stack Overflow 工程师 Kevin Montrose 开发几乎停更老项目性能优化时可能碰到Utf8JsonneueccMessagePack for C# 作者维护缓慢Unity 场景还有人惦记ServiceStack.TextServiceStack 框架自带的文本序列化管线跟随 ServiceStack 更新单独使用场景有限一句话总结新项目优先System.Text.Json老项目或强生态项目继续用Newtonsoft.Json剩下几个属于特定场景的偏门选手。但“优先”不等于“无脑用”下面展开说。2. System.Text.Json 的官方主场常用姿势与那些默认行为2.1 最基础的 Serialize 与 options 一次性配置System.Text.Json从 .NET Core 3.0 开始内置不需要额外装包直接JsonSerializer.Serialize(obj)就能把普通对象变成 JSON 字符串。但实际项目里很少有人直接裸调因为默认配置太“素”了不缩进、不转驼峰、null 字段照常输出、枚举输出数字、中文全被转成\uXXXX。真正干活要配JsonSerializerOptionsusing System.Text.Json; using System.Text.Json.Serialization; using System.Text.Encodings.Web; var options new JsonSerializerOptions { WriteIndented true, PropertyNamingPolicy JsonNamingPolicy.CamelCase, DefaultIgnoreCondition JsonIgnoreCondition.WhenWritingNull, Encoder JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Converters { new JsonStringEnumConverter() } }; var json JsonSerializer.Serialize(order, options);这里有个很容易被忽略的点JsonSerializerOptions不是轻量对象首次使用时会做校验和内部缓存。如果你在每次请求里new一个压测时 GC 和 CPU 都会很不好看。我见过不止一个团队把 options 写在 API 方法内部结果性能测试不过关。正确姿势是声明成static readonly字段复用或者直接放在依赖注入的单例里。配置里的每个选项都有讲究PropertyNamingPolicy JsonNamingPolicy.CamelCase把StatusName输出成statusName前端 JS 拿到手就能用。DefaultIgnoreCondition JsonIgnoreCondition.WhenWritingNull响应体里自动去掉 null 字段接口清爽不少。Encoder JavaScriptEncoder.UnsafeRelaxedJsonEscaping让中文保持可读而不是\u4E2D\u6587这个后面避坑部分还会细说。JsonStringEnumConverter枚举默认输出数字1、2加上这个转换器才输出字符串pending。2.2 Source Generator从反射到编译期生成的跃迁System.Text.Json普通模式下走反射这对大多数项目够了。但 .NET 6 之后官方提供了 Source Generator 模式在编译期就生成序列化代码不再依赖运行时反射。这个功能在 .NET 8/9 里越来越重要因为 AOT 发布和高并发场景都吃这一套。用法是先定义一个继承JsonSerializerContext的 partial 类用[JsonSerializable]挂上要序列化的类型[JsonSerializable(typeof(Order))] [JsonSourceGenerationOptions( WriteIndented true, PropertyNamingPolicy JsonKnownNamingPolicy.CamelCase)] internal partial class AppJsonContext : JsonSerializerContext { } var json JsonSerializer.Serialize(order, AppJsonContext.Default.Order);对比一下就能感受到差别普通模式首次序列化要扫反射元数据源生成模式在程序启动前就把逻辑写死了启动速度、内存占用、代码裁剪trimming都更可控。如果你发布的是 Native AOT 程序不使用源生成模式基本等于给自己找罪受。不过源生成也不是万能药。它只能处理编译期已知的静态类型遇到object、dynamic或运行时才确定的类型需要额外配置。道理和“模板早就印好了没法临场发挥”差不多。所以很多项目的策略是核心 DTO 走源生成偶发的动态对象走反射模式。2.3 ASP.NET Core 两套 JsonOptions 的同名陷阱这块是新手重灾区。ASP.NET Core 里配置 JSON 序列化的 API 有两个// 影响 minimal API / HttpJsonOptions builder.Services.ConfigureHttpJsonOptions(options { options.SerializerOptions.DefaultIgnoreCondition JsonIgnoreCondition.WhenWritingNull; }); // 影响 MVC Controller 的 JSON 输出 builder.Services.AddControllers().AddJsonOptions(options { options.JsonSerializerOptions.PropertyNamingPolicy JsonNamingPolicy.CamelCase; });名字都带JsonOptions但管的是两套完全不同的配置。如果你只配了ConfigureHttpJsonOptionsMVC 的 controller 不会受影响反过来也一样。更离谱的是ControllerBase.Json()方法可以临时再塞一个JsonSerializerOptions优先于全局配置代码一多就彻底不知道实际生效的是哪层。我的经验是要么团队明确规定只用 minimal API 或只用 MVC要么在代码评审时专门盯这个点把两套配置尽早统一到同一个静态类里维护避免各配各的。3. Newtonsoft.Json 为什么还没退场灵活性与生态惯性3.1 灵活的成员控制与 LINQ to JSONNewtonsoft.Json能在社区“称王”十几年核心原因不是快而是灵活。它上面有一套非常成熟的成员控制体系[JsonProperty]改名字、[JsonIgnore]忽略字段、[JsonConverter]自定义转换、NullValueHandling、DefaultValueHandling几乎你能想到的映射需求都有现成选项。更杀手级的是它自带的 LINQ to JSON 模型JObject、JArray、JToken。拿到一段不确定结构的 JSON可以像操作内存对象一样改数据var parsed JObject.Parse(json); parsed[status] paid; parsed[items] new JArray(new[] { 1, 2, 3 }); var result parsed.ToString();这个能力在日常维护中太常用了。比如对接第三方接口对方返回的字段结构偶尔“抽风”你不想为此建一整套强类型模型直接JObject顶上去几行代码就兜住了。System.Text.Json后来虽然也有JsonNode/JsonDocument但在 API 顺手程度上和历史积累上还是差了一截。另一个很多人不知道的细节Newtonsoft.Json默认会把公共字段public field也序列化而System.Text.Json默认只认属性property。如果你在系统里用字段定义 DTO换序列化器时很容易出现“这个字段怎么丢了”的诡异问题。这也是老项目迁移时必须排查的点。3.2 与 System.Text.Json 的同台竞技一张表看清差异直接看常规场景的对比对比项Newtonsoft.JsonSystem.Text.Json输出属性命名默认原名可配 ContractResolver默认原名可配 PropertyNamingPolicy公共字段默认序列化需要 IncludeFields true循环引用ReferenceLoopHandling.Ignore / PreserveReferencesHandlingReferenceHandler.Preserve / IgnoreCycles枚举默认数字数字日期默认ISO 8601ISO 8601非字符串字典键长期兼容int/long 键自动转字符串后续版本逐步支持旧版本可能抛异常半动态 JSON 操作JObject/JToken 非常成熟JsonNode/JsonDocument 可用API 不同源生成/AOT无统一官方支持官方源生成机制完善维护方社区微软官方这么对比下来会发现System.Text.Json在 AOT、UTF-8 字节级操作、分配效率上是占优的但“灵活”这件事上还是Newtonsoft.Json的舒适区。这也是为什么很多大型项目的实际状态是两者共存API 层用 STJ模块内部或第三方 SDK 依赖的场景继续用 Newtonsoft。3.3 TypeNameHandling 的风险和个人建议提到 Newtonsoft 就绕不开TypeNameHandling。这个选项可以把 .NET 类型名写进 JSON反序列化时再还原成具体类型听着很美好但实践中一旦数据源不可信它就是一个高危口子。社区里围绕它出过多次安全问题几乎成了安全评审的必查项。我的建议很简单生产环境禁止开TypeNameHandling.All或TypeNameHandling.Objects。多态需求可以用KnownType、JsonConverter或干脆自己维护一个类型字段来替代。别图一时省事给攻击者留了路。另外现在很多第三方 SDK支付、短信、云厂商老版本内部仍然依赖 Newtonsoft。哪怕你的主工程完全用 STJ也可能因为某个包强制拉进 Newtonsoft。这种情况下别想着“物理删除”接受现实把它当作基础设施依赖来管理反而更省心。4. 性能流小众派Jil、Utf8Json、ServiceStack.Text 值不值得碰4.1 Jil在 .NET Framework 黄金期做到极致的 IL 生成派Jil是前 Stack Overflow 工程师 Kevin Montrose 搞出来的东西核心思路是在第一次序列化某个类型时用Reflection.Emit现场生成一段专门针对该类型的 IL 代码。因为是“定制”的所以比通用反射快很多当年的基准测试里常常把 Newtonsoft 甩开几倍。但它的问题也很明显依赖动态代码生成天然不兼容 AOT 和 iOS 这类禁止运行时生成代码的环境而且项目本身早在 .NET Framework 时代之后就没怎么更新了。如果你今天还在维护一个跑在 .NET Framework 上的高频计算服务看到 Jil 不奇怪新项目就没必要为那点性能去碰一个半停更的库了。4.2 Utf8JsonUnity 与高性能输出场景的特别选项Utf8Json是MessagePack for C#作者 neuecc 的作品思路和 Jil 类似也是靠 IL 生成 formatter 来提速。它还有个特殊优势对 Unity 比较友好曾经在移动端序列化场景里被大量使用并且支持直接写入IBufferWriter能绕开中间字符串拷贝。但实际情况是Utf8Json 近年的维护节奏也比较慢.NET 6/7/8 的新特性跟进不足。如果你正在做 Unity 客户端可以考虑它如果是纯服务端新项目我建议先试System.Text.Json的源生成性能已经很能打没必要额外引一个停更库给自己留维护隐患。4.3 ServiceStack.Text一套全家桶里的 JSON 管线ServiceStack.Text是 ServiceStack 框架自带的文本序列化组件能处理 JSON、JSV、CSV 多种格式可通过JsConfig做全局配置。它的序列化性能也不错而且 API 设计偏向“极简调用”。不过它的定位更像是 ServiceStack 全家桶的附属品。如果你并不用 ServiceStack 的 Web 框架只是单纯为了 JSON 序列化引入它那属于为了一个鸡腿点了一桌满汉全席意义不大。只有当团队整个技术栈都是 ServiceStack 生态时顺手用它的序列化才算合理。4.4 我的性能测试结论新项目不必为性能选它们早年在 .NET Framework 和 .NET Core 2.x 时代这些库的“数倍性能优势”是真实存在的。但System.Text.Json出现后官方在分配效率、Span 字节级解析上下了很大功夫加上源生成模式常规业务里那点差距已经不太能感知到了。我自己拿 .NET 8 简单跑过 benchmarkSTJ 源生成和 Newtonsoft 之间确实有差距但远没有当年那么夸张真正拉开差距的往往是序列化之外的东西比如网络带宽、数据库查询。所以我的态度很明确除非你有确认过的性能瓶颈并且指标证明问题就在序列化这一段否则别为了“快”去选冷门库。冷门意味着少人踩坑、少人维护、轮子坏了没人修。5. 对象转 JSON 的十大实战雷区从现象到根因再到修复这一节挑我实际处理过的高频问题展开每个都按“现象 → 根因 → 修复”来讲。5.1 循环引用最经典的栈溢出和 500现象把 EF Core 的实体类直接序列化父对象里有子集合子对象又导航回父对象结果抛JsonException提示检测到循环引用如果没走入保护逻辑可能就是无限递归。根因对象图里存在环序列化器默认不具备“已经访问过”的认知只能一路递归到底。修复分三层考虑。第一层业务上尽量用 DTO不要把实体直接丢给序列化器这是最干净的方案。第二层如果确实要序列化带环的对象图用ReferenceHandler.PreserveSTJ 会输出$id/$ref这种元数据来还原引用关系较新版本还提供ReferenceHandler.IgnoreCycles遇到重复引用直接跳过。Newtonsoft 侧对应的是ReferenceLoopHandling.Ignore和PreserveReferencesHandling.All。第三层如果只是接口返回给前端更推荐直接按需裁剪字段把导航属性去掉前端也不需要那些东西。5.2 日期格式与时区前端看到的数字和字符串对不上现象老系统返回给前端的日期长这样\/Date(13745678900000800)\/前端用new Date()解析时偶尔正常偶尔差 8 小时。根因JavaScriptSerializer和DataContractJsonSerializer经典版本的日期格式就是这种带时间戳和时区偏移的格式和主流 JSON 生态流行的 ISO 8601 不一样。修复新代码统一使用DateTimeOffset并规范为 UTC序列化配置保证输出 ISO 8601。STJ 默认就输出2024-06-01T08:00:0000:00, 这是大多数前端库能直接消化的格式。记住一个原则数据落地、跨服务传输的一律用 UTC只在展示层转为本地时间。把时区转换散落在各处迟早会出事。5.3 中文被转义成 \uXXXX日志没法看接口被误解现象调用JsonSerializer.Serialize结果中文全变成\u4E2D\u6587这种转义序列。很多人以为是自己代码写错了其实这是 STJ 的默认安全策略。它对非 ASCII 字符做转义是为了避免输出内容被当成 HTML/JS 注入到页面。根因默认编码逻辑优先安全牺牲了可读性。修复在明确知道 JSON 不会被塞进 HTML 上下文的情况下设置Encoder JavaScriptEncoder.UnsafeRelaxedJsonEscaping中文就能原样输出。但注意如果接口数据要直接渲染到浏览器页面里而且字段里可能包含用户输入这个“Unsafe”是真的 unsafe别乱开。折中方案是把 HTML 敏感字符单独转义其余保留。5.4 枚举、字段、只读属性与命名策略一堆默认值反直觉现象前端说“我要的是 pending 这种字符串为什么给我 1”后端说“我明明定义了字段为什么 JSON 里没有”根因两个库的默认行为不同。枚举默认序列化为数字STJ 默认不序列化公共字段命名策略默认保留 C# 属性原名PascalCase而不是前端常用的 camelCase。修复全局统一挂JsonStringEnumConverter和驼峰命名策略字段方案直接放弃把所有 DTO 都改成属性方法上明确配置PropertyNamingPolicy不要依赖“Web 环境自动驼峰”这种隐式行为。5.5 大整数精度long 和雪花 ID 在前端悄悄失真现象订单号7426789012345678901传到浏览器控制台一打印变成了7426789012345679000数字尾巴全变了。根因JS 的 Number 类型能安全表达的最大整数只有 2^53 - 1而 C# 的long最大到 9.2e18远超这个范围。前端拿到超长数字再用 JS 存储或参与运算精度就悄悄丢了。修复需要跨前后端保持精度的 long 字段尤其是 ID、时间戳、金额成分值统一序列化为字符串。STM 的做法是自定义一个JsonConverterlongpublic sealed class LongAsStringConverter : JsonConverterlong { public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) long.Parse(reader.GetString()!); public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) writer.WriteStringValue(value.ToString()); }需要注意的是Nullablelong和long[]还要额外包一层ConverterFactory或者给这些类型单独标记转换器。这个坑在很多电商和支付项目里出现属于“不压测上线根本发现不了”的问题。6. 选型决策表与一次遗留系统迁移的复盘6.1 不同场景下的推荐组合回到开头的问题结合上面所有分析我给出下面这份决策表使用场景建议方案理由新的 .NET 8/9 Web APISystem.Text.Json 源生成官方内置、AOT 友好、持续迭代维护老项目且大量 SDK 依赖 Newtonsoft保留 Newtonsoft仅新接口考虑 STJ兼容优先降低迁移风险与 WCF/DataContract 服务互操作DataContractJsonSerializer契约严格多态用 KnownType 描述老 WebForms/ASMX 维护中尽可能不动只做兼容层封装修改收益低风险高Unity/游戏客户端Utf8Json 或 Newtonsoft视 IL2CPP 限制而定运行时常量与裁剪约束高吞吐网关或原生 AOTSTJ 源生成模式编译期生成、零反射、分配低核心建议是不要试图在一套系统里只用一种序列化 API。真实项目里往往会有历史依赖和上下游约束能做的不是“消灭其他库”而是明确“哪一层归谁管”把边界划清楚。6.2 一次 .NET Framework 迁 .NET 8 的混合序列化教训我经手过一个真实项目一个 .NET Framework 4.7 的系统里面有 WebForms、ASMX、WCF、Web API 四种接口形态代码里三种序列化器混着用。迁移到 .NET 8 时我们一度想“一步到位全部切 STJ”结果很快就碰壁了。第一堵墙是日期格式。老接口输出的是/Date(...)/前端已经习惯用一段兼容函数去解析全切 STJ 后时间段格式变了前端没改页面时间全错。第二堵墙是匿名类型和JObject的大量使用。跑批脚本里到处都是JObject动态构造 JSONSTJ 的JsonNode虽然能做类似的事但迁移成本比想象中大一个SelectToken的差异就是一堆坑。第三堵墙是支付回调的验签逻辑强依赖 Newtonsoft 的序列化顺序。后来我们定的策略是“新旧分治”新接口一律 STJ DTO老接口保持 Newtonsoft由网关层做格式收敛同时写了一个兼容层把前端依赖的/Date()解析逻辑保留下来。整个过程耗时比预期长了近一倍但也让我彻底认清一个事实序列化器的切换不只是换 API更是在换一套数据契约。6.3 最后想留给你的一句话建议如果你现在只记住一件事那就记这个JSON 序列化的工作重点不在“用哪个库”而在“序列化前把对象整理成什么形态”。多花时间设计 DTO、统一时间规范、把大整数处理干净比纠结换哪家库重要得多。真正的高手不是只会调用Serialize而是能在对象图上做减法让输出 JSON 一开始就符合前端的期望。最后再分享一个小习惯我写任何接口前会先手写一个期望的 JSON 样例再对着样例去设计 C# 类型和序列化配置。这个习惯帮我躲过了不少“类型对不上、命名不一致、日期格式畸形”的坑比事后调试省心太多。