有没有算过你日常项目的样板代码到底占了多少行?我以前统计过一个不大不小的业务项目,DTO、枚举转换、通知属性这些“复制粘贴型”代码能占到四成。改一个字段名要动三四个地方,漏一处就在运行时报奇怪的错。后来我用.NET源码生成器把这些活全部放到编译期解决,顺手打了个NuGet包给团队共用。这篇文章就把整个思路和落地过程完整拆一遍:partial范式到底解决什么问题、生成器怎么写、NuGet分析器包怎么打才不踩坑。全程是实操路线,你在自己的项目里可以直接照做。
1. 整体设计与思路拆解:为什么是源码生成器而不是其他方案
1.1 样板代码的痛点与源码生成器的定位
写代码的时间长了你会发现,项目里最没意思的部分往往不是业务逻辑,而是那些“非写不可”的重复结构。比如定义一个带通知属性的ViewModel,通常要写字段、写属性、在setter里调PropertyChanged;定义一个DTO,要手写Equals和GetHashCode;定义枚举,要让每个枚举值附带展示文案和绑定值。这些代码不是不会写,而是写着烦,改起来更烦。
传统的处理方式有几种,但都各有各的难受。运行时反射最灵活,可它有两个硬伤:第一是性能损耗,虽然现代反射已经优化过,但大量使用仍然会带来预热成本和CPU开销;第二是错误时滞,字段名拼错了反射并不报错,要等用户真正触发那条路径才炸出来。T4模板听上去不错,但生成的代码是实实在在落盘的,多人协作时很容易在代码仓库里产生冲突,而且模板更新后经常忘记重新生成。外置代码生成工具的体验更割裂,生成器在命令行里跑完,把代码贴进项目,之后手改和生成的代码就开始分家,维护成本直线上升。
源码生成器不一样。它在编译过程中读取代码的语法树和语义模型,在当前编译单元的“上游”生成新的C#代码,然后和手写代码一起参与后续编译。这意味着几个非常关键的优点:没有任何运行时开销,生成逻辑全部在编译期完成;类型错误在编译期就会被编译器抓住,而不是拖到生产环境才显形;生成的代码不落盘,不会污染仓库也不会产生合并冲突。你可以把它理解成“给编译器外挂了一个自动化助手”,它看到的不是字符串,而是有类型信息、有命名空间解析能力的完整语义世界。
1.2 为什么partial范式是生成器的地基
源码生成器有一个能力边界需要先认清:它不能修改已经存在的代码,只能在编译单元里追加新的语法节点。这个限制听起来很严格,但配合C#的partial机制,反而变成了一套非常优雅的设计范式。
partial类型允许一个类、结构体或接口的定义分散在多个源文件中。编译器在编译时会把这些分散的部分合并成一个完整的类型。源码生成器生成的代码,本质上就是往这个合并过程中“塞”了一个新的文件。只要手写部分声明了partial,两边就能无缝合成一个整体,字段、属性、方法互相可见。这就好比你要给房子加装一套新风系统,前提是装修时在墙体里预留了管道井;partial就是这个管道井。
如果目标类型没有声明partial,生成器仍然可以生成一个全新的类型,但那样做意义就大打折扣了。比如你不能给一个普通类补上INotifyPropertyChanged的实现,因为生成的代码无法合并进原有的类定义。所以几乎所有的源生成器项目都会约定:使用方必须把目标类声明为partial,并在类上打一个特征标记(Attribute),生成器通过这个标记找到目标,然后生成同命名空间下的partial声明。这不是技术上的妥协,而是唯一能让生成代码和手写代码“共用一个身体”的方式。
我们实际项目中积累的经验是:在代码评审阶段就要把这个约定写进团队规范,凡是准备用源生成器的类型,统统声明partial。你永远不知道下一个生成器会不会用到这个类,提前声明是零成本的,但漏了再补就要等下一次编译才能发现问题。
1.3 增量生成器与数据管道设计思路
早期版本的Roslyn提供的是ISourceGenerator接口,实现起来直观,但性能有隐患。只要编译输入有任何风吹草动,整个生成器就会重新跑一遍,在大型解决方案里,IDE的输入序列化、代码补全这些高频操作会被拖慢。后来引入了IIncrementalGenerator,它的核心思想是“管道化+缓存化”。
你要做的不是直接拿到Compilation就去生成代码,而是把处理过程拆成多个阶段:先用SyntaxProvider筛选出候选节点,再做语义解析提取需要的数据,最后把数据转换成生成代码。每一步都输出一个不可变的小型模型,Roslyn会对比前一次编译时的模型,如果没变化就直接复用缓存,只有真正发生变化的分支才会重新执行。这套思路就像水管接口,水压不变时管道里的水是静止的;只有水压变了,对应那段管道才会开始流动。
一个容易忽视的要点是:中间模型的类型必须具备稳定的相等性判断。如果你传入的是引用类型,每次执行都返回一个新实例,那就算内容完全一样,缓存系统也会认为它变了。最省事的办法是使用record类型,或者在自定义类中重写Equals和GetHashCode。这里的设计直接决定你在Visual Studio里保存文件时的体验,初期不重视,后面增量构建全失灵,排查起来相当费劲。我写代码的时候会把模型设计成尽量精简的只读记录,属性用get-only初始化,确保同一输入永远产生同一模型。
2. 核心细节解析与实操要点:partial范式的工程落地
2.1 目标类型的声明方式与约束
实际使用时,调用方的代码大概长这样:
using MyToolkit.ComponentModel; namespace Acme.OrderModule { [AutoNotify] public partial class OrderViewModel { public string OrderNo { get; set; } public decimal TotalAmount { get; set; } } }生成器扫描到[AutoNotify]特性后,会为OrderViewModel生成一个partial扩展文件,里面实现INotifyPropertyChanged,并在每个属性的setter里触发PropertyChanged事件。这个场景里,partial是关键,[AutoNotify]是坐标,两个条件缺一不可。
但有一些约束是初学者特别容易碰到的。首先是可见性边界,如果目标类型是一个私有的嵌套类型,对外层类型来说是私有的,生成器在语义模型里查找时能拿到符号,但生成的代码如果和外层类型不在同一个程序集或同一种访问上下文里,就会出现CS0122这类访问级别错误。其次是命名空间的匹配,生成的partial声明必须和目标类型的命名空间一模一样,一个字母都不能差,否则编译后就是两个完全不同的类型,生成器就等于白跑了。
嵌套类型的情况最麻烦。假设OrderViewModel被嵌套在OrderPage类里面,生成代码不能只写:
namespace Acme.OrderModule { public partial class OrderViewModel { // 属性实现 } }因为这样会生成一个顶层类型OrderViewModel,而不是嵌套类型OrderPage.OrderViewModel。正确做法是先把外层类型的partial骨架写出来,再在外层类型内部生成嵌套的partial类型:
namespace Acme.OrderModule { public partial class OrderPage { public partial class OrderViewModel { // 属性实现 } } }在生成器实现中,我习惯写一个工具方法,递归解析目标类型的ContainingType链,把每一层命名空间和外围类型的partial声明串起来。这个细节如果漏掉,你会在数据集模型里看到一堆“生成的类型无法匹配目标类型”的诡异错误,排查半天才发现是最外层的容器没写partial。
2.2 生成内容的组织方式与命名空间策略
源码生成器的输出是通过SourceProductionContext.AddSource完成的。每个输出源需要一个唯一的HintName,这个名称会出现在Visual Studio的解决方案资源管理器里,也会决定编译器如何处理这个虚拟文件。推荐格式是“{目标类型全名}.{生成器标识}.g.cs”,全名中命名空间的点号建议替换成下划线,避免文件系统路径的歧义。
生成代码的字符串拼接是一个实践性很强的问题。最朴素的做法是用StringBuilder拼字符串,但缩进和换行全靠手写,一旦嵌套层次加深,生成的代码就变成了灾难。我推荐使用微软提供的SourceWriter类,这是一个专门面向代码生成的缩进工具,它的接口类似StringWriter但自动处理缩进级别,写起来和正常的C#代码几乎没有区别。当然,如果你用.NET 6以上的环境,直接用IndentedTextWriter也是一样的效果。
var writer = new StringWriter(); var indentWriter = new IndentedTextWriter(writer, " "); indentWriter.WriteLine("// <auto-generated />"); indentWriter.WriteLine("#nullable enable"); indentWriter.WriteLine(); indentWriter.WriteLine($"namespace {typeNamespace}"); indentWriter.WriteLine("{{"); indentWriter.Indent++; indentWriter.WriteLine($"public partial class {typeName}"); indentWriter.WriteLine("{{"); indentWriter.Indent++; // 生成属性和方法 indentWriter.Indent--; indentWriter.WriteLine("}}"); indentWriter.Indent--; indentWriter.WriteLine("}}"); context.AddSource(hintName, SourceText.From(writer.ToString(), Encoding.UTF8));这里要特别注意编码。SourceText.From默认的编码不是你传入的Encoding,它会自己判断。为了确保生成的文件在中文系统写入时不会出现乱码,建议显式指定一个固定的编码参数。另外,第一行写“// ”不只是惯例,它还告诉IDE的静态分析工具“这个文件是生成的,不要给用户报风格警告”,这个注释对我的实际体验帮助很大,FormattableAnalyzer会对生成代码产生大量意见,有这个注释就能直接压制。
2.3 partial方法、partial属性与生成器配合的新姿势
写完基础的属性生成之后,你会遇到一个需求:用户希望在某些属性值变化时执行自己的业务逻辑,比如更新总价、触发联动校验。如果生成器把setter完全接管,这个口子怎么留?
C#在partial机制里提供了两个关键语法元素。第一个是partial方法,C# 9之前partial方法有很多限制:必须是私有、必须返回void、不能有out参数。如果你要在生成代码中调用一个用户自定义的partial方法,签名必须满足这些限制。C# 9之后放宽了限制,partial方法可以带访问修饰符、可以返回非void类型。所以现在你可以这样设计生成代码:
public partial class OrderViewModel { partial void OnTotalAmountChanged(decimal newValue); public decimal TotalAmount { get => _totalAmount; set { if (!EqualityComparer<decimal>.Default.Equals(_totalAmount, value)) { _totalAmount = value; OnTotalAmountChanged(value); OnPropertyChanged("TotalAmount"); } } } }用户如果想在金额变化时做点什么,就在自己的partial文件里补一个partial方法的实现:
partial void OnTotalAmountChanged(decimal newValue) { // 计算折扣、刷新库存等业务逻辑 }编译器会把手写实现和生成声明合并成同一个方法调用。如果用户不提供实现,编译器会自动移除调用点并消除所有参数求值副作用,整个过程零开销。这套模式的精髓在于:生成器负责“固定套路”,用户负责“特殊逻辑”,两条代码路径通过partial建立契约,互不覆盖,互不冲突。
第二个是C# 13引入的partial属性,这对源生成器来说是又一大利好。以前要生成一个带后备字段的自动属性,你只能在生成代码里完整定义属性,用户完全没有插入点。partial属性允许声明和实现分离,声明带访问修饰符和签名,实现由生成器生成。这样用户可以在自己的代码里写public partial string Name { get; set; },生成器提供实现并挂上通知逻辑,代码可读性和扩展性比partial方法更直接。不过要注意,partial属性在旧语言版本下不可用,如果你的包要兼容老项目,还是得多用partial方法方案。
3. 实操过程与核心环节实现:从零搭建一个源生成器项目
3.1 项目结构与依赖准备
一个源生成器的解决方案最好拆成三个项目:第一个是目标库项目,比如叫Library;第二个是生成器项目,比如叫Library.Generator;第三个是测试或示例项目,用来验证生成效果。有人喜欢把特性定义和生成器放同一个项目,我建议不要这样,因为特性最终会被目标项目引用,而生成器项目是被编译器加载的,两个项目的引用关系和目标框架都不一样,拆开才能让各自的职责清晰。
生成器项目的.csproj是这个方案的第一道坎。直接上配置:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> <LangVersion>latest</LangVersion> <Nullable>enable</Nullable> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> <IsRoslynComponent>true</IsRoslynComponent> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" /> </ItemGroup> </Project>TargetFramework必须锁定netstandard2.0,原因在第四章会详细说,这里先记住结论。Microsoft.CodeAnalysis.CSharp是生成器的核心依赖,它提供增量生成器接口、语法工厂和语义模型API。版本号选择要谨慎,它在很大程度上决定你的生成器能在哪些版本的Visual Studio和.NET SDK里使用。比如4.8.0对应较新的SDK,如果你的队友还在用老版本IDE,就得往下调整。
3.2 特性定义与生成器核心代码实现
特性定义在目标项目里,它只是普通代码:
namespace MyToolkit.ComponentModel { [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] public sealed class AutoNotifyAttribute : Attribute { } }为了让生成器能看到这个特性,有一个两难问题:生成器项目通常会引用目标项目,但目标项目又会引用生成器项目,形成循环。最常见的解法是把特性定义所在的源文件通过 直接链接进生成器项目,或者更彻底一点,把生成器项目里的特性定义和Microsoft.CodeAnalysis依赖分开,生成器只引用特性符号而不依赖具体程序集。
生成器核心代码的结构用增量管道来描述最清楚。我用一个自动化通知属性的生成器作为完整示例:
using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; namespace MyToolkit.Generators { [Generator(LanguageNames.CSharp)] public sealed class AutoNotifyGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( "MyToolkit.ComponentModel.AutoNotifyAttribute", static (node, _) => node is ClassDeclarationSyntax, static (ctx, ct) => { var classSymbol = (INamedTypeSymbol)ctx.TargetSymbol; var typeNamespace = classSymbol.ContainingNamespace.IsGlobalNamespace ? string.Empty : classSymbol.ContainingNamespace.ToDisplayString(); var typeName = classSymbol.Name; object? nestedPrefix = null; if (classSymbol.ContainingType is { } outer) { nestedPrefix = outer.ToDisplayString(); // 递归收集外围类型 } var properties = classSymbol.GetMembers() .OfType<IPropertySymbol>() .Where(p => p.SetMethod is { DeclaredAccessibility: Accessibility.Public }) .Select(p => new PropertyModel(p.Name, p.Type.ToDisplayString(), p.IsStatic)) .ToArray(); return new ClassModel(typeNamespace, typeName, nestedPrefix?.ToString(), properties); }); context.RegisterSourceOutput(candidates, static (spc, model) => { if (model.Properties.Length == 0) { return; } var source = BuildSource(model); spc.AddSource($"{model.FlattenedName}.AutoNotify.g.cs", SourceText.From(source, Encoding.UTF8)); }); } private static string BuildSource(ClassModel model) { // 用StringWriter和IndentedTextWriter生成partial类代码 } } internal sealed record PropertyModel(string Name, string Type, bool IsStatic); internal sealed record ClassModel( string Namespace, string TypeName, string? NestedPrefix, IReadOnlyList<PropertyModel> Properties) { public string FlattenedName => string.IsNullOrEmpty(NestedPrefix) ? $"{Namespace}.{TypeName}".Replace('.', '_') : $"{Namespace}.{NestedPrefix}.{TypeName}".Replace('.', '_'); } }ForAttributeWithMetadataName是增量管道里最高效的入口,它把“查找带特性标记的节点+解析语义模型”封装成一站式操作,只返回真正带有目标特性的类。transform阶段从语义符号中提取我们需要的纯数据——命名空间、类名、属性列表,这些数据被装进record类型,具备值相等性,可以被增量缓存安全缓存。register阶段用这些纯数据拼字符串,不碰编译对象,不碰语法树,性能自然有保障。
3.3 生成代码的调试三板斧
源生成器调试起来比普通库麻烦一些,因为它运行在编译器内部,你看不到运行过程。我总结了一套效率还不错的调试方法。
第一板斧是让生成结果可见。在目标项目里打开.csproj,加上:
<PropertyGroup> <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> <CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)generated</CompilerGeneratedFilesOutputPath> </PropertyGroup>重新构建后,到obj/generated目录里找生成的文件。这里能看到生成代码的实际内容,绝大多数“我明明写了生成逻辑但没输出”的问题在这一步就能暴露。
第二板斧是直接跑一个Driver。给生成器项目添加一个临时的可执行启动项,写一个控制台程序,引用Microsoft.CodeAnalysis.CSharp包,用CSharpCompilation构造编译对象,再调用GeneratorDriver运行生成器:
var compilation = CSharpCompilation.Create("Demo", new[] { CSharpSyntaxTree.ParseText(File.ReadAllText("sample.cs")) }, new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) }, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); GeneratorDriver driver = CSharpGeneratorDriver.Create(new AutoNotifyGenerator()); driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); foreach (var item in driver.GetRunResult().GeneratedTrees) { Console.WriteLine(item.GetText().ToString()); }这套方法的好处是可以断点调试生成器方法,每次运行只耗时几百毫秒,比反复开Visual Studio构建省太多时间。
第三板斧是性能排查。如果你怀疑生成器让构建变慢了,打开dotnet build -bl生成MSBuild的binlog,再用MSBuild结构化的日志查看器打开,里面能看到每个生成器的执行时间。另外在命令行设置DOTNET_EnableGeneratorReporting=true,让CLI编译下生成器上报执行统计,一般足够定位慢在哪一环。
3.4 性能与缓存注意事项
增量生成器虽然自带缓存,但不代表随便写就能享受到增量红利。两个最常见的性能杀手值得专门注意。
第一个是transform阶段做太多事情。语义模型访问是所有编译操作中最昂贵的,获取一个符号可能触发整个命名空间的解析。所以在transform里,只提取生成代码必需的最小信息集,其他一概不碰。比如判断一个ClassDeclarationSyntax是不是记录类型,直接用IsRecord属性就行,不要加载完整符号再反射。
第二个是缓存平等性缺失。如果你的中间模型用的是普通class,而且没有重写Equals,那每次编译时生成的实例都是全新对象,缓存系统比对后发现不相等,接下来的整个链路全部重跑。record的引入就是为了解决这个问题,它自动生成基于属性值的Equals和GetHashCode。还有一个隐性坑:模型里如果有数组或List,record的相等性无法自动覆盖集合内容。我一般会改造成返回ImmutableArray ,或者手动聚合到字符串、元组里。
代码输出阶段也要讲究确定性。同一输入必须产生完全相同的字符串,否则增量缓存会被构建系统的指纹识别打穿。比如不要用DateTime.Now给生成的代码打时间戳,不要依赖枚举的默认排序,属性列表在transform阶段就要稳定排序。
4. NuGet打包与分发设计:从本地项目到团队一键引用
4.1 分析器包的结构原理
生成器写好了,只能自己用是不行的,放进NuGet包分发给团队,才是源生成器价值的最大兑现方式。但是源生成器的包结构和普通类库不一样,这个差异让很多第一次打包的人摔跟头。
普通类库的dll要放在lib目录或者lib/netstandard2.0目录下,引用方会把dll复制到输出目录。但源生成器包必须把生成器dll放进analyzers/dotnet/cs目录下。这个目录是Roslyn编译器加载分析器的约定位置,编译器启动时会从这个路径加载程序集,在编译进程中执行,而不是把它复制到项目输出目录。如果把生成器dll放到lib目录,引用项目会把一个为netstandard2.0编译的程序集复制到输出目录,而它根本没有运行时逻辑,除了一堆“无法加载分析器”的警告,什么作用都没有。
包的最小结构长这样:
MyCompany.Generators.PocoNotify.1.0.0.nupkg └── analyzers └── dotnet └── cs ├── MyCompany.Generators.PocoNotify.dll └── MyCompany.Generators.PocoNotify.xmldotnet指代Roslyn的C#语言,cs也是。如果将来要支持VB,就需要在analyzers/dotnet/vb下再放一份。这里不用放lib目录,也不用放任何运行时依赖。
4.2 csproj打包配置详解
在生成器项目的.csproj里,把所有Pack相关的配置一次性列出来:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> <LangVersion>latest</LangVersion> <Nullable>enable</Nullable> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> <IsRoslynComponent>true</IsRoslynComponent> <!-- 打包配置 --> <PackageId>MyCompany.Generators.PocoNotify</PackageId> <Version>1.0.0</Version> <Authors>MyCompany</Authors> <Description>编译期自动生成INotifyPropertyChanged实现</Description> <IncludeBuildOutput>false</IncludeBuildOutput> <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking> <DevelopmentDependency>true</DevelopmentDependency> <NoWarn>$(NoWarn);NU5128</NoWarn> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" /> </ItemGroup> <!-- 显式把输出dll打进analyzers目录 --> <ItemGroup> <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> </ItemGroup> </Project>逐个解释这些属性的作用。IncludeBuildOutput=false告诉NuGet不要把默认的lib输出打进去,默认打包逻辑会把编译出来的dll放入lib目录,对我们来说这就是灾难。SuppressDependenciesWhenPacking=true避免把Microsoft.CodeAnalysis.CSharp及其依赖链作为PackageReference写入nuspec,因为使用方项目的编译器环境本身就带着Roslyn程序集,你再声明一份反而容易引起版本撕裂。DevelopmentDependency=true标记为开发依赖,这样使用方项目在打包发布自己时不会把我们的包带进产品的依赖列表。
有个细节值得单独提醒:EnforceExtendedAnalyzerRules和IsRoslynComponent这两个属性不是给NuGet打包用的,但它们会影响编译过程。IsRoslynComponent会在项目里启用一些和分析器相关的编译诊断规则,避免写出可以运行但不是最佳实践的代码。EnforceExtendedAnalyzerRules则开启RS1035等规则,防止我们在生成器代码里使用禁止的API,比如访问文件系统。这两个属性加上去,一开始会新增一批警告,修完后生成器质量会明显提高。
4.3 依赖dll的打包策略
如果生成器逻辑里用到了第三方库,比如Humanizer的字符串转换、MediatR的元数据解析,这些依赖dll去哪里了?答案是他们不会随着你的生成器dll自动出现在NuGet包中。因为SuppressDependenciesWhenPacking=true已经把包依赖声明压掉了,即使不压掉,NuGet也不会自动帮你把依赖装到analyzers目录下。
最稳妥的做法是让生成器尽量自包含,不要依赖大型运行时包。万一确实需要,比如你写了一个基于正则或字符串处理的辅助库,可以直接把那个库的dll一并包进来:
<ItemGroup> <None Include="$(OutputPath)\MyDependency.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> </ItemGroup>但这里有个隐患:这个依赖dll会直接参与编译进程的AssemblyResolution,它和项目里其他程序集存在同名加载冲突时,结果取决于加载顺序,极难调试。所以我的原则是:能不用就不用,如果非用不可,一定选择无依赖的小型库,并且锁死版本。项目里所有源生成器共享同一个依赖程序集时,尤其要小心版本漂移。
4.4 多目标与兼容性验证
为什么TargetFramework必须用netstandard2.0,现在可以展开讲了。源生成器dll有两个宿主环境要加载:第一个是MSBuild编译进程,它可能运行在.NET Framework或.NET Core上,具体取决于你在用什么SDK和操作系统;第二个是Visual Studio的IDE分析进程,它同样可能是.NET Framework或新的.NET。netstandard2.0是这些运行时的最小公共子集,保证在所有宿主里都能加载。
这意味着一个现实约束:你在生成器代码里不能使用任何netstandard2.0之外的新API,比如System.Text.Json里某些只在net6.0可用的重载。LangVersion设置成latest只影响C#语法层面的能力,比如可以用record、switch表达式,但底层的基础类库API还得老老实实遵守netstandard2.0边界。
打包完成后,本地验证是做不得假的环节。我一般会创建两个测试项目:一个是netstandard类库,一个是老式.NET Framework项目,分别引用刚打的nupkg,在本地NuGet源里配置好路径,然后检查三件事。第一,编译是否无警告;第二,obj目录下generated文件夹中是否出现了生成器输出;第三,输出目录里是否有生成器dll出现,如果有,说明打包目录配错了,必须改到analyzers下重新打。
5. 常见问题与排查技巧实录
5.1 生成结果没有出现或被忽略
这是整个过程中出现频率最高的故障,症状是引用包之后,编译成功了,但目标类没有任何生成代码的效果。排查顺序我从高到低列一下:
| 表现 | 可能原因 | 处理方式 |
|---|---|---|
| 编译无输出,IDE无反应 | 包未被识别为分析器 | 检查nuspec中的路径是否为analyzers/dotnet/cs |
| 编译成功但类型少方法 | 目标类没有partial | 给目标类补partial关键字 |
| 只有Release没Debug | 条件编译路径不同 | 检查生成器里的#IF分支 |
| 输出出现在obj但IDE看不到 | 未开启显示生成文件 | 使用EmitCompilerGeneratedFiles并检查obj/generated |
另一个容易忽略的原因是NuGet缓存。调试时改了生成器代码,重新打包还是老版本,这时候需要nuget locals all -clear,或者直接在Visual Studio里清除包缓存。
5.2 partial缺失导致类型合并失败
很多用户在目标类上忘写partial,结果编译报CS0260(缺少partial声明)或者CS0101(重复定义)。生成器项目里打印错误时要把提示写成“请确保目标类型标记为partial”,否则用户看到报错根本不知道生成器在说什么。
还有一种更隐蔽的情况:手写代码和生成代码的类型签名不一致。比如手写部分把类声明成了public partial class OrderViewModel : IEquatable ,而生成器生成的是public partial class OrderViewModel : INotifyPropertyChanged,编译器会把两个部分合并成一个,但要求所有partial声明的基类列表必须一致,否则报CS0263。我建议生成器在生成代码时只生成目标特征,不要试图声明额外的接口或基类,这些留给手写部分去写。如果生成器确实需要约束基类,必须在文档里明确告诉调用方。
5.3 增量构建失灵与性能问题
桌面IDE保存文件时,如果每次都要全量重跑生成器,用户会明显感觉卡顿。增量失效的排查思路是把“缓存命不命中”可视化的环境变量开起来。在命令行里设置DOTNET_EnableGeneratorReporting=true,构建一次,然后在binlog里看生成器的执行次数。如果第二次构建时生成器仍然执行了,说明管道中某个输入被判定为“变了”。
最常见的元凶就是我前面提到的模型相等性。假如你在transform阶段输出的不是record,而是带List属性的class,那每次编译List都会得到一个新实例,缓存判定永远为不等。解决方法是模型使用record,并把集合换成ImmutableArray,或者直接拼接成逗号分隔的字符串。
还有一类容易忽略的输入变化是AnalyzerConfigOptionsProvider,也就是MSBuild属性。如果生成器在管道里合并了编译选项,而构建过程每次都会改变某些属性值,那么缓存就会频繁失效。可以在transform阶段对相关属性做规范化处理,比如排序、去重。
5.4 NuGet引用后被当成普通包
一个典型的错误场景是:项目引用包后,bin目录里出现了生成器dll,而且IDE报警告“加载了无法识别的分析器”。这几乎可以断定是打包路径错了。我建议打包后用解压缩工具直接看nupkg的结构,如果发现Lib目录下有一堆dll,立即检查IncludeBuildOutput是否被设成了true,或者是不是用了默认的打包逻辑。
还有一种情况,生成器的dll本身没问题,引用项目也没有把dll复制到输出目录,但就是没效果。这时要看PackageReference有没有被错误地设置成ExcludeAssets="all",或者Parent项目使用了NoWarn把NU5128压掉的同时把分析器警告也压了。标准的引用写法是:
<PackageReference Include="MyCompany.Generators.PocoNotify" Version="1.0.0" PrivateAssets="all" />PrivateAssets=all表示这个包不会被传递到上层项目,也不会被当成编译依赖暴露给使用者,只本地生效。如果项目里有人图省事删掉了PrivateAssets,这个包可能会被带到另一个项目,然后在那个项目里再触发一次生成,导致同样的生成代码在多个项目里出现,虽然通常不致命,但会增加包体积和编译时间。
5.5 编译器版本不兼容的坑
最后再说一个特别容易把新手绕晕的点:生成器依赖的Microsoft.CodeAnalysis.CSharp版本和使用方编译环境的Roslyn版本不一致。Roslyn有一套“编译环境版本与API可用性”的匹配规律,如果生成器引用的是4.8.0,而调用方使用的是老版本SDK自带的4.4.0,运行时大概率会抛出FileLoadException或MissingMethodException。
应对办法是尽量让你的生成器基于较低的Roslyn API版本构建。比如Microsoft.CodeAnalysis.CSharp 4.4.0甚至4.0.1,这套API的核心增量生成接口从4.0就稳定了,完全够用。不要盲目追求最新版包里的新API,新增的特性对你的生成器来说通常不是刚需。如果你确实用了某个新版API,就必须在包说明文档里标注最低编译器版本,否则队友一编译就炸,到时候别人会怀疑你包的质量。
我非常建议在生成器的README里放一张兼容性表:SDK版本、Visual Studio版本、Roslyn版本、生成器包版本,四列对应关系写清楚。维护这个表格虽然麻烦,但它能把“为什么我的项目用不了这个包”的排查时间从一小时缩短到三分钟。
写在最后的一点体会
调这个生成器的过程中,我踩过的坑有不少,但最值得分享的其实是流程上的经验。源生成器的调试周期比普通库长,因为每次改完都要重新编译目标项目才能看到效果,所以一定先搭一个极简的最小验证闭环:一个目标类、一个特性、一个生成方法,跑通后再往里面加复杂度。我见过不少人一上来就想做“万能代码生成器”,结果在聚合各种语法树上花了一晚上,连最基础的通知属性都没跑通,这种投入产出比非常不划算。源生成器最适合处理规则明确的重复代码,特性标注就是契约;而偏重业务变体的手写代码,再香也别硬套。先把部分代码交给编译器,用的时候,你会明显感觉改样板代码这件事,终于不用亲力亲为了。