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.Json | Newtonsoft.Json |
|---|---|---|
| 性能与分配 | 优,Span 驱动 | 一般 |
| AOT 兼容 | 源生成后完全支持 | 不支持 |
| 多态 | 需显式声明鉴别器 | TypeNameHandling 自动 |
| 私有字段 | 需 [JsonInclude] | 默认支持 |
| 循环引用 | 抛异常,需 ReferenceHandler | 自动处理 |
| 动态类型 | JsonNode | JObject 更成熟 |
| 日期格式 | 严格 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 形状一旦发布就成了不可撤销的接口。把源生成、显式鉴别器与契约测试组合起来,才能让这份接口在多次迭代后仍然稳定。下一篇将进入并发世界,讨论线程同步原语的取舍。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。