AOT 编译对应用友好,对类库不友好。并且有两条硬约束:
- 运行期不能动态生成代码:
RequiresDynamicCode,触发IL3050。 - 裁剪之后不能引用被裁掉的成员:
RequiresUnreferencedCode,触发IL2026。
而飞书 .net SDK 恰好是这两条的集中地:上千个请求/响应 DTO,过去全靠 System.Text.Json 的反射默认实现------运行时遍历类型元数据、动态生成 JsonTypeInfo。裁剪器做静态全程序分析时,根本无从知道「哪些 DTO 会在运行时被反射用到」,于是要么告警满天飞,要么运行时直接抛 NotSupportedException。这就是飞书 SDK 一直「AOT 不友好」的根。
改造需要干的事,就是把这个反射集中地整个搬进 Native AOT。下面按实际踩坑的顺序讲:问题是什么、架构怎么改、门禁怎么守住回归。
一、问题:飞书 SDK 的 AOT 为什么难
1.1 反射无处不在
飞书开放平台有 32 个业务模块(FeishuModule 枚举),对应的接口两百多个、DTO 上千个。改造之前,这些 DTO 的序列化全部依赖 System.Text.Json 的反射默认实现,在 JIT 模式下毫无问题;一到 Native AOT,裁剪器就把这些反射调用标记为 IL2026 / IL3050。
你可能会想:那我逐个给 DTO 补上 [JsonSerializable] 不就完了?问题在于数量------这不是十个八个,是几千个,而且分布在几十个模块里,靠人力补,补着补着就会有漏网之鱼。
1.2 三条底线
| 约束 | 对应告警 | 后果 |
|---|---|---|
| 不可动态生成代码 | IL3050 |
反射 MakeGenericType、表达式树在 AOT 下直接崩 |
| 不可引用被裁成员 | IL2026 |
反射加载未登记类型,运行时抛异常 |
| 源生成上下文与运行时元数据错位 | 静默退化 | 某个 JsonSerializerContext 覆盖不到的类型,退化为反射,AOT 下静默失效------这种不报错的最坑 |
最后一类最麻烦:它不报告警,只是悄悄退回反射,等你真在 AOT 产物里跑到那条路径时,才以一个 NotSupportedException 收场。
1.3 踩过的坑
| 缺陷 | 现象 | 处置 |
|---|---|---|
SYSLIB1031 |
7 组同名 DTO 让源生成上下文发生命名冲突 | 按命名空间语义重命名 |
AOT006 噪音 |
netstandard2.0 与 net6.0 上各约 3740 条无效告警(合计约 7480 条) | 按 TFM 精确豁免 |
required 冲突 |
配置绑定源生成器用 new T() 构造,required 触发 CS9035 |
配置 DTO 改由 Validate() 校验 |
开放泛型误标 [JsonSerializable] |
源生成无法为开放泛型产元数据 | 移除错误标注 |
这些坑指向同一个本质:AOT 要求「类型必须在它被使用的编译单元里显式登记」,而大量历史代码写成了「运行时才发现类型」。后面所有架构决策,都是在把「运行时发现」改成「编译期登记」。
二、架构:把元数据在编译期「固化」下来
一条可合并、幂等、带兜底的解析器链,加上全链路源生成:
flowchart TB subgraph Gen"编译期(源生成)" G1"FeishuJsonContext\
(SDK 内置事件/Webhook 类型)" G2"DataModels 33 个模块\
+ 1 个响应包装 Context" G3"WebSocket / Webhook / EventCallback\
各自 JsonContext" end subgraph Chain"运行期解析器链(FeishuJsonDefaults)" C1"① SDK 内置 Context(链首)" C2"② 用户自定义 Resolver" C3"③ 反射兜底(链尾)" C1 --> C2 --> C3 end subgraph Entry"AOT 安全入口" E"FeishuJsonAot.Serialize / Deserialize" end G1 --> C1 G2 --> C2 G3 --> C2 E -->|"options.GetTypeInfo()"| Chain
链首是源生成 Context,被覆盖的类型永远走 AOT 安全路径;链尾的反射兜底只处理没人覆盖的用户自定义类型,既不削弱 AOT 保证,又保证跨 TFM 行为一致。
2.1 方法一:全链路源生成,全局监控
AOT 开关收敛到根目录一个文件,所有工程统一继承,杜绝「各写各的」:
xml
<!-- Directory.Build.props -->
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0' AND '$(TargetFramework)' != 'net6.0'">
<IsAotCompatible>true</IsAotCompatible>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<TrimMode>full</TrimMode>
<WarningsAsErrors>$(WarningsAsErrors);AOT001;AOT002;AOT003;AOT004;AOT007</WarningsAsErrors>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
<!-- 严格模式(可选):把 IL2xxx/IL3xxx/AOT00x 升级为错误 -->
<PropertyGroup Condition="'$(AotStrictMode)' == 'true'">
<WarningsAsErrors>$(WarningsAsErrors);IL2026;IL2046;IL2050;IL2057;IL2067;IL2070;IL2072;IL2075;IL2080;IL3050</WarningsAsErrors>
</PropertyGroup>
AOT 能力只在 net8.0+ 生效,netstandard2.0 / net6.0 走反射路径。多 TFM 兼容矩阵就是这样保住的------一套代码,用条件编译隔离:
| TFM | JSON 路径 | 配置绑定 |
|---|---|---|
| netstandard2.0 | 反射 | 反射 |
| net6.0 | 反射 | 反射 |
| net8.0+ | 源生成 | 源生成 |
| net10.0 | 源生成 | 源生成 |
2.2 方法二:每个模块自动生成 [JsonSerializable] 上下文
只在 SDK 内部写一个 Context 不够,DTO 分布在几十个模块里。我们让脚手架(Mud.HttpUtils.JsonContextScaffolder)为每个业务模块自动生成源生成上下文:
csharp
// Mud.Feishu.DataModels/Generated/OrganizationJsonContext.g.cs(脚手架生成,勿手动改)
#if NET8_0_OR_GREATER
[JsonSourceGenerationOptions(
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(global::Mud.Feishu.DataModels.DepartmentsV1.DepartmentLeaderV1))]
[JsonSerializable(typeof(global::Mud.Feishu.DataModels.DepartmentsV1.DepartmentInfo))]
[JsonSerializable(typeof(global::Mud.Feishu.DataModels.DepartmentsV1.DepartmentCreateResult))]
// ... 数百个 DTO 逐个登记 ...
public partial class OrganizationJsonContext : JsonSerializerContext { }
#endif
当前规模:33 个 DataModels 模块 Context + 1 个响应包装 FeishuApiResultJsonContext ,加上 10 个 EventCallback 域 Context(覆盖 80+ 事件类型)以及 WebSocket / Webhook 各自的 Context。全部展开后,DataModels 里有 2915 个 [JsonSerializable] 登记、EventCallback 里有 158 个 ,累计覆盖 3000+ 个类型。编译期就把它们展开成可直接调用的 JsonTypeInfo 元数据,运行时零反射。
2.3 方法三:一条可合并、幂等的解析器链
生成的 Context 是散的,需要一个中枢串起来,这就是 FeishuJsonDefaults.ConfigureUserResolver:
csharp
public static void ConfigureUserResolver(IJsonTypeInfoResolver userResolver)
{
lock (_sync)
{
// 幂等:同一实例只合并一次(防止解析器链无限膨胀,ARC-3)
if (!ContainsInstance(_userResolvers, userResolver))
_userResolvers.Add(userResolver);
#if NET8_0_OR_GREATER
var chain = new List<IJsonTypeInfoResolver>(_userResolvers.Count + 2)
{
FeishuJsonContext.Default
};
chain.AddRange(_userResolvers);
chain.Add(CreateReflectionFallback());
var combined = JsonTypeInfoResolver.Combine(chain.ToArray());
ApplyOptions(FeishuJsonContext.Default.Options, combined);
#else
var combined = JsonTypeInfoResolver.Combine(ToChain(userResolver));
ApplyOptions(DeserializerOptions, combined);
#endif
}
}
这里有一个容易漏掉的取舍:链尾为什么要留一个 DefaultJsonTypeInfoResolver 反射兜底?
SerializerOptions / DeserializerOptions 是公开 API。用户拿它们去序列化一个没有被任何源生成 Context 覆盖的自定义类型 时,没有兜底的话 net8+ 会直接抛 NotSupportedException,而 net6 / netstandard2.0 却正常------同一个调用,两种行为。兜底的作用是让跨 TFM 行为一致。真 AOT(PublishAot)下这类类型本来也序列化不了,所以兜底不削弱 AOT 的保证;至于源生成 Context 覆盖到的类型,因为排在链首,仍然走 AOT 安全路径。
2.4 方法四:模块自治装配
33 个 Context 没道理让我手写。每个模块提供一个 Configure*Resolver() 入口,SDK 启动时自动接线:
csharp
// Mud.Feishu/Extensions/FeishuJsonResolverExtensions.cs
public static void ConfigureDataModelsResolver()
{
var dataModelsResolver = JsonTypeInfoResolver.Combine(
FeishuApiResultJsonContext.Default, // P0-1: 优先匹配响应包装
AIJsonContext.Default,
ApprovalJsonContext.Default,
AttendanceJsonContext.Default,
BitableJsonContext.Default,
// ... 其余 29 个 DataModels Context ...
OkrJsonContext.Default
);
FeishuJsonDefaults.ConfigureUserResolver(dataModelsResolver);
}
配套的还有 ConfigureWebhookResolver()、ConfigureWebSocketResolver()、ConfigureEventCallbackResolver(),由各模块的 ServiceBuilder 在任何 JSON 序列化发生之前自动调用。业务开发者全程不用手写一行 Context。
2.5 方法五:AOT 安全的序列化入口
JsonSerializer.Serialize<T>(value, options) 带着反射告警标注,AOT 下不能直接调。我们提供一个语义完全等价的替代入口 FeishuJsonAot:
csharp
public static string Serialize<TValue>(TValue value, JsonSerializerOptions options)
{
if (options == null) throw new ArgumentNullException(nameof(options));
#if NET8_0_OR_GREATER
if (options.TypeInfoResolver != null)
{
// 先 GetTypeInfo 解析出 JsonTypeInfo,再走非泛型重载------零反射
return JsonSerializer.Serialize(value, options.GetTypeInfo(typeof(TValue)));
}
#endif
// options 没配 TypeInfoResolver 时,AOT 安全路径本就不存在,显式豁免反射
#pragma warning disable IL2026, IL3050
return JsonSerializer.Serialize(value, options);
#pragma warning restore IL2026, IL3050
}
关键在于语义等价 :泛型重载内部本来就是按 typeof(TValue) 解析元数据,我们先 GetTypeInfo 解析出同一个 JsonTypeInfo 再走非泛型重载,元数据解析结果与调用方类型完全一致,不会悄悄改成运行时类型、引起多态序列化行为漂移。GetTypeInfo 自身没有 AOT 标注,这条路径天然安全。
2.6 方法六:配置绑定也走源生成
反射的另一个集中地是配置绑定。ConfigurationBinder.Bind / Configure<T>(IConfiguration) 的反射实现同样触发 IL2026/IL3050:
xml
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
这带出一条对外可见的约束:配置 DTO 不再用 required(源生成器用 new T() 构造,required 会触发 CS9035),校验统一收敛到 Validate():
csharp
public class FeishuAppConfig
{
public string AppKey { get; set; } = string.Empty; // 不再 required
public string? Description { get; set; }
public void Validate() // 校验集中到这里
{
if (string.IsNullOrWhiteSpace(AppKey))
throw new InvalidOperationException("AppKey 不能为空");
}
}
三、门禁:拿「零反射告警」给 AOT 背书
架构做到 AOT 友好还不够,还得用工程手段防止回归。否则下一轮迭代谁手滑加一个反射调用,AOT 能力就被静默弄丢了。
3.1 严格模式门禁
AotStrictMode=true 会把 IL2026 / IL2046 / IL2050 / IL2057 / IL2067 / IL2070 / IL2072 / IL2075 / IL2080 / IL3050 全部升级为编译错误。
3.2 逐工程 + --no-incremental 冒烟
verify-build.ps1 步骤 3 对 9 个源工程逐个 做严格模式构建,断言 0 个 AOT00x / IL2026 / IL3050 诊断。
这里藏着一个不那么显眼的工程坑:MSBuild 的 CoreCompile 增量检查只比较输入/输出的时间戳,并不比较 csc 命令行。如果你紧跟在上一步普通构建之后立刻做严格模式构建,编译期会判定「已是最新」直接跳过,于是「0 告警」是假绿。
这不是纸上谈兵------去掉 --no-incremental 之前,严格模式恒输出 [ OK ];加上之后,立刻暴露了 Mud.Feishu.WebSocket 的 4 条违规 。--no-incremental 把这道假绿封死了。
3.3 端到端验证
静态门禁只能证明「编译期干净」,证明不了「AOT 产物真能跑」。单独建了一个验证工程 Demos/Mud.Feishu.AotVerification,用 PublishAot=true 做 win-x64 / linux-x64 双 RID 发布:
xml
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<PublishAot>true</PublishAot>
<IsAotCompatible>true</IsAotCompatible>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
bash
dotnet publish -r linux-x64 -c Release /p:PublishAot=true
./bin/Release/net8.0/linux-x64/publish/Mud.Feishu.AotVerification --smoke
两个细节:
- 工程通过
PackageReference消费已发布的Mud.Feishu 3.0.0包,而不是源码工程引用------它验证的是用户真正拿到手的包,不是我们本地那份源码。 - 故意保留了一条反射路径 (第 7 项 Widget 多态序列化,用
#pragma豁免IL2026/IL3050),专门验证「用户在 AOT 下仍然可以自行使用反射JsonSerializer」------这才是反射兜底真实存在的意义。
--smoke 模式下在真 AOT 二进制里跑 11 项冒烟,关键几项:
| 验证项 | 覆盖点 |
|---|---|
[4] Webhook 响应 DTO 序列化 |
源生成 JSON 链 |
[6] protobuf-net 二进制 |
WebSocket 二进制协议 |
[8] FeishuEventHeader 强类型反序列化 |
SDK 内置 Context 强类型路径 |
[9] FeishuApiResult<T> 闭合泛型反序列化 |
响应包装泛型 |
[10] WebSocket 协议消息 |
合并解析器链 |
[11] EventCallback 事件 |
合并解析器链 + 80+ 事件类型 |
四条链路(源生成 JSON、protobuf、WebSocket 协议、EventCallback 事件)在 AOT 下跑通,才算「AOT 一等支持」落地。门禁接入 CI,任何 PR 都会触发 AOT 冒烟。
四、对比分析
4.1 收益对比(典型飞书回调服务)
| 指标 | JIT(net8.0) | Native AOT | 收益 |
|---|---|---|---|
| 冷启动 | ~800ms | ~50ms | 约 16× |
| 常驻内存 | ~80MB | ~20MB | 约 4× |
| 发布体积 | ~60MB | ~4MB | 约 15× |
| 运行时反射 | 大量 | 0(覆盖类型) | --- |
4.2 谁更需要 AOT
| 场景 | 为什么 |
|---|---|
| Serverless / FAAS | 冷启动占比高,缩到零后的唤醒体验决定成败 |
| 边缘节点 / IoT 网关 | 体积、内存、启动都吃紧 |
| K8s 缩容到零 | 唤醒延迟直接对齐 AOT 启动 |
| 安全敏感环境 | 单文件、无 JIT,攻击面更小 |
4.3 上手基本零成本
对使用方,AOT 就是一个发布开关,SDK 内部的 resolver 自动装配:
bash
dotnet publish -r linux-x64 -c Release /p:PublishAot=true
业务里有自定义事件负载类型的话,启动早期通过 ConfigureUserResolver 把自定义 Context 并入解析器链即可被覆盖:
csharp
FeishuJsonDefaults.ConfigureUserResolver(MyCustomJsonContext.Default);
ConfigureUserResolver 是幂等累加模式,同一实例多次传入会被去重,调用多少次都安全。
五、总结
5.1 四个里程碑
| 阶段 | 目标 | 结果 |
|---|---|---|
| P0 | 致命缺陷(Context 未覆盖类型静默退化、同名冲突) | ✅ |
| P1 | 高风险加固(泛型响应、WebSocket/EventCallback 协议类型) | ✅ |
| P2 | 工程化(全局配置、rd.xml 兜底、DTO 重命名) | ✅ |
| CI | 严格模式门禁 + 双 RID 验证 | ✅ |
5.2 以后需要注意的
- 假绿比报错更危险。
--no-incremental那桩案子在一个[ OK ]下瞒了不知多少轮,直到有人较真去比对命令行才翻出来。门禁的价值不在「有」,在「能挡住假绿」。 - 兜底不是妥协,是 API 契约。 反射兜底表面上是 AOT 的例外,实际上守的是「同一份代码在六个 TFM 上行为一致」这条更难的承诺。
- 验证工程要验证「用户拿到的包」,不是「我们写的源码」。 用
PackageReference消费已发布版本这件事,比任何单元测试都更接近真实交付。
5.3 后面想做
- 在 net10.0 上继续收紧裁剪,把反射兜底的触发面压到更窄。
- 让源生成 Context 的覆盖持续扩大,理想状态下「兜底」只剩理论上的存在。
- 端到端验证工程继续当 AOT 能力的门卫沉淀在仓库里。
结语
Native AOT 对类库从来不是开个开关的事,而是一次关于「元数据归谁管」的重构:把运行期的灵活性,前置成编译期的确定性。
Mud.Feishu 3.0 用全链路源生成 + 可合并解析器链 + 严格门禁,把 32 个业务模块、3000+ 个类型,第一次整体跑进了一个 4MB 的原生 AOT 二进制。这套「源生成 Context 链首 + 幂等解析器链 + 反射兜底」的经验不限于飞书 SDK------任何面向 .NET 的类库要在 AOT 与 JIT 之间平滑过渡,都会走到同一条路上。
想亲手试一次:
bash
git clone https://github.com/mudtools/MudFeishu && cd Demos/Mud.Feishu.AotVerification
dotnet publish -r linux-x64 -c Release /p:PublishAot=true
./bin/Release/net8.0/linux-x64/publish/Mud.Feishu.AotVerification --smoke
你在 AOT 部署里遇到的每一个「未覆盖类型」,都是下一条解析器链要吞掉的边缘情况。欢迎到 Issue 里聊聊。