.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 Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> <LangVersion>latest</LangVersion> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <IsRoslynComponent>true</IsRoslynComponent> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" /> <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" /> </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" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> </ItemGroup>注意两个关键点:OutputItemType="Analyzer"是把生成器 DLL 作为分析器交给编译器,ReferenceOutputAssembly="false"是告诉项目“这个引用不参与运行时程序集引用”。如果你漏了后一个,生成器程序集会被打进你的运行时依赖,非常坑。
随后你在目标项目里写:
Console.WriteLine(HelloGenerated.Hello.Say());能正常输出就说明接入成功了。
3.4 生成结果到底在哪:打开 EmitCompilerGeneratedFiles
很多人第一次跑生成器,四处找不到生成文件,以为没生效。其实默认情况下生成文件只存在于编译器内存里,磁盘上不落地。想查看,需要在目标项目里开一个开关:
<PropertyGroup> <EmitCompilerGeneratedFiles>true</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 .OfType<Microsoft.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(); }这段代码有一点值得注意:比较用的是EqualityComparer<T>.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、Count,IntelliSense 也有。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或者实现值相等,并且集合字段建议用只读数组或EquatableArray<T>包装。
还有另一个常见坑: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> <TargetFramework>netstandard2.0</TargetFramework> <IncludeBuildOutput>false</IncludeBuildOutput> <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking> <DevelopmentDependency>true</DevelopmentDependency> <IsRoslynComponent>true</IsRoslynComponent> <PackageId>AutoNotifyGenerator</PackageId> <Version>1.0.0</Version> </PropertyGroup> <ItemGroup> <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> </ItemGroup>几个配置逐一说清楚:
IncludeBuildOutput=false:默认 pack 会把程序集放到 lib,我们必须关掉,否则包里同时出现 lib 和 analyzers 两个位置的同名 DLL,引用方会困惑;SuppressDependenciesWhenPacking=true:生成器引用的Microsoft.CodeAnalysis.CSharp是编译环境自带的,不能让包去拉一遍依赖;DevelopmentDependency=true:表示这个包不参与下游传递依赖;None Include=... PackagePath=analyzers/dotnet/cs:手工把 DLL 放进分析器目录。
6.3 打包与本地安装验证
执行打包:
dotnet pack -c Release -o ./artifacts跑完去看artifacts/AutoNotifyGenerator.1.0.0.nupkg,用压缩软件打开,目录结构应该是:
analyzers/dotnet/cs/AutoNotifyGenerator.dll然后建一个测试项目,用本地源引用这个包。我习惯先把本地源加进 NuGet.Config:
<add key="local" value="D:\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 生成,甚至把团队内部的规范直接固化成生成器,让业务代码天然合规。我在后续项目里已经把一部分用户操作埋点代码交给生成器统一生成了,效果比人肉保证强太多。如果有人从某个由反射实现的框架迁移到源码生成器,你会明显感受到那种“运行时代码突然变得可见、可断点、可跳转”的踏实感。