干过这几年 .NET 项目,要说最值得投入的技术方向,源码生成器绝对排在我列表的前三。尤其是基于 partial 范式去设计生成逻辑,再把它打包成 NuGet 分发给团队使用,这一整套流程,做完一次以后基本就离不开了。很多人对源码生成器的理解停留在“自动生成一堆代码”这个层面,但真正把它做成一个可维护、可交付、能进 CI 的工程化能力,中间涉及的细节远比想象中多。我今天就围绕 partial 范式开发、打包分发、以及踩坑经验这几个方面,把这套完整链路从头到尾捋一遍。
先说清楚这个内容适合谁。如果你已经在项目里写过最简单的ISourceGenerator,想弄清楚为什么大家都要用 partial,以及怎么把一个生成器从“自己身边能跑”变成“打进 nuget 包让整个团队都能用”,那这篇文章就是给你准备的。如果你是完全零基础,看到“源码生成器”这个词就发怵,我建议先把 C# 的 partial class 和属性定义这些基础补一补,再回来读,理解起来会顺畅很多。下面我尽量用实际操作里的经验来讲,不扯太虚的原理。
1. partial 是一切生成逻辑的地基:为什么生成器非要和 partial 绑定
1.1 编译器合并机制:手写部分和生成部分如何拼成一个类型
Roslyn 源码生成器有一个绕不过去的边界条件:生成器在编译期向编译单元里追加的代码,不能凭空定义出一个和现有类型完全重名的新类型。如果你在一个 namespace 下手写了一个UserController,然后生成器又生成了一个独立的UserController,编译器直接给你报 CS0111 之类的类型冲突错误。这时候 partial 就是唯一的合法通道。
partial class 的本意是把一个类型拆到多个文件里,编译器在最终编译时会按照类型的全名把它们合并成一个完整的类型。源码生成器利用的就是这个合并机制:开发者在项目里手写类型的一部分,生成器负责补上另一部分,两者通过 partial 关键字建立“我们是同一个类型”的契约。听起来简单,但这个契约一旦定下来,整个项目的代码组织方式都会跟着改变。
举个例子。我们经常要写一类数据传输对象,字段经常变,但又想避免手写一堆重复的属性样板:
// 手写的部分:只负责声明数据契约 namespace MyApp.Models { public partial class UserDto { public string Name { get; set; } public int Age { get; set; } } }生成器负责生成的部分则可能是:
// 生成的代码:根据 UserDto 结构的元数据自动补全 namespace MyApp.Models { public partial class UserDto { public string DisplayName => $"{Name} ({Age}岁)"; public bool IsAdult => Age >= 18; } }两个文件合并之后,UserDto同时拥有手写的属性和生成的成员,使用方完全感知不到哪些成员是手写的、哪些是自动生成的。这种做法的价值在于:手写部分专注于业务数据的稳定性,生成部分专注于可推导的逻辑。
1.2 为什么说 partial 是生成器唯一安全的“落地窗口”
如果只从技术约束上看,生成器不依赖 partial 也能工作,比如它可以生成一个全新的、不重名的类型。但真实项目里,生成出来的代码几乎总是要和手写代码交互的。你生成一个帮助类,手写代码要调用它;你生成一个实现类,手写代码要实现它的接口;你生成一个 Partial 扩展,手写代码得能无缝访问内部成员。这些场景没有 partial 就会卡死。
我参与过一些项目,早期生成器采用的是“生成完整独立类型”的做法。结果每次手写代码和生成代码之间互相调用,都要通过 public 接口去绕,绕来绕去 IDE 的智能感知还经常因为生成时机出问题,调试起来特别痛苦。后来改成 partial 范式,手写类直接声明为 partial,生成器补全成员,代码结构一下子清晰了。
这里还有个特别重要的场景:partial 方法。C# 9 之前,partial 方法有很多限制,比如只能返回 void、不能有访问修饰符、不能有 out 参数。现在这些限制大幅放宽,生成器完全可以利用这个能力设计“扩展点”。生成器生成一个方法的签名(无方法体或者只有默认实现),开发者可以再手写一个对应的实现,编译器负责把两个部分合并到一起。这个机制比较像插槽,生成器留口子,手写代码填内容,双方都不用知道对方的完整实现细节。
说白了,partial 不是生成器的“可选项”,而是生成器和手写代码共存时最符合编译器工作方式的协作协议。这个认知到位了,后面设计生成器的 API 边界时就会自然偏向 partial 范式。
2. 增量生成器的骨架设计:从语法接收器到语义模型
2.1 新一代 IIncrementalGenerator 为什么更适合 partial 场景
我最初写源码生成器的时候用的是老的ISourceGenerator接口,每次编译都要重新跑一遍全量分析,稍大一点的项目编译时间肉眼可见地变长。后来切到IIncrementalGenerator,体验完全不同。它能把输入数据按来源拆成一个个独立的“数据管道”,只要某个管道的输入没变化,对应的中间结果就能复用,不必每次重新计算。
对应 partial 范式,这个特性非常关键:手写部分和生成部分的代码经常一起变,但很多时候变的只是手写部分,生成部分完全没动。增量生成器可以做到只重新分析变化的那部分,没变的部分直接命中缓存,编译速度稳定很多。
推荐的做法是注册ForAttributeWithMetadataName。这是 Roslyn 提供的一个按特性名筛选的入口,比如:
[Generator] public sealed class MyGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var candidates = context.SyntaxProvider .ForAttributeWithMetadataName( "MyApp.GeneratePartialAttribute", static (node, _) => node is ClassDeclarationSyntax, static (ctx, _) => ctx.TargetSymbol as INamedTypeSymbol) .Where(static symbol => symbol != null); context.RegisterSourceOutput(candidates, static (spc, symbol) => { var source = GeneratePartialCode(symbol); spc.AddSource($"{symbol.Name}.g.cs", SourceText.From(source, Encoding.UTF8)); }); } }老接口下常用的SyntaxReceiver需要自己遍历语法树、自己匹配类名,写起来啰嗦还容易漏。ForAttributeWithMetadataName把“按特性筛选类型”这件事收敛成了框架能力,代码量和出错概率都下来不少。
2.2 数据管道里的每一步都要避免副作用
既然叫增量生成器,那所有参与管道的函数都必须遵守一个原则:相同输入必须得到相同输出,不能依赖外部状态。我见过有人为了性能在生成器里加静态缓存,结果因为多编译并行执行,缓存数据串了,生成的代码内容跟实际语法树对不上,排查起来非常痛苦。
具体到 partial 范式的项目,需要特别注意的地方就是:如果你要根据某个 partial 类上的多个特性去追加生成代码,先把所有特性值收集成一个不可变的模型对象,再进入后面的生成步骤。不要在整个管道里散落着一堆对 compilation 的全局访问,不然增量缓存根本没法判断什么时候该失效。
我自己习惯的做法是做一个自定义的Model类,里面只放生成代码必需的字段,比如命名空间、类型名、特性参数、成员列表。这个模型类全程只读,管道里任何一步都只基于模型输入产生模型输出。这样既方便缓存,也方便写单元测试。
internal sealed record PartialModel( string Namespace, string TypeName, IReadOnlyList<string> PartialMembers);这种“建模驱动”的设计,前期看起来多写了一点代码,后期调试增量失效、排查生成内容异常时,价值完全是碾压式的。
3. 生成代码的语法细节与编译期易错点
3.1 字符串拼接生成和 SyntaxFactory 生成怎么选
很多刚接触源码生成器的人会纠结:生成代码到底是直接拼字符串,还是用 Roslyn 的SyntaxFactory构造语法树?我的结论很明确:90% 的常规生成器用字符串拼接就够了,语法树构造更适合那些需要后续对生成的代码形态做复杂分析的边缘场景。
字符串拼接看似不优雅,但它有几个实打实的优势:代码可读性好、容易套模板、调试时可以直接把生成的字符串打印出来看。配合 C# 的原始字符串字面量,生成多行代码非常顺手。只要注意规范性,比如缩进统一、命名空间完整、分号不要漏,字符串拼接完全能保证准确性和可维护性。
比如前面那个例子,GeneratePartialCode方法内部,我会这样拼:
private static string GeneratePartialCode(INamedTypeSymbol symbol) { var ns = symbol.ContainingNamespace.ToDisplayString(); var name = symbol.Name; return $$""" // <auto-generated /> namespace {{ns}} { public partial class {{name}} { public string GeneratedMarker => "generated-from-generator"; } } """; }原始字符串字面量和插值配合,模板里的{{ }}直接代入值,整个生成逻辑看起来跟普通文本模板一样清楚。需要动态拼多个成员时,先把成员片段用string.Join拼好,再嵌进整体模板里。
3.2 生成代码写出“死代码”的三种常见原因
即使有编译器把关,生成器开发的排错也是出了名的麻烦。我列几个自己踩过、也在同事代码里见到过的典型问题。
第一个是partial 方法签名不匹配。C# 里一个 partial 方法若要被调用,它必须有实现,或者说它必须被“实现”才会把调用语句保留。生成器生成的方法名、参数列表如果和手写实现有一丁点出入,编译器不会立刻报错,而是当作两个不同的方法处理,结果就是生成方法没有实现、手写实现又无法被识别,日志里留下一堆挖不出来的坑。这时候最好的办法是写一个小的端到端测试项目,用编译断言去验证生成的代码里确实有对应的实现。
第二个是命名空间不一致。曾经我把生成器里输出类型的命名空间写死,结果类被放到手写代码引用不到的地方。IDE 里看不出问题,一编过全部报“找不到类型”。后来所有命名空间一律从INamedTypeSymbol的ContainingNamespace取,绝不手写字面量。
第三个是生成类型与手写类型的方法重名。partial 合并时的成员冲突在编译期才会暴露,而且报错信息有时候比较误导。我在生成代码里会刻意加上能标明来源的成员名后缀,比如GeneratedXxx,或者干脆给生成的方法加一个独特的前缀。这样即使将来命名规则变化,冲突也更容易定位。
3.3 如果生成代码本身出现问题,怎么尽早发现
生成器开发最大的痛点是“生成阶段成功了,但生成出的代码编译失败”。编译器错误信息里可能连生成器文件的具体哪一行都不给,调试很痛苦。我的经验是,在开发阶段就让生成器把产出内容写到项目目录里,而不是只交给编译管道。可以直接在Debug配置下输出到一个临时目录,人工检查生成结果。
更可靠的方式是集成编译验证测试。把生成器写成一个独立类库,然后建一个专门用于测试的消费项目,在里面引用生成器并通过Microsoft.CodeAnalysis.CSharp的 API 直接执行一次编译。只要生成的代码有问题,测试里就能拿到完整的诊断信息。
这个配套测试结构一开始搭建需要一点时间,但后面每次改生成规则都能快速回归。它是整个源码生成器工程化里我认可度最高的投入之一。
4. NuGet 打包流程与分发闭环
4.1 只有生成器程序集进了包可不行,打包结构要说清楚
源码生成器不是普通的类库,它的消费方式比较特殊:需要通过 NuGet 包分发,但包里的主要内容不是运行时程序集,而是生成器程序集和配套的构建脚本。我做第一版打包的时候只把生成器 DLL 塞进了 lib 目录,结果消费项目引用后根本没有任何生成效果,折腾了很久才明白问题出在哪。
正确的做法是,把生成器打包成一个纯 analyzer 风格的 NuGet 包。项目文件里要明确声明生成器 DLL 放在analyzers/dotnet/cs目录下,同时把IncludeBuildOutput关掉:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> <IncludeBuildOutput>false</IncludeBuildOutput> <NoPackageAnalysis>true</NoPackageAnalysis> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" /> </ItemGroup> <ItemGroup> <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> </ItemGroup> </Project>这里有几个关键点需要重点解释。
第一个是TargetFramework 必须是 netstandard2.0。生成器要能在不同版本的 .NET SDK 里被加载,netstandard2.0 是兼容性最好的目标框架。很多人的生成器项目一开始顺手定成了 net8.0,结果换到另外一台机器上的老 SDK 环境直接加载不了。
第二个是Microsoft.CodeAnalysis.CSharp 要标记 PrivateAssets="all"。这个依赖是编译器自带的,不需要也不应该传到消费项目里。打包时如果不设置,可能会造成 NuGet 依赖解析冲突。
第三个是IncludeBuildOutput 必须设为 false。因为生成器 DLL 已经手动指定放到 analyzers 目录了,不再需要默认的 lib 输出。如果不关,包里会同时出现 lib 和 analyzers 两份程序集,行为很混乱。
4.2 自动挂载机制:为什么有时候生成器没生效
很多团队是在组内分发私有 NuGet 包,流水线把包推上去,其他人一引用,生成器就该自动工作。可实际经常出现这种情况:包确实装上了,项目里却没有任何生成代码。
根源在于,NuGet 默认不会把一个包里的 analyzer 自动传递给目标项目。要让引用方自动加载生成器,包里必须带一个.props或.targets文件,把生成器 DLL 显式加进项目编译过程的 analyzer 列表。
对于 .NET SDK 风格的项目,最省事的做法是把一个buildTransitive属性文件放进包。比如在生成器项目里加:
<ItemGroup> <None Include="buildTransitive\MyGenerator.props" Pack="true" PackagePath="buildTransitive\MyGenerator.props" /> </ItemGroup>props 文件里的内容如下:
<Project> <ItemGroup> <Analyzer Include="$(MSBuildThisFileDirectory)..\analyzers\dotnet\cs\MyGenerator.dll" /> </ItemGroup> </Project>为什么用buildTransitive而不是build?区别在于传递性。build目录下的文件只在直接引用包的项目里执行,如果项目 A 引用了包,项目 B 再引用项目 A,B 并不会继承这个 analyzer。buildTransitive目录则会把构建逻辑传递下去。团队内部包往往会被多个上层项目间接引用,所以buildTransitive更稳。
4.3 多目标框架、私有依赖和版本冲突的坑
打包源码生成器比打包普通工具库更容易踩版本冲突。生成器和编译器分析器一样,依赖的 Roslyn API 版本最好跟 SDK 自带版本保持兼容。如果生成器代码里用了某个特别新的 API,而消费项目用的 SDK 版本过低,运行时可能直接抛MissingMethodException,而且错误信息非常难懂。
我的建议是:生成器项目尽量引用较低但可用的 Roslyn 版本。比如Microsoft.CodeAnalysis.CSharp4.8 或 4.9 这一代,能在大量 .NET 6/7/8 项目里稳定加载。不要随便追最新版,除非你能确认团队所有消费项目都已经升级到了对应 SDK。
还有一个坑是:生成器本身如果引用了其他依赖库,比如某个序列化库,那么打包时必须把这些依赖一起考虑。简单情况可以通过 ILMerge 合并,或者把依赖 DLL 也放进 analyzers 目录。但合并 Roslyn 相关的程序集风险较高,一般不建议。更多时候,我会尽量让生成器只依赖 Roslyn 自身提供的 API,不引入额外第三方运行时依赖,把冲突风险降到最低。
4.4 包的验证流程:不用“装好就断言成功”
给团队发布生成器包的时候,千万不能只看“包安装成功”就宣布完成。生成器这种编译期组件,失败是静默的,安装成功和实际工作完全是两回事。
我个人的验证流程大概是这么几步:
- 建一个空白的类库项目,引用刚打好的 NuGet 包。
- 在手写代码里使用规定的特性或 partial 声明。
- 编译整个项目,检查生成的代码文件有没有出现。
- 在生成的代码文件里手动加一个故意写错的符号,触发编译错误,确认生成器确实参与了编译。
- 如果生成器支持增量缓存,再连续编译两次,确认第二次没有报错也没有慢到离谱。
这一步的最后一环尤其重要。因为增量生成器在实际项目中很容易遇到缓存命中的边界问题。如果测试项目里第一次编译成功了,第二次改动手写代码后却没有任何生成更新,说明管道里的缓存失效条件没写对。打包前发现还能当场改;发布到团队里再发现问题,就只能等下个版本了。
5. 实战经验补充:增量缓存、并发安全与扩展点设计
5.1 增量缓存在 partial 场景下容易忽略的失效条件
我之前维护过一个生成器,功能是根据 partial 类上的特性自动生成事件订阅代码。起初用着没问题,后来同事反馈:在某个类上加了新事件字段,生成的内容却没有跟着变。排查了很久,最后发现是因为管道里用于筛选的 predicate 只匹配了“类声明”和“特性声明”,没有把类内部的字段列表纳入输入。
Roslyn 的增量缓存判断其实比较敏感,只要某个输入的语法节点变化了,相关管道的输出就会重新计算。问题是我当时把“当前类的所有成员”这个信息放在了后面才访问,前面筛选阶段只返回了INamedTypeSymbol本身,而INamedTypeSymbol如果没有被正确纳入模型,缓存系统可能误判为没有变化。
这里的教训是:一切可能影响最终生成内容的输入,都必须显式进入管道数据模型。比如要参考事件字段生成代码,就把字段列表快照出来放进模型;要参考特性参数,就把参数值提取到模型。宁可模型大一点,也不能让缓存漏判。
5.2 生成器并发下的全局状态管理
源码生成器项目在大型解决方案里会被并行加载到多个编译会话中。同一个生成器实例、不同编译会话之间,绝对不能共享可变状态。我刚接触生成器时犯过一次错:用静态ConcurrentDictionary缓存类型全名和生成结果的映射,想省时间。结果两个并行编译同时跑,最终产物互相覆盖,生成代码变成了另一个项目的样子。
正确做法很简单:把所有计算状态都保存在管道的模型对象里,模型对象本身 immutable,生成函数只依赖传入参数。要缓存,就依赖 Roslyn 增量机制缓存管道的某个中间节点,而不是自己引入额外字典。
可能有人觉得 performance 上不行,但实际测试下来,大部分凭模型驱动的生成器在增量机制下已经足够快。真要提性能,重点应该放在降低模型转换的复杂度,而不是搞可变缓存。
5.3 把 partial 方法设计成可扩展点的小技巧
partial 范式的精髓不只是 class 类型的拼接,更值得深挖的是 partial method。利用 C# 9 之后的增强,我在生成器里经常这样设计:
// 生成的代码 public partial class OrderService { public void SubmitOrder(Order order) { BeforeSubmit(order); // ... 生成器自动生成的订单提交逻辑 AfterSubmit(order); } partial void BeforeSubmit(Order order); partial void AfterSubmit(Order order); }开发者在手写代码里可以提供BeforeSubmit或AfterSubmit的实现,生成器完全不需要知道实现细节。这种设计就像给生成代码留了几个“钩子”,手写逻辑和生成逻辑通过 partial 方法解耦。比起接口回调和事件订阅,这一套方式在阅读代码时更直观,没有多余的调度开销。
需要注意的是,partial 方法的实现必须同样以 partial 方式书写,且签名要完全一致。我一般会在生成代码的注释里直接写出可选实现示例,减少使用者踩签名不一致的坑。
5.4 生成代码与手写代码的职责边界如何划分
一个常见问题是:到底哪些逻辑该放生成器里,哪些该留在手写代码里?我的判断标准很简单。凡是能从已有代码中机械化推导的、重复度高的部分,才值得放进生成器,比如从字段生成属性、从事件生成订阅、从接口生成默认实现。凡是有业务判断、策略差异、异常处理的部分,应该留在手写代码里,让开发者能直接控制上下文。
用 partial 范式配合这个标准,效果最好:生成器负责把那些“看到 A 就该有 B”的规则固化成代码,手写代码则专注于业务变化点。这样生成器不会抢业务逻辑的活,也不需要频繁跟着业务规则修改而修改。边界理清了,团队的代码评审也会更轻松,因为评审者可以快速分辨“这是模板产物”和“这是手写业务逻辑”。
6. 一次完整的打包实操示例与发布后验证
6.1 从生成器源码到 nupkg 的完整链路
我再把整个链路串成一个可复制的流程。
- 创建生成器类库项目,目标框架设为
netstandard2.0,实现IIncrementalGenerator。 - 编写生成逻辑,注意管道内的所有数据都走 immutable 模型。
- 配置项目文件,关闭
IncludeBuildOutput,添加buildTransitiveprops。 - 执行
dotnet pack -c Release得到 nupkg。 - 用本地 NuGet 源或者直接指定包路径,在消费项目中引用。
- 编译消费项目,确认生成文件出现。
- 将包推送到内部 NuGet 源,通知团队测试。
这一步里最容易出错的还是 props 文件的路径。很多人在 props 里写成了相对项目目录的路径,结果包被安装到不同机器后路径就失效了。最稳妥的方式是始终基于$(MSBuildThisFileDirectory)这个变量展开路径,它指向的是 props 文件本身所在的位置,不会因为项目位置不同而变化。
6.2 一个容易忽略的包内文件层级问题
打包时.props放在buildTransitive目录下,而 analyzer DLL 放在analyzers/dotnet/cs目录下,这两个目录之间没有天然的对应关系。如果你在 props 里写的 analyzer 路径和 DLL 实际打包路径不一致,编译时就会静默地找不到 analyzer,生成器完全不生效。
这个坑我在第一次打包时栽过跟头。后来我在一个干净的消费项目上,用解压 nupkg 的方式去核对文件树,确认 DLL 确实在analyzers/dotnet/cs/下,props 里引用的路径也确实指向这个位置,才算真正放心。其实这一步可以在 CI 脚本里顺手加上一个检查命令,提前拦截路径错误。
6.3 团队内小范围灰度发布和回滚注意事项
源码生成器属于“编译期生效”的组件,出了问题影响的是整个构建流水线,不是运行时。所以团队内部灰度发布尤其重要。我的做法是先在 CI 出的测试分支上安装新版本,全量编译一次,并把生成的代码提交到临时分支做 diff。确认生成内容和预期一致后,再在正式项目里逐步升级引用版本号。
如果出现问题需要回滚,最方便的是统一管理包版本号,让项目通过一个中心化的Directory.Packages.props锁定生成器版本。这样回滚只改一个文件,重新编译即可,不用在每个项目里找引用。这里不算复杂,但一旦生成器更新频繁,中心化版本管理的收益会被明显放大。
7. 还有一些值得长期坚持的开发习惯
源码生成器这个东西,写一个能跑的 demo 不难,难的是后续长期维护。我建议从第一天起就给生成器项目配上完整的测试。不要觉得生成器只是编译期工具,不用写测试。恰恰相反,因为它不参与运行时,问题更难在线上暴露,测试反而更重要。
测试一条简单的路径:构造一段源码字符串,用CSharpGeneratorDriver跑一次生成器,然后把生成的源码和预期字符串做对比。这种方式能验证 90% 的生成逻辑。我团队里的生成器现在都有三四百个这样的用例,每次改完生成规则,跑一遍就能知道有没有破坏产出内容。
另一个习惯是:给生成代码加上固定的// <auto-generated />头。这个头不是给编译器看的,是给 IDE 和代码评审工具看的。有了它,IDE 默认不会把文件纳入手动编辑范围,评审工具也能自动过滤差异,团队协作时省掉很多无意义的 review 噪音。
最后再说一个我踩过多次的细节。生成器项目顺手写一个本地 debug 用的 console 入口,直接把要生成的模型打印出来或者写入磁盘。不要每次调试都去翻编译日志,那成本太高。把生成器的中间模型可视化出来,很多问题一眼就能定位。这个“调试入口”看起来不起眼,但对生成器这种调试手段有限的组件来说,是效率提升最明显的一个小工具。
打包源码生成器这件事,技术门槛在于理解 Roslyn 编译管线和 NuGet 加载机制,工程门槛则在于把增量缓存、错误诊断、回归测试这些基础设施搭好。只要这两条腿都站稳了,partial 范式的生成器就会成为手写代码的有力补充,而不是又一种新技术包袱。