C#: Newtonsoft.Json 到 System.Text.Json 迁移避坑指南

3 阅读1分钟

1. 核心设计哲学差异

在进行代码迁移前,必须牢记这两个库在底层设计哲学上的根本分歧,这是几乎所有反序列化报错的根源:

  • Newtonsoft.Json (主打兼容与灵活) :它非常宽容,会尽最大努力去猜测你的意图,在底层默默帮你做各种隐式的类型转换和容错处理。
  • System.Text.Json (主打性能与安全) :微软为了追求极致的执行效率而原生打造。它极其严格,要求 JSON 数据结构和 C# 模型“严丝合缝”,绝不会越界替你做任何类型转换。

2. 基础特性与配置替换对照表

场景Newtonsoft.Json (旧)System.Text.Json (新)迁移备注
指定 JSON 键名[JsonProperty("name")][JsonPropertyName("name")]必须逐个替换。
忽略某字段[JsonIgnore][JsonIgnore]基本一致。
忽略空值 (Null)NullValueHandling.Ignore全局 Options: DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull推荐在全局 JsonSerializerOptions 中统一配置,减少序列化体积。
忽略默认值DefaultValueHandling.Ignore[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]新版将其合并到了 JsonIgnore 特性中。

3. 四大高频“踩坑”重灾区及标准解决方案

🚨 坑一:大小写严格敏感 (Case Sensitivity)

问题描述:很多第三方 API 返回的小驼峰命名(如 userProfile),而 C# 模型是大驼峰命名(如 UserProfile)。老版能完美自动映射,新版只要大小写不一致,直接反序列化为 null

解决方案:在反序列化时,务必全局传入配置允许忽略大小写:

var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var result = JsonSerializer.Deserialize<MyModel>(jsonString, options);

🚨 坑二:基础类型严格匹配(数字与字符串的鸿沟)

问题描述:对接外部不可控 API 时经常遇到格式不规范的数据。比如 ID 字段有时是数字 ("id": 123),有时是字符串 ("id": "A-123");金额字段有时返回字符串 ("price": "19.99"),甚至用空字符串表示无数据 ("discount": "")。

新版只要遇到 JSON 节点类型与 C# 声明类型(如 stringint)不匹配,会直接抛出 JsonException 崩溃。

解决方案:不要指望内置配置项能完美兜底(尤其是处理空字符串),建议直接封装自定义 JsonConverter

🛠️ 通用工具 1:数字安全转字符串转换器 (NumberToStringConverter)

用途:当 C# 模型定义为 string Id,但外部 JSON 传入的是数字 123 时,自动将其转换为 "123" 且不报错。

using System;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace YourNamespace.Helpers 
{
    public class NumberToStringConverter : JsonConverter<string?>
    {
        public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType == JsonTokenType.Number) return reader.GetInt64().ToString(); 
            if (reader.TokenType == JsonTokenType.String) return reader.GetString();
            return null;
        }

        public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options)
        {
            if (value == null) writer.WriteNullValue();
            else writer.WriteStringValue(value);
        }
    }
}
// 实体类使用方式:[JsonConverter(typeof(NumberToStringConverter))]

🛠️ 通用工具 2:字符串安全转可空金额转换器 (StringToDecimalConverter)

用途:当 C# 模型定义为 decimal? Price,但 JSON 传入的是 "19.99" 或者是代表无值的空字符串 "" 时,安全地将其转换为 decimalnull

using System;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace YourNamespace.Helpers 
{
    // 注意:泛型必须与属性类型完全一致(此处为可空类型 decimal?)
    public class StringToDecimalConverter : JsonConverter<decimal?>
    {
        public override decimal? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType == JsonTokenType.Number) return reader.GetDecimal();
            if (reader.TokenType == JsonTokenType.String)
            {
                string? strValue = reader.GetString();
                // 很多老旧 API 喜欢用空字符串代表没有值,安全处理为 null
                if (string.IsNullOrWhiteSpace(strValue)) return null;
                if (decimal.TryParse(strValue, out decimal result)) return result;
            }
            return null;
        }

        public override void Write(Utf8JsonWriter writer, decimal? value, JsonSerializerOptions options)
        {
            // 序列化时,可根据对接方 API 的偏好决定是否转回字符串
            if (value.HasValue) writer.WriteStringValue(value.Value.ToString("0.00"));
            else writer.WriteNullValue();
        }
    }
}
// 实体类使用方式:[JsonConverter(typeof(StringToDecimalConverter))]
// 警告:此转换器必须配合 public decimal? Price { get; set; } 使用!不可用于非空 decimal。

🚨 坑三:动态类型 object 的解析陷阱

问题描述:当模型中存在 public object Value { get; set; }(例如用于接收不确定结构的数据、扩展字段 metadata 等),老版会猜测并转化为 string, int 等具体 C# 基础类型。而新版会统一将其解析为 JsonElement 结构体

危险操作:任何试图将反序列化后的 object 强转回基础类型的操作都会导致运行时崩溃!

  • string val = (string)model.Value; -> 抛出 InvalidCastException
  • if (model.Value is string s) -> 永远为 false
  • string val = model.Value as string; -> 永远返回 null

安全的操作规范(提取真实数据)

  1. 纯中转/序列化/拼接场景(最稳妥) :利用 Convert.ToString() 提取字面量。

    // 完美应对 JsonElement。
    // 配合 InvariantCulture 防止部署在不同国家服务器时,小数点被转换成逗号的问题。
    string safeStringValue = Convert.ToString(model.Value, CultureInfo.InvariantCulture)!;
    
  2. 业务逻辑需严格执行类型判断:通过检查 JsonElement.ValueKind

    if (model.Value is JsonElement element)
    {
        if (element.ValueKind == JsonValueKind.String) 
            string s = element.GetString();
        else if (element.ValueKind == JsonValueKind.Number) 
            decimal d = element.GetDecimal();
    }
    

🚨 坑四:字段 (Fields) 被静默忽略

问题描述:老版会自动序列化和反序列化 public string name; 这种公开的字段 (Fields)。新版默认只处理属性 (Properties) ,即带有 { get; set; } 的成员,对字段直接静默忽略,不报错但数据会全部丢失。

解决方案

  1. 最佳实践:将实体类的成员强制重构为标准属性 { get; set; }

  2. 兼容方案:若存在大量历史代码难以修改,需在全局 Options 中显式开启:

    var options = new JsonSerializerOptions { IncludeFields = true };
    

4. 迁移与调试的黄金法则

在未来遇到 System.Text.Json 抛出异常或反序列化出 null 时,请严格遵循以下排查步骤:

  1. 绝不盲猜数据结构:不要依赖 API 文档。务必将 response.Content.ReadAsStreamAsync() 临时替换为 ReadAsStringAsync(),把原始 JSON 字符串完整打印到日志中,肉眼确认真实的层级和数据格式。
  2. 检查大小写配置:确认代码中是否遗漏了 PropertyNameCaseInsensitive = true 的配置。
  3. 检查类型严格性:排查 JSON 里的 "123" 和 C# 里的 int 是否发生了直接碰撞,若有,必须引入 Converter。
  4. 检查转换器泛型匹配:贴在可空类型属性上的转换器,其继承的基类绝对不能是非空类型(如 JsonConverter<decimal?> 绝不能用于 decimal 属性),必须分毫不差。