System.Text.Json 源生成:三个“看起来已经生效“的静默失效

[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。报错信息会直接把缺的那个类型名给你,比任何性能剖析都直观。

六、一句话总结

源生成的价值不在"更快",而在把序列化的类型契约提前到编译期。它的失效方式也正来源于此:契约没覆盖到的调用点会安静地退回反射。所以验收标准不该是"接口返回正常",而应该是"关掉反射开关之后,测试还能全绿"。

相关推荐
用户7884773163416 小时前
用 C# 把每周 6 小时的 PDF 录入干到 20 分钟:一份可复制的桌面 OCR 方案
c#
csdn_aspnet18 小时前
C# .NetCore Swagger ISchemaFilter 处理枚举、特殊类型
c#·.netcore·swagger
滴滴答答哒18 小时前
C# 技巧:优雅实现对象的“局部更新”与“字段合并”
开发语言·c#
code_long1 天前
.net core 连接打印机 使用zpl指令打印机,并且位图本地渲染zpl指令
c#·.netcore·位图·打印机·模板编辑·模板预览
csdn_aspnet1 天前
C# .NetCore Swagger ISchemaFilter Long 转 String 防精度丢失
c#·.netcore·swagger
下页、再停留2 天前
【C#桌面客户端系列学习-11】将SQLite数据库打包到exe里
数据库·c#·visual studio
下页、再停留2 天前
【C#桌面客户端系列学习-12】封装SQLite数据库基础操作工具类(CURD)
数据库·c#·visual studio