System.Text.Json 序列化进阶

深入讲解 System.Text.Json 的源生成模式、自定义转换器、多态序列化与版本兼容策略,对比 Newtonsoft.Json 的行为差异,并给出 AOT 友好、低分配的序列化工程实践。

1. 从反射到源生成

一句话总结: System.Text.Json 默认走反射与运行时代码生成,首次调用开销大且不兼容 AOT 裁剪,源生成器把元数据与读写逻辑编译期固化,同时解决性能与裁剪两个问题。

早期版本的 JsonSerializer 依赖反射枚举属性、构造读写委托,并在运行时用 Reflection.Emit 生成序列化代码。这带来两个后果:首次序列化某个类型有明显开销;在 Native AOT 或裁剪发布下,反射元数据可能被裁掉,导致运行时抛异常。

.NET 7 引入的源生成器 System.Text.Json.SourceGeneration 把这一切前移到编译期。

using System.Text.Json.Serialization;

[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<Order>))]
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    WriteIndented = false)]
public partial class AppJsonContext : JsonSerializerContext
{
}

生成的 AppJsonContext.Default 是一个静态实例,直接把它传给序列化 API 即可:

string json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
Order? back = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);

对比反射模式,源生成带来三项收益:启动时无首次生成开销、Native AOT 下完全可用、运行时无反射调用。代价是必须显式列出所有需要序列化的类型,遗漏类型会在运行时抛 NotSupportedException。

维度反射模式源生成模式
首次调用需要生成元数据与委托无额外开销
AOT 兼容否,需要裁剪根是
运行时反射有无
类型覆盖自动需显式声明
启动内存较高较低

1.1 源生成模式与元数据模式

一句话总结: 元数据模式只生成类型信息、仍用通用读写路径,序列化模式生成专用代码、性能更好但产物更大,一般默认选序列化模式。

JsonSourceGenerationOptions 上的 GenerationMode 决定生成策略:Metadata 只产生类型元数据,体积小;Serialization 额外生成针对每个类型的快速读写方法。

[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Serialization)]
[JsonSerializable(typeof(Order))]
public partial class FastJsonContext : JsonSerializerContext { }

服务端热路径建议用 Serialization;仅偶尔序列化、且在意程序集体积的客户端可以用 Metadata。

2. 自定义转换器

一句话总结: 当内置规则无法表达业务格式时,写一个 JsonConverter<T> 是唯一正确的扩展点,而不是在模型上堆砌特性。

内置的 [JsonConverter] 特性、命名策略与数字处理只能覆盖常见情形。遇到时间戳格式、货币、枚举别名、加密字段等业务格式,需要自定义转换器。

public sealed class UnixTimestampConverter : JsonConverter<DateTimeOffset>
{
    public override DateTimeOffset Read(
        ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        long seconds = reader.GetInt64();
        return DateTimeOffset.FromUnixTimeSeconds(seconds);
    }

    public override void Write(
        Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
        => writer.WriteNumberValue(value.ToUnixTimeSeconds());
}

注册方式有两种,全局或按属性:

var options = new JsonSerializerOptions
{
    Converters = { new UnixTimestampConverter() },
};

// 或只作用于单个属性
public sealed class Event
{
    public string Name { get; init; } = "";

    [JsonConverter(typeof(UnixTimestampConverter))]
    public DateTimeOffset OccurredAt { get; init; }
}

2.1 工厂模式与泛型转换器

一句话总结: 泛型转换器必须通过 JsonConverterFactory 创建,因为特性上的 typeof 无法表达开放泛型。

public sealed class StrongIdConverterFactory : JsonConverterFactory
{
    public override bool CanConvert(Type t)
        => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(StrongId<>);

    public override JsonConverter CreateConverter(Type t, JsonSerializerOptions o)
    {
        var arg = t.GetGenericArguments()[0];
        var converterType = typeof(StrongIdConverter<>).MakeGenericType(arg);
        return (JsonConverter)Activator.CreateInstance(converterType)!;
    }
}

public sealed class StrongIdConverter<T> : JsonConverter<StrongId<T>>
{
    public override StrongId<T> Read(
        ref Utf8JsonReader reader, Type t, JsonSerializerOptions o)
        => new StrongId<T>(reader.GetString()!);

    public override void Write(
        Utf8JsonWriter writer, StrongId<T> value, JsonSerializerOptions o)
        => writer.WriteStringValue(value.Value);
}

注意 CanConvert 必须覆盖开放泛型判断,否则嵌套类型不会被路由到工厂。

2.2 与源生成共存的转换器

一句话总结: 源生成模式下自定义转换器依然生效,但转换器本身必须是 AOT 安全的,不能依赖反射。

在源生成上下文中注册转换器:

[JsonSourceGenerationOptions(Converters = new[] { typeof(UnixTimestampConverter) })]
[JsonSerializable(typeof(Event))]
public partial class EventJsonContext : JsonSerializerContext { }

若转换器内部使用 Activator.CreateInstance(如上面的工厂),在 AOT 下会触发裁剪警告,应改用 JsonConverterFactory 配合源生成,或在 AOT 场景为每个封闭泛型显式注册。

3. 多态序列化

一句话总结: 多态序列化必须显式声明鉴别器,System.Text.Json 默认不写入类型信息,反序列化到基类会丢失派生数据。

把派生类实例序列化为基类时,默认只写出基类属性,反序列化回来也是基类实例。正确做法是使用 [JsonPolymorphic] 与 [JsonDerivedType]。

[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(EmailNotification), "email")]
[JsonDerivedType(typeof(SmsNotification), "sms")]
public abstract class Notification
{
    public string Title { get; init; } = "";
}

