到强类型Route:用Source Generator根治反射路由的编译期隐患)
我到现在还记得那天深夜改完最后一行代码时的心情把 FUI 框架里的路由扫描逻辑从AppDomain.CurrentDomain.GetAssemblies()配合GetTypes()的反射方案整体替换成 Source Generator 生成的强类型 Route之后启动日志安静得让人有点不习惯。没有类型加载异常没有“路由重复”的告警也没有启动阶段那几十毫秒的反射扫描时间——更关键的是那些以前只能在运行时才能炸出来的路由错误现在直接变成了编译错误。最近好几个朋友在聊各自框架的路由设计时都问过同一个问题“反射跑得好好的为什么要换成源生成器折腾那么一大圈图什么”这篇文章就把 FUI 这次设计的完整思考链条写出来从 GetTypes() 到强类型 Route中间经历了什么问题、源生成器到底解决了什么、迁移过程中踩了哪些坑。无论你是在写自己的 Web 框架还是单纯对 Source Generator 感兴趣应该都能从这里拿到一些可复用的判断。1. 第一次用 GetTypes() 做路由发现时我以为找到了一劳永逸的方案1.1 FUI 早期路由模型一个接口加一行特性剩下的交给运行时先说 FUI 当时面临的问题。这是一个给内部中后台项目用的 Web 框架核心诉求就一句话开发者写一个类、在方法上标一个[Route]路由就能生效最好不要额外注册配置。所以最初的实现非常标准约定所有控制器都实现IFuiController接口路由发现阶段扫一遍程序集里所有类型找出哪些类实现了这个接口然后反射出方法上的特性构建路由表。public class ReflectionRouter { public IReadOnlyListRouteEntry BuildRouteTable() { var entries new ListRouteEntry(); var controllerTypes AppDomain.CurrentDomain.GetAssemblies() .SelectMany(GetLoadableTypes) .Where(t t is { IsClass: true, IsAbstract: false } typeof(IFuiController).IsAssignableFrom(t)); foreach (var type in controllerTypes) { foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance)) { var routeAttr method.GetCustomAttributeRouteAttribute(); if (routeAttr is null) continue; entries.Add(new RouteEntry( HttpMethod: routeAttr.HttpMethod, Path: routeAttr.Path, HandlerType: type, HandlerMethod: method)); } } return entries; } private static IEnumerableType GetLoadableTypes(Assembly assembly) { try { return assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) { return ex.Types.Where(t t is not null)!; } } }这段代码现在看很朴素但在当时是被验证过的经典套路。它确实兑现了“写完类就有路由”的体验无需启动时手工注册不用中央配置新加一个 Controller 文件谁都不用通知。1.2 反射方案早期为什么是合理的得说句公道话FUI 早期用反射路由并不是错误决策。对于一个快速迭代、需要靠约定取胜的框架来说反射方案有三个优点当时是完全适配的。第一开发速度极快。GetTypes()GetCustomAttribute是运行时元数据最直接的入口不需要额外工程结构几行代码就能把一套按约定自动发现的路由系统跑起来。第二动态性好。插件机制、热更新模块、独立程序集扩展都可以直接建立在“扫描程序集”之上。第三对使用者透明。调用方不需要关心注册机制类写对了路由自然生效。这三个优点让 FUI 在头半年里迭代得非常顺畅。但问题也随之积累——当框架用户从十几个项目变成上百个、当有人开始尝试 NativeAOT 发布、当团队的代码规模从千级方法涨到万级方法时“运行时才知道结果”这件事就变成了一笔越来越重的技术债。这里我想先说一个容易被低估的事实路由发现用反射最痛的不是性能而是它把本可以在编译期发现的问题全部推迟到了运行时。等到这一类问题集中爆发时你才发现它像慢性病一样渗透在框架的各个角落。2. 框架变大之后GetTypes() 模式被逐条引爆的暗伤2.1 暗伤一程序集枚举的连带副作用先聊最直接挂在脸上的问题GetTypes()会触发整个程序集的类型元数据加载而一个程序集里只要有一个类型在加载阶段依赖了缺失的原生库或者错误版本的程序集整个枚举就会抛ReflectionTypeLoadException。FUI 踩过最典型的一次某个业务模块引用了第三方 SDK那个 SDK 在类型初始化时需要加载本地动态库。早版本路由扫描只遍历了核心程序集后面用户为了省事直接对入口程序集调GetTypes()结果第三方 SDK 里的某个类型无法加载整个路由表构建崩溃。日志里出现的不是“某个类型加载失败”而是一大段LoaderExceptions数组里面每条 message 语义模糊得靠猜去定位是哪个依赖出了问题。这类问题的荒谬之处在于你的业务接口和路由代码可能完全没问题只因为某个无关类型的加载失败整个应用就起不来了。我后来见过有人为了规避这个问题在GetLoadableTypes()里写了一堆异常吞噬逻辑结果路由表在不知不觉间变少了一半还很难发现。2.2 暗伤二字符串路由让签名变化只能靠运行时暴露如果说类型加载问题是“反射路由的病”那字符串路由就是“反射时代叠加的雷”。FUI 初版的路由特性长这样[Route(HttpMethod.Post, /api/orders/{orderId}/cancel)]路径是一个普通字符串。方法签名是用户自己写的比如public async TaskIResult CancelOrder(string orderId) { // ... }问题来了路径里的占位符{orderId}和方法的参数名orderId之间没有任何编译期约束。开发者把参数名改成id或者把路径拼成{order_id}框架在编译期不会有任何反应。路由在启动时也能构建甚至 Swagger 元数据都能生成真正的报错发生在第一个真实请求打进来之后——要么 404要么参数绑定不上。这种错误对团队协作杀伤力极大。你自己改的代码自己知道但框架的另一个使用者不知道“占位符必须和参数名一致”这条默认约定他以为改个名字只是重构结果上线后才发现路由全部失效。我印象里一次线上事故就是这种问题一个老接口从orderId改成id请求路径没变但绑定时框架拿不到动态参数值所有调用全部报错。2.3 暗伤三AOT 裁剪后的类型“消失”这两年 NativeAOT 的呼声越来越高FUI 也开始有用户尝试裁剪发布。然后反射方案就碰到了底层逻辑问题AOT 裁剪器不知道你在运行时要通过反射枚举类型它默认把未引用的类型都裁掉。你觉得自己代码里写得清清楚楚的 Controller 类型发布后GetTypes()根本枚举不到。有人会说你用DynamicDependency和RequiresUnreferencedCode标注啊。确实可以但一个框架如果把“让每次发布都得手工给类型加保留标记”作为使用前提那这个框架在设计上就已经失败了。更要命的是运行时反射调用的性能损耗在 AOT 场景下会被进一步放大——没有 JIT 帮忙Activator.CreateInstance和MethodInfo.Invoke的代价比普通调用高出一个数量级。FUI 的定位是一个“拿起来就能用”的框架如果每接一个用户都要给裁剪器写配置清单这个方案基本就算被判死刑了。3. 强类型 Route 的建模目标从“运行时猜”到“编译期约定”3.1 “强类型 Route”到底强在哪三层模型升级决定做 Source Generator 改造之前我先干了一件事把“强类型 Route”这个词拆清楚。如果只是把路由路径从string换成一个泛型常量那没意义。FUI 要的强类型是三层模型同时升级。第一层路由表本身的类型。路由项不再是“方法 特性字符串”的组合而是一个携带泛型参数的描述符类型上能看出 Handler 是谁、请求参数类型是谁、返回类型是谁。public sealed class RouteDescriptorTHandler, TRequest, TResponse : IRouteDescriptor where THandler : IFuiController { public required HttpMethod HttpMethod { get; init; } public required string Path { get; init; } }第二层参数绑定的类型安全。方法的参数列表不再通过运行时GetParameters()逐个反射读取而是在编译期被转换成明确的请求模型或参数元数据。开发者写的参数类型就是路由元数据的参数类型不存在“字符串转 int 失败”这种运行时才出现的问题。第三层反向 URL 生成的类型安全。原来生成一个链接要么手动拼字符串要么按照路由名做运行时解析。强类型方案里框架直接为每个路由生成一个调用方法比如FuiLinks.GetOrder(int orderId)参数类型写错直接在编译期报错。这三层合在一起才是完整的“强类型 Route”体验。3.2 为什么选 Source Generator而不是继续优化反射也有朋友建议既然反射痛搞个Expression编译缓存、把MethodInfo.Invoke换成委托不行吗答案是那能解决性能和动态调用的问题但解决不了**“类型集合不可见”**的问题。反射最大的隐患不是你调用方法慢而是框架的每一个能力——发现、绑定、文档生成、参数校验——都只能在运行时见过真实类型后才生效。而 Source Generator 代表的是一种完全不同的思路在编译期间读一遍类型信息把结果直接写成一等公民的 C# 代码。这些代码能静态分析、能参与类型检查、能被 IDE 智能提示、能被裁剪器正常处理。源生成器和反射最关键的区别是反射在问“程序集里现在有哪些类型”源生成器在问“当前编译里有哪些类型被声明了、它们实现了谁的接口”。前者的结果取决于运行时环境后者的结果在编译结束那一刻就已确定。这与 FUI 要的核心体验是匹配的。一个框架如果希望开发者写出的路由在编译期就确定下来那 Source Generator 是当前 C# 生态里最自然的手段。4. FUI Source Generator 的关键实现让编译器替你“找类型”4.1 增量生成器的管道设计先筛候选再精算元数据实现前先定设计基调FUI 的路由扫描以前是“遍历所有类型找到实现 IFuiController 的”现在换成 Source Generator不能简单粗暴地让生成器遍历整个编译的所有语法树、逐个类型做语义判断——那是把反射的笨办法搬到了编译期性能上不可接受。FUI 的生成器采用的是管道式增量模型。先定义一个特性[Route]作为显式标记然后用 Roslyn 4.3.1 以上版本提供的ForAttributeWithMetadataName只对带这个特性的方法做处理[Generator(LanguageNames.CSharp)] public sealed class FuiRouteGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var candidates context.SyntaxProvider.ForAttributeWithMetadataName( Fui.Routing.RouteAttribute, predicate: static (node, _) node is MethodDeclarationSyntax, transform: static (ctx, _) TryCreateRouteCandidate(ctx)); context.RegisterSourceOutput( candidates.Where(static c c is not null), static (spc, candidate) EmitRoute(spc, candidate!)); } }这个代码有一个关键点很多人第一次接触时会搞混ForAttributeWithMetadataName的工作方式不是“先找出所有类型再看谁有特性”而是在语法树节点里找“带有指定特性的语法树节点”对没有特性的类型连语义模型都不会触发。这对编译性能友好得多——候选集天然小而且每个节点只需要做一次语义查询。4.2 从语法节点到语义模型的判断拿到语法树候选后真正的核心逻辑在TryCreateRouteCandidate里。这里需要把MethodDeclarationSyntax提升成语义层面的IMethodSymbol然后做几件事确认方法所在类是否实现了IFuiController、方法是否 public、路径里的占位符和方法参数是否一一对应。private static RouteCandidate? TryCreateRouteCandidate(GeneratorAttributeSyntaxContext context) { if (context.TargetSymbol is not IMethodSymbol method) return null; if (method.DeclaredAccessibility ! Accessibility.Public) return null; if (!IsControllerType(method.ContainingType)) return null; var routeAttr context.Attributes[0]; // 提取 [Route] 构造参数HttpMethod 和 Path if (routeAttr.ConstructorArguments.Length 2) return null; var httpMethod routeAttr.ConstructorArguments[0].Value?.ToString() ?? GET; var path routeAttr.ConstructorArguments[1].Value?.ToString(); if (string.IsNullOrEmpty(path)) return null; // 校验路径占位符与参数名 var placeholderNames ExtractPlaceholders(path); var parameterNames method.Parameters.Select(p p.Name).ToHashSet(); foreach (var name in placeholderNames) { if (!parameterNames.Contains(name)) { // 丢诊断错误路径占位符找不到对应参数 return null; } } return new RouteCandidate(method, httpMethod, path); }这个阶段最大的价值不只是“找到类型”而是把校验逻辑前置到编译期。以往反射方案在运行时要做“路径模板匹配、参数名比对、类型转换失败归因”现在生成器在编译时就能对开发者喊停你路径里写了{order_id}但方法参数名是orderId这种错误根本不该等到部署后再发现。4.3 生成代码的稳定性部分类 静态集合生成器最终要产出的是普通 C# 代码。FUI 用了一个约定生成一个Fui.Generated命名空间下的 partial 静态类里面放一个静态只读的路由数组。为什么用partial因为 FUI 希望这套生成代码不仅能被请求管道读取而且在生成器之外的某些手动扩展场景下还能让开发者自己补充额外的非生成路由项。partial 类给两边留了汇合的窗口。// auto-generated / #nullable enable namespace Fui.Generated { internal static partial class FuiRouteTable { public static readonly IReadOnlyListIRouteDescriptor Routes new IRouteDescriptor[] { new RouteDescriptorOrders.OrderController, Orders.GetOrderQuery, Orders.OrderDto( HttpMethod: GET, Path: /api/orders/{orderId}) }; } }这里有个细节值得留意生成代码里的new RouteDescriptorTHandler, TRequest, TResponse是真实编译期的类型引用。你可以在 IDE 里点进这个类型判断它是否真的存在、参数类型是否真的匹配。这才是“强类型”的根生成器输出的不是一段藏在字符串里的运行时脚本而是和开发者手写代码享受同等检查力度的普通 C#。5. 生成出来的强类型路由表到底服务了谁5.1 启动阶段的变化从扫描程序集到读静态数组改造成源生成器之后FUI 的启动流程简化成了这样请求管道直接引用FuiRouteTable.Routes不再需要调用AppDomain.GetAssemblies()也不再需要GetLoadableTypes()那套异常吞噬逻辑。请求进来时路由匹配算法不再针对每个路由动态解析模板而是基于生成代码里已经确定的字符串常量和类型信息做匹配。因为路径都是常量字符串FUI 甚至能在生成代码里把路径按 HTTP Method 和长度预先分组匹配时先过滤再精匹配。这部分优化在旧反射方案里没法做——反射拿到的路径虽然是常量但框架不能确定它是不是常量只能每次做通用的字符串解析。5.2 路由反向生成、参数绑定和 OpenAPI 元数据启动只是表象源生成器更深的红利体现在三个具体使用场景。第一是链接生成。旧方案如果要生成一个指向订单详情的 URL通常写urlHelper.Link(get_order, new { orderId order.Id })靠字符串路由名关联。Source Generator 版本里生成器在扫描路由的同时生成一个静态链接类namespace Fui.Generated { public static partial class FuiLinks { public static string GetOrder(int orderId) $/api/orders/{orderId}; } }前端调用就变成了FuiLinks.GetOrder(order.Id)。传参类型不对编译器直接就报错不存在“运行时才发现链接拼错”的情况。第二是参数绑定。旧方案里请求到达后要反射调用方法的ParameterInfo再用Convert.ChangeType把字符串转成目标类型。新方案里生成器已经知道了所有参数的类型和顺序直接在生成代码里产出强类型的委托调用参数绑定被简化成一个生成好的方法调用。第三是OpenAPI/Swagger 生成。FUI 以前生成 OpenAPI 文档必须靠反射遍历方法参数、读取 XML 注释。现在这些信息从生成器的语义模型里直接读出来——比反射更干净而且不会因为类型被裁剪而缺失。5.3 一次内部的对比数据下面这些数字是我在自己机器上的一次粗测仅供参考但趋势是有参考价值的。测试条件是100 个控制器、800 条路由冷启动后立刻处理 5000 个请求。指标GetTypes() 反射版FUI Source Generator 版路由扫描耗时约 230ms含类型加载副作用约 0ms静态字段初始化每个请求的参数绑定平均耗时2.1μs 左右0.3μs 左右无法加载类型导致启动崩溃可能出现不会无运行时扫描参数名/路径不一致运行时请求才可能发现编译期直接报错NativeAOT 发布需要大量裁剪保留配置无特殊处理要求当然这个表不能说明“所有反射方案都该换成源生成器”。但至少对 FUI 这类框架收益是实打实的。6. 从旧路由迁移到生成路由的落地路径不推翻、两步走6.1 第一刀让两套路由表并行跑起来迁移最忌讳的是“删旧换新”一步到位。FUI 已经有存量用户不可能要求所有人一个晚上把[Route]改明白。所以我当时的改动策略是先做双路由表并行。具体设计是抽象一个IFuiRouteTable接口public interface IFuiRouteTable { bool TryMatch(HttpContext context, out RouteMatch match); }原来的ReflectionRouteTable保留不动新写的GeneratedRouteTable作为另一个实现。请求管道的入口处用配置开关控制当前走哪张表同时把两个表的结果都打一条结构化日志var generatedMatched generatedTable.TryMatch(context, out var generatedMatch); var reflectionMatched reflectionTable.TryMatch(context, out var reflectionMatch); if (generatedMatched ! reflectionMatched) { logger.LogWarning(FUI route mismatch. Generated: {GM}, Reflection: {RM}, Path: {Path}, generatedMatched, reflectionMatched, context.Request.Path); }这一步的意图很明确先让新路由表在灰度环境里跑起来同时持续观测它与旧表的差异。并行运行阶段收集到的 mismatch 日志就是接下来整改的清单。我见过太多迁移失败是因为“直接换掉跑挂了才意识到老逻辑偷偷兼容了某些边缘情况”。6.2 第二刀让标记用户灰度再把旧扫描代码请走并行表稳定运行一段时间后开始推第二刀让用户逐步把新框架包升级上去并且把 Controller 类从原来的“仅实现接口”改成“实现接口 方法标[Route]”。这里有个新老方案之间的隐性差异必须在迁移前说清楚反射版本可以发现“实现了 IFuiController 但方法没标 Route”的类源生成器版本则要求方法必须显式标记[Route]才会生成路由。换句话说从“按接口约定发现”变成“按特性显式发现”规则其实更严了。FUI 在迁移时保留了最后的显式注册通道如果某个 Controller 是运行时动态生成的、或者来自外部插件程序集源生成器覆盖不到开发者可以在启动代码里手动调用RouteRegistrar.Add(handlerType, methodName, routeTemplate)。这条通道本质上仍然用了反射但它是一个边界明确的例外而不是框架的默认路径。灰度切换的比例也要讲究。不要一上来 100% 流量切新表建议先 1%、再 10%、再 50%每一步都盯路由 mismatch 日志和错误率。等确认存量接口全部覆盖后再把ReflectionRouteTable和GetLoadableTypes()这套代码从框架源码里整段删除。7. 移植途中踩过的三个坑和完整的排查链路7.1 坑一特性加在方法上但生成器一个候选都没看到迁移过程中最诡异的体验是方法上明明写了[Route]生成的.g.cs文件里却什么都没有。我在群里看到过好几次类似的求助这里把排查链路完整还原一遍。第一步先打开obj/Debug/net8.0/generated/Fui.FuiGenerator/FuiRouteGenerator目录看生成器到底有没有执行。如果目录不存在或者文件是空的说明生成器根本没被触发。最可能原因使用方项目没有引用Fui.SourceGenerators这个包。源生成器和使用框架必须是独立项目不能只引用运行时包。第二步如果生成器执行了但候选为空问题大概率出在ForAttributeWithMetadataName的第一个参数上。这个 API 要求完整的特性元数据名称Fui.Routing.RouteAttribute和实际命名空间必须完全一致。如果特性类本身是 internal 的或者开发者用using RA Fui.Routing.RouteAttribute做了别名部分场景下匹配可能异常。第三步确认方法权限。小坑生成器过滤条件里如果包含method.DeclaredAccessibility ! Accessibility.Public而用户把路由方法写成了private那自然不会被候选到。旧反射版本里GetMethods(BindingFlags.Public | BindingFlags.Instance)同样忽略 private 方法行为是一致的但新方案里你看不到任何报错只能靠生成文件为空来猜。排查结论先看生成目录再查特性全名最后检查方法可访问性。顺序不要反否则容易在语义模型里绕半天。7.2 坑二重载方法出现“两个路由描述符长相一样”另一个让我印象深刻的坑是重载方法。一个 Controller 里有两个GetOrder一个接收int orderId另一个接收string orderNo并且两个方法上都标了[Route(/api/orders/{id})]。反射版本对这种代码是容忍的启动时两张路由都会被注册进字典但请求进来时最先匹配谁完全取决于字典遍历顺序可能今天走 A 明天走 B标准的行为不确定 bug。Source Generator 版本比反射更早暴露出问题——生成代码里出现两个相同泛型参数结构、相同路径的路由项编译器会报重复项错误或者在路由表的静态初始化阶段抛异常。排查链路是这样的先看生成文件里是不是出现了两个几乎一样的描述符。如果是接着检查方法是不是重载且路径模板相同。修复方案是给每条路由引入一个可选的RouteName显式标识比如[Route(GET, /api/orders/{orderId}, RouteName GetOrderById)] public TaskIResult GetOrder(int orderId) [Route(GET, /api/orders/{orderNo}, RouteName GetOrderByNo)] public TaskIResult GetOrder(string orderNo)路径模板不相同URL 生成时才不会产生二义性。这种错误在反射时代要等到真实请求才可能发现而且表现是无声的“偶尔错乱”非常阴险。换成源生成器后至少在编译期或者启动第一毫秒就爆炸这其实是一种进步。7.3 坑三增量生成器留下的旧生成代码掩盖了真实错误最后一个坑是关于增量生成器缓存机制的。有次我在本地改了 Controller 的命名空间同时又调整了[Route]路径前缀rebuild 之后 IDE 的智能提示新命名空间是对的但运行时请求一直打到旧路径上。排查了很久才发现问题不在于生成器逻辑而在于incremental generator 的缓存没有在项目属性变动时正确失效。生成器根据输入语法树的 hash 决定要不要重新跑但当时EmitRoute里依赖了一些编译选项之外的全局设置比如一个静态配置类这个配置变了之后生成器没有真正重跑旧版本的生成代码被继续使用。复现和解决的链路遇到生成代码和预期不一致时先手动删除obj/和bin/下的所有生成产物执行一次dotnet build。如果恢复正常说明问题是增量缓存失效导致。检查生成器项目里是否引用了非IncrementalValueProvider的静态可变状态。源生成器必须保持纯函数式思维同样的输入永远要产生同样的输出任何外部可变全局都要排除。给生成器项目加一个版本号并把它拼进生成代码的注释头里。这样每次升级生成器包编译器会检测到源变化强制触发重新生成。这个坑不常见但一旦踩中会非常消耗时间。现在我在 FUI 的生成代码里都留了一行// Generator: Fui 1.6.0排查问题的时候先对一眼版本至少能快速排除“生成了旧代码”这种可能性。最后再分享一点个人经验FUI 从 GetTypes() 走到强类型 Route 的这趟改造技术上谈不上多惊艳但对我自己的框架设计观影响很大。以前做功能时我下意识会问“这个功能运行时能不能做得足够灵活”现在会先问一句“这些信息编译器明明都知道为什么要拖到运行时再猜一次”源生成器真正的价值不是消灭反射而是把那些本来应该在编译期锁死的约定用真实的 C# 代码固化下来。我也要泼一盆冷水并不是所有场景都适合抛弃反射。如果你的框架核心诉求是插件化、是运行期动态组装类型那 Source Generator 的“编译期定案”特性反而会是劣势。FUI 能这么改前提是它的 Controller 集合在编译时就是完整、确定的。先看清自己框架的形态再决定用哪种技术别为了“赶时髦”把路由扫描强行塞进源生成器。实际动手做类似改造时我的建议是先找一个最小的 Controller 子集跑通第一条生成路由把生成文件打开亲眼看一遍确认它生成的代码符合你的预期再逐步把接口扩大。源生成器这东西看文档十遍不如实际生成一个.g.cs文件、然后断点进去看一次调用链来得快。希望这篇记录能帮你少走几个弯路。