ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

System.Text.Json迁移指南:Newtonsoft常见问题与解决方案

System.Text.Json迁移指南:Newtonsoft常见问题与解决方案 System.Text.Json 从 .NET Core 3.0 开始成为官方 JSON 库很多老项目从 Newtonsoft.Json 迁移过来后第一反应是“配置太裸了”。这不是错觉。System.Text.Json 的设计目标偏向高性能、低分配、默认安全因此很多 Newtonsoft 里“顺手就用了”的功能在原生实现里要么没有要么换了完全不同的用法。如果你正在做迁移或者已经踩到“序列化结果变了”“反序列化抛异常”“DataTable 直接报错”这类问题这篇内容值得看完。下面直接按迁移中最高频的问题拆开讲每个问题都会给补救思路和可复制代码。1. 先搞清楚System.Text.Json 不是 Newtonsoft 的上位替代1.1 两个库的出发点不同Newtonsoft.Json 发展早功能覆盖非常多尤其对老式 .NET 类型、动态类型、多态类型、特殊格式的处理都做得很周到。它更像一个“能把 JSON 折腾成任何样子”的功能型库。System.Text.Json 是 .NET 团队面向现代应用定制的实现。它的优先级是性能、低内存分配、默认安全、跨平台可预测性。为了达到这些目标它砍掉了一批“方便但昂贵”的行为。比如默认不写类型信息、默认不支持 DataTable、默认对循环引用直接报错。理解这一点后再看“功能缺失”就不会觉得是产品质量问题而是设计取舍。你真正要做的是评估自己的项目更依赖哪种能力。1.2 迁移前先做功能影响面盘点不要一上来就全局替换。先扫描项目里所有 Newtonsoft 的使用点最好列一个清单是否用了JsonConvert.SerializeObject/DeserializeObject是否用了JObject、JArray这类 LINQ to JSON是否写了自定义JsonConverter是否配置了TypeNameHandling、ReferenceLoopHandling、NullValueHandling、DateFormatString是否直接序列化了DataTable、DataSet、ExpandoObject、匿名类型、dynamic是否有循环引用的实体对象扫描完之后你会很清楚哪些代码是基础序列化哪些依赖了 Newtonsoft 的特殊能力。建议先从基础序列化类开始替换把特殊类型和特殊配置留到第二轮。不要在一个大 PR 里同时替换所有模块否则出了问题很难定位到具体改动点。2. DataTable、dynamic、多态、循环引用这些“熟悉的操作”为什么都要改2.1 DataTable / DataSet直接序列化会报错Newtonsoft 可以直接把 DataTable 序列化成 JSON 数组也能把 JSON 数组反序列化成 DataTable。System.Text.Json 默认不支持会抛类似NotSupportedException的异常。原因是 DataTable 不是普通 POCO也不是标准集合它内部有行、列、关系这些状态原生实现不想为这个老类型背兼容包袱。补救方式有两种。第一种是把 DataTable 转成ListDictionarystring, object?再序列化第二种是写一个自定义JsonConverterDataTable。第一种适合临时用第二种适合项目里 DataTable 出现频率高的情况。下面是一个可跑的最小 Converter 示例using System.Data; using System.Text.Json; using System.Text.Json.Serialization; public class DataTableConverter : JsonConverterDataTable { public override DataTable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { using var doc JsonDocument.ParseValue(ref reader); var table new DataTable(); var rows doc.RootElement.EnumerateArray().ToList(); if (rows.Count 0) return table; foreach (var prop in rows[0].EnumerateObject()) { table.Columns.Add(prop.Name, typeof(string)); } foreach (var row in rows) { var dataRow table.NewRow(); foreach (var prop in row.EnumerateObject()) { dataRow[prop.Name] GetCellValue(prop.Value); } table.Rows.Add(dataRow); } return table; } public override void Write(Utf8JsonWriter writer, DataTable value, JsonSerializerOptions options) { writer.WriteStartArray(); foreach (DataRow row in value.Rows) { writer.WriteStartObject(); foreach (DataColumn col in value.Columns) { writer.WritePropertyName(col.ColumnName); object? cell row[col]; if (cell null || cell DBNull.Value) { writer.WriteNullValue(); } else { JsonSerializer.Serialize(writer, cell, cell.GetType(), options); } } writer.WriteEndObject(); } writer.WriteEndArray(); } private static object? GetCellValue(JsonElement element) { if (element.ValueKind JsonValueKind.String) return element.GetString(); if (element.ValueKind JsonValueKind.Number) return element.TryGetInt32(out var i) ? i : element.GetDouble(); if (element.ValueKind JsonValueKind.True) return true; if (element.ValueKind JsonValueKind.False) return false; if (element.ValueKind JsonValueKind.Null) return DBNull.Value; return element.GetRawText(); } }然后在JsonSerializerOptions里注册var options new JsonSerializerOptions(); options.Converters.Add(new DataTableConverter());这个示例里反序列化时所有列都先按 string 处理真实项目里你可以根据JsonElement.ValueKind推断列类型。需要注意的是注册 Converter 后序列化DataTable会走这里的Write但如果 DataTable 的某个 cell 是自定义类型JsonSerializer.Serialize仍会按该类型自己的规则处理。2.2 ExpandoObject 和 dynamic不能按老写法硬来Newtonsoft 对ExpandoObject和dynamic的支持比较宽容很多时候序列化一个 dynamic 对象不会报错。System.Text.Json 对动态行为的支持一直有版本差异有些版本能跑有些版本直接抛异常。更稳妥的做法是不要依赖动态类型而是把动态内容转换成明确的字典或 DTO。如果确实需要处理ExpandoObject可以写一个 Converter。核心思路是写的时候把它当成IDictionarystring, object?遍历读的时候递归把JsonElement转回ExpandoObjectusing System.Dynamic; using System.Text.Json; using System.Text.Json.Serialization; public class ExpandoObjectConverter : JsonConverterExpandoObject { public override ExpandoObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { using var doc JsonDocument.ParseValue(ref reader); var expando new ExpandoObject(); var dict (IDictionarystring, object?)expando; foreach (var prop in doc.RootElement.EnumerateObject()) { dict[prop.Name] ToExpando(prop.Value); } return expando; } public override void Write(Utf8JsonWriter writer, ExpandoObject value, JsonSerializerOptions options) { writer.WriteStartObject(); foreach (var kvp in (IDictionarystring, object?)value) { writer.WritePropertyName(kvp.Key); if (kvp.Value null) { writer.WriteNullValue(); } else { JsonSerializer.Serialize(writer, kvp.Value, kvp.Value.GetType(), options); } } writer.WriteEndObject(); } private static object? ToExpando(JsonElement element) { if (element.ValueKind JsonValueKind.Object) { var expando new ExpandoObject(); var dict (IDictionarystring, object?)expando; foreach (var prop in element.EnumerateObject()) { dict[prop.Name] ToExpando(prop.Value); } return expando; } if (element.ValueKind JsonValueKind.Array) return element.EnumerateArray().Select(ToExpando).ToList(); if (element.ValueKind JsonValueKind.String) return element.GetString(); if (element.ValueKind JsonValueKind.Number) return element.TryGetInt64(out var l) ? l : element.GetDouble(); if (element.ValueKind JsonValueKind.True) return true; if (element.ValueKind JsonValueKind.False) return false; return null; } }在实际业务里我更建议把进入接口的数据统一成 DTO。dynamic 一时方便但后续维护和排查的成本不低。如果只是临时接第三方数据直接用JsonDocument或JsonNode读取字段比硬转ExpandoObject更安全。2.3 多态反序列化安全和方便之间的取舍Newtonsoft 里的TypeNameHandling可以把.NET类型名写进 JSON反序列化时自动还原成派生类型。这个功能很强大但也存在反序列化风险如果 JSON 来源不可控攻击者可能指定一个危险类型。System.Text.Json 默认不写类型信息从 .NET 7 开始提供了更安全的多态支持using System.Text.Json.Serialization; [JsonPolymorphic(TypeDiscriminatorPropertyName $type)] [JsonDerivedType(typeof(User), user)] [JsonDerivedType(typeof(Admin), admin)] public class User { public string Name { get; set; } string.Empty; } public class Admin : User { public string Role { get; set; } string.Empty; }使用这个特性后System.Text.Json 会在 JSON 中写一个鉴别字段反序列化时根据该字段选择派生类型。如果你的目标框架是 .NET 7 及以上建议优先用这个方案而不是重新实现 Newtonsoft 的$type逻辑。如果目标框架比较老就需要自己写 Converter手动读 discriminator再JsonSerializer.Deserialize到具体派生类型。这个方案可控但要注意派生类可能有额外字段手写 Converter 时要保证字段映射完整。2.4 循环引用默认处理策略完全不同Newtonsoft 遇到循环引用时默认会抛异常但你可以在ReferenceLoopHandling.Ignore中让它忽略循环。System.Text.Json 默认也是遇循环引用就抛异常但补救方式不太一样。.NET 6 及以上版本提供了ReferenceHandler.IgnoreCyclesvar options new JsonSerializerOptions { ReferenceHandler ReferenceHandler.IgnoreCycles }; var json JsonSerializer.Serialize(boss, options);IgnoreCycles会在遇到循环时输出 null适合大多数接口场景。如果希望保留引用关系可以用ReferenceHandler.Preserve它会生成$id、$ref这类结构。需要注意使用Preserve序列化的 JSON反序列化时也必须使用同样的配置否则结构对不上。如果项目还在 .NET 5 以下IgnoreCycles不存在只能自己控制对象图比如在 DTO 里去掉回引用属性或者用自定义 Converter 在写到某个属性时跳过。我的建议是能通过调整 DTO 解决循环引用就不要依赖ReferenceHandler。它虽然方便但会让接口输出带上额外结构消费者端不一定容易处理。2.5 日期、枚举、null 忽略都有替代品但细节要重新配Newtonsoft 最常用的几个全局配置System.Text.Json 几乎都有等价物只是 API 名字不一样。NullValueHandling.Ignore对应var options new JsonSerializerOptions { DefaultIgnoreCondition JsonIgnoreCondition.WhenWritingNull };DefaultValueHandling.Ignore对应var options new JsonSerializerOptions { DefaultIgnoreCondition JsonIgnoreCondition.WhenWritingDefault };枚举转字符串对应var options new JsonSerializerOptions(); options.Converters.Add(new JsonStringEnumConverter());日期时间自定义格式没有全局配置选项需要自己写 Converter。下面是一个把DateTime输出成yyyy-MM-dd HH:mm:ss的示例using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; public class DateTimeFormatterConverter : JsonConverterDateTime { public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { return DateTime.Parse(reader.GetString()!, CultureInfo.InvariantCulture); } public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) { writer.WriteStringValue(value.ToString(yyyy-MM-dd HH:mm:ss, CultureInfo.InvariantCulture)); } }如果模型里还有DateTime?还需要再写一个 nullable 版本或者把所有可空日期统一改成 string 属性在外部做转换。注意System.Text.Json 默认使用 ISO 8601 格式如果接口消费者已经按 ISO 8601 解析改成自定义格式反而是破坏。不是所有项目都需要日期 Converter。2.6 其他差异速查表下面这张表是我在迁移中常遇到的差异可以当作初步排查参考功能NewtonsoftSystem.Text.Json补救建议JObject/JArray原生支持使用JsonNode/JsonDocument.NET 6 用JsonNode只读场景用JsonDocumentDataTable/DataSet支持不支持自定义 Converter 或先转 DTOExpandoObject支持不同版本支持不稳定自定义 Converter或避免动态类型多态TypeNameHandling[JsonPolymorphic][JsonDerivedType].NET 7 优先用特性低版本手写 Converter循环引用ReferenceLoopHandlingReferenceHandler.IgnoreCycles/Preserve优先调整 DTO避免循环输出日期格式DateFormatString默认 ISO 8601自定义JsonConverterDateTime枚举字符串StringEnumConverterJsonStringEnumConverter注册到 Options忽略 nullNullValueHandling.IgnoreDefaultIgnoreCondition.WhenWritingNull配置 Options自定义属性命名ContractResolverPropertyNamingPolicy/JsonNamingPolicy按需配置 camelCase忽略默认值DefaultValueHandling.IgnoreDefaultIgnoreCondition.WhenWritingDefault配置 Options这张表不用背迁移时对着扫一遍就行。3. 补救代码的通用套路从 Converter 到 TypeInfo 修改器3.1 最小 JsonConverter 结构自定义 Converter 是补救 System.Text.Json 缺功能的最常用手段。它需要继承JsonConverterT实现Read和Write。关键点是Read从Utf8JsonReader里读 JSON返回目标对象。Write用Utf8JsonWriter把对象写成 JSON。不要在 Converter 内部创建新的JsonSerializerOptions并重新序列化同一个对象否则容易造成递归调用或死循环。注册方式有三种// 1. 全局注册 var options new JsonSerializerOptions(); options.Converters.Add(new DateTimeFormatterConverter()); // 2. 属性注册 public class MyModel { [JsonConverter(typeof(DateTimeFormatterConverter))] public DateTime CreateTime { get; set; } } // 3. ASP.NET Core 中全局注册 builder.Services.AddControllers().AddJsonOptions(o { o.JsonSerializerOptions.Converters.Add(new DateTimeFormatterConverter()); });第一种适合项目里很多地方都用同样的规则第二种适合局部字段有特殊格式第三种适合 Web API 项目所有 Controller 返回结果都统一处理。3.2 什么时候用JsonTypeInfo修改器如果问题不是某个字段格式而是“整个类型的属性映射规则需要调整”比如要隐藏某个属性、重命名属性、给所有 string 字段加统一处理可以用DefaultJsonTypeInfoResolver的Modifiers。这个能力在 .NET 6 之后可用是替代 NewtonsoftContractResolver的更现代方式。示例序列化某个模型时忽略名为Temp的属性using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; var options new JsonSerializerOptions(); var resolver new DefaultJsonTypeInfoResolver(); resolver.Modifiers.Add(typeInfo { if (typeInfo.Type ! typeof(MyModel)) return; var prop typeInfo.Properties.FirstOrDefault(p p.Name Temp); if (prop ! null) { prop.ShouldSerialize (obj, value) false; } }); options.TypeInfoResolver resolver;这种方式适合做动态规则比如根据环境变量决定是否输出某个字段。不过它比普通 Converter 更难排查所以项目里能用 DTO 解决的尽量不要用动态修改器。3.3 把 Newtonsoft 配置“翻译”成 System.Text.Json迁移时最常见的不是写复杂代码而是把一个配置翻译成另一个配置。我建议先写一份对照清单把 Newtonsoft 用的设置逐项翻译。例如// Newtonsoft var settings new JsonSerializerSettings { NullValueHandling NullValueHandling.Ignore, DateFormatString yyyy-MM-dd HH:mm:ss, ContractResolver new CamelCasePropertyNamesContractResolver() };对应的 System.Text.Json 写法是var options new JsonSerializerOptions { DefaultIgnoreCondition JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy JsonNamingPolicy.CamelCase }; options.Converters.Add(new DateTimeFormatterConverter());日期格式没有全局选项但可以用 Converter 补偿。翻译的时候要特别小心ContractResolver可以做的事情非常多System.Text.Json 里没有一个完全等价的类。遇到复杂 Resolver优先考虑改 DTO不要想着 100% 复制老行为。4. 混用两个库的时机和边界4.1 混用不是坏事但要有边界同一个项目里可以同时引用 Newtonsoft.Json 和 System.Text.Json这在迁移过渡期很常见。但要注意不要在一条序列化链路里混着用。比如 Controller 返回对象用 System.Text.Json 序列化内部服务转 DTO 又用 Newtonsoft最后接口字段大小写和 null 处理可能对不上。更好的做法是按模块划分边界。老模块继续用 Newtonsoft新模块用 System.Text.Json两个模块之间通过明确的数据结构或接口协议对接。不要在一个对象上同时挂两个库的特性否则排查成本会很高。4.2 接口边界和 DTO 设计如果项目里有很多实体类直接暴露给接口迁移时最好先把实体类替换成 DTO。比如UserEntity不要直接序列化而是定义UserResponse。这样即使底层库换掉接口 JSON 结构也可能保持不变。对于已经有大量DataTable、ExpandoObject、动态类型进入接口的老项目我更建议先修数据流让接口层不再直接暴露这些类型。这比写一堆自定义 Converter 更彻底。4.3 包体量、启动时间和性能System.Text.Json 在性能和内存分配上通常比 Newtonsoft 有优势尤其是配合 SourceGenerator 时可以在运行时减少反射对 AOT 应用也更友好。但这是有前提的如果你为了补功能写了一堆自定义 Converter性能优势可能被抵消。Newtonsoft 虽然功能全但包体量更大启动时反射更重。混用两个库意味着两个依赖都要维护升级时都要回归测试。所以我一般建议如果项目对新接口、新功能要求不高而且现有的 Newtonsoft 代码已经稳定跑了很久可以不急着迁。迁移的收益是长期的可维护性和性能但要投入测试成本。5. 迁移验证从单条任务到批量任务的排查链路5.1 先做“同一对象的双库输出对比”迁移最怕的不是报错而是“不报错但结果变了”。建议先写一个对照工具把同一个对象分别用两个库序列化输出到两个文件逐字段对比。对比时重点看属性名大小写null 字段是否输出日期时间格式枚举输出的是数字还是字符串循环引用是否被跳过非 ASCII 字符是否被转义这些差异在单条样例上就能发现。先不要跑全量数据先用一条典型数据把规则对齐。5.2 反序列化异常常用排查顺序反序列化报错时我一般按这个顺序排查先看 JSON 样例和 DTO 字段名是否匹配。再看不匹配是否因为大小写。System.Text.Json 默认区分大小写可以设置PropertyNameCaseInsensitive true。看枚举字段。如果 JSON 里是字符串而模型属性是枚举需要注册JsonStringEnumConverter。看日期字段。如果 JSON 是自定义格式需要写 Converter。看构造函数。System.Text.Json 默认使用无参构造函数如果类没有无参构造函数可能需要[JsonConstructor]或在 .NET 8 下的特殊处理。最后看有没有自定义 Converter 被全局注册可能覆盖了默认行为。这个顺序基本能解决大多数反序列化问题。不要一开始就怀疑是库 BUG大部分情况是输入格式和配置不匹配。5.3 批量任务要关注成功率与稳定性项目里有批量序列化或反序列化任务时不要只看“能不能跑通一条”。要准备一批典型数据包括空对象、null、超大数字、超长字符串、Unicode、特殊符号、重复字段、未知字段、深层嵌套等。批量验证时我建议记录三个指标成功率成功处理条数 / 总条数失败类型是反序列化异常、序列化异常还是超时耗时分布单条最长时间、平均时间、是否有明显波动如果失败集中在某一种输入格式上通常是 Converter 或类型映射问题。如果整体变慢可能是反射开销或资源占用问题。System.Text.Json 处理大 JSON 时可以尽量用JsonDocument或JsonNode做流式读取不要一次性把超大字符串加载成 string。5.4 日志和测试覆盖自定义 Converter 一定要有单元测试。我一般会写这些用例空值字段null 输入日期边界值特殊字符枚举未定义值循环引用对象未知 JSON 字段测试不一定要覆盖全部业务但要覆盖异常路径。线上出问题时日志里要能看出是哪个 Converter 抛的异常而不是笼统地“序列化失败”。6. 迁移检查清单与最终建议6.1 代码层面扫一遍迁移前先全局搜索以下关键词Newtonsoft.JsonJsonConvertJObjectJArrayJsonPropertyJsonIgnoreContractResolverReferenceLoopHandlingTypeNameHandlingDateFormatStringStringEnumConverter每找到一个就判断这个用法能否直接替换还是需要自定义 Converter。扫完一遍后你会对工作量有更准确的估计。6.2 数据样例验证清单准备一份“通用验证 JSON”尽量包含以下类型空
返回列表