public sealed class EmailNotification : Notification
{
    public string To { get; init; } = "";
}

public sealed class SmsNotification : Notification
{
    public string Phone { get; init; } = "";
}

序列化 EmailNotification 时会写出 {"$type":"email","title":"...","to":"..."}。反序列化 Notification 时按 $type 分派。

var n = JsonSerializer.Deserialize<Notification>(json, AppJsonContext.Default.Notification);
// n 的实际类型是 EmailNotification

兼容性注意:鉴别器的值一旦发布就不能更改,因为客户端可能持久化了旧值。新增派生类型必须用新的鉴别器值追加,不可复用或重命名。

3.1 与源生成配合的多态

一句话总结: 源生成上下文需要把基类与每个派生类都列入 JsonSerializable,否则多态反序列化会在运行时报类型未知。

[JsonSerializable(typeof(Notification))]
[JsonSerializable(typeof(EmailNotification))]
[JsonSerializable(typeof(SmsNotification))]
public partial class NotifyJsonContext : JsonSerializerContext { }

遗漏派生类型是源生成加多态最常见的故障,症状是 NotSupportedException: Metadata for type ... was not provided。

4. 版本兼容与容错

一句话总结: 反序列化对未知字段必须宽容、对缺失字段必须有默认值、对字段增删要保证向后与向前双向兼容。

服务间通信中,生产者与消费者的版本永远在漂移。三类场景必须处理:

  • 新增字段:旧消费者应忽略,靠默认 JsonSerializerOptions 的忽略未知属性行为。
  • 删除字段:旧生产者不再发送,新消费者的属性应保留默认值。
  • 重命名字段:通过 [JsonPropertyName] 显式固定线上名称,与 C# 属性名解耦。
public sealed class UserProfile
{
    [JsonPropertyName("user_id")]
    public string UserId { get; init; } = "";

    [JsonPropertyName("display_name")]
    public string DisplayName { get; init; } = "";

    [JsonPropertyName("locale")]
    public string Locale { get; init; } = "zh-CN";   // 缺失时的默认值

    [JsonExtensionData]
    public Dictionary<string, JsonElement>? Extra { get; set; }   // 保留未知字段
}

[JsonExtensionData] 收集未匹配的属性,适合网关、代理这类需要原样转发未知字段的场景。

// 宽容反序列化:忽略大小写、允许尾随逗号、允许注释
var tolerant = new JsonSerializerOptions
{
    PropertyNameCaseInsensitive = true,
    AllowTrailingCommas = true,
    ReadCommentHandling = JsonCommentHandling.Skip,
    NumberHandling = JsonNumberHandling.AllowReadingFromString,
};

AllowReadingFromString 尤其重要:不同语言生态对数字与字符串的边界不一致,前端常把 id 写成字符串,开启后能避免整条消息因类型不符而失败。

4.1 必填字段与校验

一句话总结: .NET 7 起可用 required 成员与 [JsonRequired] 强制字段存在,缺失时抛 JsonException,比事后判空更早暴露契约违约。

public sealed class PaymentCommand
{
    [JsonRequired]
    public string OrderId { get; init; } = "";

    public required decimal Amount { get; init; }
}

配合 JsonSerializerOptions.RespectRequiredConstructorParameters(.NET 8 默认开启)可让构造函数参数缺失时立即失败。

5. 对比 Newtonsoft.Json

一句话总结: System.Text.Json 更快、更省内存、AOT 友好,但功能面更窄;迁移时应先补齐行为差异清单,再逐模块替换。

能力System.Text.JsonNewtonsoft.Json
性能与分配优,Span 驱动一般
AOT 兼容源生成后完全支持不支持
多态需显式声明鉴别器TypeNameHandling 自动
私有字段需 [JsonInclude]默认支持
循环引用抛异常,需 ReferenceHandler自动处理
动态类型JsonNodeJObject 更成熟
日期格式严格 ISO 8601宽松

最常见的迁移差异是日期。Newtonsoft 默认宽松解析多种格式,System.Text.Json 只接受 ISO 8601 子集,遇到 "2026/10/01" 会直接失败。

// 处理非标准日期:用自定义转换器兜底
public sealed class FlexibleDateConverter : JsonConverter<DateTime>
{
    private static readonly string[] Formats =
        { "yyyy-MM-dd", "yyyy/MM/dd", "yyyy-MM-ddTHH:mm:ss" };

