恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
源码生成器实战:从AutoNotify到NuGet打包发布全流程
首页
资讯中心
/
源码生成器实战:从AutoNotify到NuGet打包发布全流程
源码生成器实战:从AutoNotify到NuGet打包发布全流程
发布时间:2026/10/18 13:47:04
写源码生成器这件事很多人一开始是奔着“花活”去的看着帮别人自动补齐了一大堆重复代码觉得很酷。但真把一个生成器做成能发布、能被别人通过 NuGet 装进项目里用涉及的东西就远不止写一段Emit或拼字符串那么简单。我在实际做 AutoNotify 这类生成器时最深的感受是**生成器其实不是在生成代码而是在和手写代码谈判。**双方怎么撞到一起、怎么分工、怎么避免打架全靠 partial 范式撑着而怎么把成品交付出去又绕不开 NuGet 打包的 analyzer 目录约定。这篇文章就把这条链路完整走一遍从IIncrementalGenerator的写法到 partial class / partial method 的配合再到打包成analyzers/dotnet/cs的细节和踩坑记录适合已经在项目里用过 Source Generator、想自己动手写一个并发布的人看。1. 为什么是源码生成器和 partial 范式1.1 传统方案的瓶颈做代码自动化的老办法无非三类反射、运行时 IL 织入、代码模板 T4。反射的问题很直白性能开销和启动损耗先不提类型信息在编译后已经“缩水”了你能拿到的只是元数据的快照想根据字段声明顺便推导出完整属性逻辑得靠一堆BindingFlags加上字符串拼接写出来像在猜谜。IL 织入比如各种 AOP 框架能力很强但调试、部署、框架版本兼容都麻烦稍不留神就背上一个重量级运行时依赖。T4 则停留在设计时的“一次性生成”生成完就是死代码源模型一变你得记得重新跑模板。源码生成器把这些问题的答案换了个位置它在编译过程中、编译器已经做完语法分析和符号绑定之后执行。这时候你看到的不再是文本而是真正的IFieldSymbol、INamedTypeSymbol、SemanticModel这意味着你可以像编译器一样理解代码。生成的代码在编译的后续阶段又会被编译器当作普通源码继续处理所以 IDE 智能提示、断点调试、引用查找全都正常。还有一点容易被忽略生成器是增量执行的。你改了一个字段不会把整个项目的生成管线全部重跑只有受影响的语法节点对应的那部分逻辑会被重新执行。这一点对大型项目至关重要。1.2 源码生成器改变的不只是性能性能只是表面本质是“把运行时才能做的事提前到编译期”。以 AutoNotify 为例传统 MVVM 框架靠反射监听属性变化或者靠基类方法调用通知而生成器做法是直接把INotifyPropertyChanged的样板代码写进你的 partial 类里。最终产物里没有魔法没有运行时拦截就是一份平平无奇的属性 setter。这意味着你仍然可以ctrl点击跳到那个自动生成的属性定义里看代码性能上和手写完全一致没有额外的库被拖进运行时依赖。从工程角度说生成器的最大价值不是“省多少行代码”而是把约定变成了编译器强制检查的东西。字段_name上挂了[AutoNotify]生成器就保证属性Name存在如果手写代码和生成代码产生了冲突编译器直接报错而不是等到运行时报一个莫名其妙的异常。1.3 partial 在生成器中的地位partial 是 C# 里为“同一个类型分散到多个文件”而生的语法这恰好就是源码生成器和手写代码共享一个类型时所需要的东西。你手写一个public partial class Customer生成器再产出另一个public partial class Customer的源文件两者在编译期被合并成同一个类型。字段、属性、方法、事件、接口实现都可以分别放在两边。如果只是合并类型partial class 就够了但很多场景还需要“钩子”——用户想在自动生成的行为里插入自定义逻辑。这时候 partial method 就是设计上的神来一笔。老版的 partial method 有个特性如果某个partial void Foo();没有实现编译器会把所有对它的调用一并删掉。也就是说生成器既可以提供一个空实现让你覆盖又可以让你先声明一个 partial 方法由生成器补上实现。哪种方式都能做到“缺席时的优雅降级”。这就是为什么我说 partial 是生成器的自然配菜它让两边都能独立编译又能在合并时天衣无缝地衔接。2. 实现一个 AutoNotify 生成器从 Attribute 到增量生成器2.1 创建生成器工程与所需依赖如果你只是给某个项目内部用生成器项目可以直接放同一个解决方案里。我习惯这样创建MyLib.SourceGenerators // 生成器本体TargetFramework 必须是 netstandard2.0 MyLib.Attributes // 存放 Attribute 类型的运行时类库 SandboxApp // 一个用来验证生成结果的普通控制台/类库项目生成器项目为什么要netstandard2.0因为生成器要同时跑在 Visual Studio 的进程老式 .NET Framework 托管和命令行dotnet build环境下netstandard2.0是两边都兼容的最低公共目标。如果你只认net8.0在 Visual Studio 里加载生成器时很容易直接报 CS8032。csproj 的核心部分长这样Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknetstandard2.0/TargetFramework LangVersionlatest/LangVersion Nullableenable/Nullable IsRoslynComponenttrue/IsRoslynComponent EnforceExtendedAnalyzerRulestrue/EnforceExtendedAnalyzerRules /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.CodeAnalysis.CSharp Version4.8.0 PrivateAssetsall / PackageReference IncludeMicrosoft.CodeAnalysis.Analyzers Version3.3.4 PrivateAssetsall / /ItemGroup ItemGroup ProjectReference Include..\MyLib.Attributes\MyLib.Attributes.csproj PrivateAssetsall / /ItemGroup /ProjectPrivateAssetsall是为了防止 Roslyn 这些包顺着依赖关系流到使用方项目里。IsRoslynComponent让编译器知道这是一个 analyzer/生成器组件会启用一些额外的代码分析规则。2.2 定义自有 Attribute如果你希望用户源码里能写[AutoNotify]那么这个 Attribute 类型必须存在于用户的编译单元里。方案有两种单独打包一个 Attributes 程序集让用户引用或者让生成器在初始化时把 Attribute 的源码也输出进用户项目。单独打包更直观。Attribute 类本身很简单using System; namespace MyLib.Attributes; [AttributeUsage(AttributeTargets.Field, AllowMultiple false, Inherited false)] public sealed class AutoNotifyAttribute : Attribute { }这个库目标框架可以放低一点比如netstandard2.0方便老项目引用。如果用户用的是传统非 SDK 风格项目也可以额外打一个net472的 lib 目录但绝大多数新项目已经不需要了。2.3 编写增量生成器主体生成器继承IIncrementalGenerator在Initialize里注册一个基于语法识别的数据源。这里我强烈推荐用ForAttributeWithMetadataName它是 Roslyn 4.3 以后提供的高阶 API直接按“某个类型上的某个 Attribute”筛选节点省掉自己匹配命名空间的麻烦[Generator(LanguageNames.CSharp)] public sealed class AutoNotifyGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var provider context.SyntaxProvider.ForAttributeWithMetadataName( MyLib.Attributes.AutoNotifyAttribute, predicate: static (node, _) node is FieldDeclarationSyntax, transform: static (ctx, _) GetTarget(ctx)); context.RegisterSourceOutput(provider, static (spc, target) { if (target is null) return; spc.AddSource(target.HintName, SourceText.From(target.ToSource(), Encoding.UTF8)); }); } }transform这一步非常关键。你在这里不应该直接生成源码字符串而是把语义模型里的信息提炼成一个小而纯的数据类。增量生成器会做缓存对比如果两次编译之间这个数据类Equals成立后续的生成步骤会被跳过。如果你在 transform 里就把字符串拼好缓存失效的判断就会退化成字符串比较虽然也能用但命中率和内存占用都变差。GetTarget里做的是正事拿到字段符号判断是不是 static/readonly确定字段类型、容器类型名、命名空间并把最终的 hintName 算好private static Target? GetTarget(GeneratorAttributeSyntaxContext context) { if (context.TargetNode is not FieldDeclarationSyntax fieldDecl) return null; if (fieldDecl.Declaration.Variables.Length 0) return null; var fieldSymbol context.SemanticModel .GetDeclaredSymbol(fieldDecl.Declaration.Variables[0]) as IFieldSymbol; if (fieldSymbol is null) return null; if (fieldSymbol.IsStatic || fieldSymbol.IsReadOnly) return null; var type fieldSymbol.ContainingType; var propertyName ToPascalCase(fieldSymbol.Name); string hintName ${type.ToDisplayString() .Replace(, _).Replace(, _).Replace(., _)}_{propertyName}.g.cs; return new Target( hintName, type.ContainingNamespace.IsGlobalNamespace ? string.Empty : type.ContainingNamespace.ToDisplayString(), type.Name, fieldSymbol.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), propertyName, fieldSymbol.Name, NeedPartialMethodImplementation(type, propertyName)); }这里有个细节hintName 会作为生成文件的文件名里面不能带、、.这些不合适的字符。嵌套类型在ToDisplayString里会出现OuterInner这种形状所以先替换成Outer_Inner再拼入文件名避免生成时直接报 CS8785。2.4 输出源码与使用 partial 范式拼装生成的核心逻辑是把用户手写的字段扩展成带通知的属性同时补全 partial method 的实现。我用数据类Target的ToSource()来产出代码public string ToSource() { var sb new StringBuilder(); sb.AppendLine(// auto-generated/); sb.AppendLine(#nullable enable); sb.AppendLine(); if (!string.IsNullOrEmpty(Namespace)) { sb.AppendLine($namespace {Namespace}); sb.AppendLine({); } string indent string.IsNullOrEmpty(Namespace) ? : ; // 不写访问修饰符、不写基类列表避免和用户手写 partial 声明冲突 sb.AppendLine(${indent}partial class {TypeName}); sb.AppendLine(${indent}{{); sb.AppendLine(${indent} public {PropertyType} {PropertyName}); sb.AppendLine(${indent} {{); sb.AppendLine(${indent} get {FieldName};); sb.AppendLine(${indent} set); sb.AppendLine(${indent} {{); sb.AppendLine(${indent} if (!global::System.Collections.Generic.EqualityComparer{PropertyType}.Default.Equals({FieldName}, value))); sb.AppendLine(${indent} {{); sb.AppendLine(${indent} {FieldName} value;); sb.AppendLine(${indent} On{PropertyName}Changed();); sb.AppendLine(${indent} }}); sb.AppendLine(${indent} }}); sb.AppendLine(${indent} }}); sb.AppendLine(); if (NeedPartialMethodImpl) { sb.AppendLine(${indent} partial void On{PropertyName}Changed()); sb.AppendLine(${indent} {{); sb.AppendLine(${indent} }}); } else { sb.AppendLine(${indent} partial void On{PropertyName}Changed();); } sb.AppendLine(${indent}}}); if (!string.IsNullOrEmpty(Namespace)) { sb.AppendLine(}); } return sb.ToString(); }用户在项目里只需要写using MyLib.Attributes; public partial class Customer { [AutoNotify] private string _name; partial void OnNameChanged(); }生成器第二次编译时就会产出partial class Customer { public string Name { get _name; set { ... _name value; OnNameChanged(); } } partial void OnNameChanged() { } }注意我在NeedPartialMethodImplementation里做了判断如果用户已经在某个 partial 部分里给了OnNameChanged的实现生成器就不再输出空实现否则两个实现同时存在会直接报 CS8795。这是我在实际项目里踩过最典型的坑等到了“常见问题”一节再展开。提示生成代码里的访问修饰符要克制。partial class不要写public或internal让用户手写的那部分声明来决定访问级别。基类列表也不要出现在生成声明里避免和手写声明冲突。3. NuGet 打包让生成器被更多项目透明引用3.1 analyzer 目录约定的由来源码生成器本质上是一个 analyzerNuGet 对 analyzer 的安装行为有一条约定包内analyzers/dotnet/cs目录下的 dll 会被 NuGet 自动加入编译器的 analyzer 引用列表。用户装包之后什么都不用配IDE 和命令行构建都会自动加载生成器。这不只是.nupkg的一个放置位置问题而是你发布生成器时的唯一正式通道。这里要稍微展开一下 Roslyn 版本目录。老版本的 analyzer 直接放analyzers/dotnet/cs从 Roslyn 4.0 开始支持带版本段的目录比如analyzers/dotnet/roslyn4.0/cs。如果你用IIncrementalGenerator最低要求就是 Roslyn 4.0这时候最好把生成器放到analyzers/dotnet/roslyn4.0/cs下让 NuGet 在较老的编译器上自动跳过这个 analyzer避免用户项目因为生成器不兼容而构建失败。反过来如果你把使用了ForAttributeWithMetadataName需要 Roslyn 4.3的生成器放到不带版本段的目录里在旧编译器上就会报 CS8032。稳妥起见我会用 Roslyn 4.0 以上目录并把Microsoft.CodeAnalysis.CSharp包版本拉到一个可控的基线。3.2 打包配置实战生成器工程要在 pack 时同时带上两个 dllPropertyGroup PackageIdMyLib.SourceGenerators/PackageId Version1.0.0/Version AuthorsFor demo use/Authors DescriptionAutoNotify source generator demo/Description DevelopmentDependencytrue/DevelopmentDependency GeneratePackageOnBuildtrue/GeneratePackageOnBuild /PropertyGroup ItemGroup None Include$(OutputPath)\MyLib.SourceGenerators.dll Packtrue PackagePathanalyzers/dotnet/roslyn4.0/cs Visiblefalse / None Include$(OutputPath)\MyLib.Attributes.dll Packtrue PackagePathlib/netstandard2.0 Visiblefalse / /ItemGroup其中DevelopmentDependencytrue会让 NuGet 在导出依赖时把这个包标记为开发依赖用户项目发布时不会把生成器带进传递依赖。这个属性很容易忘忘了的后果就是使用方每次dotnet restore都会多出一个实际上根本不该出现的包。为什么 Attributes 程序集要单独进lib/netstandard2.0因为用户代码里的[AutoNotify]需要这个类型可被直接引用。生成器的 dll 不能放在 lib 里它引用了 Microsoft.CodeAnalysis如果被当作普通引用会把 Roslyn 拖进使用方应用的运行时依赖那是不对的。3.3 消费端引用差别普通依赖 vs analyzer消费端引用生成器包其实没有第二种选择就是普通PackageReferenceItemGroup PackageReference IncludeMyLib.SourceGenerators Version1.0.0 PrivateAssetsall / /ItemGroup但这里有个容易误解的点这个包同时包含 analyzer 资产和 lib 资产。PrivateAssetsall并没有阻止 analyzer 生效它只是阻止这个包在更上层的项目里被传递引用。也就是说如果用户 A 的项目引用了类库 BB 引用了生成器包并设了PrivateAssetsall那么 A 不会看到生成器也不会看到 Attributes 类型。这是合理的因为生成器是“实现细节”不应该绕过封装暴露给上层。如果你遇到“装了包但生成的代码没出现”的问题第一件事就是检查这个PrivateAssets和目录结构是不是自己把它拦住了。3.4 版本与兼容性策略生成器包的版本号需要谨慎。它不是普通类库生成器的行为变化会直接影响使用方编译出来的代码。我一般遵循两个原则破坏性生成结果变化比如删除了原有生成的属性、改了属性名必须记 major 版本新增可选的扩展点比如新增一个可覆盖的 partial 方法记 minor 版本。另外要留意Microsoft.CodeAnalysis.CSharp包版本和消费者环境的关系。理论上生成器项目引用的 Roslyn 版本不能高于编译器宿主支持的版本否则可能加载失败。如果你引用了过新的 Roslyn API但用户的 VS 还比较旧就会在使用方那边报分析器错误。合理做法是让你的生成器项目引用一个“足够新但不激进”的版本并在 README 里写明最低 VS / .NET SDK 版本。4. 调试与测试没有这两步别想交付4.1 利用 Debugger.Launch 快速定位生成器跑在编译进程里普通的断点调试对源码生成器并不总好使因为 Visual Studio 的构建进程可能不是你的调试宿主。最暴力的办法是在生成器代码入口加一句#if DEBUG System.Diagnostics.Debugger.Launch(); #endif当你重新构建使用方项目时会弹出一个“选择调试器”的对话框选当前 Visual Studio 实例就能进入断点。我至今仍然觉得这是排查“生成器内部到底跑到哪一步”最快的方式。上线前记得把这个调试开关去掉或者用条件编译保证 Release 包里没有它。不过Debugger.Launch有一个坏处每次构建都会弹窗很烦。所以后来我更推荐用单元测试方案。4.2 用 GeneratorDriver 做单元测试Roslyn 提供了CSharpGeneratorDriver你可以在测试里手工构造一个编译单元跑一次生成器然后检查生成的源码和诊断信息。这比依赖 IDE 里的“生成后肉眼观察”可靠得多也是我做生成器交付前的必经环节。核心测试代码using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Xunit; public class AutoNotifyGeneratorTests { [Fact] public void Generate_Property_When_Field_Has_Attribute() { var source using MyLib.Attributes; public partial class Customer { [AutoNotify] private string _name; partial void OnNameChanged(); } ; var syntaxTree CSharpSyntaxTree.ParseText(source); var references AppDomain.CurrentDomain.GetAssemblies() .Where(a !a.IsDynamic !string.IsNullOrWhiteSpace(a.Location)) .Select(a MetadataReference.CreateFromFile(a.Location)) .Append(MetadataReference.CreateFromFile(typeof(AutoNotifyAttribute).Assembly.Location)); var compilation CSharpCompilation.Create( TestAssembly, new[] { syntaxTree }, references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var generator new AutoNotifyGenerator(); GeneratorDriver driver CSharpGeneratorDriver.Create(generator); driver driver.RunGeneratorsAndUpdateCompilation( compilation, out _, out var diagnostics); var runResult driver.GetRunResult(); var generated runResult.Results[0].GeneratedSources .FirstOrDefault(s s.HintName.Contains(Customer_Name.g.cs)); Assert.NotNull(generated); Assert.Contains(public string Name, generated.SourceText.ToString()); } }这段代码在测试里构造了一个“带 Attribute、带 partial 类”的最小编译单元然后断言生成器确实产出了属性。用这种方式你可以在不启动 IDE 的情况下批量验证各种边界情况比如字段是 readonly、字段是 static、嵌套类、用户已经实现了 partial 方法等。4.3 快照测试延展为了控制生成结果的变更影响我还会引入快照测试把第一次跑出的生成结果保存为基线文件后续改动生成器时对比差异凡是差异都要人工确认。生成器这种“源码即产物”的东西太容易在你无意识的时候改了输出格式然后下游项目一片红。快照测试能逼着你把每一次输出变化说清楚。5. 踩坑记录与排查思路5.1 安装了包却不生成任何代码最常发生在新手期。可能原因按概率排序包里的 dll 不在analyzers/dotnet/cs或者对应的 roslyn 版本目录下生成器项目引用了较新 API 而当前编译器不支持导致生成器根本没被加载PrivateAssets配置导致包没进入当前项目的 analyzer 链。排查方法是看构建输出窗口正常加载 analyzer 时会有一条“Analyzer ‘MyLib.SourceGenerators’ loaded from ...”的日志。找不到就说明 NuGet 资产没被识别先检查包内容结构。5.2 CS8032 或 AD0001 这类分析器错误CS8032 是“analyzer 实例无法创建”十有八九是生成器项目引用的 Roslyn 版本和编译器宿主不一致。我经历过一次生成器引用了 4.8用户环境还是老的 VS2019结果本地好好的换台机器就崩。解决办法是降低Microsoft.CodeAnalysis.CSharp版本或者按 Roslyn 版本目录区分发布。AD0001 是生成器内部异常。这种错误不会让普通编译直接失败而是变成一条 warning混在构建输出里。第一次见很容易忽略。看到 AD0001 时不要慌它带完整堆栈配合Debugger.Launch基本能定位。5.3 提示名字冲突与非法字符spc.AddSource的 hintName 不只是“文件名”它在同一编译单元里必须唯一而且不能包含非法文件字符。我早期直接用类型完整名加属性名拼结果嵌套类型命名空间一复杂生成文件名字就炸了。现在统一做一次Replace把、、.、,、空格全替换成_。另外不同字段生成的文件名要保证唯一所以我习惯在 hintName 里带上字段名而不是只用类型名。5.4 partial method 实现重复这是 partial 范式里最容易踩的坑。用户可能自己写了一个 partial 方法实现生成器又生成一个实现编译器立刻报“已有实现”。我在 2.4 节里的NeedPartialMethodImplementation就是为了处理这个。判断逻辑是拿到OnXxxChanged这个符号后如果PartialImplementationPart不为 null说明用户已经给了实现生成器就只输出方法的定义声明分号结尾而不输出花括号实现。private static bool NeedPartialMethodImplementation(INamedTypeSymbol type, string propertyName) { var name $On{propertyName}Changed; var symbol type.GetMembers(name).OfTypeIMethodSymbol().FirstOrDefault(); return symbol?.PartialImplementationPart is null; }这里还有个隐藏细节如果用户写的 partial 方法是带访问修饰符的比如private partial void OnXChanged();那么它就不是“可省略”的 partial 方法了编译器要求必须实现。生成器如果没有感知到这一点输出的是不带访问修饰符的定义两边会不一致。所以要么文档里约定死“不要给这个钩子加访问修饰符”要么在生成器里做语法检查并在诊断信息里明确提示。5.5 生成代码没有随源码更新增量生成器的缓存逻辑要求你从transform返回的数据类型正确地实现相等性判断。如果你返回的是一个引用类型的 model没重写Equals每次编译都会重新生成失去增量意义反过来说如果某个影响输出的信息没有放进 model比如字段名之外的某个配置被忽略了那改配置后生成结果不会变更让人困惑。我的规矩是放进 model 的数据就是“生成结果的全部输入”。如果生成逻辑依赖了某个全局配置那这个配置值也必须成为 model 的一个字段并参与相等比较。6. 实际上手后的几条经验把上面的内容串起来之后我还想聊几句非代码层面的习惯。第一生成器的“输入面”越窄越好。我写 AutoNotify 时只盯着FieldDeclarationSyntax和对应的符号绝不扫描整个编译单元。这样做既是为了增量性能也是为了让行为可预测。第二生成结果的格式要稳定。很多人不在意生成的源码里缩进、空行但你要知道这些代码会出现在用户的错误堆栈和代码搜索里所以我会故意在顶部生成一行// auto-generated/让使用方知道这块不是手写的。第三如果你的生成器是给团队内部用的强烈建议配一个很小的示例项目放在仓库里当集成测试。示例项目引用生成器包、写几个用例、跑一次构建比任何文档都直观。Debugger.Launch、GeneratorDriver 单测、快照比对这三板斧用熟之后我写生成器的速度明显上来了。最后再提一个小技巧发布新版本时把生成器项目和 Attributes 类库项目放到同一个解决方案里用dotnet pack一次打出完整包并在本地建一个空项目引用本地 nupkg 验证无误后再推到私有源。生成器这种东西翻车成本极高一个不稳定的生成器能让整个项目每天报错所以发布前务必跑一遍“从零安装”的流程。