ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Roslyn源码生成器实战:从编译器增量管线到开源落地

Roslyn源码生成器实战:从编译器增量管线到开源落地 你有没有想过C#代码在你敲下保存的那一刻编译器背后究竟在做多少事从词法分析到语法树从符号绑定到IL生成每一步都有严谨的流程。而Roslyn源码生成器Source Generator就是这条流水线上一个可以“插手”的节点——它在语义分析完成后、IL生成之前运行允许你动态地向当前编译单元里追加代码。听起来像是编译器的魔法实际上它已经在很多生产项目里默默工作了。这套玩意的“烟火”之处在于传统写法里你要么用T4模板在开发期生成文件要么用反射在运行期做动态逻辑。而源码生成器是编译期动手不进运行时代码不损失启动性能还能拿到比反射更准确的类型信息。我最初接触它是因为被DTO、MVVM样板代码写烦了后来花了两周把一套基于Roslyn的开源代码生成器从零搭起来今天就把整个过程中的思路、踩坑和最佳实践一次性写清楚。这篇东西适合三类人被重复代码淹没的业务开发者、想给团队做基建的架构师、以及想了解编译器扩展但不知道从何入手的C#爱好者。1. 为什么说源码生成器是.NET社区里“不一样的烟火”1.1 运行时反射、T4模板和源码生成器的本质区别很多人在第一个项目里都写过“反射工具类”比如根据类型动态拼接SQL、动态生成ViewModel的属性赋值。反射的特点是灵活但它有几个绕不开的问题启动性能占用、无法在编译期发现错误、不容易做AOT裁剪而且当你用nameof和GetProperty对字符串拼来拼去时重构一个字段名都可能静默失效。T4模板则是另一条路在Visual Studio里用文本模板生成.cs文件并直接入库。它的最大问题是产物一旦提交进Git就形成了“模板代码”和“生成代码”双份维护团队里任何一个人手动改了生成文件下次模板运行就会把改动冲掉最后要么认怂删掉生成文件夹要么把模板代码供起来再也不敢动。源码生成器把这两者的优势结合到了一起既不参与运行时也不污染源代码仓库。它作为分析器Analyzer被打包进编译器进程在每次编译时增量执行生成代码只会存在于内存中参与IL生成但不会出现在你的项目目录里。如果你特别好奇生成结果可以用EmitCompilerGeneratedFiles把它们落地查看。1.2 编译器级代码生成到底动了哪一步Roslyn编译器管线的顺序大概是源码文本 → 语法树SyntaxTree→ 符号绑定SemanticModel→ IL。源码生成器插入的位置就在语义模型可用之后、最终编译之前。这也是它最厉害的一点它可以访问SemanticModel拿到完整编译上下文。这意味着什么反射在运行期看到的Type元数据生成器在编译期就能通过INamedTypeSymbol看到而且更准确——因为它看到的就是当前编译单元里的真实类型包括你引用程序集里的元数据、泛型约束、访问修饰符、标注的Attribute等。我用一个生活化类比来解释反射像是到了酒店前台才翻花名册找房间生成器则是在盖楼施工图上直接改设计但改的不是原来的图纸而是“额外追加的楼层”——原有墙体不动编译器在组装阶段把新楼层一起砌了上去。1.3 什么项目值得用源码生成器什么不值得在一头扎进实现细节前先说说它的适用边界。我见过有人尝试用源码生成器生成所有SQL语句也有人用它去模拟动态代理实际上这些场景未必合适。值得做的典型场景包括POCO样板代码自动生成ToString、Equals、GetHashCode、Clone以及记录属性的变更。MVVM绑定在WPF、WinForms、.NET MAUI里用[ObservableProperty]标记字段自动生成INotifyPropertyChanged属性通知逻辑。这个方向现在已经有非常有名的开源库了但自己写一个能学到更多。强类型配置把appsettings.json的某个节点映射成强类型属性配置变更时编译期报错。API客户端根据Swagger/OpenAPI定义生成强类型调用方法让“调REST API”这件事有编译期保障不用拿着字符串路径拼URL。本地知识库检索封装RAG场景里把向量索引的字段映射、查询列表定义自动生成强类型代码避免业务层用魔法字符串拼Embedding字段。不适合的场景则包括需要运行时动态改变行为源码生成器的产物是静态的需要大量IO或网络操作的逻辑那应该在服务层而不是生成代码里以及团队里没人维护生成器本身的情况。2. 从零搭建一个开源的Roslyn生成器三步定骨架2.1 一个开源生成器仓库的标准配置如果你去看GitHub上成熟的生成器项目仓库结构基本都是“三件套”生成器工程Generator、属性定义工程Attributes、单元测试工程Tests。为什么要把Attribute单独拆一个工程核心原因是生成的代码必须能引用Attribute类型而这个类型得能被用户项目正常引用。生成器本身往往打包成analyzer不进用户项目的引用程序集列表所以需要一个专门的Attribute程序集随NuGet包一起分发。生成器工程的csproj有几个必须注意的配置Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknetstandard2.0/TargetFramework LangVersionlatest/LangVersion IsRoslynComponenttrue/IsRoslynComponent IncludeBuildOutputfalse/IncludeBuildOutput /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.CodeAnalysis.CSharp Version4.8.0 PrivateAssetsall / /ItemGroup /Projectnetstandard2.0是重中之重。编译器进程是独立的生成器dll必须能加载进该进程Roslyn本身是netstandard2.0时代的产物你如果直接target net8.0VS老版本和命令行编译都可能加载失败。IsRoslynComponent会让项目自动把输出dll作为分析器处理简化打包。2.2 最小的IIncrementalGenerator自动生成ToString现在的Roslyn推荐使用IIncrementalGenerator而不是旧的ISourceGenerator。后者每次编译都全量跑一遍前者支持增量缓存性能和体验都好很多。一个最简的自动生成ToString的生成器可以这样写[Generator] public sealed class AutoToStringGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var targets context.SyntaxProvider.ForAttributeWithMetadataName( MyLib.AutoToStringAttribute, static (node, _) node is ClassDeclarationSyntax, static (ctx, _) { var symbol (INamedTypeSymbol)ctx.TargetSymbol; var props symbol.GetMembers() .OfTypeIPropertySymbol() .Where(p !p.IsStatic p.DeclaredAccessibility Accessibility.Public) .Select(p p.Name) .ToArray(); return new ToStringModel( symbol.Name, symbol.ContainingNamespace.ToDisplayString(), props); }); context.RegisterSourceOutput(targets, static (spc, model) { if (model.Properties.Length 0) return; var sb new System.Text.StringBuilder(); sb.AppendLine(// auto-generated/); sb.AppendLine($namespace {model.Namespace};); sb.AppendLine($public partial class {model.TypeName}); sb.AppendLine({); sb.AppendLine( public override string ToString()); sb.AppendLine( {); sb.AppendLine($ return $\[{model.TypeName}] {{string.Join(\, \, new[]{{ {string.Join(, , model.Properties.Select(p $$\{p}{{{p}}}\))} }})}}\;); sb.AppendLine( }); sb.AppendLine(}); spc.AddSource(${model.TypeName}.g.cs, sb.ToString()); }); } } internal sealed record ToStringModel(string TypeName, string Namespace, string[] Properties);这段代码的关键是把定位目标、提取信息、生成代码三个步骤拆开。ForAttributeWithMetadataName是Roslyn 4.0以后非常推荐的API它替你做了语法树和语义模型的匹配只要传入Attribute的完全限定名就能拿到被标注类型对应的INamedTypeSymbol。2.3 在普通项目里接入并验证产物光写生成器还不够你要验证它真的生效。最直接的验证方式是在某个测试项目里引用生成器项目定义一个带有[AutoToString]的类然后编译。为了让生成的代码落地可见在被测试项目里打开PropertyGroup EmitCompilerGeneratedFilestrue/EmitCompilerGeneratedFiles CompilerGeneratedFilesOutputPath$(BaseIntermediateOutputPath)GeneratedFiles/CompilerGeneratedFilesOutputPath /PropertyGroup编译后打开obj/GeneratedFiles你会看到生成器输出的.g.cs文件。这一步非常重要因为如果你没有实际看过生成产物后面写复杂生成器时会像在盲人摸象。我一般会在生成器输出时加上// auto-generated/头避免代码分析器对生成文件发出警告。3. 增量机制不是玄学管线设计与性能取舍3.1 为什么编译会越跑越慢增量怎么救很多初学者写完生成器后发现一个尴尬问题加上生成器后每次编译都慢了很多。这就是没有理解增量管线导致的。Roslyn的增量生成器本质是一个数据流图每个Provider节点都有缓存输入没变时直接沿用上一次的中间结果只有变化的输入才触发后续重算。如果你在Select之后返回的是普通class对象而这个class没有实现值的相等性比较编译器就会认为结果“总是变化”导致下游全链路失效相当于每次全量生成。我在自己的项目里就踩过这个坑。解决方案是让中间模型使用record或实现IEquatableT并且集合元素不能直接用数组——数组的Equals是引用比较。社区里一个常见做法是自定义EquatableArrayT结构体用ReadOnlyMemory包装并实现逐个元素比较。3.2 定位目标类型的三种写法生成器里最常做的事就是“找出所有满足条件的类型”。定位方式有三种按推荐程度排序第一种是ForAttributeWithMetadataName直接按Attribute定位静态时几乎零开销语义模型节点自动缓存。第二种是CreateSyntaxProvider自己写语法谓词筛选节点再在语义谓词里做类型检查。第三种是CompilationProvider配合Compilation.GetSymbolsWithName严格按符号名匹配。大多数情况下第一够用只有当你需要根据方法名或类型名而非Attribute来定位目标时才用第二种。我建议不要把第三种写进主流程因为GetSymbolsWithName会遍历整个编译的符号表一旦目标程序集很大编译速度会非常痛苦。3.3 语义模型里“该拿的信息”与“不该拿的信息”增量生成器性能的另一个关键点是不要在Provider链里传递重量级对象。SemanticModel、Compilation这些对象包含大量内存引用不适合放进中间数据流你应在语义模型可用的一刻就把需要的信息提取成轻量模型之后所有管线只操作这个模型。举个例子如果你需要生成一个类的所有公开属性第一步在ForAttributeWithMetadataName回调里拿到INamedTypeSymbol后立刻把属性名称、类型全名、可空性等信息转成一个record而不是把INamedTypeSymbol一直往下传。同时还要注意生成器最好不依赖宿主编译环境之外的资源例如读磁盘文件、查环境变量。虽然技术上可以但这会破坏增量缓存的可预测性。如果确实要读项目文件或额外文件请用AdditionalTextsProvider和AnalyzerConfigOptionsProvider它们的设计就是干这个的。4. 我这套开源项目里踩过的坑一次性全抖出来4.1 生成器写了但没生效先查这三处几乎每个初次接触生成器的人都会遇到“代码没生成VS也不报错”的情况。我排查这类问题的顺序通常是第一检查生成器类是否有[Generator]特性并且类是public的。第二检查引用方式在测试项目里是ProjectReference在NuGet包里是PackageReference但都必须落在analyzers目录而非lib目录。第三检查生成器运行环境如果你在生成器代码里写了一个异常但没捕获整个生成器会静默失败不会暴露给你的主项目。我当时调试最崩溃的一次是生成器在本地测试工程里正常工作但打包成NuGet后无论如何不生效。最后发现是因为打包配置少了analyzers/dotnet/cs目录的映射dll被放进了lib目录。这就是为什么要在第2章强调IsRoslynComponent和打包项必须精确配置。4.2 Debugger.Launch不是万能的但确实很好用调试生成器不像调试普通代码那么直观因为你不能直接F5。最原始的方法是回到命令行用dotnet build跑起编译器进程然后在生成器代码里塞一句if (!System.Diagnostics.Debugger.IsAttached) System.Diagnostics.Debugger.Launch();编译到这句时系统会弹出调试器附加确认框选择你的Visual Studio实例即可断点进去。这个方法适合早期验证但它会打断命令行构建流程不能用于CI。更推荐的方式是写测试用例用CSharpGeneratorDriver在内存中执行生成器然后断言生成结果。这样既能调试也能作为回归测试保留下来。4.3 同名Attribute冲突和netstandard2.0的限制源码生成器社区里最经典的一个“命名空间雷区”是PostInitialization注入Attribute和用户自己声明同名Attribute的冲突。很多教程喜欢教你用context.RegisterPostInitializationOutput把Attribute源码注入编译单元这样做确实省事但一旦用户的代码里已经存在同名的Attribute编译器会报“重复定义”错误。我在开源项目里选择的是“Attribute单独打包”方案Attribute定义在独立的netstandard2.0库中作为正常程序集引用生成器只通过完全限定名匹配。这样用户代码中不会出现两份Attribute定义生成的代码也能正常引用到Attribute类型。另外记住生成器dll本身也受netstandard2.0限制不能用System.Text.Json这种新库的部分API。如果需要复杂序列化手动拼字符串或使用IndentedTextWriter。4.4 单元测试与快照测试的搭配要把生成器做好测试不是可选项而是必需品。我常用的测试方式是构造一段源码字符串创建CSharpCompilation引用基本的运行时程序集和Attribute程序集然后调用GeneratorDriver.RunGeneratorsAndUpdateCompilation再从输出编译中提取生成语法树进行断言。对于复杂生成逻辑快照测试更划算。用Verify这类库把生成结果保存为.verified.cs文件改动生成逻辑后只要跑一次测试就能直观看到差异避免我因为少加一个分号导致用户项目整个编译失败。5. 开源分包与落地让别人能用、敢用你的生成器5.1 NuGet包结构Analyzer包与Attribute包怎么拆如果只是自己项目里用ProjectReference就够了。但开源意味着你要发布NuGet包这时包的结构直接决定用户安装体验。一个标准的生成器NuGet包同时包含两部分analyzers/dotnet/cs/MyGenerator.dll生成器主程序集。lib/netstandard2.0/MyAttributes.dllAttribute定义程序集让用户代码能够引用[AutoToString]。如果项目只有一个生成器dll可以在csproj里用None节点打包ItemGroup None Include$(OutputPath)\$(AssemblyName).dll Packtrue PackagePathanalyzers/dotnet/cs Visiblefalse / /ItemGroup如果Attribute是独立项目则可以用一个打包项目把两边的输出合并。最简洁的方式是用MultiTarget打包项目或者直接用dotnet pack配合PackagePath指定。5.2 README与使用文档的“最小可用模板”开源项目能不能被人用起来一半取决于文档。源码生成器本来就比普通库多了一层“魔法”如果你的README不提清楚下面几件事用户大概率会在Issues区提问支持的.NET版本范围以及Roslyn版本要求。如何引用包PackageReference和ProjectReference的区别。如何开启EmitCompilerGeneratedFiles查看生成代码。生成的代码与你手写代码冲突时以哪个为准。一个从安装到跑起来的最小示例最好带截图。我见过不少不错的生成器项目最后死在“文档只介绍功能不介绍用法”上。尤其是.NET SDK版本不同会导致生成器加载行为有差异你必须在README里写清楚最低版本。5.3 处理用户反馈编译报错、版本升级、组合功能生成器开源后用户的反馈集中在这几类升级宿主项目版本后生成器失效、生成的代码和用户手写的partial类冲突、或者用户期望能配置生成开关却发现没有扩展点。版本兼容是最大头。Roslyn API在minor版本间也可能有行为变化当用户从VS 17.4升到17.8生成器可能依然工作但某些旧API会被标记过时。我现在的做法是在生成器代码里尽量只用稳定API并为不同Microsoft.CodeAnalysis版本做条件编译或提供多个包版本。还有个容易被忽略的设计决策是否提供“关闭生成”的开关。我建议在所有生成器里都支持通过AnalyzerConfigOptions或者[AttributeUsage]的构造函数参数来控制细节例如是否生成特定成员。这样用户遇到奇怪行为时可以先用开关降级到手写代码而不是只能卸载整个包。5.4 我眼中一个生成器项目的理想发展路径如果让我规划一个长期维护的开源生成器我不会一上来就追求“全能生成所有代码”。更靠谱的路径是先用一个单一、确定的小功能跑通全流程——比如自动生成ToString然后在这个基础上增加Attribute参数、可配置选项再把成功案例扩展到MVVM、DTO映射等高价值场景。这个路径的好处是每一层都给用户带来即时价值同时你的基础设施生成管线、测试框架、部署流程可以不断复用。我见过太多人第一个项目就想做“万能框架”结果在生成器本身的调试泥潭里就挣扎了两个月。6. 一段来自实战的补充经验到这里主体内容基本讲完了。最后再分享几个在实操中反复验证的心得它们不在任何官方文档里但都很实用第一个心得是生成器项目的启动成本比你想象的低。你不需要先精通整个Roslyn API只要会用ForAttributeWithMetadataName和RegisterSourceOutput就能写出有用的东西。剩下的知识可以边写边查网上有大量开源项目源码可以参考。第二个心得是永远不要迷信“生成器可以取代反射”。反射的运行时动态能力是生成器不具备的生成器的价值是“编译期确定性的元编程”两者适合的场景不同。不要在项目里为了用而用要针对具体痛点选型。第三个心得是把生成的代码当成公开API来设计。用户包括未来的你会依赖这些生成的成员所以命名、可空性、访问修饰符都要谨慎。一旦某个生成的成员在下一个版本改了个签名所有使用方都会被破坏。设计时多问一句“如果这东西是手写的我会怎么命名”如果你现在正被大量重复样板代码困扰我建议从给现有项目加一个最简单的Attribute和生成器开始先走通“标注→生成→验证”这个最小闭环。等你看到编译产物里真的多了一整段代码时那种“编译器为我打工”的感觉就是.NET社区里最独特的烟火气。
返回列表