
1. 为什么不用反射和T4而是选源码生成器1.1 被重复代码逼出来的需求大概在项目做到第四个微服务的时候我终于受不了了。每个服务里都有那么一堆类长得几乎一模一样DTO之间的字段映射、实现了INotifyPropertyChanged的通知属性、依赖注入的注册代码、枚举和字符串之间的转换。一开始觉得也就多写几行复制粘贴改改就行但迭代三个月之后随手改一个DTO字段就要在全工程里搜索五六处映射代码的日子确实太折磨人了。那会儿团队里有人提议用反射在运行时统一处理映射和通知逻辑。我们确实也写过一版跑起来功能没问题但有两个事情一直心里不踏实一是性能那段反射代码在接口调用量上来之后GC压力明显大了一圈二是安全路由映射那块用了反射之后好多类型错误从编译期变成了运行期经常是发布到测试环境才炸出来。后来又试过T4模板但T4的问题是生成时机太被动必须手动触发或者改动了模板才重新生成而且模板生成的代码和手写代码之间没有任何语法层面的绑定关系字段改了之后经常出现两边对不上。最后我停下来认真思考了一下需求本质要的不是运行时帮我做映射而是编译的时候就直接把映射代码写死。这就是源码生成器Source Generator最典型的应用场景。它能在编译过程中读取你的源代码分析语义模型然后往程序集里注入额外生成的代码。整个过程发生在编译期最终产物里就是普普通通的C#代码没有反射没有动态代理没有运行时的额外开销。1.2 反射、T4、运行时Emit的对比在决定方案之前我把市面上主流的路子都过了一遍这里直接把对比结论列出来方便你判断自己的场景该选哪个。方案生成时机运行开销类型安全与业务代码联动适用场景反射运行时有且GC压力明显不完整类型错误延期暴露弱改动后靠测试发现插件系统、动态类型处理T4模板设计时/手动触发无不完整字符串拼接为主弱字段变更不同步静态代码块生成一次性脚手架运行时Emit运行时首次生成有开销后续可缓存不完整弱动态代理、AOP框架源码生成器编译期无完整参与编译和错误检查强直接基于语义模型重复逻辑代码、序列化映射、通知属性源码生成器最核心的差异点在于它读取的是经过编译器解析的语法树和语义模型而不是简单的文本。这意味着生成器和你的业务代码共享同一套类型系统字段类型改了、属性重命名了编译器能立刻告诉你哪里对不上而不是等你写到一半才发现。1.3 源码生成器不是魔法它是编译期的帮手很多人第一次接触源码生成器会觉得这玩意有点像代码魔术。实际上它的工作原理并不神秘编译器在编译你的项目时会先做语法分析和语义分析建立起语法树和符号表源码生成器就挂在这个阶段拿到这些分析结果然后调用上下文里的AddSource方法把新的代码片段加入编译输入。这些新代码和你的手写代码一起再经过后续的编译步骤最终进入同一个程序集。关键点在于生成器是编译管道的参与者它必须遵循编译器的规则。比如生成代码里引用了不存在的类型编译照样报错。这个特性既是约束也是福利——它能保证生成代码的质量不至于搞出运行期才发现的低级错误。说到这里就该引出本文的核心范式了怎么让生成器生成的代码和手写代码能共享同一个类、互相互补而不是两边各玩各的。这个问题的答案就是partial。2. partial范式让生成器和手写代码共存的那条缝2.1 partial class生成器的“工作台”C#里的partial关键字很多人的印象可能还停留在把一个大类拆到多个文件里这种代码组织功能上。但站在源码生成器的视角partial真正强大的地方在于它给生成器留了一个接入点。设想一个很常见的需求——DTO映射。你手写一个类public partial class UserDto { public string UserName { get; set; } public int Age { get; set; } }如果这个类被标记成partial生成器就可以生成另一个同名的partial类文件在里面补充MapFrom、MapTo这类方法// 这部分代码由源码生成器自动生成 public partial class UserDto { public static UserDto FromEntity(UserEntity entity) { return new UserDto { UserName entity.Name, Age entity.Age }; } }编译之后两个文件的内容会合并成一个完整的UserDto类。手写代码管你自己的业务逻辑生成器管重复的样板代码互不干扰。这种模式最舒服的一点是你在IDE里写代码时intellisense完全有效点进去能看到生成器产出的全部成员没有任何黑盒感。2.2 partial方法从私有约束到公开契约单纯的partial class只是让两边能住在同一个类里真正把扩展点暴露给生成器的是partial方法。C# 9之前partial方法有诸多限制必须返回void、必须是私有的、不能有访问修饰符、不能有out参数。那时候源码生成器还很新这两种机制撞在一起能做的扩展比较有限。典型用法是通知属性public partial class ViewModelBase { public string Title { get _title; set { if (_title ! value) { _title value; OnTitleChanged(); OnPropertyChanged(nameof(Title)); } } } private string _title; partial void OnTitleChanged(); }如果没有任何类实现OnTitleChanged这个partial方法编译器会自动移除所有调用代码就是干净的。如果生成器给它补了一个实现体就能在这个钩子里塞入额外的逻辑比如自动保存或者联动刷新。C# 9之后partial方法的限制被大幅放宽可以有访问修饰符、可以返回非void、可以有out参数。如果声明了带访问修饰符的partial方法编译器就要求必须存在实现体否则报错。这从有则可选变成了必须实现的强契约。这套语法演进和源码生成器的需求几乎是一一对应的。我开发生成器时经常利用这个特性生成器在某个partial类里声明一个带public修饰的partial方法作为扩展钩子如果使用者在自己的partial代码里实现了它生成器生成的代码里就会调用它。这种模式下生成器和业务代码之间的关系不再是单向的我生成、你使用而是双向的你声明、我调用我生成、你补充。2.3 用partial声明“契约”换取编译期校验我见过不少人写生成器习惯让生成器自动去扫描所有类然后偷偷摸摸地改它们。这种做法最大的问题是隐蔽性太强使用者根本不知道生成器动了哪些代码出了问题排查起来非常痛苦。partial范式恰好可以矫正这个坏习惯。与其让生成器在项目里到处扫描不如让使用者在需要扩展的类上主动标记partial在需要暴露扩展点的地方主动声明partial方法。生成器只处理那些明示了要处理的类型。这种做法看起来似乎更麻烦但实际上它是在用一点声明成本换取整个项目的可预测性。生成器产出的代码是可预期的——使用者大概知道哪些类会被处理哪些不会编译器也会对生成代码进行类型检查写错了一目了然。我自己在维护生成器版本的时候这种显式声明的模型让我少踩了很多坑。推荐这条路径。3. 项目骨架与核心生成逻辑实现3.1 从零搭建生成器项目的csproj源码生成器本身也是一个.NET类库项目但它的目标框架必须选.NET Standard 2.0。原因很简单生成器要能够被不同版本的编译器宿主加载从老版本的Visual Studio到最新的.NET SDK.NET Standard 2.0是目前兼容性最好的选择。我的csproj文件长这样Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknetstandard2.0/TargetFramework LangVersionlatest/LangVersion Nullableenable/Nullable EnforceExtendedAnalyzerRulestrue/EnforceExtendedAnalyzerRules IncludeBuildOutputfalse/IncludeBuildOutput IsRoslynComponenttrue/IsRoslynComponent /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.CodeAnalysis.CSharp Version4.8.0 PrivateAssetsall / PackageReference IncludeMicrosoft.CodeAnalysis.Analyzers Version3.3.4 PrivateAssetsall / /ItemGroup /Project三个关键配置分别解释一下IsRoslynComponent告诉编译器这个程序集是Roslyn组件启用一些针对性的分析规则。IncludeBuildOutput设为false避免把生成器程序集作为普通依赖打进NuGet包的lib目录它应该放进analyzers目录后面细说。Microsoft.CodeAnalysis.CSharp核心依赖提供语法树、语义模型、符号操作等一系列API。注意把PrivateAssets设为all防止它作为传递依赖污染使用方的项目。3.2 用IIncrementalGenerator而不是老掉牙的ISourceGenerator.NET源码生成器刚出来时只能实现ISourceGenerator接口。后来微软推出了IIncrementalGenerator引入了增量管线的概念生成器的输入被切分成一个个可缓存的节点只有发生变化的节点才会重新计算。对于大项目这个性能差距非常明显。我两个接口都写过这里给你一个真实的数据感受在某个有几百个实体的项目里ISourceGenerator版本冷启动编译要额外增加3秒左右而IIncrementalGenerator版本基本无感知。如果你现在才动手做生成器直接跳过ISourceGenerator学IIncrementalGenerator。核心骨架[Generator(LanguageNames.CSharp)] public class MappingGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { // 1. 从语法树中筛选出标记了 [GenerateMapping] 的类 var candidates context.SyntaxProvider .ForAttributeWithMetadataName( MyGen.GenerateMappingAttribute, static (node, _) node is ClassDeclarationSyntax, static (ctx, _) (ClassDeclarationSyntax)ctx.TargetNode) .Collect(); // 2. 用 RegisterSourceOutput 将组合结果输出为代码 context.RegisterSourceOutput(candidates, static (spc, classes) { foreach (var classDecl in classes) { var source GenerateMappingSource(classDecl); spc.AddSource($Mapping_{classDecl.Identifier.Text}.g.cs, SourceText.From(source, Encoding.UTF8)); } }); } static string GenerateMappingSource(ClassDeclarationSyntax classDecl) { // 在这里读取类名、命名空间、字段列表拼装生成代码 // ... return string.Empty; } }代码里的注释有点简略实际开发时这里会有不少细节要处理。ForAttributeWithMetadataName是我强烈推荐的API它专门根据指定的特性名筛选节点性能比手动遍历语法树要高出几个量级。3.3 语法树解析的实用细节生成器要干活第一件事就是读懂使用者的代码。这不像文本匹配那样简单因为你需要的是语义层面的信息类有哪些属性属性的类型是什么是否实现了某个接口这些都要通过语义模型来获取。这里我给个具体的例子演示怎么获取一个类的所有属性static IReadOnlyListIPropertySymbol GetProperties(SemanticModel semanticModel, ClassDeclarationSyntax classDecl) { var model semanticModel.GetDeclaredSymbol(classDecl); if (model is not INamedTypeSymbol symbol) return Array.EmptyIPropertySymbol(); return symbol.GetMembers() .OfTypeIPropertySymbol() .Where(p !p.IsStatic p.DeclaredAccessibility Accessibility.Public) .ToArray(); }注意一个我最初踩过的坑获取类成员不要只用类声明语法节点的Members属性。GetMembers()返回的是这个类型的全部成员包括继承来的、隐式的。而语法层面的Members只能看到自己写的这几行两者结果差别很大。我因为这个问题一开始生成出来的代码总是少处理了继承属性。还有一个关于命名空间的细节。生成代码里你需要正确写出命名空间声明但类可能嵌套在namespace里也可能没有namespace。稳妥的做法是向上遍历语法节点找namespacestatic string GetNamespace(SyntaxNode? node) { while (node is not null and not NamespaceDeclarationSyntax and not FileScopedNamespaceDeclarationSyntax) node node.Parent; // 再处理嵌套namespace的拼接 // ... }这种基础工具函数值得积累后面每个生成器都用得上。3.4 调试生成器一个被低估的救命技源码生成器的调试体验比较特殊——它运行在编译器进程内不是普通的应用程序。如果你直接F5运行大概率什么都不会发生。我常用的调试手段有两个第一个是Debugger.Launch()。在Initialize或者执行生成逻辑的代码里写上这句话然后用Debug配置编译使用方项目运行到这一行时系统会弹出调试器附加的对话框附加之后就能正常打断点、看堆栈了。第二个是输出诊断信息。在RegisterSourceOutput回调里调用spc.ReportDiagnostic往编译器的错误列表里写一条提示信息这样即使不挂调试器在输出窗口也能看到生成器的执行痕迹。context.RegisterSourceOutput(candidates, static (spc, classes) { spc.ReportDiagnostic(Diagnostic.Create( new DiagnosticDescriptor( MYGEN001, MappingGenerator, $开始生成共 {classes.Length} 个候选类, Generator, DiagnosticSeverity.Info, isEnabledByDefault: true), Location.None)); // ... });这条诊断会直接显示在使用方项目的错误列表窗口里相当于生成器给自己打日志。我开发的时候经常靠这种日志快速确认生成器到底有没有跑起来省掉了一大堆猜测。还有一个容易忽略的配置如果使用方项目启用了EmitCompilerGeneratedFiles生成器产出的代码会落盘到obj目录你可以直接打开看看生成结果。PropertyGroup EmitCompilerGeneratedFilestrue/EmitCompilerGeneratedFiles /PropertyGroup这条配置在开发生成器阶段是神器强烈建议开着能直观看到生成代码长什么样、是否和你预想的一致。4. NuGet打包把生成器变成可分发资产4.1 打包目录结构背后的原理生成器开发完成重头戏在分发。如果你直接把生成器项目打包成普通NuGet包使用方引用它之后会发现生成器根本不会生效。原因在于NuGet包的目录约定。普通代码库DLL放在lib目录下被作为程序集引用源码生成器或分析器DLL必须放在analyzers/dotnet/cs目录下编译器才会把它作为生成器组件加载。这两种目录在打包时位置不同、用途完全不同。一个规范的生成器包内部结构大致长这样MyGen.1.0.0.nupkg ├── analyzers/dotnet/cs/MyGen.dll // 生成器主程序集 ├── lib/netstandard2.0/ // 通常为空仅占位 └── _._ // 可选标记空lib的占位文件为什么有_._这个文件这是NuGet约定表明这个包里没有实际的lib程序集依赖可以避免使用方项目产生不必要的程序集引用。4.2 csproj打包的最小配置清单在csproj里做NuGet打包我一般会这样配置PropertyGroup PackageIdMyGen/PackageId Version1.0.0/Version Authorsyour-name/Authors Description一个基于partial范式的源码生成器用于自动生成DTO映射代码。/Description PackageTagssource-generator;dotnet;mapper/PackageTags !-- 关键不把生成器DLL放进lib而是放进analyzers -- IncludeBuildOutputfalse/IncludeBuildOutput TargetsForTfmSpecificContentInPackage$(TargetsForTfmSpecificContentInPackage);_AddAnalyzersToOutput/TargetsForTfmSpecificContentInPackage /PropertyGroup Target Name_AddAnalyzersToOutput ItemGroup TfmSpecificPackageFile Include$(OutputPath)\$(AssemblyName).dll PackagePathanalyzers/dotnet/cs/PackagePath /TfmSpecificPackageFile /ItemGroup /Target核心逻辑就是两条关闭默认的lib输出把构建产物手动添加到analyzers目录。如果你使用dotnet pack命令这个Target会自动在打包时执行。这里有个容易踩的坑如果你引用了Microsoft.CodeAnalysis.CSharp的特定版本而这个版本依赖了较新的Roslyn运行时使用方项目的编译器版本不够新时生成器在编译期可能会直接抛出异常。我遇到过类似的情况当时使用方项目还挂在.NET 6的SDK上而生成器依赖了Roslyn 4.8的API一编译就崩。后来我把代码改成只使用Roslyn 4.0级别的API同时声明最低支持版本是.NET 6问题才解决。4.3 本地打包验证的完整流程每次改动生成器后我都有一个固定的验证流程这里分享给你先在生成器项目目录下执行打包命令dotnet pack -c Release然后使用方项目的csproj里加上本地包源PropertyGroup RestoreSources$(RestoreSources);C:\local-nuget-feed/RestoreSources /PropertyGroup或者你可以在使用方项目旁边建一个LocalPackages目录把nupkg文件拷进去然后通过nuget.config指定这个目录为唯一的包源。两种方式我都用过如果只需要给一个项目做验证直接设置RestoreSources最省事。验证通过的判断标准有三条第一使用方项目能正常构建没有编译错误第二EmitCompilerGeneratedFiles生成的obj目录里有对应的.g.cs文件第三实际调用生成代码的功能逻辑符合预期。这三条缺一不可尤其是第三条一定要写一个简单的单元测试覆盖生成的功能不然改动生成器之后很容易哪天突然发现映射逻辑悄悄变了。5. 实测效果与维护心得5.1 接入前后的代码量与启动性能对比生成器做完之后我在一个真实项目里做了次对比测试。这个项目有42个业务实体、23个DTO、26个枚举转换器改造前手写的映射和转换代码大概在2800行左右。接入源码生成器之后手写代码只保留了实体的定义和partial标记大约400行。编出来的程序集里生成器补充的代码自动补齐了剩余的2400行。关键性能指标上因为生成的是普通静态方法都不需要额外出库接口查询耗时基本没有变化启动时间反而因为去掉了反射初始化阶段实测快了约50ms。对于服务端应用来说体感不算明显但GC压力下降是可测量的。5.2 生成器迭代中的兼容性维护生成器代码一旦发布出去升级时就得格外小心因为它是在别人的编译进程中运行的。我的几条经验是第一新增API不要改签名。生成器对外暴露的特性比如[GenerateMapping]如果改了参数顺序或者行为语义使用方项目编译不会立刻报错但生成出来的代码可能变得不符合预期。这种静默变化最麻烦。所以我的原则是能加新特性不改旧特性。第二不同Roslyn版本要做好兼容。编译器API在不同版本间会有些微调。如果你的项目要支持全范围的.NET版本建议在CI里建几个矩阵测试用不同的SDK编译使用方项目确保生成器在所有目标版本下都能正常工作。这个坑我实打实踩过当时只在.NET 8上测试通过发布出去后有人用.NET 6一编译就炸排查了很久才发现是API兼容性问题。第三过滤器一定要写在生成器内层。很多生成器的性能问题都出在候选节点筛选阶段。如果筛选条件太宽泛每次编译都会把大量不相关的类拉进来做语义分析项目一大就卡。我的策略是先用语法层面的快速判断比如节点类型、特性名做第一层筛选只有通过第一层的节点才进入语义分析阶段这样能让99%的无关节点在语法层就被过滤掉。5.3 我个人的几条实操建议如果你打算在项目里引入源码生成器以下是几条来自实践的忠告先从小而确定的场景切入别一上来就搞通用框架。选择一个重复度最高、最容易验证的场景比如DTO映射、通知属性做出最小可用版本再逐步推广。生成器代码要写清楚日志和诊断信息。编译器窗口是开发者唯一能看到生成器状态的地方诊断信息写清楚了使用者出了问题才能自己排查。务必为生成的功能写单元测试。生成器本身的正确性可以通过生成器单元测试来验证但更重要的是使用方项目中要有对生成代码的集成测试保证生成逻辑和业务逻辑的联动是正常的。别用字符串拼接生成复杂代码用SyntaxFactory或者至少缩进良好的字符串模板。字符串拼接代码一旦逻辑复杂很容易拼出语法错误而且难以排查。我自己的习惯是搭配IndentedTextWriter来生成缩进规整的代码文本可读性高也方便在落盘时检查。回看整个项目我对当初选择partial范式和源码生成器的判断还是满意的。它解决了一个很实际的问题——把编译器的力量用到了极致用可预期的契约换取重复代码的自动化。这种方式带来的收益不是一次性的之后每加一个字段生成器都会自动跟着更新再也不用担心漏改哪处映射。对于深受重复代码折磨的.NET开发者这确实是一条值得投入的研究方向。