我这一两周一直在整理 SourceGenerator 相关的资料,越写越觉得有一件事再强调都不为过:整个源生成器机制里,真正撑起手写代码和生成代码协作关系的,是那个平时总被一笔带过的关键字 partial。更准确地说,是 partial 范式——把“谁来声明、谁来实现、边界在哪”讲清楚的一套纪律。
这篇文章我想用一个小而完整的示例来切一遍这个主题:写一个简化版的 AutoNotify 源生成器,把 private string _name 这种字段自动生成为带 INotifyPropertyChanged 通知的属性,同时让用户能在自己的代码里用 partial 方法接住属性变更。然后我会把配套的测试策略、快照测试、运行时验证以及我在实际项目里踩过的一堆坑一起交代出来。
如果你是第一次看 SourceGenerator,或者已经写了一两个生成器但总感觉测试无从下手,这篇文章应该能帮你把整条链路打通。我不打算追求那种几百行、覆盖所有边界的工业级实现,重点是把 partial 协作的范式讲明白,让你看完能直接上手改。
1. partial 范式——源生成器必须遵守的协作规则
1.1 源生成器到底在“织”什么
要理解 partial 范式,先得理解源生成器这个机制本身。你可以把它想象成一个“编译期织布机”:编译器在真正生成程序集之前,会调用我们注册进去的生成器,生成器往编译管线里追加新的 C# 源码,然后这些源码会跟手写的代码一起参与编译。
这个机制解决的核心痛点是“重复且模式化”的代码。比如 MVVM 里的 INotifyPropertyChanged 实现、日志封装、接口桩、DTO 映射,这类代码逻辑固定、写法雷同,手写容易漏,又难保持一致。过去我们靠 T4 模板、代码片段、反射或者 AOP(比如 Fody)来搞,但 T4 模板要手动触发、反射又在运行时才有感知、Fody 对付费版本控制太多。SourceGenerator 的优势在于它是编译器原生支持的,生成时机在编译期,IDE 能直接感知到生成的代码,也没有运行时反射损耗。
但问题来了:编译器在编译的时候才能调用生成器,而用户的业务代码也是在同一份编译里。换句话说,生成器生成的代码和手写代码要“同处一个项目、同名类型、甚至同名方法”,这怎么共存?partial 就是这个问题的标准答案。
1.2 数据库范式与 partial 范式的类比
热搜词里有一串很有意思的词条:“数据库范式怎么求”“关系数据库范式”。数据库范式约束的是表结构的设计纪律——减少冗余、消除异常依赖、把数据关系规范化。而 SourceGenerator 里的 partial 范式,我理解就是手写代码与生成代码之间的设计纪律:
- 手写代码负责“声明意图”,比如标记一个字段需要自动生成通知属性;
- 生成代码负责“填充实现”,把属性、事件、方法体都生成出来;
- 两者靠
partial class共享类型成员,靠partial method提供扩展点; - 边界一旦被打破,比如手写代码重复声明了生成器的成员,编译立刻报错。
数据库范式约束的是数据,partial 范式约束的是代码职责。这个类比在团队里讲起来特别顺,新人一听就能明白“为什么生成器要这么做”。
1.3 partial method 的九年进化,才让这件事真正可行
partial method 在 C# 3.0 时代就有,但早期限制特别死:只能返回 void、不能有访问修饰符、不能用 out 参数、如果有声明但没有实现,编译器会直接把声明和所有调用点一起删掉。这套规则的初衷是给“可插拔的钩子”用的,比如设计器生成的代码留一个空方法让你补逻辑,你没写实现,编译器就当作没发生过,零开销。
SourceGenerator 火起来之后,大家忽然发现这套机制简直是天作之合:生成器可以生成一个 partial void OnNameChanged(string value); 的“钩子声明”,用户在手写代码里写对应的实现,也可以选择不写,编译器自动移除调用,性能完全无感。
C# 9 又放宽了 partial method:允许返回值、允许访问修饰符、允许 out 参数。代价是这些“高级形态”一旦用了,就必须有实现,否则会编译错误。这个设计很聪明:普通钩子可以随缘,但一旦你期待返回值或访问性,编译器就必须保证一致性,不能让你暗自吞掉调用。
所以在写源生成器时我会刻意分两层:
- 可省略钩子:用
partial void,用户能写实现、也能不写; - 必须实现的契约:用带修饰符或返回值的 partial method,或者直接生成抽象基类、接口约束。
这就是 partial 范式的核心:声明可以交给生成器,实现留给用户;用户为所欲为的自由由编译器校验。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手实现一个 AutoNotify 源生成器
聊完理念,直接上代码。我准备实现一个功能裁剪过的生成器,它的效果是这样:
csharp复制public partial class PersonViewModel : INotifyPropertyChanged
{
[AutoNotify]
private string _name;
[AutoNotify]
private int _age;
partial void OnNameChanged(string value)
{
Console.WriteLine($"Name 变成 {value}");
}
}
生成器会在编译时把它扩展成:
csharp复制public partial class PersonViewModel
{
public string Name
{
get => _name;
set
{
if (!global::System.Collections.Generic.EqualityComparer<string>.Default.Equals(_name, value))
{
OnNameChanging(value);
_name = value;
OnNameChanged(value);
OnPropertyChanged(currentName: "Name");
}
}
}
// Age 属性类似,这里省略细节
partial void OnNameChanging(string value);
partial void OnNameChanged(string value);
partial void OnAgeChanging(int value);
partial void OnAgeChanged(int value);
public event global::System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged(string propertyName)
=> PropertyChanged?.Invoke(this, new global::System.ComponentModel.PropertyChangedEventArgs(propertyName));
}
注意生成的代码里声明了 partial void OnNameChanged(string value);,但没实现。用户手写代码可以提供实现,也可以不提供。如果用户没写,编译器会把“调用点”全部删掉,零开销;如果写了,生成的 setter 会在属性赋值后精准调用。
2.1 生成器项目的基础结构
先看项目结构,按我习惯的做法分三个项目:
AutoNotifyGenerator:netstandard2.0 类库,引用Microsoft.CodeAnalysis.CSharp包,里面放生成器;AutoNotify.Tests:单元测试项目,引用生成器项目,也通过CSharpGeneratorDriver直接驱动生成器;DemoApp:演示项目,通过ProjectReference把生成器配置成 Analyzer 方式。
如果你用的是新版 SDK 风格的 csproj,可以在生成器项目里加这个配置:
xml复制<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
</ItemGroup>
</Project>
PrivateAssets="all" 是为了不让 Roslyn 的程序集泄漏到引用方,否则演示项目里会多出一堆编译器相关依赖,挺恶心的。EnforceExtendedAnalyzerRules 是 Roslyn 团队给 analyzer/generator 作者准备的规则集,能提醒你别在生成器里用一些不支持或有坑的 API,我建议直接打开。
演示项目里引用生成器的方式也要注意,不要按普通类库引:
xml复制<ProjectReference Include="..\AutoNotifyGenerator\AutoNotifyGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
OutputItemType="Analyzer" 让 MSBuild 把它当分析器喂给编译器,ReferenceOutputAssembly="false" 则不让用户代码直接拿到生成器程序集。这样生成器本身就成了“编译期的工具”,运行时不会出现。
2.2 让生成器精准找到要处理的字段
生成器的入口从 ISourceGenerator 换到了 IIncrementalGenerator。老接口每次编译全量跑一遍、没法做缓存,增量接口则允许我们把中间计算缓存下来,只有源文件变化时才重跑对应部分。除非你要兼容特别老的 VS 版本,否则新项目直接上增量接口:
csharp复制using Microsoft.CodeAnalysis;
namespace AutoNotifyGenerator;
[Generator(LanguageNames.CSharp)]
public sealed class AutoNotifyIncrementalGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var targets = context.SyntaxProvider.ForAttributeWithMetadataName(
"AutoNotifyGenerator.AutoNotifyAttribute",
static (node, _) => true,
static (ctx, _) => new Model(
FieldName: ctx.TargetSymbol.Name,
FieldType: ctx.TargetSymbol.GetTypeSymbol()?.ToDisplayString() ?? "object",
TypeName: ctx.TargetSymbol.ContainingType.Name,
Namespace: ctx.TargetSymbol.ContainingType.ContainingNamespace?.ToDisplayString() ?? ""));
context.RegisterSourceOutput(targets, static (spc, item) =>
{
var code = GenerateCode(item);
spc.AddSource($"{item.TypeName}_{item.FieldName}.g.cs", code);
});
}
static string GenerateCode(Model model)
{
// 生成字符串,稍后演示
}
}
internal sealed record Model(string FieldName, string FieldType, string TypeName, string Namespace);
ForAttributeWithMetadataName 是 Roslyn 4.3.1 之后出的 API,它最大的好处是“带语义信息”:不用自己写 SyntaxReceiver 再查编译模型,它直接给你 TargetSymbol、TargetNode 和 Attributes。字段上挂 [AutoNotify],这里拿到的 TargetSymbol 就是 IFieldSymbol。
这里有个地方值得说:ctx.TargetSymbol.GetTypeSymbol() 这个扩展方法不是内置的,实际写的时候你用 ((IFieldSymbol)ctx.TargetSymbol).Type 就能拿到字段的类型符号。上面代码我是为了简洁把 Model 结构简化成 record,真实实现里建议把字段符号整个留着,方便在生成代码时判断类型是否需要 full name 等。
2.3 生成 partial 类、partial 方法声明的代码文本
生成代码最稳妥的方式是逐字字符串拼接,但它有个麻烦:大段 C# 文本里到处是双引号,写起来费劲。我更推荐先用 StringBuilder 拼接,再用 IndentedTextWriter 控制缩进,但那样代码会多一些。这里我展示一个可读性优先的写法:
csharp复制static string GenerateCode(Model model)
{
string propName = ToPascalCase(model.FieldName);
string lowerField = model.FieldName;
string fieldType = model.FieldType;
string ns = model.Namespace;
return $$"""
// <auto-generated />
#nullable enable
namespace {{ns}}
{
public partial class {{model.TypeName}}
{
public {{fieldType}} {{propName}}
{
get => {{lowerField}};
set
{
if (!global::System.Collections.Generic.EqualityComparer<{{fieldType}}>.Default.Equals({{lowerField}}, value))
{
On{{propName}}Changing(value);
{{lowerField}} = value;
On{{propName}}Changed(value);
OnPropertyChanged("{{propName}}");
}
}
}
partial void On{{propName}}Changing({{fieldType}} value);
partial void On{{propName}}Changed({{fieldType}} value);
public event global::System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged(string propertyName)
=> PropertyChanged?.Invoke(this, new global::System.ComponentModel.PropertyChangedEventArgs(propertyName));
}
}
""";
}
注意 #nullable enable 放在生成文件开头,这样生成代码不会被用户项目的 nullable 设置影响。global::System.Collections.Generic.EqualityComparer<T>.Default 这种全名写法是我强烈推荐的,因为生成代码在用户命名空间里展开,不写全名很容易撞到用户自己的同名类。
ToPascalCase 就把下划线去掉、首字母大写,比如 _name 变 Name。真实实现要处理 _age、s_name、mName 等多种命名风格,这里不做展开,聚焦 partial 主题。
生成器内部如果出现异常,最好捕获并 spc.ReportDiagnostic 输出诊断,而不是让异常炸掉整个编译。这个我在第四章会单独说,因为它是新手最容易踩的坑。
3. 给源生成器写测试,三层体系缺一不可
生成器跟普通业务代码不一样,你没法直接 new 一个对象调用它。它的输入是“C# 源码 + 编译选项”,输出是“新的源码树 + 诊断”。测试必须建在这个模型上。
3.1 测试基础设施:编译、驱动、取生成结果
为了测试生成器,我们要在测试项目里手动搭建一个“迷你编译”:
csharp复制using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using AutoNotifyGenerator;
internal static class GeneratorTestHelper
{
public static (Compilation OutputCompilation, string[] GeneratedSources) RunGenerator(string source)
{
var syntaxTree = CSharpSyntaxTree.ParseText(source);
var references = new List<MetadataReference>
{
MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location),
MetadataReference.CreateFromFile(typeof(INotifyPropertyChanged).Assembly.Location),
MetadataReference.CreateFromFile(typeof(PropertyChangedEventArgs).Assembly.Location),
};
var compilation = CSharpCompilation.Create(
"TestAssembly",
new[] { syntaxTree },
references,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var generator = new AutoNotifyIncrementalGenerator();
GeneratorDriver driver = CSharpGeneratorDriver.Create(new[] { generator.AsSourceGenerator() });
driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _);
var generatedSources = outputCompilation.SyntaxTrees
.Skip(1)
.Select(tree => tree.ToString())
.ToArray();
return (outputCompilation, generatedSources);
}
}
不了解 Roslyn 的话,这里最反常的是“为什么要手动建编译”。因为生成器是编译管线的一部分,你要测它,就必须模拟一个可编译的项目上下文。上面的 references 其实有坑:在现代 .NET 里,光引用 object 所在程序集不够,INotifyPropertyChanged 可能在单独的 System.ComponentModel.Primitives 程序集里。更省事的办法是直接引入 Basic.Reference.Assemblies 这个包,一行代码拿到对应目标框架的完整引用集合:
csharp复制using Basic.Reference.Assemblies;
var references = Net80.References.All;
实测下来这个包能避免绝大多数“类型找不到”的幺蛾子,强烈建议测试项目直接装。
3.2 第一层测试:验证“生成了什么”
第一层测试关注生成代码的文本内容。比如我们期望生成的代码里包含 public string Name 属性、包含 partial void OnNameChanged(string value); 声明:
csharp复制[Fact]
public void 应为带AutoNotify的字段生成Name属性()
{
var source = """
using System.ComponentModel;
using AutoNotifyGenerator;
namespace Demo;
public partial class PersonViewModel : INotifyPropertyChanged
{
[AutoNotify]
private string _name;
}
""";
var (_, generated) = GeneratorTestHelper.RunGenerator(source);
var genText = generated.Should().ContainSingle().Subject;
genText.Should().Contain("public string Name");
genText.Should().Contain("partial void OnNameChanged(string value);");
}
这套做法的优点是跑得快、断言直观,缺点是对生成文本的改动很敏感。只要生成器调整了缩进、加了注释,断言就可能挂。所以它适合做“关键特征存在性”的检查,不适合当唯一测试手段。
这里有个经验:断言生成代码时,别用 Assert.Contains(genText, "Name") 这种模糊匹配。“Name”可能出现在 OnNameChanged、PropertyName、命名空间里,断言之会误导你。宁可把断言写细一点,比如断言属性块、断言方法签名、断言事件声明分开做。
3.3 第二层测试:把生成代码编译进内存,跑真实行为
只检查文本还不够。因为生成代码是字符串,语法上完全不保证它能编译通过,更不保证运行逻辑正确。第二层测试就是把这棵语法树真正编译成程序集,然后加载并调用,验证行为。
csharp复制[Fact]
public void 属性赋值应触发PropertyChanged事件()
{
var source = """
using System.ComponentModel;
using AutoNotifyGenerator;
namespace Demo;
public partial class PersonViewModel : INotifyPropertyChanged
{
[AutoNotify]
private string _name = "初始值";
}
""";
var (compilation, _) = GeneratorTestHelper.RunGenerator(source);
using var ms = new MemoryStream();
var emitResult = compilation.Emit(ms);
emitResult.Success.Should().BeTrue();
if (!emitResult.Success) return;
ms.Position = 0;
var assembly = AssemblyLoadContext.Default.LoadFromStream(ms);
var type = assembly.GetType("Demo.PersonViewModel")!;
var instance = Activator.CreateInstance(type)!;
var changed = new List<string>();
var evt = type.GetEvent("PropertyChanged")!;
var handler = new PropertyChangedEventHandler((_, e) => changed.Add(e.PropertyName!));
evt.AddEventHandler(instance, handler);
type.GetProperty("Name")!.SetValue(instance, "新值");
changed.Should().Contain("Name");
}
这段代码有几个细节容易搞翻车,我直接说结论:
Emit之前MemoryStream的指针在开头,直接LoadFromStream会读到空流,要先ms.Position = 0,或者用ms.ToArray()再LoadFromStream。我第一次写就栽在这。AssemblyLoadContext.Default.LoadFromStream在 xunit 这类测试宿主里是可用的,但如果你的测试项目 target 是 .NET Framework,要走Assembly.Load(byte[]),写法得调整。- 通过反射订阅事件时,
PropertyChangedEventHandler类型要保证测试项目能引用到,如果找不到类型,多半是 references 少了System.ComponentModel.Primitives,用 Basic.Reference.Assemblies 包能规避。
这一层测试已经能验证“生成的代码可以跑、事件真的触发了”。但它仍然不是端到端:用户手写代码里的 partial 实现能不能跟生成的声明接上、能不能在属性赋值时被调用,这里还没验到。
3.4 第三层测试:把手写 partial 实现也塞进去编译
要让用户手写的 partial 实现也进入测试,只需在迷你编译里多传一棵语法树:
csharp复制var source = """
using System.ComponentModel;
using AutoNotifyGenerator;
namespace Demo;
public partial class PersonViewModel : INotifyPropertyChanged
{
[AutoNotify]
private string _name = "初始值";
partial void OnNameChanged(string value)
{
LastNameChanged = value;
}
}
""";
// 然后照常 RunGenerator + Emit + 反射调用
type.GetProperty("LastNameChanged")!.GetValue(instance).Should().Be("新值");
这一下就把“手写代码声明意图 → 生成器生成钩子 → 用户实现钩子 → 赋值时执行用户逻辑”整条链路打通了。我通常把第三层测试当作生成器最重要的“行为契约测试”,因为它是从用户视角验证整个 partial 范式是否成立。
三层测试各有侧重,总结成一张表:
| 测试层级 | 核心问题 | 示例断言 | 跑一次耗时 |
|---|---|---|---|
| 文本层 | 生成器有没有生成需要的成员 | 包含 public string Name |
毫秒级 |
| 编译运行层 | 生成代码能否编译并正确触发事件 | 事件列表包含属性名 | 几十毫秒 |
| 端到端 partial 层 | 手写实现与生成声明能否接上 | 自定义逻辑被执行 | 几十毫秒 |
说起来耗时都不高,但真实项目里建议控制在“每个测试都要秒级完成”,否则 CI 会慢到让人不愿意跑。
4. partial 范式实战中的“地狱级”坑
4.1 partial method 高级形态必须有实现,否则编译错误
前面说过 C# 9 之后 partial method 可以有返回值和访问修饰符,但代价是必须提供实现。这个规则在生成器场景里非常容易踩:你想生成一个 public partial bool TryValidate();,准备让用户手写实现,如果用户忘了写,编译就会报错,不像 partial void 那样“没有实现就静默删调用”。
这里的建议是:设计生成器的扩展点时要区分“可选钩子”和“必选契约”。
- 可选钩子用
partial void,没实现不影响编译,调用点自动移除; - 必选契约用接口、抽象基类,或者带修饰符的 partial method,保证漏写时编译能明确报错。
我在实际项目里把可选的日志钩子设计成 partial void,把必须返回校验结果的方法设计成了接口约束。这样既享受了 zero-overhead 的快乐,又不会把错误留到运行时。
4.2 手写代码和生成代码同文件冲突
partial class 允许成员分散到多个文件,但同一个成员名字不能出现两次。有些刚上手的人会不理解:为什么我在手写代码里也写了个 Name 属性就编译报错?
因为生成器生成的文件是真实参与编译的,它跟用户手写的文件地位完全平等。所以生成代码里要尽量用不常见的标识符,比如 OnPropertyChanged、PropertyChanged 这类,同时要在文档里明确告诉用户“哪些名字已经被生成器占用”。更稳妥的做法是:生成器生成私有成员时用带独特前缀的名字,比如 __AutoNotify_Generated_PropertyChanged,降低冲突概率。
不过也别走极端,公共 API 的名字没法靠前缀闪避,只能在文档里写清楚“属性名由字段名推导,不要手动重复声明”。
4.3 IDE 里看不到最新生成代码
用生成器时最让人困惑的是:我改完生成器代码,也重新编译了,但在 IDE 里看到的生成文件还是旧的,甚至根本不刷新。
我实测下来,处理办法是区分“项目缓存”和“IDE 缓存”:
- 项目层面:改完生成器类库代码后,要重新生成生成器项目。如果生成器项目被引用为 Analyzer,记得“重新生成解决方案”,而不是只生成应用项目。
- IDE 层面:VS 和 Rider 对生成文件有缓存,有时需要关闭解决方案重新打开,甚至删除
.vs、obj目录再做恢复。 - 命令行确认:不确定是不是缓存,就在项目目录跑
dotnet build -t:Rebuild,然后去obj\generated\AutoNotifyGenerator\AutoNotifyGenerator.AutoNotifyIncrementalGenerator\目录看实际生成的.g.cs文件。生成器输出路径和命名空间有关,不同版本可能不一样。
4.4 生成器内部异常会直接炸掉编译,而且错误信息极其反人类
源生成器在编译管线里运行,如果你在生成器里写了个没捕获的 NullReferenceException,编译会直接失败,错误信息是一长串“Generator failed to generate source: ... Exception message ...”,跟你的业务代码毫无关系。
更麻烦的是,如果生成器频繁抛异常,会影响每一次编译,导致队友连 dotnet build 都执行不了。所以我的建议很直接:
- 生成器顶层方法用
try/catch包住,任何异常都转成诊断错误,至少让用户看到“哪个类型、哪个属性、什么原因”; - 关键路径上所有
!能不用就不用,宁可空值返回诊断,也不要裸奔到NullReferenceException; - 本地开发时开一个“诊断专用输出”,比如把异常信息写入生成的注释里,方便调试。
csharp复制static string SafeGenerate(Model model, out Diagnostic? error)
{
try
{
return GenerateCode(model);
}
catch (Exception ex)
{
error = Diagnostic.Create(Descriptor, Location.None, model.TypeName, ex.Message);
return null!;
}
}
4.5 生成器测试引用为什么总是莫名其妙缺类型
这一条虽然不算 partial 的坑,但我在写生成器测试时天天遇到:CSharpCompilation.Create 给的 MetadataReference 不全,编译结果报“类型或命名空间找不到”。
比如只用 typeof(object).Assembly.Location,在 .NET 6+ 里经常拿不到 PropertyChangedEventArgs,因为它不在 System.Private.CoreLib 里。更隐蔽的是 System.Runtime 里一堆转发类型,光引一个程序集满足不了。
我最终的解决方案就是引入 Basic.Reference.Assemblies,然后用 Net80.References.All(或者按你项目 target framework 选),一劳永逸。你如果不信邪,自己维护 references 列表,后面大概率会骂人。
5. 把 partial 范式测试接入团队自动化体系
5.1 生成器测试和普通单测一起跑
源生成器的测试本质是“编译管线的自动化测试”,它跟普通单元测试可以共用同一个测试框架(xunit、NUnit 都行)。我一般把生成器的测试目录独立出来,比如 AutoNotify.Tests,里面分三层:
GenerationTests:文本层断言;BehaviorTests:运行时行为断言;CompatibilityTests:多个源文件组合、多字段、嵌套类、命名空间边界等回归场景。
CI 里只要跑 dotnet test,生成器测试就跟着普通单测一起跑,不需要额外配置。但要注意一点:生成器测试会真正启动 Roslyn 编译器,单测进程的内存占用会比普通测试高,CI agent 内存小的话,建议给测试项目加 xunit 并行限制,避免一堆编译任务同时起飞把 agent 压垮。
5.2 在 PR 里做“生成代码改动防线”
说完跑测试,再讲一个团队协作层面的建议。生成器输出是文本,最容易被“顺手改毁”的地方是格式化、注释、空行。别人维护生成器代码的时候,经常只是加了句注释,结果生成文本变了,一堆文本层断言失败。
更好的做法是引入快照测试。比如用 Verify 这类库,把生成代码保存为 .verified.txt 文件,任何改动都会触发快照 diff,人工确认“这次变更是否合理”。这相当于给生成器加了一道“人工审查闸门”,在 reviewer 眼里特别直观。
csharp复制[Fact]
public Task 生成代码快照()
{
var (_, generated) = GeneratorTestHelper.RunGenerator(Source);
return Verify(generated);
}
快照跑第一次会把 .verified.txt 生成出来,之后每次改动都要重新 approval。好处是“生成代码长什么样”被固化下来,坏处是生成器一有正常改动就要批量更新快照,有点烦。我的经验是:文本层断言和快照二选一即可,不然维护成本翻倍。
5.3 自动化像“老化测试脚本”一样无人值守
热搜词里有一条“设备老化测试全自动执行脚本”。这个思路挺适合生成器:老化测试是让设备长时间自动跑,发现问题才报警;生成器测试也应该做成“无人值守、每个 PR 自动跑、失败才盯人”的机制。
具体落地就三步:
- CI 流水线在
dotnet test阶段执行全部生成器测试; - 快照文件纳入代码审查,生成代码的意外变化会出现在 PR diff 里;
- 新增生成能力时,要求同时提交文本层断言和运行时行为测试,否则不许合入。
这三步一旦跑顺,团队其他人改生成器的时候会非常安全:改坏了类型,测试立刻报警;改了生成文本,快照 diff 让 reviewer 能看到;漏掉调用场景,运行时测试能兜住。
我还遇到过一种情况:某个生成器依赖内部静态字典缓存结果,多测试并发跑时互相污染。解决方法是测试类里尽量不共享静态状态,或者共享时用 lock 保护。这条经验看起来小,但能省掉非常多排查时间。
最后分享一点自己的体会
把 partial 范式想清楚之后,我对源生成器的理解上了一个台阶。它本质上是一套“谁声明、谁实现、谁删除”的权限分配:编译器拥有删除权(没实现就移除调用),生成器拥有声明权(生成钩子和模板代码),用户拥有实现权(手写业务逻辑)。这三者配合好,生成器才是真正好用的工具,而不是一个“生成一堆代码让你改的文本模板”。
如果你要开始写自己的 SourceGenerator,我的建议是先把这个迷你 AutoNotify 做完整,三层测试建好,再去碰复杂的 analyzers 和增量缓存。开始时别贪多,生成器越大越难调试,等基础套路熟练了,再往里面加代码修复、诊断器、选项配置这些进阶能力。
最后说个好用的小技巧:写生成代码字符串时,把“用户可能用到的类名、属性名”全部列成常量,放在一个专门的 WellKnownNames.cs 里。这样生成器项目里改一个名字,生成的代码会同步变,测试也能第一时间发现不一致。这个小习惯救过我很多次。