    public override DateTime Read(
        ref Utf8JsonReader reader, Type t, JsonSerializerOptions o)
    {
        var s = reader.GetString()!;
        return DateTime.TryParseExact(s, Formats,
            CultureInfo.InvariantCulture, DateTimeStyles.None, out var d)
            ? d
            : DateTime.Parse(s, CultureInfo.InvariantCulture);
    }

    public override void Write(
        Utf8JsonWriter w, DateTime v, JsonSerializerOptions o)
        => w.WriteStringValue(v.ToString("O"));
}

另一个差异是循环引用。EF Core 实体导航属性常形成环,Newtonsoft 默认处理,System.Text.Json 直接抛异常,需要配置 ReferenceHandler.IgnoreCycles,或改用 DTO 投影——后者是更好的工程实践,因为它顺带解决了实体泄漏与过度取数问题。

6. 流式与低分配处理

一句话总结: 大文档或高吞吐场景应使用 Utf8JsonReader 与 Utf8JsonWriter 手工读写,或直接用 PipeReader 与流式 API,避免把整份 JSON 载入内存。

JsonDocument 会把整份 JSON 解析为 DOM,分配可观;JsonElement 则是对已解析缓冲区的一个视图,不可脱离 JsonDocument 存活。对于超大文档,应使用 Utf8JsonReader 逐 token 前进。

public static int 统计数组元素数(ReadOnlySpan<byte> utf8)
{
    var reader = new Utf8JsonReader(utf8);
    int count = 0;
    while (reader.Read())
    {
        if (reader.TokenType == JsonTokenType.StartArray)
        {
            int depth = reader.CurrentDepth;
            while (reader.Read() && !(reader.TokenType == JsonTokenType.EndArray
                                      && reader.CurrentDepth == depth))
                count++;
        }
    }
    return count;
}

写侧同理,Utf8JsonWriter 直接写入目标缓冲区,避免中间字符串。

public static byte[] 写数组(IEnumerable<int> values)
{
    using var ms = new MemoryStream();
    using (var writer = new Utf8JsonWriter(ms))
    {
        writer.WriteStartArray();
        foreach (var v in values) writer.WriteNumberValue(v);
        writer.WriteEndArray();
    }
    return ms.ToArray();
}

对于网络流,JsonSerializer.DeserializeAsyncEnumerable<T> 能把顶层 JSON 数组当作异步流逐项消费,非常适合大型导出文件。

await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<Order>(
    stream, AppJsonContext.Default.Order))
{
    await ProcessAsync(item!);
}

7. 工程实践与陷阱

一句话总结: 序列化配置应集中一处、复用 JsonSerializerOptions 实例、上线前用契约测试锁定线上 JSON 形状。

第一,JsonSerializerOptions 创建后会被冻结并缓存元数据,绝不能每次调用都 new 一个,那会摧毁全部缓存收益。

// 反例:每次都新建,元数据缓存全部失效
string Bad(Order o) => JsonSerializer.Serialize(o, new JsonSerializerOptions());

// 正例:静态单例,或源生成的 Default
string Good(Order o) => JsonSerializer.Serialize(o, AppJsonContext.Default.Order);

第二,把线上 JSON 形状固化进测试,任何字段重命名、类型变更都会让测试失败。

[Fact]
public void 契约_字段名与类型固定()
{
    var json = JsonSerializer.Serialize(new Order { Id = 1, Total = 9.9m },
        AppJsonContext.Default.Order);
    Assert.Equal("{\"id\":1,\"total\":9.9}", json);
}

第三,注意几个高频陷阱:

  • 大小写策略必须端到端一致,服务端用 camelCase 时,反序列化外部数据要开 PropertyNameCaseInsensitive。
  • decimal 与 double 的精度差异会在金融场景放大,金额一律用 decimal 且显式用字符串传输。
  • 枚举默认序列化为数字,跨语言时应用 JsonStringEnumConverter 输出名称,避免数字顺序变化导致语义漂移。
  • 源生成上下文遗漏类型只在运行时暴露,应在启动时对关键类型做一次冒烟序列化。
  • 枚举序列化可在源生成选项里用 UseStringEnumConverter = true 一次性切换为字符串输出,无需为每个类型单独挂转换器。

8. 总结

环节要点
源生成AOT 与启动性能的首选,需显式声明全部类型,遗漏只在运行时暴露
生成模式热路径用 Serialization,体积敏感用 Metadata
自定义转换器泛型走 JsonConverterFactory,注意 CanConvert 覆盖开放泛型
多态用 JsonPolymorphic 声明鉴别器,鉴别器值一旦发布不可更改
版本兼容线上名用 JsonPropertyName 固定,缺失字段给默认值,未知字段可收集
对比 Newtonsoft日期与循环引用是最大差异点,迁移前先列行为清单
工程实践复用 options 单例,用契约测试锁定 JSON 形状

序列化的难点从来不在 API 调用,而在于契约的长期演进:字段会增删、类型会漂移、客户端版本参差不齐,而线上 JSON 形状一旦发布就成了不可撤销的接口。把源生成、显式鉴别器与契约测试组合起来,才能让这份接口在多次迭代后仍然稳定。下一篇将进入并发世界,讨论线程同步原语的取舍。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排