.NET源码生成器实战:用partial范式终结样板代码

发布时间:2026/10/21 7:53:50
.NET源码生成器实战:用partial范式终结样板代码 有没有算过你日常项目的样板代码到底占了多少行我以前统计过一个不大不小的业务项目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 (!EqualityComparerdecimal.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 SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknetstandard2.0/TargetFramework LangVersionlatest/LangVersion Nullableenable/Nullable EnforceExtendedAnalyzerRulestrue/EnforceExtendedAnalyzerRules IsRoslynComponenttrue/IsRoslynComponent /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.CodeAnalysis.CSharp Version4.8.0 PrivateAssetsall / /ItemGroup /ProjectTargetFramework必须锁定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() .OfTypeIPropertySymbol() .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, IReadOnlyListPropertyModel Properties) { public string FlattenedName string.IsNullOrEmpty(NestedPrefix) ? ${Namespace}.{TypeName}.Replace(., _) : ${Namespace}.{NestedPrefix}.{TypeName}.Replace(., _); } }ForAttributeWithMetadataName是增量管道里最高效的入口它把“查找带特性标记的节点解析语义模型”封装成一站式操作只返回真正带有目标特性的类。transform阶段从语义符号中提取我们需要的纯数据——命名空间、类名、属性列表这些数据被装进record类型具备值相等性可以被增量缓存安全缓存。register阶段用这些纯数据拼字符串不碰编译对象不碰语法树性能自然有保障。3.3 生成代码的调试三板斧源生成器调试起来比普通库麻烦一些因为它运行在编译器内部你看不到运行过程。我总结了一套效率还不错的调试方法。第一板斧是让生成结果可见。在目标项目里打开.csproj加上PropertyGroup EmitCompilerGeneratedFilestrue/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_EnableGeneratorReportingtrue让CLI编译下生成器上报执行统计一般足够定位慢在哪一环。3.4 性能与缓存注意事项增量生成器虽然自带缓存但不代表随便写就能享受到增量红利。两个最常见的性能杀手值得专门注意。第一个是transform阶段做太多事情。语义模型访问是所有编译操作中最昂贵的获取一个符号可能触发整个命名空间的解析。所以在transform里只提取生成代码必需的最小信息集其他一概不碰。比如判断一个ClassDeclarationSyntax是不是记录类型直接用IsRecord属性就行不要加载完整符号再反射。第二个是缓存平等性缺失。如果你的中间模型用的是普通class而且没有重写Equals那每次编译时生成的实例都是全新对象缓存系统比对后发现不相等接下来的整个链路全部重跑。record的引入就是为了解决这个问题它自动生成基于属性值的Equals和GetHashCode。还有一个隐性坑模型里如果有数组或Listrecord的相等性无法自动覆盖集合内容。我一般会改造成返回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 SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknetstandard2.0/TargetFramework LangVersionlatest/LangVersion Nullableenable/Nullable EnforceExtendedAnalyzerRulestrue/EnforceExtendedAnalyzerRules IsRoslynComponenttrue/IsRoslynComponent !-- 打包配置 -- PackageIdMyCompany.Generators.PocoNotify/PackageId Version1.0.0/Version AuthorsMyCompany/Authors Description编译期自动生成INotifyPropertyChanged实现/Description IncludeBuildOutputfalse/IncludeBuildOutput SuppressDependenciesWhenPackingtrue/SuppressDependenciesWhenPacking DevelopmentDependencytrue/DevelopmentDependency NoWarn$(NoWarn);NU5128/NoWarn /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.CodeAnalysis.CSharp Version4.8.0 PrivateAssetsall / /ItemGroup !-- 显式把输出dll打进analyzers目录 -- ItemGroup None Include$(OutputPath)\$(AssemblyName).dll Packtrue PackagePathanalyzers/dotnet/cs Visiblefalse / /ItemGroup /Project逐个解释这些属性的作用。IncludeBuildOutputfalse告诉NuGet不要把默认的lib输出打进去默认打包逻辑会把编译出来的dll放入lib目录对我们来说这就是灾难。SuppressDependenciesWhenPackingtrue避免把Microsoft.CodeAnalysis.CSharp及其依赖链作为PackageReference写入nuspec因为使用方项目的编译器环境本身就带着Roslyn程序集你再声明一份反而容易引起版本撕裂。DevelopmentDependencytrue标记为开发依赖这样使用方项目在打包发布自己时不会把我们的包带进产品的依赖列表。有个细节值得单独提醒EnforceExtendedAnalyzerRules和IsRoslynComponent这两个属性不是给NuGet打包用的但它们会影响编译过程。IsRoslynComponent会在项目里启用一些和分析器相关的编译诊断规则避免写出可以运行但不是最佳实践的代码。EnforceExtendedAnalyzerRules则开启RS1035等规则防止我们在生成器代码里使用禁止的API比如访问文件系统。这两个属性加上去一开始会新增一批警告修完后生成器质量会明显提高。4.3 依赖dll的打包策略如果生成器逻辑里用到了第三方库比如Humanizer的字符串转换、MediatR的元数据解析这些依赖dll去哪里了答案是他们不会随着你的生成器dll自动出现在NuGet包中。因为SuppressDependenciesWhenPackingtrue已经把包依赖声明压掉了即使不压掉NuGet也不会自动帮你把依赖装到analyzers目录下。最稳妥的做法是让生成器尽量自包含不要依赖大型运行时包。万一确实需要比如你写了一个基于正则或字符串处理的辅助库可以直接把那个库的dll一并包进来ItemGroup None Include$(OutputPath)\MyDependency.dll Packtrue PackagePathanalyzers/dotnet/cs Visiblefalse / /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_EnableGeneratorReportingtrue构建一次然后在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有没有被错误地设置成ExcludeAssetsall或者Parent项目使用了NoWarn把NU5128压掉的同时把分析器警告也压了。标准的引用写法是PackageReference IncludeMyCompany.Generators.PocoNotify Version1.0.0 PrivateAssetsall /PrivateAssetsall表示这个包不会被传递到上层项目也不会被当成编译依赖暴露给使用者只本地生效。如果项目里有人图省事删掉了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版本、生成器包版本四列对应关系写清楚。维护这个表格虽然麻烦但它能把“为什么我的项目用不了这个包”的排查时间从一小时缩短到三分钟。写在最后的一点体会调这个生成器的过程中我踩过的坑有不少但最值得分享的其实是流程上的经验。源生成器的调试周期比普通库长因为每次改完都要重新编译目标项目才能看到效果所以一定先搭一个极简的最小验证闭环一个目标类、一个特性、一个生成方法跑通后再往里面加复杂度。我见过不少人一上来就想做“万能代码生成器”结果在聚合各种语法树上花了一晚上连最基础的通知属性都没跑通这种投入产出比非常不划算。源生成器最适合处理规则明确的重复代码特性标注就是契约而偏重业务变体的手写代码再香也别硬套。先把部分代码交给编译器用的时候你会明显感觉改样板代码这件事终于不用亲力亲为了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询