拓冰建站拓冰建站
首页 / 资讯中心 / 正文

从反射到源生成器:FUI 路由注册机制重构实践

最近把我自己维护的接口中间层 FUI 的路由注册机制重构了。之前是靠启动时GetTypes()反射扫描所有 Handler再逐个注册路由现在换成了基于 Source Generator 在编译期直接生成强类型 Route 注册代码。这篇文章会围绕 FUI Source Generator 这次设计演进把背景、取舍、实现细节、踩坑记录完整写下来。如果你手头有 ASP.NET Core 类框架、Minimal API或者在做“自动注册 Controller / Handler”这类功能这篇东西应该能帮你把方案想清楚。先说明适合什么人看一是正在纠结“用反射做通用注册还是上源生成器”的 .NET 开发者二是已经开始写IIncrementalGenerator、但对缓存和调试环节还没完全摸清的同行三是那些不想为了“少写几行路由”在启动阶段牺牲性能和可维护性的人。FUI 这个名字是我们内部对这个接口编排层的叫法代号不重要重要的是它当初踩过的坑大概率也是你现在的坑。1. 为什么要从 GetTypes() 走到强类型 Route1.1 FUI 最开始的长相启动期用 GetTypes() 扫描最早版本的 FUI 做了一个很自然的假设开发者在业务模块里写一堆类每个类上贴一个[FuRoute]标签框架启动时扫描程序集发现带标签的类就通过反射把类里的Handle方法提取出来注册成路由。核心逻辑大概是这个形态var types typeof(FuApplication).Assembly.GetTypes() .Where(t t.IsDefined(typeof(FuRouteAttribute), false)); foreach (var type in types) { var route type.GetCustomAttributeFuRouteAttribute(); var method type.GetMethod(Handle); var instance Activator.CreateInstance(type); app.MapGet(route.Path, new DelegateWrapper(instance, method).Invoke); }头两个版本用起来很舒服。新加一个模块贴标签、补方法启动就能用。因为当时模块数量少启动扫描一次只有几十个类型耗时在几十毫秒内没人觉得有毛病。但这种“舒服”是建立在运行期把所有信息摊开看的代价之上的。很多人第一反应是“能跑不就行了为什么要折腾”如果你只是个人项目确实没问题。可当 FUI 要接十几个团队的项目、几十个程序集、还要支持裁剪发布和 AOT 编译时GetTypes()的软肋会全部暴露出来。后面我会详细说。1.2 GetTypes() 模式在真实项目中埋下的几个雷第一类问题是字符串错误在运行时才暴露。路由路径是字符串方法名在反射场景下也是字符串。某个模块里路径写成api/orders/{id}另一个地方消费时写成api/order/{id}编译器不报错测试没覆盖到上线后接口 404 才有人发现。反射拿方法名也一样重构时把Handle改名成ProcessAsync结果启动直接抛异常抛异常还算运气好怕的是方法签名变了但名字没变参数列表不匹配被BindingFlags静默忽略。第二类问题是启动性能。模块少的时候不痛模块一旦到几百个GetTypes().Where(...)这个过程还算能接受但随后每个类型都要取GetCustomAttributes、查方法、包装 Delegate部分场景还要处理构造函数注入。真跑起来JIT 需要编译的反射路径也不小。FUI 在一台低配服务器上启动时间一度到 900ms其中接近 40% 耗在路由发现和注册上。第三类问题被很多人忽略GetTypes()本身就是 AOT 和裁剪场景的拦路虎。发布时如果启用了PublishTrimmed或 Native AOT程序集里的类型信息是会被裁剪掉的你对一个类型做反射不一定能拿到完整元数据。那时框架不再是在“慢”和“快”之间选择而是“能启动”和“根本启动不了”的区别。FUI 当初接到 AOT 需求时GetTypes()方案基本等于直接废弃。1.3 当时为什么没有选择运行时缓存或表达式树也有人问既然反射慢能不能启动时扫描一次把 Delegate 缓存起来之后直接查缓存。这确实缓解了性能问题但没有解决“AOT 裁剪后拿不到类型”的根源。还有人想用 Expression Tree 动态生成委托但动态生成依旧依赖运行时元数据代码复杂度和 source generator 不相上下调试反而更难受。Source Generator 解决的是本质问题把“运行时通过反射发现类型”变成“编译期看到源码里的类型和方法把注册代码直接生成到程序集里”。运行时不需要扫描全部程序集也不需要提取自定义特性所有信息都以静态代码的形式躺在编译产物里天然兼容裁剪与 AOT。FUI 后面的演进路线本质就是朝这个方向走。2. Source Generator 版强类型 Route 的设计拆解2.1 强类型 Route 到底强在哪里我接触过很多框架设计“强类型”三个字容易产生歧义。FUI 这里说的强类型 Route 包含三个层次。第一层是注册入口强类型。原来写app.MapGet(api/orders/{id}, handler)时handler 多半是从反射拿到的MethodInfo框架底层要把MethodInfo.Invoke包成RequestDelegate。强类型方案里生成的代码直接调用静态方法传入的是真实签名委托编译器会对参数类型和方法名做检查。第二层是路由表生成强类型。运行时不需要“发现”路由表生成器把路由表定义成一个静态的、有序的列表列表里每一项都是一个FuRouteDefinition字段类型在编译期已经确定。反射时代的字符串路径、方法名、参数类型组合所有这些散落在运行期处理的信息都被提前固化下来。第三层是消费端的可读性。原来要查“FUI 到底注册了哪些路由”得在启动日志里翻或者用 debugger 去遍历内部集合。现在生成的注册扩展方法就是一份可读代码实现类、路由、HTTP Method、委托签名全部摊在那里。审计路由、排查重复路径看生成文件比看运行时数据靠谱得多。核心设计目标总结成一句话让错误在编译期暴露而不是在运行期猝死。2.2 生成器管道整体设计三步走到了实现环节IIncrementalGenerator的设计可以拆成三步。第一步是找目标。源生成器要处理的不是全项目所有类型而是带有[FuRoute]特性的方法或类。这里首选ForAttributeWithMetadataName它专门用来按 Attribute 全名匹配性能比手动遍历语法树再逐个查语义模型好很多。生成器会把候选方法收集起来形成一个中间数据列表。第二步是提取编译期模型。拿到每一个方法的IMethodSymbol后要提取方法所在类的命名空间、类型名、方法名、方法是否 public static、HttpGet 或 HttpPost 等还要从 Attribute 构造参数里解析路由模板。这个阶段千万不要直接把ISymbol或SemanticModel原样扔进下游模型因为增量缓存要求模型可比较、可序列化。FUI 的做法是建立一个专门的RouteCandidate记录。第三步是生成注册代码。把候选列表聚合后由生成器拼接出MapFuRoutes扩展方法里面为每个候选方法生成一句MapGet/MapPost调用同时生成一个全局的FuRouteDefinition枚举列表供运行时框架使用。聚合后只输出一个源文件减少文件数量也方便调试。2.3 收集模型时强调可比较性为增量缓存打底这一步是新手最容易翻车的地方。如果直接把ITypeSymbol或IMethodSymbol作为中间模型保存第一次编译没问题但后续你在任意一个文件里添加一个空行整个生成器都会重新执行。原因很简单ISymbol默认没有实现值相等比较Roslyn 无法判断它是否变化只能全量重跑。FUI 最终使用的RouteCandidate大概长这样internal sealed record RouteCandidate( string TypeNamespace, string TypeName, string MethodName, string Route, string HttpMethod, string ReturnType, bool IsStatic, bool IsPublic);这些字段都是纯字符串或布尔值记录自带值相等判断。只有这样一个文件改动才不会导致所有路由信息全部重新生成实现真正的增量缓存。实测下来在 200 多个候选方法的项目里无关代码改动触发路由生成的耗时只有个位数毫秒。2.4 为什么注册代码要生成一个扩展方法而不是直接改 Map设计上还有一个选择生成器可以直接在Program.cs里生成一段代码吗不行源生成器只能在独立文件中新增代码无法修改用户已有源码。所以 FUI 的统一做法是生成一个FuiRouteEndpointExtensions静态类对外暴露一个MapFuRoutes(this IEndpointRouteBuilder builder)扩展方法。用户改动只有一个地方var builder WebApplication.CreateBuilder(args); var app builder.Build(); app.MapFuRoutes(); // 这一行代替了原来 GetTypes() 的整个扫描过程 app.Run();生成器内部生成多个路由调用用户侧代码保持极简。后续如果要做路由分组、加鉴权、加版本前缀也只需要改这一个扩展方法的生成策略。3. 实操手写一个 FUI 路由源生成器3.1 项目结构最小化搭建动手写之前先建立一个能跑通的最小工程。FUI 的标准目录大概是下面这样FUI.Routing.Abstractions/ // 定义 Attribute 和 FuRouteDefinition FUI.SourceGenerator/ // 源生成器项目 DemoApi/ // 演示项目关键一点是FUI.Routing.Abstractions里不要放任何逻辑只放 Attribute 和纯数据类。源生成器虽然引用了这个项目来保证字符串一致但生成器最终解耦于框架运行时它在语法树上看到的是完全限定名称。Attribute 定义很简单namespace FUI.Routing.Abstractions; [AttributeUsage(AttributeTargets.Method, AllowMultiple false, Inherited false)] public sealed class FuRouteAttribute : Attribute { public FuRouteAttribute(string route, string method GET) { Route route; Method method; } public string Route { get; } public string Method { get; } }注意这里 Attribute 目标是方法而不是类。这种设计能让路由桩更细粒度也让源生成器的收集逻辑简单一点。如果你希望一定用类级别比如一个类里放一组相关 handler那还要额外扫描类里的 public static 方法复杂度会明显上升。3.2 生成器的核心骨架生成器项目首先要引用以下包dotnet add package Microsoft.CodeAnalysis.CSharp源生成器本身建议目标框架netstandard2.0这样才能同时被 .NET Framework 和 .NET 8/9 项目引用。生成器最基础的类长这样using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; using System.Text; namespace FUI.SourceGenerator; [Generator(LanguageNames.CSharp)] public sealed class FuRouteGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var candidates context.SyntaxProvider.ForAttributeWithMetadataName( FUI.Routing.Abstractions.FuRouteAttribute, predicate: static (node, _) node is MethodDeclarationSyntax, transform: static (ctx, _) { var methodSymbol (IMethodSymbol)ctx.TargetSymbol; var attr ctx.Attributes[0]; var route attr.ConstructorArguments[0].Value?.ToString(); var httpMethod attr.ConstructorArguments[1].Value?.ToString() ?? GET; return new RouteCandidate( methodSymbol.ContainingType.ContainingNamespace?.ToDisplayString(), methodSymbol.ContainingType.Name, methodSymbol.Name, route!, httpMethod, methodSymbol.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), methodSymbol.IsStatic, methodSymbol.DeclaredAccessibility Accessibility.Public); }) .Where(static c c.Route is not null); var collected candidates.Collect(); context.RegisterSourceOutput(collected, static (spc, list) { if (list.IsDefaultOrEmpty) { return; } var source Emitter.Generate(list); spc.AddSource(FuiRouteEndpointExtensions.g.cs, SourceText.From(source, Encoding.UTF8)); }); } }很多资料里建议先写ISyntaxReceiver再拿语法节点做语义解析。但既然 .NET 7 以后已经有了ForAttributeWithMetadataNameFUI 就直接用新 API省去大量自定义语法过滤代码。这个 API 会先按 Attribute 元数据匹配再回调完整语义信息效率和正确性都更好。3.3 生成 Emitter从 RouteCandidate 到 MapFuRoutes核心生成逻辑不复杂就是把收集到的候选方法拼成一个扩展方法。Emitter 的骨架如下internal static class Emitter { public static string Generate(ImmutableArrayRouteCandidate routes) { var sb new StringBuilder(); sb.AppendLine(// auto-generated /); sb.AppendLine(#nullable enable); sb.AppendLine(namespace FUI.Routing.Generated); sb.AppendLine({); sb.AppendLine( public static class FuiRouteEndpointExtensions); sb.AppendLine( {); sb.AppendLine( public static global::Microsoft.AspNetCore.Builder.IEndpointRouteBuilder MapFuRoutes(); sb.AppendLine( this global::Microsoft.AspNetCore.Builder.IEndpointRouteBuilder builder)); sb.AppendLine( {); foreach (var route in routes) { // 过滤器只处理 public static 方法 if (!route.IsPublic || !route.IsStatic) { continue; } // 拼接 MapGet / MapPost 调用 // 注意 MapGet 方法需要导入 Microsoft.AspNetCore.Builder.HttpMethod 扩展 sb.AppendLine($ builder.Map{ToMethodName(route.HttpMethod)}(\{route.Route}\, (global::System.Delegate)({route.TypeNamespace}.{route.TypeName}.{route.MethodName}));); } sb.AppendLine( return builder;); sb.AppendLine( }); sb.AppendLine( }); sb.AppendLine(}); return sb.ToString(); } }这里写(global::System.Delegate)(...)转成 Delegate 是为了让所有方法都用一个统一的结构注册。但实际用起来你会发现如果能直接把方法组传给MapGet代码会更简单同时也能让 ASP.NET Core 自己做重载和路线匹配。FUI 的早期版本确实是直接写MapGet(api/xxx, DemoApi.OrderEndpoints.GetOrder)不过后来框架需要在注册时统一包装鉴权、异常捕获、Trace 等中间件才统一转成 Delegate 再走内部管道。从“强类型 Route”角度讲方法组method group在编译期一定会做更好的类型校验。这里我建议你按自己框架能力来如果 FUI 只是单纯做路由注册直接生成方法组最合适。3.4 改造业务侧代码一个实际的 Controller 示例以订单模块为例改造后业务代码长这个样子using FUI.Routing.Abstractions; namespace DemoApi.OrderEndpoints; public static class OrderEndpoints { [FuRoute(api/orders/{id}, GET)] public static async TaskIResult GetOrder(int id) { var order await OrderService.FindAsync(id); return order is null ? Results.NotFound() : Results.Ok(order); } [FuRoute(api/orders, POST)] public static async TaskIResult CreateOrder(CreateOrderRequest request) { var id await OrderService.CreateAsync(request); return Results.Created($/api/orders/{id}, id); } }生成器会自动收集到两个方法。最终生成的注册文件内容是类似这样的概念代码// auto-generated / namespace FUI.Routing.Generated { public static class FuiRouteEndpointExtensions { public static global::Microsoft.AspNetCore.Builder.IEndpointRouteBuilder MapFuRoutes( this global::Microsoft.AspNetCore.Builder.IEndpointRouteBuilder builder) { builder.MapGet(api/orders/{id}, global::DemoApi.OrderEndpoints.OrderEndpoints.GetOrder); builder.MapPost(api/orders, global::DemoApi.OrderEndpoints.OrderEndpoints.CreateOrder); return builder; } } }整套链路里路由扫描没了、反射没了、Activator.CreateInstance也没了。原来启动时的类型遍历逻辑被完全挤到编译期。这也是 FUI 这次演进中最核心的差异注册信息不再是一份“运行期临时拼出来的数据”而是一段编译期就存在、可读、可裁剪、可静态分析的代码。注意如果你生成的类和方法名在 IDE 里看着像“凭空出现”记得把生成文件关联到项目。FUI 的演示项目里会把生成文件放在obj/Generated下但默认不展开。想看时可以在项目文件里临时加EmitCompilerGeneratedFilestrue/EmitCompilerGeneratedFiles它会把所有生成文件物理输出到obj/Generated/AnalyzerName/目录下。3.5 引入生成器项目引用别搞错很多第一次写源生成器的朋友容易卡在“引入”这一步。不是 NuGet 包安装错就是怎么都不触发生成。如果 FUI 框架是本地项目推荐在 DemoApi.csproj 里这样配置ItemGroup ProjectReference Include..\FUI.Routing.Abstractions\FUI.Routing.Abstractions.csproj / /ItemGroup ItemGroup ProjectReference Include..\FUI.SourceGenerator\FUI.SourceGenerator.csproj OutputItemTypeAnalyzer ReferenceOutputAssemblyfalse / /ItemGroup注意第二组ProjectReference必须加上OutputItemTypeAnalyzer否则生成器项目只是作为普通类库被引用根本不会在编译时运行。ReferenceOutputAssemblyfalse是防止生成器程序集携带到业务输出目录。这两个参数少一个都会踩坑。DemoApi 里还需要显式调用扩展方法app.MapFuRoutes();如果你把调用放在Program.cs的中间生成器已经提前生成好类型编译器能正常解析编译不通过基本只可能是命名空间写错或生成器没运行。4. 迁移过程中踩过的坑与排查技巧4.1 生成器不触发先检查 Analyzer 引用方式FUI 在试点项目集成时遇到过最隐蔽的问题是生成器“偶尔不跑”。现象是代码里明明写了[FuRoute]生成的扩展方法就是找不到。排查一圈后发现两处第一OutputItemTypeAnalyzer被误删了。有人把生成器项目引用成普通ProjectReference代码里能引用到生成器类型但生成器并不执行。第二项目用了集中式包管理Microsoft.CodeAnalysis.CSharp版本和 SDK 自带版本不一致导致 Analyzer 加载失败。Roslyn 的生成器失败不会直接报错到你业务代码里它只是静默不产出源码。排查建议先在 csproj 里加EmitCompilerGeneratedFilestrue/EmitCompilerGeneratedFiles编译后去obj/Generated目录看是否有生成文件没有就调出编译日志搜索warning CS8785或error CS8785。这类错误会包含生成器内部异常信息能看到是加载失败还是代码抛异常。4.2 增量缓存失效无关代码改动导致全量重跑之前提到中间模型要可比较。FUI 第一次把生成器接入大项目后同事反馈“改一个配置文件整个项目重新生成路由要 1 秒多”。原因就是缓存模型里直接塞了ITypeSymbol和Location之类的引用类型。Location包含了文件路径和行号哪怕只是另一个文件多了一个换行模型比较也会认为某些对象的引用位置变了导致后续步骤全部重跑。怎么排查最直接的验证方式是把候选模型字段全换成字符串、枚举或布尔值然后在transform阶段结束前不要保留任何编译器对象。另一个经验是给Collect()接一个自定义比较器collected.WithComparer(...)。如果候选记录类型里还有集合ImmutableArray的相等判断也要自己写。FUI 的RouteCandidate设计尽量保持扁平没有嵌套集合省了很多麻烦。4.3 生成代码里的可空性和命名空间冲突生成代码默认会带上#nullable enable但 FUI 早期生成模板没加。当调用方项目开启 NRT 后生成代码里没有标注空值状态就会出现 CS8618 等警告甚至被某些团队当作错误阻断提交。解决方法是生成模板固定写#nullable enable路由字段从 Attribute 构造参数读出来保证不是 null就加上null-forgiving或在构造函数中直接赋值。命名空间冲突是另一个容易忽略的问题。如果你生成的类型注册在namespace DemoApi下而业务代码里也存在同名类就会产生冲突。FUI 的做法是把所有生成内容统一放进FUI.Routing.Generated这个独立命名空间不污染业务项目的默认命名空间。在生成时对业务类型使用全限定名尽量避免using依赖减少符号解析出错。4.4 调试生成器的实用技巧源生成器的调试比普通库痛苦一些因为它在编译器进程中运行断点方式不是按 F5 就行。FUI 长期使用的两招第一在生成器方法里写#if DEBUG环境变量输出把生成的源码写到临时目录#if DEBUG System.IO.File.WriteAllText( D:\temp\fui_generated_route.txt, source); #endif这个方法简单、直观并且能在调试时直接双击打开生成的代码文件检查路由调用和签名是否有误。不要在生产配置里走这段逻辑生成器本身也会进编译进程写文件有 IO 开销。第二如果确实需要断点可以把调试器附加到dotnet.exe但麻烦在于要等到下一次编译触发。实践中更多人会选择写一个独立的小控制台项目直接实例化CSharpGeneratorDriver喂给一组 syntax tree然后跑RunGeneratorsAndUpdateCompilation这才是最常见的源生成器调试姿势。第一次搭起来有些成本后面调试效率会高很多建议 FUI 这个量级的框架直接搭一个这样的测试壳。4.5 路由冲突和重复方法名问题源生成器能避免运行时扫描但无法完全避免“业务侧写重复路由”。如果有两个方法都写了[FuRoute(api/orders, GET)]生成器会在编译结束时报错或留下两个相同 MapGet 调用导致启动时路由冲突。FUI 的生成器在RegisterSourceOutput前加了一层校验按HttpMethod Route分组发现数量大于 1 就上报Diagnostic直接编译失败。这种“把运行期容错转成编译期拦截”的做法比等 WebApplication 启动抛异常友好得多。5. 迁移之后真实效果和个人体感5.1 启动性能和代码体感的变化接入强类型 Route 生成后FUI 在演示项目里做了一组简单对比。路由数量 150 个左右同样一次普通启动指标GetTypes() 反射版Source Generator 版应用启动到服务可用的时间约 430ms约 210ms其中包含约 60ms JIT 首次预热路由注册阶段约 110msGetTypes 特性反射约 2ms执行 MapFuRoutes修改业务代码后是否需要重新生成路由不需要运行期扫描需要但编译期自动完成PublishTrimmed下是否可用基本不可用可用类型写错/路由拼错是否可在编译时报错否是数据不是严格基准但趋势非常明显。路由注册阶段从 100ms 降到个位数毫秒这没有悬念因为运行时扫描逻辑直接被删掉了。对没有启动性能压力的小项目可能感知不强但一旦有几十个 Handler、还挂了几个大型 DI 模块差异是能感知到的。代码体感上最明显的是以前在 Program.cs 里要写一堆“启动映射”配置现在只有一行app.MapFuRoutes()。业务团队新增接口时不需要理解框架如何发现它因为方法上标注[FuRoute]后路由已经以静态代码形式存在于编译产物里。5.2 对设计者和维护者的一点建议如果让我重新走一遍这个过程我会把“先扫原型、再换生成器”的路子再走一遍吗会但不建议把 GetTypes() 方案拖到大后期才换。很多技术选型是这样的反射扫描是性价比最高的原型手段它帮你快速验证路由标签、装饰器、Handler 等概念是否合理。一旦框架开始承载生产流量就应该把迁移计划提到日程上。迁移本身不需要一步到位。FUI 的做法是先有一个UseLegacyRouteDiscovery开关新模块用生成器注册老模块暂时保留反射扫描两边并行跑一段时间灰度确认没有问题再把反射路径彻底删掉。我个人在实际操作中体会最深的一点是源生成器不是银弹它只是把“运行时做的事”提前到“编译期做”所以你在设计时反而要更谨慎。比如路由模板的规范、Attribute 参数校验、重复路由检测这些原来可以由运行期报错兜底的事现在必须由源生成器尽早承担。设计越前期把这些约束定义清楚后面的迁移成本越低。5.3 后续还能扩展的方向FUI 这次落地的只是最小闭环。后续计划把三块再补全一是把路由和 DI 容器集成起来让静态方法能够拿到构造注入的服务目前 FUI 的做法是显示地传IServiceProvider参数生成器从路由注册处自动注入二是把 OpenAPI 元数据生成也做进同一套 pipeline方法上的注释、返回值、路由参数本来就在编译期信息里顺手产出OpenApiOperation描述比运行时再反射要稳三是给不同应用分组生成多个扩展方法类似 ASP.NET Core 的MapGroup让 FUI 能支撑更复杂的模块化工程。一套路由从GetTypes()迁移到 Source Generator 之后代码量没有增加多少但系统的边界清晰了很多。路由发现是编译器的职责运行时的职责只负责“根据已就位的静态表执行”。这也是 FUI 这套设计演进里最值得记住的一点。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门