ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

.NET源码生成器实战:基于Roslyn与partial范式打造AutoNotify生成器

.NET源码生成器实战:基于Roslyn与partial范式打造AutoNotify生成器 .NET 源码生成器Source Generator这两年已经从“高级黑魔法”变成我日常工作中相当依赖的常规武器了。它能让你在编译期间用 Roslyn 解析代码结构按规则自动生成新的 C# 源码并且这些源码会以 partial 类型的形式和手写代码合并在一起最终产出一个完整、可调用的类型。这篇文章我打算围绕 partial 范式这个核心思路从为什么需要它讲起再到动手写一个 AutoNotify 通知属性生成器最后把 NuGet 打包和发布验证的完整链路走一遍。不管你是被反射性能坑过、被成堆样板代码烦过还是单纯想给团队造点顺手轮子这篇都值得花十分钟看完。写之前先说明白这不是一篇 Roslyn API 手册我不打算把每个接口的签名都抄一遍。我会按自己做项目的实际顺序来讲哪些步骤是必要的、哪些地方最容易翻车、为什么这么设计都尽量说清这样你照着做就能跑起来。1. 样板代码逼我认识源码生成器从反射到编译期生成1.1 我原来是怎么被通知属性折磨的接触 WPF、MAUI 或 Blazor 的开发者基本都写过这样的属性private string _name; public string Name { get _name; set { if (_name ! value) { _name value; OnPropertyChanged(nameof(Name)); } } }一个两个还能忍当 ViewModel 里躺着二十几个属性、每个属性都是这段复制粘贴改个名字的代码时人很容易麻。团队里常见的解法有三类手写或者用代码片段生成量大之后漏写OnPropertyChanged是家常便饭上反射写一个[AutoNotify]特性在基类里通过反射统一处理运行时性能损耗不说AOT 场景直接不能玩接 AOP 框架在 IL 层面织入代码库的引入成本和调试复杂度都比较高。这三条路我都走过说实话各有各的问题。反射方案最坑的是你在 IDE 里跳转不到属性、性能损耗也隐蔽AOP 方案对团队要求高出了 bug 排查链路会很痛苦。1.2 源码生成器跟上面几种方案有什么本质区别源码生成器的做法很“笨”让编译器在编译时运行你的自定义代码扫描当前项目里的语法树和语义信息然后按你的规则输出额外的 C# 源码。这些源码不是 IL 层面的黑魔法也不是运行时反射而是明明白白的文本文件会跟手写源码一起参与编译、一起进程序集。它和传统思路的核心差异我整理成了一张表方案生效时机产物运行时依赖调试体验典型性能反射运行时无需要断点能跟上但隐藏开销多每次调用都有开销IL 织入 / AOP编译后IL 代码可能需要框架需要专门插件接近手写T4 模板编译前手动触发生成 .cs 文件无产物可见但流程外置接近手写源码生成器编译中生成 .cs 源码无产物可见、可打断点同手写一致对绝大多数业务场景来说源码生成器是在“开发体验”和“运行性能”之间平衡得比较好的选择没有运行时反射开销没有额外框架依赖生成的代码就是普通源码反编译出来干干净净。1.3 什么样的项目适合用生成器我用下来这几类场景最值XAML 系项目的 ViewModel 通知属性、命令属性DTO 映射、对象复制依赖注入容器的手写注册代码System.Text.Json 的源生成器模式这个官方已经在做了日志包装方法、枚举扩展、权限校验等重复模式。不适合的场景也很明确强业务逻辑、需要运行时动态决策的东西老老实实手写别把生成器当万能胶。2. partial 范式是生成器的地基为什么叫“生成一半手写一半”2.1 partial 类到底解决了什么问题“源码生成器基于 partial 范式”这句话很多教程是一笔带过的但我觉得这是整个机制的灵魂。partial关键字允许你把这个类型的定义拆到多个文件中编译时它们会被合并成同一个类型。为什么生成器非要依赖它因为生成器不会“修改”你手写的代码它只能“新增”代码。而业务代码里大量场景需要往现有类里塞成员——加一个属性、加一个通知方法。如果没有 partial生成器就只能在旁边新建一个类那样手写代码和生成代码就没有归属关系了很多需求根本实现不了。举个例子手写文件里声明了[AutoNotify] public partial class MainViewModel { private string _title; }生成器才有资格往同一个类里补充一个Title属性并且这个属性可以正常访问手写文件里的_title字段。整个过程不需要动你手写的那部分这就是 partial 范式的核心价值。2.2 partial method 的演进C# 9 是个分水岭生成器不仅依赖partial class很多时候还要配合partial method。这个概念值得展开讲。C# 9 之前partial 方法限制很多必须返回 void不能有访问修饰符不能有 out 参数如果没有任何一部分实现它编译器会直接移除对它的所有调用。这些限制让 partial 方法只适合做“轻量级回调钩子”。C# 9 开始解绑了这些限制partial 方法可以有返回值、可以声明private/public但同时要求“有声明就一定要有实现”。这个变化对生成器生态是重大利好因为生成器可以声明一个有访问修饰符的 partial 方法由手写代码提供实现从而形成一种“生成代码定义骨架、手写代码填细节”的协作模式。不过要提醒一句如果你需要的是“用户不实现也能跑”的回调还得用老式无修饰符 partial 方法或者通过虚方法绕一下这个是在设计生成器 API 时最容易纠结的地方。2.3 生成器代码和手写代码如何和平共处实际写生成器的时候需要注意下面几个“和平共处”的规则生成文件里的namespace必须和手写文件一致否则就是两个不同的类型生成的成员最好用下划线或特定前缀命名避免和手写成员冲突给生成文件加// auto-generated/头方便 IDE 和工具识别也避免代码分析器误伤生成代码也要写#nullable enable否则会因为编译器上下文不同出现空引用警告。还有一个反直觉但很重要的点生成代码不是“生成一次就固定了”。每次编译时都会重新生成所以你千万别手动去编辑输出目录里的生成文件改了也会在下次编译时被覆盖。3. 搭一个能跑的增量生成器最小项目与首次接入3.1 项目结构和 csproj 配置生成器本身是一个类库项目目标框架固定用netstandard2.0。为什么不用 .NET 8因为编译器进程跑在 .NET Framework 或 .NET 之上只有 netstandard2.0 才能同时兼容 VS 里的老式编译器进程和 dotnet CLI 的编译器进程这是 Roslyn 组件的事实标准。一个最小生成器项目的 csproj 大致这样Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknetstandard2.0/TargetFramework LangVersionlatest/LangVersion Nullableenable/Nullable ImplicitUsingsenable/ImplicitUsings IsRoslynComponenttrue/IsRoslynComponent EnforceExtendedAnalyzerRulestrue/EnforceExtendedAnalyzerRules /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.CodeAnalysis.CSharp Version4.8.0 PrivateAssetsall / PackageReference IncludeMicrosoft.CodeAnalysis.Analyzers Version3.3.4 PrivateAssetsall / /ItemGroup /Project这里我推荐直接引用Microsoft.CodeAnalysis.CSharp不要引整个Microsoft.CodeAnalysis减少一些不必要的程序集体积。IsRoslynComponent和EnforceExtendedAnalyzerRules是给 IDE 看的标记告诉分析器基础设施“这是一个生成器/分析器项目”能顺便打开一些针对生成器的强制规则比如禁止在生成器里执行 IO 操作。3.2 用 IIncrementalGenerator 写一个 Hello 生成器我第一次写生成器的时候用的是老的ISourceGenerator现在已经不推荐了。官方推荐用IIncrementalGenerator它引入了增量管线编译缓存利用更充分大项目里性能差异会非常明显。最小实现using Microsoft.CodeAnalysis; using System.Text; namespace HelloGenerator { [Generator(LanguageNames.CSharp)] public sealed class HelloGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { context.RegisterPostInitializationOutput(ctx { ctx.AddSource(Hello.g.cs, SourceText.From( // auto-generated/ #nullable enable namespace HelloGenerated { public static class Hello { public static string Say() hello from generator; } } , Encoding.UTF8)); }); } } }这段代码的效果是目标项目一编译就会自动多出一个HelloGenerated.Hello类。RegisterPostInitializationOutput适合生成完全固定的代码比如特性定义、常量类等。3.3 在目标项目里接入生成器有两种方式开发阶段我推荐项目引用ItemGroup ProjectReference Include..\HelloGenerator\HelloGenerator.csproj OutputItemTypeAnalyzer ReferenceOutputAssemblyfalse / /ItemGroup注意两个关键点OutputItemTypeAnalyzer是把生成器 DLL 作为分析器交给编译器ReferenceOutputAssemblyfalse是告诉项目“这个引用不参与运行时程序集引用”。如果你漏了后一个生成器程序集会被打进你的运行时依赖非常坑。随后你在目标项目里写Console.WriteLine(HelloGenerated.Hello.Say());能正常输出就说明接入成功了。3.4 生成结果到底在哪打开 EmitCompilerGeneratedFiles很多人第一次跑生成器四处找不到生成文件以为没生效。其实默认情况下生成文件只存在于编译器内存里磁盘上不落地。想查看需要在目标项目里开一个开关PropertyGroup EmitCompilerGeneratedFilestrue/EmitCompilerGeneratedFiles CompilerGeneratedFilesOutputPath$(BaseIntermediateOutputPath)GeneratedFiles/CompilerGeneratedFilesOutputPath /PropertyGroup设置之后重新编译去obj/GeneratedFiles目录下就能看到每个生成器的输出文件。这个开关在调试阶段强烈建议打开可以直观确认生成内容是否符合预期。4. 实战AutoNotify 生成器把重复的通知属性交给编译器4.1 需求和设计目标现在回到开头说的通知属性场景我们来做一个简化版但能实际用的生成器。设计目标很清晰手写代码里定义[AutoNotify]标记的 partial 类类里的字段标记[Notify]生成器自动为这些字段生成属性包装器在 setter 里触发OnPropertyChanged生成代码要与手写代码兼容不影响原有逻辑。本案例假定你的 ViewModel 基类已经实现了INotifyPropertyChanged并且暴露了protected virtual void OnPropertyChanged(string propertyName)方法。现实中也可以用 partial 方法让用户自定义实现但为了篇幅这里先用基类方案。4.2 定义特性与获取候选节点生成器需要识别标记而标记本身最好由生成器自动生成用户不用单独建文件。所以在RegisterPostInitializationOutput里顺便生成两个特性context.RegisterPostInitializationOutput(ctx { ctx.AddSource(AutoNotifyAttribute.g.cs, SourceText.From( // auto-generated/ #nullable enable namespace AutoNotify { [System.AttributeUsage(System.AttributeTargets.Class, Inherited false, AllowMultiple false)] public sealed class AutoNotifyAttribute : System.Attribute { } [System.AttributeUsage(System.AttributeTargets.Field, Inherited false, AllowMultiple false)] public sealed class NotifyAttribute : System.Attribute { } } , Encoding.UTF8)); });然后利用 Roslyn 提供的ForAttributeWithMetadataName一步到位筛选带有指定特性的语法节点完全不用手动匹配类名。这里写一个候选数据类private sealed record FieldModel(string FieldName, string FieldType, string PropertyName);在Initialize里接查询管线var classCandidates context.SyntaxProvider.ForAttributeWithMetadataName( AutoNotify.AutoNotifyAttribute, static (node, _) node is Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax, static (ctx, _) { var classDecl (Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax)ctx.TargetNode; var fields classDecl.Members .OfTypeMicrosoft.CodeAnalysis.CSharp.Syntax.FieldDeclarationSyntax() .Where(f f.AttributeLists.Any(a a.ToString().Contains(Notify))) .SelectMany(f f.Declaration.Variables.Select(v { var fieldType f.Declaration.Type.ToString(); var fieldName v.Identifier.Text; return new FieldModel(fieldName, fieldType, ToPropertyName(fieldName)); })); return new ClassModel( classDecl.Identifier.Text, classDecl.GetNamespace(), fields.ToArray()); }); context.RegisterSourceOutput(classCandidates, static (spc, model) { spc.AddSource(${model.ClassName}.generated.cs, SourceText.From(GenerateClass(model), Encoding.UTF8)); });ToPropertyName就做两件事去掉开头的下划线首字母转大写private static string ToPropertyName(string fieldName) { var trimmed fieldName.TrimStart(_); return char.ToUpperInvariant(trimmed[0]) trimmed.Substring(1); }4.3 生成完整类代码生成代码用字符串模板拼起来private static string GenerateClass(ClassModel model) { var sb new StringBuilder(); sb.AppendLine(// auto-generated/); sb.AppendLine(#nullable enable); sb.AppendLine($namespace {model.Namespace}); sb.AppendLine({); sb.AppendLine($ public partial class {model.ClassName}); sb.AppendLine( {); foreach (var field in model.Fields) { sb.AppendLine($ public {field.FieldType} {field.PropertyName}); sb.AppendLine( {); sb.AppendLine($ get {field.FieldName};); sb.AppendLine( set); sb.AppendLine( {); sb.AppendLine($ if (!global::System.Collections.Generic.EqualityComparer{field.FieldType}.Default.Equals({field.FieldName}, value))); sb.AppendLine( {); sb.AppendLine($ {field.FieldName} value;); sb.AppendLine($ OnPropertyChanged(nameof({field.PropertyName}));); sb.AppendLine( }); sb.AppendLine( }); sb.AppendLine( }); } sb.AppendLine( }); sb.AppendLine(}); return sb.ToString(); }这段代码有一点值得注意比较用的是EqualityComparerT.Default而不是!。因为!遇到重载运算符的引用类型会出问题且值类型装箱也有开销通用写法是这一点。4.4 运行结果长什么样消费端这样写using AutoNotify; using System.ComponentModel; public class MainViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; protected virtual void OnPropertyChanged(string propertyName) PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } [AutoNotify] public partial class MainViewModel : MainViewModelBase { [Notify] private string _title; [Notify] private int _count; }编译后生成的代码就是你熟悉的通知属性而且你的MainViewModel里直接能点到Title、CountIntelliSense 也有。EmitCompilerGeneratedFiles打开后能在 obj 目录看到完整产物。4.5 这个案例没解决的事上面的实现能跑但距离工程级还差几步没做语义校验比如字段是否 readonly类是否真的继承自带OnPropertyChanged的基类没处理多个变量声明[Notify] private int _a, _b;也没处理字段类型是数组等复杂情况。工程实践里这些可以通过 Roslyn 的SemanticModel取字段类型的完整符号避免字符串拼接出错。我这里的写法偏“玩具”但思路链路是完整的。5. 调试生成器直接断点、单测和增量陷阱5.1 在 Visual Studio 里给生成器下断点生成器跑在编译器进程里不像普通程序一句话 F5 就完事。实战中有两种断点方式我用得比较多的是把生成器项目设为启动项目然后让它在启动时拉起一个测试宿主。更推荐的做法是写一个测试项目在测试里用 Roslyn 的CSharpGeneratorDriver驱动生成器然后断言生成结果。这种方式可重复、可进 CI比手动开 VS 调试快得多。测试核心代码大致长这样var compilation CSharpCompilation.Create( Tests, new[] { CSharpSyntaxTree.ParseText(source) }, new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), MetadataReference.CreateFromFile(typeof(INotifyPropertyChanged).Assembly.Location) }, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var generator new AutoNotifyGenerator().AsSourceGenerator(); GeneratorDriver driver CSharpGeneratorDriver.Create(generator); driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); var generatedTrees outputCompilation.SyntaxTrees .Where(t t.FilePath.Contains(generated)) .ToList();这里有个细节直接new AutoNotifyGenerator()是IIncrementalGenerator需要调.AsSourceGenerator()包装后才能给CSharpGeneratorDriver用。5.2 增量生成器的缓存陷阱增量生成器内置了缓存但缓存只认你输入模型的“值相等性”。如果管道里传的是 class 且没有重写Equals每次编译都会触发重新生成增量优化直接失效。所以我的候选模型建议用record或者实现值相等并且集合字段建议用只读数组或EquatableArrayT包装。还有另一个常见坑AddSource的 hintName 重复。如果你遍历两个类但 hintName 都写成了generated.g.cs第二次调AddSource会直接抛异常。所以 hintName 一定要包含类名或者 GUID。5.3 生成代码报错怎么排查生成代码也参与编译错误会正常显示在错误列表里但定位过去要么跳不到文件要么跳到一个临时目录。我的排查套路是打开EmitCompilerGeneratedFiles去obj/GeneratedFiles看最终代码如果编译错误指向生成文件把这个生成文件复制到临时工程里手动改快速定位问题是拼接语法错误还是语义错误检查是不是漏了global::前缀这是生成代码最常见的命名空间污染源。6. NuGet 打包与安装验证让生成器变成团队共享轮子6.1 生成器包的目录结构原理普通类库打 NuGet 包DLL 放在lib目录项目引用后程序集自动进运行时。生成器包的 DLL 放在analyzers/dotnet/cs目录编译器会在编译时把这里的 DLL 当作分析器加载。也就是说打包的本质不是魔法而是把生成器 DLL 挪到包内正确的位置。6.2 pack 前的必要配置我实战用的 csproj 配置如下PropertyGroup TargetFrameworknetstandard2.0/TargetFramework IncludeBuildOutputfalse/IncludeBuildOutput SuppressDependenciesWhenPackingtrue/SuppressDependenciesWhenPacking DevelopmentDependencytrue/DevelopmentDependency IsRoslynComponenttrue/IsRoslynComponent PackageIdAutoNotifyGenerator/PackageId Version1.0.0/Version /PropertyGroup ItemGroup None Include$(OutputPath)\$(AssemblyName).dll Packtrue PackagePathanalyzers/dotnet/cs Visiblefalse / /ItemGroup几个配置逐一说清楚IncludeBuildOutputfalse默认 pack 会把程序集放到 lib我们必须关掉否则包里同时出现 lib 和 analyzers 两个位置的同名 DLL引用方会困惑SuppressDependenciesWhenPackingtrue生成器引用的Microsoft.CodeAnalysis.CSharp是编译环境自带的不能让包去拉一遍依赖DevelopmentDependencytrue表示这个包不参与下游传递依赖None Include... PackagePathanalyzers/dotnet/cs手工把 DLL 放进分析器目录。6.3 打包与本地安装验证执行打包dotnet pack -c Release -o ./artifacts跑完去看artifacts/AutoNotifyGenerator.1.0.0.nupkg用压缩软件打开目录结构应该是analyzers/dotnet/cs/AutoNotifyGenerator.dll然后建一个测试项目用本地源引用这个包。我习惯先把本地源加进 NuGet.Configadd keylocal valueD:\packages\artifacts /再dotnet add package AutoNotifyGenerator编译测试项目确认生成代码正常、目标项目里能点到生成的属性。6.4 依赖不被打进包的坑如果生成器内部用了第三方库比如 HardCodedString很常见的问题就是编译环境加载不到这个依赖。因为编译器进程不是你项目的运行时用户项目引用的库和编译器进程加载的程序集是两套。解决思路有两个尽量不使用第三方库纯 Roslyn API BCL 搞定非用不可时用 ILRepack 之类的工具把依赖合并进生成器 DLL再打进包或者手动把依赖 DLL 也放进analyzers/dotnet/cs目录。我个人强烈建议先想清楚能不能避免依赖因为每次合并依赖都会带来版本冲突隐患。6.5 版本与兼容性生成器包对 Roslyn 版本是有隐性要求的。比如你用了ForAttributeWithMetadataName就需要 Roslyn 4.3.0 以上意味着目标项目 IDE 必须足够新、SDK 版本不能太老。这个兼容面在团队里很难控制如果队友还在用旧版 Visual Studio生成器会直接不生效且没有任何友好提示。规避办法是尽量使用低版本 Roslyn 都支持的 API或者在Initialize里做版本检查不满足时通过Diagnostic报一个明显错误而不是让用户对着“啥也没生成”干瞪眼。7. 我把生成器用到实际项目后的几点体会最后分享几个用下来比较实在的经验。生成器是“给编译器写的代码”它和普通库代码的调试体验完全不同。我的习惯是先把生成器逻辑用普通类写好、用单元测试验证再把逻辑复制进生成器项目里接 Roslyn 管道。这样能少踩很多 IDE 断点不生效的坑。命名这件事比想象中重要。生成文件里的类型一定要加命名空间前缀字符串拼接阶段就把global::写死不要指望缩进和格式好看编译能过、语义正确永远是第一优先级。生成的代码要加// auto-generated/和#nullable enable否则接手的同事会看到一堆奇怪的警告体验很劝退。对被生成者也就是消费方来说生成器一旦接入整个项目就被“隐藏代码”包围了。打开EmitCompilerGeneratedFiles是必须养成的习惯遇到任何“为什么有这个成员”“这个成员哪来的”的问题先去obj/GeneratedFiles看一眼比猜快得多。自动通知属性只是生成器能力的冰山一角。你完全可以在此基础上做命令属性生成、数据校验包装、API Client 生成甚至把团队内部的规范直接固化成生成器让业务代码天然合规。我在后续项目里已经把一部分用户操作埋点代码交给生成器统一生成了效果比人肉保证强太多。如果有人从某个由反射实现的框架迁移到源码生成器你会明显感受到那种“运行时代码突然变得可见、可断点、可跳转”的踏实感。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进