[JsonSerializable] 加上、partial class 写上、编译通过、接口返回的 JSON 一切正常------很多人到这一步就认为源生成已经生效了。但源生成不是"开了就快"的开关,它是一个把 metadata 构建从运行时搬到编译期 的机制。只要调用点没有把编译期生成的那份 JsonTypeInfo 真正传进去,运行时就悄悄退回反射路径:结果正确、日志干净、性能收益为零,直到你切到 PublishAot 才在线上炸出来。
这篇文章讲清楚它到底生成了什么、哪三个地方最容易静默失效、以及什么场景根本不该用它。
一、结论先行
| 你以为的 | 实际发生的 | 后果 |
|---|---|---|
| 声明了 context,所有序列化都走源生成 | 只有显式传 JsonTypeInfo/context 的调用点才走 |
其余静默走反射 |
Serialize<object>(obj) 会按运行时类型序列化 |
按 object 的 metadata 处理,输出 {} |
数据静默丢失 |
new JsonSerializerOptions { TypeInfoResolver = ... } 等价于 context |
metadata 缓存随 options 实例重建 | 每次调用重复初始化 |
| 源生成一定比反射快很多 | 收益主要在启动时间与常驻内存 | 稳态吞吐差距有限 |
四条可以概括成一句:源生成的生效范围由"调用点"决定,不由"是否声明"决定。
二、编译期到底生成了什么
[JsonSerializable(typeof(Order))] 让生成器产出一个 JsonSerializerContext 子类,里面为每个声明类型生成一个 JsonTypeInfo<Order> 属性,外加一个静态 Default 实例:
csharp
using System.Text.Json;
using System.Text.Json.Serialization;
public sealed record OrderLine(string Sku, int Quantity, decimal UnitPrice);
public sealed record Order(
string Id,
string Customer,
IReadOnlyList<OrderLine> Lines,
DateTimeOffset CreatedAt);
// 每个"闭合"类型都要单独声明,泛型不会自动展开
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
GenerationMode = JsonSourceGenerationMode.Default)]
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(Order[]))]
[JsonSerializable(typeof(List<Order>))]
[JsonSerializable(typeof(Dictionary<string, Order>))]
public partial class OrderJsonContext : JsonSerializerContext;
JsonTypeInfo<T> 里固化的是属性访问器委托、属性名映射、converter 引用。运行时 JsonSerializer.Serialize(value, OrderJsonContext.Default.Order) 直接查这张表,不再反射读 PropertyInfo,也不需要在首次调用时构建缓存。
关键点在于 JsonSerializerContext 本身就是个 IJsonTypeInfoResolver。所以"能不能走源生成",等价于"这次调用能不能拿到这个 resolver 生成的 JsonTypeInfo"。
三、三个静默失效点
坑 1:调用点的静态类型不是具体类型
JsonSerializer.Serialize<TValue>(TValue value) 里,TValue 是编译期 推断的。写成 object,拿到的就是 object 的 metadata:
csharp
object payload = order;
string wrong = JsonSerializer.Serialize(payload); // 输出 {},不报错
string alsoWrong = JsonSerializer.Serialize<object>(order); // 同上
// 正确:把类型信息显式补回去
string ok1 = JsonSerializer.Serialize(payload, typeof(Order), OrderJsonContext.Default);
string ok2 = JsonSerializer.Serialize((Order)payload, OrderJsonContext.Default.Order);
JsonSerializer.Serialize<object>(order) 返回 {} 是 STJ 的既定行为------它老老实实序列化了 object 类型的可见属性 ,而 object 没有。整个过程不抛异常、不打日志。任何泛型包装层(Result<T>、ApiResponse<T>、消息总线里的 Envelope<TPayload>)只要在某一环把类型擦成 object,整条链路就退化了。
坑 2:每个请求 new 一个 JsonSerializerOptions
csharp
// 反例:resolver 是对的,但 options 实例是新的
static string Bad(Order order) =>
JsonSerializer.Serialize(order, new JsonSerializerOptions
{
TypeInfoResolver = OrderJsonContext.Default
});
// 正例:复用 context 自带的只读 options
private static readonly JsonSerializerOptions s_options = OrderJsonContext.Default.Options;
static string Good(Order order) => JsonSerializer.Serialize(order, s_options);
JsonSerializerOptions 是 metadata 缓存的宿主:第一次用它序列化某个类型时,会通过 resolver 解析出 JsonTypeInfo 并缓存。换一个新实例,这份缓存就是空的,得从头再解析一遍。resolver 指向源生成 context 只是让解析结果 是编译期产物,并不代表解析过程免费。
这是个典型的"配置看着全对、性能就是上不去"的坑,而且在单测里完全测不出来------单测每次只调用一次。
坑 3:resolver 链末尾留了反射兜底
TypeInfoResolverChain 会按顺序找第一个能处理该类型的 resolver。常见写法是"源生成优先、反射兜底",即用 JsonTypeInfoResolver.Combine(OrderJsonContext.Default, new DefaultJsonTypeInfoResolver()) 赋给 TypeInfoResolver。
这段代码在非 AOT 环境下很"稳"------任何漏声明的类型都会静默落到 DefaultJsonTypeInfoResolver,功能正常,只是走了反射。问题在于它把"漏声明"这个编译期就该发现的错误 推迟成了发布后才暴露的性能问题 ,而在 AOT 下又直接变成 NotSupportedException。
我的建议是:非 AOT 项目也不要挂这个兜底,或者至少挂一个只在 DEBUG 下启用的版本,让漏声明在开发期就崩掉。
四、边界与不适用场景
运行时才确定的类型不适合。 插件系统、ExpandoObject、脚本引擎这类场景,类型集合在编译期不可知,源生成帮不上忙------这时老老实实用 JsonNode / JsonDocument,比反射序列化更可控。
自定义 JsonConverter 不因为源生成就自动 AOT 安全。 converter 本身仍会被调用,如果它内部又调了 JsonSerializer.Serialize(object),一样会在 AOT 下失效。判断标准是看 converter 内部有没有"类型擦除"的调用。
多态必须显式声明派生类型。 [JsonDerivedType(typeof(A), "a")] 标在基类上之后,A 仍然需要出现在某个 [JsonSerializable] 里,否则 AOT 下解析到派生类型时失败。这是设计使然:生成器不会替你遍历继承树。
它治不了建模问题。 源生成只改变 metadata 的来源,不改变序列化语义。把 EF 实体直接当 DTO 序列化带来的循环引用、泄露内部字段、N+1,一个都跑不掉。
五、选型建议
| 场景 | 做法 |
|---|---|
| Web API / gRPC-JSON | 在 ConfigureHttpJsonOptions 里设 TypeInfoResolver,DTO 全部声明 |
| 客户端 / 库作者 | 输出 JsonSerializerOptions 静态单例,别让调用方自己 new |
| 计划上 AOT | csproj 里提前打开 JsonSerializerIsReflectionEnabledByDefault=false |
| 动态 schema | 用 JsonNode,别硬套 source-gen |
第三条值得单独说:把这个开关提前打开,等于在开发期就把"漏声明"变成编译后的运行期异常,而不是等到发布 AOT 才发现。这是我目前认为性价比最高的一个动作。
xml
<PropertyGroup>
<PublishAot>true</PublishAot>
<!-- 非 AOT 项目也建议提前开启,用于验证源生成覆盖率 -->
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
开着这个开关跑一遍集成测试,所有静默回退都会变成 NotSupportedException: Metadata for type 'X' was not provided by TypeInfoResolver。报错信息会直接把缺的那个类型名给你,比任何性能剖析都直观。
六、一句话总结
源生成的价值不在"更快",而在把序列化的类型契约提前到编译期。它的失效方式也正来源于此:契约没覆盖到的调用点会安静地退回反射。所以验收标准不该是"接口返回正常",而应该是"关掉反射开关之后,测试还能全绿"。