1. 从手动重复劳动说到为什么是partial范式
1.1 那些年被重复代码支配的恐惧
做业务系统做了几年之后,你会发现最让人崩溃的不是业务逻辑有多复杂,而是那些高度重复、改一处就得跟着改七八处的"连锁代码"。拿我去年参与的一个订单中台项目来说,一个订单领域对象要同时承担消息契约、数据库映射、缓存序列化、接口出入参好几重身份。开发流程是这样的:先建实体类,然后手写DTO,再写Entity到DTO的映射,再为每个DTO写JsonConverter,最后还要为所有消息类型维护一张类型名和程序集名的映射表。新同学接这种活,一天能憋出三个类就算效率高;老手倒是能写,但写着写着就开始怀疑人生——这些代码除了字段名不同,结构上几乎一模一样。
当时项目里的做法是复制粘贴,然后全局替换字段名。听起来很快,实际上隐患非常大:字段替换不干净、漏改类型、某个DTO少抄了一个属性,这些错误编译器根本发现不了,只能等联调时接口字段对不上才暴露出来。更难受的是,一旦实体基类调整,所有手写的映射代码都要跟着动,动漏一个就是线上事故。
我意识到,这个问题的本质不是"能不能写得快",而是"这类代码本身就不应该出现在源码仓库里"。它完全可以根据已有的类型定义,机械地推导出来。既然机械可推导,那就应该交给工具去生成。
1.2 为什么最后选了Source Generator而不是T4、反射或IL织入
当时的备选方案我挨个试过一遍,各有各的问题,我在下面对比一下:
| 方案 | 优势 | 实际踩到的坑 |
|---|---|---|
| T4模板 | 使用简单,VS内置 | 模板生成的代码在编译前需要手动运行或配置自定义工具;跨工程复用要引入MSBuild任务,处理起来相当繁琐;而且T4生成的代码在别的开发者机器上经常出现"没刷新"的情况 |
| 运行时反射 | 灵活,写起来快 | 性能开销一直存在;在AOT裁剪场景下,反射元数据可能直接被裁掉;出问题要等运行时才暴露,不符合我对"编译期可控"的预期 |
| IL织入(如Fody) | 运行时开销极低 | 调试链长,用户态拿到的还是原类,出错时很难定位;写自定义织入插件的资料少,团队学习成本高 |
| Source Generator | 编译期执行,代码直接进编译单元,IDE即时反馈 | 需要学习Roslyn API,初看门槛偏高 |
我最后选Source Generator,核心理由是它把"代码生成"放进了编译过程本身。也就是说,当你按下F5的时候,生成器已经跑完了,生成出来的代码和你手写的代码一起参与编译,生成的类能不能用、有没有语法错误,编译器第一时间告诉你。这一点对日常开发体验的提升是决定性的:我不需要额外记住"先运行某个工具再编译",也不需要担心哪个同事忘了执行模板更新。
1.3 partial在这里扮演的角色
确定了用Source Generator之后,紧接着要回答一个问题:生成出来的代码和手写代码怎么共存在一个类型里?总不能让生成器把整个类重新输出一遍,那样你手写的业务方法就没了。
答案是partial关键字。把目标类声明成partial class,手写部分保留业务逻辑、字段和自定义方法,生成器检测到这个类的声明之后,负责输出另外半个partial类,把那些重复性、机械性的成员填充进去。这样两半代码经过编译器天然合并,对外部调用方来说,它们就是一个完整的类,没有任何运行时代价。
这种设计精妙在什么地方?它把"程序员写的代码"和"机器生成的代码"从物理文件层面隔离开,但又在类型层面合二为一。手写部分可以随时读、随时改,生成部分每次编译都会根据当前的最新定义重新生成,不会出现"生成完一次就不动了,后面手工改生成产物"这种脏局面。我在实际项目中甚至把生成出来的文件直接排除出代码审查范围,既然它是机械产物,审查它就是在浪费时间,真正要审的是生成器本身的逻辑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 生成器项目从0到1:工程结构与关键配置
2.1 项目类型、目标框架和依赖版本怎么选
Source Generator本质上是一个类库,但它有两个硬性要求:目标框架必须是netstandard2.0,因为生成器要跑在编译进程里,而编译进程可能是.NET Framework(旧版VS)也可能是.NET(新版VS),只有netstandard2.0能两边通吃;引用的Roslyn API必须来自Microsoft.CodeAnalysis.CSharp这个NuGet包。
我新建项目的时候用的是.NET Class Library模板,然后把TargetFramework改成netstandard2.0。注意这里的netstandard2.0指的是生成器项目本身,你的业务项目无论是.NET Framework 4.6.2还是.NET 8都能用,互不影响。这个target兼容性也是Source Generator能被广泛采用的原因之一,它不像某些需要特定运行时配合的库那样捆手捆脚。
依赖版本方面,Microsoft.CodeAnalysis.CSharp我选的是4.8.0。这里要记住一个原则:生成器引用的Roslyn版本不要太高,否则会限制使用方的IDE和SDK版本。如果你引用了最新的4.10,那你的用户必须把VS升到最新,这在实际团队里往往不现实。我见过好几个生成器库因为版本卡得太死,导致团队不敢升级。稳妥的做法是引用你目标用户基础版本之上的尽量低的版本,并且把API控制在那些长期稳定接口上。
2.2 csproj里那些容易漏的开关
起步阶段的csproj长这样:
xml复制<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
</ItemGroup>
</Project>
有两行开关你绝对不要省。第一行是<IsRoslynComponent>true</IsRoslynComponent>,它告诉IDE这个项目是Roslyn组件,VS会用对应的调试和加载逻辑对待它。没有这一行,你在调试生成器时可能会遇到加载行为异常,而且它还会影响一些内置分析规则的启用。第二行是<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>,它启用了针对Roslyn组件的一套额外代码分析规则,专门检查生成器常见误用,比如在static class里放可变的Dictionary、在生成代码里使用环境相关路径等。
PrivateAssets="all"放在Microsoft.CodeAnalysis.CSharp这个引用上也很讲究。它的作用是让Roslyn依赖不流向使用生成器的业务项目。如果你漏掉这个,业务项目在安装你的生成器包时会自动引入Roslyn程序集作为传递依赖,轻则一堆警告,重则和项目里已有的Roslyn版本产生冲突。我第一版打包时就漏了,结果所有使用方的项目都多出一堆"程序集版本冲突"警告。
2.3 Analyzer与Generator分离的设计思路
如果你只是写一个私有小工具,生成器和分析器放一起无所谓。但如果你要发布给团队甚至社区用,我强烈建议把"诊断编译器错误"和"生成代码"分开考虑,哪怕放在同一个项目,也至少要在代码目录上分开。
我在项目里建了Diagnostics和Generators两个目录。前者放DiagnosticDescriptor定义和诊断逻辑,后者放核心生成逻辑。这样划分让我在后期维护时省了很多事:排查一个"为什么没生成"的问题时,我能快速定位到生成逻辑;排查"为什么编译报错"的问题时,我能快速定位到诊断逻辑。高级用法里,同一个生成器还能在检测到某些条件时同时输出诊断和生成代码,比如此前提到的"目标类没加partial"——这时候既输出错误诊断,又跳过生成,用户打开错误列表立刻就知道问题在哪。
3. 用partial类构建的代码生成管线,我是怎么实现的
3.1 让生成器识别目标类:从SyntaxProvider到特征属性
我实现的场景是给标记了[GenerateJsonContract]特性的partial类自动生成序列化辅助代码。第一步是让生成器找到这些打了标记的类。新版的Roslyn提供了一个非常顺手的API:ForAttributeWithMetadataName,它能够根据特性的完整名称匹配语法节点,并且直接给你解析好的语法上下文。
你写在这个API里的完整名称必须和特性类的命名空间+类名严格一致,否则匹配不到。比如你的特性定义在MyCompany.Contracts.GenerateJsonContractAttribute,那这里就必须写这个完整全名:
csharp复制[Generator(LanguageNames.CSharp)]
public sealed class JsonContractGenerator : IIncrementalGenerator
{
private static readonly DiagnosticDescriptor NotPartialRule = new(
"GEN001",
"标记类型必须声明为partial",
"类型'{0}'使用了[GenerateJsonContract]但未声明为partial",
"JsonContractGenerator",
DiagnosticSeverity.Error,
isEnabledByDefault: true);
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var candidates = context.SyntaxProvider.ForAttributeWithMetadataName(
"MyCompany.Contracts.GenerateJsonContractAttribute",
static (node, _) => node is ClassDeclarationSyntax,
static (ctx, _) => (ClassDeclarationSyntax)ctx.TargetNode);
context.RegisterSourceOutput(candidates, static (spc, classDecl) =>
{
// 生成逻辑在这里展开
});
}
}
ForAttributeWithMetadataName相比老式的SyntaxReceiver加context.SyntaxProvider.CreateSyntaxProvider组合,好处太多了。它内部直接处理了特性引用的程序集名匹配,你不需要写一堆"这个类型的Symbol是不是我要找的"的罗嗦判断;而且它天然支持增量缓存,同一棵语法树只要没有变化就不会重新执行transform逻辑,这对大项目的编译速度非常友好。
3.2 从语法节点到可生成模型
拿到ClassDeclarationSyntax和AttributeData之后,最忌直接拿语法节点的字符串信息去拼代码。为什么?因为语法层面的信息既不准确也不完整,你是拿不到继承关系、属性成员的语义类型、可空性标注这些信息的。正确做法是先转换成语义模型,再抽取生成所需的模型数据。
我当时定义了一个JsonContractModel:
csharp复制private sealed class JsonContractModel
{
public string Namespace { get; set; }
public string ClassName { get; set; }
public List<PropertyModel> Properties { get; set; }
}
private sealed class PropertyModel
{
public string Name { get; set; }
public string TypeName { get; set; }
public bool IsIgnored { get; set; }
}
然后在transform阶段从GeneratorAttributeSyntaxContext里拿SemanticModel,通过context.TargetSymbol拿到INamedTypeSymbol,遍历它的成员:
csharp复制static JsonContractModel? Transform(GeneratorAttributeSyntaxContext context, CancellationToken ct)
{
if (context.TargetSymbol is not INamedTypeSymbol typeSymbol)
return null;
var model = new JsonContractModel
{
Namespace = typeSymbol.ContainingNamespace.ToDisplayString(),
ClassName = typeSymbol.Name
};
foreach (var member in typeSymbol.GetMembers())
{
if (member is IPropertySymbol property)
{
model.Properties.Add(new PropertyModel
{
Name = property.Name,
TypeName = property.Type.ToDisplayString(),
IsIgnored = property.GetAttributes()
.Any(a => a.AttributeClass?.ToDisplayString() == "MyCompany.Contracts.JsonIgnoreAttribute")
});
}
}
return model;
}
这里有个细节容易踩坑:typeSymbol.GetMembers()返回的是所有成员,包括从基类继承的。如果你不想把基类属性也生成进去,需要用typeSymbol.GetMembers()配合判断成员是否来自当前类型,或者用typeSymbol.GetMembers().Where(m => m.DeclaredAccessibility == Accessibility.Public)等过滤条件。我第一版没做过滤,结果那些加了标记的子类把基类的字段全部又生成了一遍,编译直接报"重复定义"。
3.3 生成SourceText的拼装策略
模型有了,下一步就是拼代码。生成器返回的是SourceText对象,它本质上就是一段字符串,但编码和换行符有讲究,推荐用SourceText.From(string, Encoding.UTF8)来构建。
我一开始天真的想法是直接用字符串拼接,写出来是这样:
csharp复制var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine($"namespace {model.Namespace}");
sb.AppendLine("{");
sb.AppendLine($" public partial class {model.ClassName}");
sb.AppendLine(" {");
// ... 循环加属性
sb.AppendLine(" }");
sb.AppendLine("}");
这样写确实能跑,但维护起来非常痛苦,缩进全靠字符串里的空格数手动控制,嵌套层级一多,稍不留神就拼出一个结构错乱的类。后来我改成了用IndentedTextWriter,它和StringWriter组合,能够自动处理缩进:
csharp复制using var writer = new StringWriter();
using var indented = new IndentedTextWriter(writer, " ");
indented.WriteLine("// <auto-generated/>");
indented.WriteLine($"namespace {model.Namespace}");
indented.WriteLine("{");
indented.Indent++;
indented.WriteLine($"public partial class {model.ClassName}");
indented.WriteLine("{");
indented.Indent++;
foreach (var prop in model.Properties)
{
indented.WriteLine($"public {prop.TypeName} {prop.Name} " + "{ get; set; }");
}
indented.Indent--;
indented.WriteLine("}");
indented.Indent--;
indented.WriteLine("}");
context.AddSource($"{model.ClassName}.g.cs", SourceText.From(writer.ToString(), Encoding.UTF8));
IndentedTextWriter的核心价值在于它让代码的层次结构一目了然,层级加深就Indent++,回退就Indent--,生成出来的代码可读性极高。这一点我特别看重,因为生成的代码虽然不参与日常审查,但在调试时是会打开看的,一份排版整齐的生成代码能让你在"这个属性到底生成成什么样了"这类问题上少花很多时间。
文件名的规范我也说一下:我用的是{ClassName}.g.cs这种格式,".g"代表generated,这是社区里比较通用的约定。你完全可以用别的后缀,但别用.cs和其他手写文件混淆,也别把多个类型的内容塞进同一个文件,那样IDE里找起来非常崩溃。
3.4 partial类型的生成细节和注意事项
partial类表面看就是加个partial关键字,但真正写生成器时会遇到几个绕不开的细节。
第一,保证命名空间和类名完全一致。这个"一致"是编译器的硬性要求,手写部分如果写的是namespace MyCompany.Order { public partial class OrderDto ... },生成部分就必须是MyCompany.Order.OrderDto。一个容易忽略的场景是顶级语句里的类,它的命名空间是空的,生成的代码里就不能带namespace块,直接在最外层输出partial class。我在实现里专门判断了typeSymbol.ContainingNamespace.IsGlobalNamespace这种情况。
第二,访问修饰符的一致性。手写部分是internal,生成部分就必须是internal;手写部分是public,生成部分必须是public。不一致会编译报错。我自己实现时直接可以从INamedTypeSymbol.DeclaredAccessibility拿,这样无论使用者写成什么,生成器都能跟随。
第三,也是最关键的:如果你的目标类没有声明为partial,生成器应该怎么办。我的选择是:抛出一个编译诊断错误,并且跳过该类型的生成。如果你不检查而继续生成,编译器会报"缺少partial修饰符"之类的错误,错误信息不直观,用户还得自己猜是哪里不对。自定义诊断可以把错误原因写得明明白白:
csharp复制static void Execute(SourceProductionContext spc, JsonContractModel? model)
{
if (model == null) return;
if (!model.IsPartial)
{
spc.ReportDiagnostic(Diagnostic.Create(NotPartialRule, model.Location, model.ClassName));
return;
}
// 正常生成
}
这个诊断信息里带上类名,用户一看错误列表就知道是哪一个类型漏了partial修饰符,修起来非常快。
3.5 后处理与代码风格统一
生成的代码还有一个容易被忽略的点:最终用户可能开启dotnet format或者在CI里做风格检查,如果你生成出来的代码风格和团队的.editorconfig不一致,CI就会挂。我踩过这个坑之后,在生成器里做了一个很笨但很有效的操作:生成完成后调用一次格式化。
具体做法是拿到SourceText之后,用CSharpSyntaxTree.ParseText把生成的代码解析成语法树,再Microsoft.CodeAnalysis.Formatting.Formatter.FormatAsync格式化,最后再把格式化后的文本输出。这样生成结果就是符合Roslyn格式化规则的代码,缩进、空格、换行全都规整,省去和团队风格纠缠的烦恼。代价是生成阶段稍微多一点耗时,但对一个类几十个成员来说,基本可以忽略。
4. 调试生成器:不靠打印日志的验证方式
4.1 挂在VS里调试生成器的两种方式
写生成器最痛苦的不是写,而是调试。你按下F5,业务项目编译了,但生成器里的断点完全不触发,这是很多新手第一次接触生成器时的第一反应。原因在于生成器是跑在编译器进程里的,它和你的调试会话根本不是一个进程。
我常用的方式有两种。第一种是给生成器项目设置调试启动项为Roslyn Component,让VS启动一个专门的实验性实例,在这个实例里打开测试项目并编译,断点就能正常命中。操作路径是:项目属性 -> 调试 -> 启动外部程序 -> 选择 devenv.exe,然后在命令行参数里加上/rootsuffix Exp。VS会启动一个实验实例,这个实例的扩展加载机制允许生成器被调试。
第二种更快,不用开新实例,直接给生成器项目添加一个"可执行文件启动"配置,指向dotnet.exe,参数里带上build命令和你要调试的测试项目路径。这样其实启动的是一个普通的dotnet build进程,但因为你从生成器项目启动调试器,调试器会把这个进程的启动路径挂在生成器dll的加载上,断点也能命中。
4.2 用GeneratorDriver写回归测试
IDE调试适合开发过程中快速看效果,但真正保证生成器长期可靠的要靠自动化测试。Roslyn提供了CSharpGeneratorDriver这个类型,它可以脱离IDE直接跑一次完整的编译:加载语法树、创建编译单元、调用生成器、得到生成结果。
我项目里的测试框架采用Xunit + Microsoft.CodeAnalysis.CSharp包,写了一个固定模式的测试用例:
csharp复制[Fact]
public void 标记类型未声明partial时应产生诊断()
{
var source = """
using MyCompany.Contracts;
[GenerateJsonContract]
public class OrderInfo
{
public string OrderId { get; set; }
}
""";
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 JsonContractGenerator().AsSourceGenerator();
GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics);
Assert.Contains(diagnostics, d => d.Id == "GEN001");
}
这个测试的价值我在长期维护中体会太深了。生成器的逻辑一改,这几十个测试用例在几十秒内告诉你哪里破坏了;如果没有它们,等使用方项目编译时爆出一堆错误,定位成本就高了。
4.3 生成结果文件的探测技巧
测试里还有一个高频需求:怎么拿到生成出来的源码内容。RunGeneratorsAndUpdateCompilation之后,可以遍历driver.GetRunResult().Results,每个GeneratorRunResult里有GeneratedSources,这就是生成文件列表。我经常在断言里检查生成源码里是否包含某个关键字符串,比如"public partial class OrderInfo",万一哪天生成器逻辑改坏了但不报错,这种断言也能及时拦住。
还有一个小技巧分享给用VS的朋友:在生成的源文件上右键,可以"快速监视"它的内容,但这个文件在磁盘上不一定出现在你的项目目录里。如果你需要在文件系统里直观看到生成结果,可以在生成器里临时加一句File.WriteAllText(@"D:\temp\order.g.cs", sourceText.ToString()),跑完编译后直接看这个文件就可以。注意这种代码只能临时加,千万别提交到仓库,否则CI上每个Agent都会往自己的临时目录写文件,纯属噪音。我自己的做法是在测试工程里做这一步,而不是在生成器本体里做。
5. NuGet打包发布:从本地包到公共源全流程
5.1 打包配置和analyzers目录
Source Generator打包成NuGet包和普通类库完全不是一回事。普通类库的dll要放在lib/net8.0目录下,用的时候会被引用为程序集依赖;但生成器的dll必须放在analyzers/dotnet/cs目录下,这样NuGet才会把它识别为Roslyn analyzer/生成器,加载到编译进程中。
这里我直接给出一个经过验证的csproj打包配置:
xml复制<PropertyGroup>
<PackageId>MyCompany.Contracts.Generators</PackageId>
<Version>1.2.0</Version>
<Authors>MyCompany</Authors>
<Description>Json契约代码生成器,基于partial范式自动生成序列化辅助代码</Description>
<PackageTags>source-generator;json;partial</PackageTags>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<IncludeBuildOutput>false</IncludeBuildOutput>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
几个关键点拆开说。IncludeBuildOutput=false是必须的,否则它会把生成的dll同时放到lib目录下,NuGet还原后使用方的项目会多出一个引用,而这个dll又依赖Roslyn,最后就是一堆版本冲突警告。SuppressDependenciesWhenPacking=true用来去掉对Microsoft.CodeAnalysis.CSharp的传递依赖,因为生成器的依赖应该由编译器宿主来提供,而不是塞给使用方项目。DevelopmentDependency=true表示这是一个只在开发期生效的包,使用方发布时不会把它的内容带进产物。
5.2 打包命令和可视化验证
配置写完之后,打包命令一行就够:
bash复制dotnet pack -c Release -o ./artifacts
打完包之后,我强烈建议你打开.nupkg文件确认一下目录结构。.nupkg本质上是一个zip文件,你用解压工具打开看看analyzers/dotnet/cs下有没有你的生成器dll。这一步看着傻,实际上能拦截掉一半的"为什么装了包不生成代码"的问题。我第一次打包时缺少PackagePath配置,dll被扔到了lib目录,结果包是装上了,生成器一个都没跑。
一个更可靠的验证方式是:把这个nupkg文件复制到本地一个NuGet源的文件夹,然后在测试项目里配置这个本地源,执行dotnet add package MyCompany.Contracts.Generators,再执行dotnet build,观察生成的代码是否出现。这个流程模拟了最终使用方的体验,和"我本地直接引项目引用"是完全不同的两个路径,项目引用能跑不代表NuGet包能用。
5.3 版本管理与企业家级细节
版本管理方面,我用的是SemVer语义化版本。功能没变只是修个小bug,1.2.0 -> 1.2.1;新增了生成器能力或属性,1.2.0 -> 1.3.0;如果生成的代码格式本身发生变化,导致使用方代码可能出现编译错误,那必须大版本,1.2.0 -> 2.0.0。这里有个小门道:生成器生成出来的代码变了,对使用方来说是"破坏性变更"还是"平滑升级",你很难提前预判。我的保守原则是,任何可能导致生成结果发生变化的改动都至少升一个minor版本,并且在发布说明里明确提醒使用方重新编译全量项目。
另外,建议在打Release包时为每个版本打一个Git标签。这个动作在后续排查"用户报的bug是不是版本不匹配"时极其好用。我遇到过用户说"我装的是最新版怎么还是旧行为",结果查了标签和nupkg的hash,发现他装的是半年前的一个私有源缓存版本。版本对应的可追溯性,在团队协作中比什么都重要。
5.4 发到NuGet.org的步骤和小坑
公开发布到NuGet.org的流程不复杂:注册账号,在API Keys页面生成一个key,然后命令行登录:
bash复制dotnet nuget push ./artifacts/MyCompany.Contracts.Generators.1.2.0.nupkg --api-key <你的key> --source https://api.nuget.org/v3/index.json
推送之前务必检查一下包是否包含xml文档文件和pdb符号。生成器这个类型的包,符号调试能力尤其重要。dotnet pack时默认会生成snupkg符号包,你可以在同目录下看到。建议把这个符号包也推送上去:--symbol-source https://api.nuget.org/v3/index.json --symbol-api-key <你的key>。
还有一个小坑:发布之后NuGet.org的索引刷新不是即时的,通常要等几分钟到十几分钟。如果你立刻在另一个项目里dotnet add package找不到新版本,别慌,等一等再试。我一般会先检查https://www.nuget.org/packages/MyCompany.Contracts.Generators页面确认版本列表里是否出现新版本,再回去试还原。
6. 踩坑实录:这些问题不经历一遍真不知道
6.1 Roslyn版本不一致,升级之后生成器直接崩了
我在2.1节里提过引用的Roslyn版本不要追新,这里用一个真实翻车现场来说说原因。
最初我给生成器引用了Microsoft.CodeAnalysis.CSharp 4.9,因为当时最新版本就这个。发布给团队用了一个月,一切正常。后来有个同事的VS自动更新到了一个新版本,他重新编译项目,生成器抛出了MissingMethodException。查了一整天才搞明白:生成器dll引用了Roslyn 4.9里某个新增API,而编译器宿主加载的Roslyn版本还是老版本的4.8,运行时就找不到那个方法。VS自动更新只会更新IDE外壳,编译器依赖的Roslyn程序集版本并不一定跟着升。
这个问题的解法有两个方向:一是把生成器引用的Roslyn版本降到长期支持的下限版本,并且确保自己的代码只用这个版本的API;二是用反射去规避新API调用,但那样代码会变得很难看,我不推荐。我现在维护的生成器固定在Microsoft.CodeAnalysis.CSharp 4.8,凡是只在新版本里出现的方法,都用传统API代替。代价是代码不如"全网最新写法"简洁,但换来了从VS2019到VS2022全系列的稳定性。
6.2 目标类漏写partial,错误提示救了半个团队
有一次团队里新同事给一个业务类加了[GenerateJsonContract]特性,忘了写partial。如果没有自定义诊断,那编译器报的错误就是"已定义包含相同参数的同名成员"之类的迷惑信息,他大概率会认为是生成器bug,跑过来找我排查。
因为有GEN001诊断,他的错误列表里清楚写着"类型'OrderView'使用了[GenerateJsonContract]但未声明为partial",他秒懂是自己漏了关键字,改完就没事了。这个细节让我意识到,生成器不能只是"静默干活的魔法",它必须能在自己无能为力时给使用者一个明确的指引。错误信息里带上类名、特性名、补救方案三要素,是最低要求。
6.3 生成器dll被当作普通工程量引用的连锁反应
另一个高频错误是使用方项目直接添加了生成器项目的ProjectReference而不是安装NuGet包。项目引用加了之后,使用方确实能正常跑,因为生成的dll被当成了普通程序集被引用。但问题在于,生成器dll会被复制到输出目录,它会带着对Roslyn的传递依赖,这些dll和编译器自带的Roslyn版本一旦不一致,运行时就会冲突。
最典型的症状是:编译时一切正常,运行时报System.IO.FileLoadException,提示无法加载Microsoft.CodeAnalysis。查了半天发现是生成器dll被额外复制到了bin目录。所以我这里再强调一遍:要引用生成器,安装NuGet包,不要用项目引用;如果团队内部开发阶段图方便用了项目引用,那调试完一定要切回NuGet包验证一次。
6.4 增量缓存导致的"假残留"和"真不生成"
Roslyn的增量生成器(IIncrementalGenerator)会自动缓存上次编译的结果,只有输入变化时才重新执行。这个机制对编译速度是好事,但也带来过两个困惑。
第一个是"假残留":同一个类名改了成员后,重新编译,发现生成代码里还残留着旧成员。排查后发现是我的transform节点里用了非确定性的数据源——我用了Guid.NewGuid()作为静态字段的初始化值。每次重新执行生成的代码都带上一个新Guid,但增量缓存判断"没变化"时就直接复用旧输出,导致新旧代码混杂。解法是:生成内容里绝对不能包含非确定性的值,包括不限于当前时间、随机数、机器名、绝对路径。
第二个是"真不生成":改了一个依赖的手写类,编译后生成器完全没反应。仔细查了才发现自己写错了增量管线的组合逻辑。我在RegisterSourceOutput里直接用了一个被WithTrackingName包裹的中间节点,某个分支没有把新输入传进来。排查增量这个问题很费时间,我的经验是严格遵循这套模板:SyntaxProvider -> Select(transform) -> Where(filter) -> Collect() -> RegisterSourceOutput,每一步都要有明确的输入输出模型,不要滥用WithTrackingName。
6.5 生成代码里不该出现的"环境烙印"
最后说一个比较隐蔽的问题:生成代码的可复现性。CI服务器和本地开发机的路径不同、操作系统不同,如果生成代码里不小心带上了绝对路径或者带平台的换行符,打出来的包每次构建内容都不一样,这会给缓存和包比对带来麻烦。
具体到我自己的做法是:生成文件头里的警告信息不写任何路径相关的内容,所有源码输出统一用\n作为换行符,并且不包含任何AutoGenerate的时间戳。如果你打开一个生成器生成的代码文件,看到的应该是一份完全确定性的文本——任何两个人在任何时间构建,生成的内容理论上都应该逐字节一致。这既是.NET团队官方对源码生成器的设计要求,也是我踩了多次坑之后总结的底线。加上这一点之后,我们的差分编译缓存命中率明显提升了,CI构建时间也稳定下来。
总的来说,一个Source Generator从能跑到能稳定地作为NuGet包分发,中间隔着的距离就是这些细节的堆积。技术选型也好,API设计也好,最终服务的都是"生成结果可靠、使用方无感、团队协作顺畅"这三个目标。你要是准备在项目里落地生成器,建议先从一个小而明确的场景做起,比如给DTO生成映射代码,跑通之后再去覆盖更复杂的业务,经验和教训都是这么一点一点攒出来的。
