如果你最近两年开始做 .NET 开发,那大概率绕不开 SourceGenerator 这个名字。我第一次真正被它打动,是在一个需要给几十个 DTO 类自动生成 INotifyPropertyChanged 实现的场景里。手写重复代码写到最后麻木,改用反射又担心启动性能和裁剪兼容性,后来切到 SourceGenerator,核心思路就是把一个原本要交给运行时做的事情挪到编译期,而且用一种很优雅的姿势:生成器只负责生成 partial 类型的另一半代码。
这篇文章我会从一个实际写过多个生成器的人的角度,把 SourceGenerator 的 partial 范式、具体的生成器实现方式,以及怎么给生成器构造一套可落地的测试方案,完整讲一遍。适合已经开始接触 Roslyn 的 .NET 开发者,也适合那些被重复代码逼得想换方案的团队。源码生成器这种东西,看起来是一次性魔法,但它并不是只有在运行时才需要验证。真正让这套 partial 范式稳下来的是测试,而且是能自动回归的测试。
1. SourceGenerator 是什么,以及 partial 范式为什么成为主流
1.1 编译期“插件”的基本原理
SourceGenerator 本质上是 Roslyn 编译器暴露出来的一套扩展点。编译器在把 C# 源码编译成程序集的过程中,会经历解析语法树、构建符号、语义绑定、生成 IL 等阶段。生成器就挂在编译管道上,它能够读取当前编译单元里的语法树和语义模型,然后向编译器追加新的 C# 源码。
我习惯把它比喻成“编译流水线上的代工厂”。你原来的代码是原材料,编译器负责组装,生成器是中间插入的一个加工环节。它不会去修改你的任何源码文件,只会把产出物以额外语法树的形式塞回编译流程。接下来编译器把“原始代码 + 生成代码”当作一个整体继续编译,最终输出程序集。这种机制天然决定了生成器只能“新增”而无法“修改”已有代码,这也是 partial 范式之所以重要的根本原因。
生成器有两种常见接口:经典写法是 ISourceGenerator,用 Execute 方法直接做遍历和生成;新写法是 IIncrementalGenerator,它把相同的输入分阶段缓存起来,在增量编译时能复用上一轮结果,性能更好。现在新项目我基本都直接推荐 IIncrementalGenerator,虽然刚上手时它的链式调用有点绕,但好处非常明显。
1.2 partial 关键字为什么成了生成器的最佳搭档
既然生成器无法修改已有源码,那它生成的代码必须找到“挂载点”。C# 的 partial 关键字几乎是为这个场景量身定做的:partial 类可以把一个类型拆到多个文件,partial 方法可以先把声明写在一个地方、把实现放到另一个地方。
生成器的典型用法就是“你写一半,我写另一半”。开发者在业务文件里写一个 partial class,声明自己想要什么;生成器检测到这些声明后,在另外的语法树里补全这个类的其余成员。以此实现“手写部分负责业务语义,生成部分负责机械实现”的分工。
如果不用 partial,生成器生成的类型和手写类型就是两个完全独立的类型,无法合并到一个类里。那样的话要么生成器被迫生成一个基类,你的业务类去继承基类,灵活性大打折扣;要么就得靠扩展方法补能力,很多场景根本走不通。所以 partial 范式几乎是所有正经 SourceGenerator 项目的最优解,形式上有 partial 类、partial 方法、partial 属性三种,后面我会分别展开。
1.3 和反射、T4、运行时动态代码的横向对比
聊 SourceGenerator 之前,很容易被问“那反射为什么不行?”我遇到过很多团队,第一版做属性通知、映射、序列化时都是用反射硬刚。反射的优势是运行时通用,代码写起来直观,不用了解编译器。但代价也很明显:启动时 Type 扫描和缓存、AOT 裁剪时元数据丢失、IL 级别优化困难。
T4 模板是另一种思路,在编译前生成一批 .cs 文件提交到代码库。它的问题是生成时机靠手动或构建脚本触发,生成结果的时效性差。改一个模型文件,忘记跑模板,生成的代码还是旧的,等到运行时才发现。
SourceGenerator 和这两个比起来,最大的优势是“即时”和“类型安全”。它在编译一开始就基于当前代码的最新状态生成源码,生成的结果马上参与编译,类型是强绑定的,IDE 也能识别。性能上它更是碾压反射——大部分逻辑在编译期已经展开,运行期不需要任何反射调用。
我用一个表格来说明取舍:
| 方案 | 运行性能 | 编译期介入 | 类型安全 | 调试体验 | 适用场景 |
|---|---|---|---|---|---|
| SourceGenerator | 高 | 是 | 强 | 中等 | 编译期可确定的重复代码 |
| 反射 | 低 | 否 | 弱 | 较好 | 运行时动态元数据访问 |
| T4 模板 | 高 | 否 | 强 | 一般 | 预生成独立代码文件 |
| 运行时代码生成 | 中 | 否 | 弱 | 较差 | 复杂动态构建场景 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. partial 范式的三种典型写法
2.1 模式一:partial 类补充成员
这是最简单的模式:生成器识别到某个类上存在约定好的特性,就为这个类补充额外的成员。因为类是 partial,生成器生成的成员会被合并进同一个类型,用户在使用时完全感知不到“这些成员是自动生成的”。
举个例子,我定义一个 [GeneratedGreeting] 特性,目标允许标注在类上。生成器检测到之后,给这个类生成一个 SayHello 方法。源码里可以这样声明:
csharp复制[GeneratedGreeting]
public partial class Greeter
{
}
生成器补全后,实际编译的代码相当于:
csharp复制public partial class Greeter
{
public string SayHello() => "Hello from generated code!";
}
这里有个很重要的细节:类必须声明为 partial。如果漏掉了 partial,编译器会报“缺少 partial 修饰符”的错误,因为生成代码里也定义了一个 Greeter 类。所以写生成器时,应该在诊断信息里明确提示用户“用 [GeneratedGreeting] 标记的类必须是 partial class”。
2.2 模式二:partial 方法实现“可插拔逻辑”
C# 9 之前,partial 方法必须是 private、返回 void、不能有访问修饰符,用途非常受限制。C# 9 开始放宽了限制,partial 方法可以有返回值、可以带访问修饰符,这给了生成器很大的发挥空间。
partial 方法的范式是:一个地方写声明,另一个地方写实现。生成器可以利用这一点,检测到 partial 方法声明后自动生成实现。典型场景是“钩子方法”。比如我在一个状态机类里定义:
csharp复制public partial class StateMachine
{
partial void OnStateChanged(string oldState, string newState);
}
生成器检测到这个声明后,生成如下实现:
csharp复制public partial class StateMachine
{
partial void OnStateChanged(string oldState, string newState)
{
// 自动生成的日志钩子,可以在这里接入日志管道
Console.WriteLine($"{oldState} -> {newState}");
}
}
这种模式的好处是,生成器生成的实现可以在以后随时替换掉,不需要改业务代码。如果不想记录日志,可以把生成逻辑改成空实现,或者干脆换成别的实现。当然,这里有一个点需要留意:只有标记了 partial 的方法才能被生成器补全实现,普通方法声明在别的文件里和生成代码合并会导致重复定义错误。
2.3 模式三:字段到属性的自动生成(AutoNotify)
这是 SourceGenerator 在 MVVM 场景下最出名的应用。通常我们给一个字段加 [AutoNotify] 特性,希望它自动变成一个带 INotifyPropertyChanged 通知的属性。
手写的时候是这样:
csharp复制private string _name;
public string Name
{
get => _name;
set
{
if (_name != value)
{
_name = value;
OnPropertyChanged(nameof(Name));
}
}
}
字段一多,这段代码写十遍、二十遍,纯属折磨。用生成器后,业务类只需要:
csharp复制public partial class ViewModel
{
[AutoNotify]
private string _name;
[AutoNotify]
private int _age;
}
生成器会为每个带 [AutoNotify] 的字段生成一个公开属性,并且带完整的通知逻辑。字段名 _name 对应的属性名是 Name,下划线前缀和驼峰规则在这里需要约定好。我会在后面的实现章节里写具体代码。
这个模式之所以经典,是因为它把“从字段生成属性”这件重复度极高的事情自动化了,而生成的代码在类型系统上完全可见,属性绑定、反射调用、AOT 都能正常处理,不会像动态生成那样出现元数据缺失。
3. 从零手写一个可运行的生成器(AutoNotify 示例)
3.1 项目骨架与依赖准备
新建一个类库项目,目标框架用 netstandard2.0,因为生成器需要被不同版本的编译器加载,netstandard2.0 兼容性最好。项目文件大致长这样:
xml复制<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
</ItemGroup>
</Project>
EnforceExtendedAnalyzerRules 会启用一组针对分析器/生成器的额外代码分析规则,比如要求不要使用 Console 输出,因为生成器在编译期运行,Console.WriteLine 只会写到编译进程的标准输出里,用户基本看不见。这面旗子对新手很有帮助,能挡掉不少毫无意义的写法。
特性定义和生成器实现通常放在同一个项目里,但要注意:引用这个生成器的项目不会自动得到这些特性类型,因为生成器项目本身不会被作为普通引用程序集暴露。一般做法是让特性类也由生成器补充生成,或者单独放到一个公共程序集里。示例里我会让生成器直接生成特性类,这样消费项目不需要额外引用什么东西。
3.2 生成器核心逻辑与实现细节
我用增量生成器接口来实现。整个链路的思路是:先通过 ForAttributeWithMetadataName 找到所有标注了 [AutoNotify] 的字段,然后针对每个字段收集足够信息,最后在 RegisterSourceOutput 里拼接源码。
之所以推荐 ForAttributeWithMetadataName,而不是自己写 SyntaxProvider.CreateSyntaxProvider 再去 Compilation 里查特性,是因为前者已经封装好了“按特性名找语法节点”的过程,性能更好,代码也更短。
生成器的完整代码大致如下:
csharp复制using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
namespace AutoNotifyGenerator
{
[Generator(LanguageNames.CSharp)]
public sealed class AutoNotifyGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// 找出所有带 [AutoNotify] 的字段
var fields = context.SyntaxProvider.ForAttributeWithMetadataName(
"AutoNotifyAttribute",
static (node, _) => node is Microsoft.CodeAnalysis.CSharp.Syntax.FieldDeclarationSyntax,
static (ctx, _) => GetTarget(ctx));
var compilationAndFields = context.CompilationProvider.Combine(fields.Collect());
context.RegisterSourceOutput(compilationAndFields, static (spc, source) =>
{
var (compilation, fieldList) = source;
var attributeSymbol = compilation.GetTypeByMetadataName("AutoNotifyAttribute");
if (attributeSymbol is null)
{
// 特性类型还没生成,先跳过
return;
}
// 生成特性类
spc.AddSource("AutoNotifyAttribute.g.cs",
SourceText.From("""
[System.AttributeUsage(System.AttributeTargets.Field)]
internal sealed class AutoNotifyAttribute : System.Attribute
{
}
""", Encoding.UTF8));
// 按类分组,为每个类生成 partial 代码
foreach (var group in fieldList.GroupBy(f => f.ContainingType, SymbolEqualityComparer.Default))
{
var builder = new StringBuilder();
// ... 输出 partial 类、属性、通知逻辑 ...
spc.AddSource($"{group.Key.Name}.g.cs", SourceText.From(builder.ToString(), Encoding.UTF8));
}
});
}
private static FieldInfo GetTarget(GeneratorAttributeSyntaxContext ctx)
{
// 这里要做语义判断,确认字段所在类是否是 partial
// 同时解析出字段名、类型名、属性名等
return new FieldInfo(/* ... */);
}
private sealed record FieldInfo(/* ... */);
}
}
用 AddSource 生成代码时,我强烈建议总是用 SourceText.From(content, Encoding.UTF8) 包装一下,不要直接传字符串。因为有些系统环境下默认编码不是 UTF-8,生成的文件会出现乱码或者 BOM 问题,虽然编译器能容忍,但调试时看到乱码会非常难受。
字段名 _name 转属性名 Name 的规则需要自己写。我的做法是先去掉下划线前缀,再把首字母大写。如果字段名不符合 _xxx 规范,我干脆生成一个诊断,提示开发者修改命名,而不是在生成器里做太复杂的容错。生成器这种东西,越早报错越好,容错逻辑只会让问题更隐蔽。
3.3 消费项目接入和验证
生成器项目编译成 dll 后,通过项目引用挂到消费项目上。在消费项目的 .csproj 里这样配置:
xml复制<ItemGroup>
<ProjectReference Include="..\AutoNotifyGenerator\AutoNotifyGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
这里有两个关键点。OutputItemType="Analyzer" 告诉 MSBuild 把这个项目产物当作分析器/生成器来加载;ReferenceOutputAssembly="false" 表示不把生成器程序集本身作为普通引用程序集加到编译中。如果忘了后面这个属性,消费项目的代码里能看到 AutoNotifyGenerator 的命名空间,虽然不一定会报错,但概念上不正确,而且会增加误用风险。
配置好之后,重新编译消费项目,生成器就会运行。查看生成结果的方法有几个:第一,在 Visual Studio 的“解决方案资源管理器”里,展开依赖项 -> 分析器 -> AutoNotifyGenerator,能看到生成的 .g.cs 文件;第二,直接去 obj/Debug/net8.0/generated/AutoNotifyGenerator/ 目录下找生成的源码。
写一个小型验证程序:
csharp复制var vm = new ViewModel();
vm.PropertyChanged += (_, e) => Console.WriteLine($"属性变化:{e.PropertyName}");
vm.Name = "Tom";
vm.Age = 18;
运行后如果正确输出两次属性变化事件,说明生成器在工作。我第一次跑通这个流程时,体会到的最直接感受是:生成代码和手写代码在类型系统里完全分不开,IDE 的 IntelliSense 能直接提示 Name 和 Age 属性,这种感觉和反射完全不同。
4. SourceGenerator 的测试:不测就等着半夜上线报警
4.1 为什么要给生成器写测试
生成器在编译期运行,一旦出错,整个构建可能直接挂掉。更麻烦的是,生成器运行在编译进程里,普通的断点调试手段不一定好用,错误信息也不会像运行时异常那样堆栈完整。如果不写测试,每次改动生成器都像是在改炸弹的引信,你根本不知道哪次会把所有消费项目的构建全炸了。
我见过一个团队给生成器新增了一个特性支持,结果没有及时更新某个分支逻辑,导致整个仓库的构建在 CI 上全线标红,十几个人卡在合并门禁前。这种问题在生成器项目里特别容易发生,因为生成器处理的输入是用户代码的“海量组合”,你不可能靠手测覆盖所有情况。所以测试不是可选项,是生成器开发的必需品。
4.2 构造测试编译环境:从项目引用到 CSharpCompilation
给生成器写测试,第一步要自己搭一个“编译环境”。常规做法是用 xUnit 或 NUnit,引用生成器项目,再引用 Microsoft.CodeAnalysis.CSharp 包。测试代码里手动创建 CSharpCompilation,把被测 C# 源码加进去,然后通过 CSharpGeneratorDriver 运行生成器。
一个最基础的测试辅助方法大致这样:
csharp复制private static Compilation RunGenerator(string source)
{
var syntaxTree = CSharpSyntaxTree.ParseText(source);
var references = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
.Select(a => MetadataReference.CreateFromFile(a.Location));
var compilation = CSharpCompilation.Create(
"TestAssembly",
new[] { syntaxTree },
references,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var generator = new AutoNotifyGenerator().AsSourceGenerator();
GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _);
return outputCompilation;
}
这中间最坑的是引用的程序集。GetAssemblies() 拿到的只是当前测试进程已经加载的程序集,但生成代码里可能会用到 System.ComponentModel 或者其他程序集的类型。最稳妥的方法是把生成器需要的所有运行时程序集都显式加进去。我的习惯是至少包含 typeof(object).Assembly、typeof(Enumerable).Assembly 和 System.Runtime 程序集,不够再补。
另外要注意,生成器编译时是在一个“干净编译环境”里执行的,它不一定能看到测试项目引用的所有东西。所以测试输入源码里尽量只依赖非常基础的 BCL 类型,避免碰上定位引用的问题。
4.3 三层断言策略:源码层、编译层、运行层
生成器的测试不能只停留在“运行不报错”这个层次。我把测试拆成三层,每层解决不同的问题。
第一层是源码层断言。拿到生成的 SyntaxTree 后,把它的文本取出来,检查关键片段是否存在。比如确认生成了 public string Name、确认有 OnPropertyChanged(nameof(Name))。这层测试适合验证生成代码的结构和命名规则。
csharp复制var generated = outputCompilation.SyntaxTrees
.Single(t => t.FilePath.Contains("ViewModel.g.cs"))
.ToString();
Assert.Contains("public string Name", generated);
Assert.Contains("OnPropertyChanged(nameof(Name))", generated);
第二层是编译层断言。用 outputCompilation.GetDiagnostics() 检查整个编译是否产生了 error 级别的诊断。这一步非常关键,因为生成器可能生成语法错误的代码,比如括号不匹配、类型不存在、访问修饰符冲突。源码层断言和编译层断言一起,才能保证生成的代码至少能通过编译器这一关。
第三层是运行层断言。这一步用 Assembly.Load 把编译出来的程序集加载进内存,通过反射创建类型、调用属性 setter,然后验证 PropertyChanged 事件是否触发。这一步是行为验证,能抓住“代码能编译但逻辑不对”的问题,比如字段比较错、事件触发顺序不对等。
csharp复制var assembly = outputCompilation.EmitToMemory(); // 自行封装 Emit
var type = assembly.GetType("ViewModel");
var instance = Activator.CreateInstance(type);
int eventCount = 0;
var evt = type.GetEvent("PropertyChanged");
var handler = CreateEventHandler(() => eventCount++);
evt.AddEventHandler(instance, handler);
type.GetProperty("Name").SetValue(instance, "Tom");
Assert.Equal(1, eventCount);
三层测试都跑通,生成器才算基本可靠。我通常会把第一层和第二层写进一个“源码快照”测试用例,第三层作为少量冒烟测试,避免运行层测试太多导致测试速度下降。
4.4 快照测试提升回归效率
写生成器的测试时,最痛苦的是断言语料很容易过时。生成逻辑一变,十几个 Assert.Contains 要跟着改,还容易漏。后来我用了快照测试,策略就是“把生成结果完整保存下来,和上次的版本对比”。
Verify 库是处理这件事的好工具,它的 VerifySourceGenerators 扩展可以让生成器的测试结果直接作为可读文件落盘。第一次运行会生成一个 Received 文件,人工确认没问题后,把它改成 Verified 文件。之后每次跑测试都会对比生成结果,一旦差异太大就失败,开发者需要主动确认是否接受变化。
这个流程最大的价值是,生成器的任何输出变化都会被测试放大给你看,避免“改了一行匹配规则,结果某个场景静默改变了行为”的情况。快照测试对生成器来说,是性价比极高的一道回归防线。
5. 常见问题与排查技巧实录
5.1 生成器不触发或 IntelliSense 不刷新
我排查过最多的问题是“我明明引用了生成器,但生成的代码没有出现”。首先是检查 .csproj 里有没有正确配置 OutputItemType="Analyzer" 和 ReferenceOutputAssembly="false"。缺了任何一个都可能导致生成器不加载或加载了但不起作用。
其次是 IDE 缓存问题。SourceGenerator 的生成结果在 Visual Studio 里可能不会立即更新,尤其是你改了生成器代码后,消费项目的 IntelliSense 还停留在旧状态。这时候强制重新构建生成器项目,再重新生成消费项目;如果还不行,就重启 IDE 或者删除 obj 目录重新编译。曾经有段时间我每天都要清理好几次 obj 目录,后来发现“生成器代码变更后一定要先重新编译生成器项目”这个顺序不能乱,否则 Visual Studio 分析用的还是旧 dll。
还有一个不起眼但很常见的原因:生成器项目的目标框架或 Roslyn 版本和当前 SDK 不匹配。比如把 Microsoft.CodeAnalysis.CSharp 包的版本回退得太老,新 SDK 的编译器可能直接拒绝加载生成器。通常做法是把包版本调到比当前 SDK 的 Roslyn 版本低一个稳定版本,兼容性会更好。
5.2 生成代码报错时如何定位
生成代码如果编译不过,错误信息会指向 .g.cs 文件里的某一处。这时候你需要在生成器代码里加诊断信息,而不是直接盲猜。SourceProductionContext.ReportDiagnostic 可以把自定义错误输出到编译错误列表,比如“在类型 X 上找到了 [AutoNotify] 字段,但类型 X 不是 partial class”。这种主动诊断比编译器自己报一堆“缺少 partial 修饰符”要友好太多。
调试生成器本身也比较特殊。可以在生成器代码里调用 Debugger.Launch(),重新编译消费项目时会弹出调试器,然后像调试普通代码一样设置断点,查看语法树和语义模型的中间状态。这个方法在极端情况下很管用,但别长期留在代码里,否则团队里每个人编译都会被弹窗骚扰。
生成代码有格式问题也是个高频坑,比如没有正确缩进、换行符不对,或者丢掉了文件头注释。我建议生成器生成代码时始终走统一的 IndentedTextWriter 或模板拼接逻辑,并且在测试用例里断言“生成的代码以 UTF-8 编码且包含文件头”。模板字符串里的 $""" 原始字符串字面量在 C# 11 以后很好用,但要注意目标版本兼容性。
5.3 增量生成器性能与缓存坑
IIncrementalGenerator 的缓存机制看起来很美好,但用不好反而会踩坑。它的核心原则是:每个阶段都要尽量保持输入的可比较性和输出的稳定性。如果在变换时往结果里放了不可比较的对象(比如某个临时类实例),增量编译时缓存可能频繁失效,整个生成器退化成每次都全量执行,性能提升就没了。
另一个常见问题是:有些开发者为了省事,把 Compilation 对象本身放进了缓存结果里。Compilation 是大型不可变对象,把它塞进缓存会导致大量内存占用,而且比较成本极高。正确做法是只提取需要的信息,比如 INamedTypeSymbol 的 Name、ContainingNamespace、字段名、类型名等,把这些不可变数据组合成自定义 record。这样缓存才能高效命中。
还有一点是关于诊断信息的。如果在增量管线里用了 RegisterSourceOutput 之外的 RegisterImplementationSourceOutput,生成的诊断可能不会每次都执行。在写生成器的时候,要分清哪些逻辑需要每次编译都跑,哪些可以走缓存,否则会出现“改了输入但诊断没更新”的怪现象。
5.4 兼容性与多版本 Roslyn
生成器最终会被加载到各种开发环境里,不同项目的 SDK 版本可能差别很大。有人用 Visual Studio 2022 17.8,有人用 17.4,还有人用纯命令行构建。如果生成器里用了太新的 Roslyn API,跑在旧编译器上就会加载失败。
我的经验是:生成器项目尽量用较低的 Roslyn API 面,Microsoft.CodeAnalysis.CSharp 包版本选 4.0 以上即可,然后避免使用那些非常新的 API。如果需要使用新版特性,可以加一个运行时判断,根据 CSharpCompilation 的版本做分支。另外,生成器目标框架用 netstandard2.0 已经是社区共识,千万不要图方便改成 net8.0,否则老项目根本用不了。
还有一个容易忽略的点:生成器里如果依赖第三方包,发布时要确保这些依赖能被加载。很多生成器把依赖打进同一个 dll 里,或者干脆不依赖任何第三方库,只靠 Roslyn 自带 API。这个决策要在项目初期就定下来,否则后面做兼容性适配会非常痛苦。
我在实际写生成器时还有一个习惯:每次改动生成逻辑,都会在测试项目里同时加一个“真实业务代码”的用例,模拟用户在复杂项目中的典型用法。这种用例能提前暴露很多单元测试里发现不了的问题,比如生成代码和原代码在同一个命名空间下的访问冲突、类型重名、以及 partial 方法在不同文件间的合并顺序。生成器开发看着像是在“拼字符串”,但拼出来的代码会和用户的代码一起被编译,所以它的测试思路不能停留在“字符串对不对”,而是要上升到“编译结果和运行时行为对不对”。希望这篇文章能让你少踩一次坑,也让你在给别人介绍 SourceGenerator 的时候,不只是说“它能生成代码”,而是能讲清楚 partial 范式为什么这么设计,以及怎么用测试把它兜住。
