免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

C#源生成器实战:用增量生成器替代反射,告别AOT崩溃

C#源生成器实战:用增量生成器替代反射,告别AOT崩溃 第一次在项目里被反射卡住是在一个老旧的WinForms模块里几十个类依赖PropertyChanged通知运行时反射读属性、发通知每次启动慢半拍不说一上.NET Native/AOT裁剪模式几乎全面崩盘。后来我把这段逻辑全部改成C#源生成器在编译期就把代码写进程序集里启动速度上去了AOT也不再炸。如果你也写过重复的样板代码或者被运行时反射的性能和裁剪问题折磨过这篇文章值得看完。我会从源生成器的执行原理讲起重点拆解增量生成器的工作方式再带大家写一个自动生成INotifyPropertyChanged实现的实战Demo顺便把调试和踩坑的经验一并交代清楚。1. 源生成器到底是什么从“运行时反射”到“编译期写死”1.1 反射、运行时生成 和 AOT 的死结很多.NET开发者在项目里遇到重复样板代码时第一反应是反射、Expression Tree或者Emit。这三个技术确实能解决一部分问题比如动态创建类型、动态调用方法、给类附加通知逻辑。但它们有一个共同的天花板代码在运行时才拼出来编译器看不到IDE看不到AOT裁剪器也看不到。以我当年写的INotifyPropertyChanged为例传统做法是写一个反射工具扫描类型属性动态生成代理类或者用CallSite表达式。这套方案有几个非常难受的地方首先是启动性能几十个类反射遍历一遍动态生成一大堆Expression每次启动都会多花几百毫秒然后是类型安全编译期根本没有校验字段名拼错、类型不匹配运行到那一刻才爆炸最后是AOT问题裁剪器无法推断这些动态代码用到了哪些成员直接优化掉结果运行时抛MissingMethodException。源生成器解决的是另一个思路不是“运行时生成”而是“编译时生成”。它在Roslyn编译器把你写的代码编译成程序集的过程中作为插件运行读取你的源码分析语义模型然后向编译单元里添加新的源代码文件。这些文件和你手写的代码一样会被正常编译、被IDE分析、被AOT处理。用大白话说就是让编译器替你把样板代码“写死”在程序集里运行时就再也没有动态生成的开销了。1.2 源生成器的执行模型不是代码模板引擎而是编译器插件源生成器和普通模板引擎最大的区别在于它不是拿到一段字符串做替换那么简单而是基于Roslyn的两大核心语法树和语义模型。语法树告诉你代码长什么样比如“这里有一个ClassDeclarationSyntax这个类包含哪些字段”语义模型告诉你这些代码是什么意思比如“这个字段的类型实际上指向哪个程序集里的哪个类型”。这样的好处是生成器可以非常精准地理解用户代码。你不必用正则表达式去解析源码也不必担心命名空间写错因为编译器已经把符号和类型信息都解析好了。源生成器只需要找到目标的字段、类、方法然后用Roslyn API提取需要的属性拼出一段新的C#代码交给编译器继续编译。有一点必须强调源生成器不能修改用户已有的代码它只能往里“添加”新文件。所以设计上需要用partial类配合或者生成新的独立类型。比如我要给某个类补充INotifyPropertyChanged的实现那个类就必须声明成partial生成器生成的另一份partial定义才能和原类合并。理解了这一点后面看Demo就好办了。1.3 增量生成器与旧式生成器的本质区别早期的源生成器API是ISourceGenerator它的问题是每次编译都全量跑一遍拿到整个Compilation对象遍历所有语法树做语义分析生成代码。大型项目编译一次可能触发几十次甚至上百次重复分析编译时间肉眼可见地变长。后来Roslyn引入了IIncrementalGenerator也就是增量生成器。它的核心思想是把“数据流水线”串起来每一步产出可缓存的结果如果输入没有变化下游步骤直接复用上一次编译的缓存不再重复计算。用更直白的话说旧式生成器每次编译都像把整个仓库重新盘点一遍增量生成器则像货架上的每个格子单独标了版本号只有某个格子数据变了才去重算那个格子。对比项ISourceGenerator旧式IIncrementalGenerator增量执行方式每次编译全量分析基于Provider流水线依赖缓存缓存机制无内置缓存每一步可缓存输入不变则跳过推荐程度新代码不推荐新项目默认选择API复杂度简单但粗暴学习曲线略高但值得我个人的建议是不要再用旧式API写新东西了。虽然IIncrementalGenerator初看有些不习惯但只要理解了Provider和缓存模型它的性能和可控性都明显更好。下面就来写一个完整的实战Demo。2. 从零开始搭一个自动生成属性通知的实时例子2.1 项目结构为什么生成器项目必须netstandard2.0先明确项目结构。我准备做一个名叫NotifyGenerator的生成器使用者在字段上打一个[AutoNotify]标记生成器自动为所在partial类补上INotifyPropertyChanged实现并为这些字段生成对应的属性存取逻辑。生成器项目本身有几个硬性要求。第一目标框架必须是netstandard2.0因为生成器会被编译器进程加载而编译器进程可能是Windows上的.NET Framework也可能是跨平台的.NET Corenetstandard2.0是当前唯一两边通吃的目标框架。第二必须引用Microsoft.CodeAnalysis.CSharp包但依赖不能传递到使用方项目里去所以要设PrivateAssetsall。生成器项目的csproj大概长这样Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknetstandard2.0/TargetFramework LangVersionlatest/LangVersion Nullableenable/Nullable EnforceExtendedAnalyzerRulestrue/EnforceExtendedAnalyzerRules IsRoslynComponenttrue/IsRoslynComponent /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.CodeAnalysis.Analyzers Version3.3.4 PrivateAssetsall / PackageReference IncludeMicrosoft.CodeAnalysis.CSharp Version4.8.0 PrivateAssetsall / /ItemGroup /Project使用方项目里生成器项目不是普通引用而是作为Analyzer引用。关键点是OutputItemType必须是Analyzer同时ReferenceOutputAssembly设为false意思是我只要它在编译时跑不要把这个程序集当运行时依赖引用进去。如果漏掉这个配置最常见的结果就是生成器根本不执行或者运行时报一堆奇怪的程序集加载错误。Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknet8.0/TargetFramework Nullableenable/Nullable LangVersionlatest/LangVersion /PropertyGroup ItemGroup ProjectReference Include..\NotifyGenerator\NotifyGenerator.csproj OutputItemTypeAnalyzer ReferenceOutputAssemblyfalse / /ItemGroup /Project2.2 特性定义与注入省掉一个Abstractions库的技巧很多初学者会卡在一个地方生成器要查找某个Attribute但这个Attribute本身需要被使用方项目引用于是就得单独建一个Abstractions类库跨项目引用非常繁琐。其实源生成器里有个RegisterPostInitializationOutput方法可以往用户的编译单元里“注入”一段固定的源代码。利用这个机制我们可以直接把AutoNotifyAttribute的定义注入到使用方项目里用户项目就能直接使用这个特性完全不用建第二个类库。context.RegisterPostInitializationOutput(static ctx { ctx.AddSource(AutoNotifyAttribute.g.cs, SourceText.From( using System; namespace NotifyGenerator { [AttributeUsage(AttributeTargets.Field, AllowMultiple false, Inherited false)] internal sealed class AutoNotifyAttribute : Attribute { public string? PropertyName { get; set; } } } , Encoding.UTF8)); });这里有个经验之谈注入的特性可以是internal因为它是直接编译进用户程序集的用户代码能访问到。但如果你要做一个面向公共社区的正式库我更推荐单独建Abstractions项目把特性公开出去避免用户代码里出现“本应由框架提供的类型却由生成器注入”的隐晦问题。示例教学阶段用注入方案最省事等真正要发布时再拆。2.3 生成器主流程查找带特性的字段接下来是生成器的主体。我们继承IIncrementalGenerator在Initialize方法里搭建流水线。第一步是用ForAttributeWithMetadataName定位所有带AutoNotify特性的字段。这里的逻辑要仔细讲一下ForAttributeWithMetadataName是Roslyn 4.4以后提供的便捷API它接收三个参数特性全名、轻量级句法谓词、转换委托。谓词阶段只做语法层面过滤比如我只关心FieldDeclarationSyntax这个阶段不要碰语义模型否则性能会退化。转换阶段收到的是GeneratorAttributeSyntaxContext里面已经包含了目标节点和目标符号这时再放心地拿IFieldSymbol做语义分析。[Generator] public sealed class AutoNotifyGenerator : IIncrementalGenerator { private const string AttributeName NotifyGenerator.AutoNotifyAttribute; public void Initialize(IncrementalGeneratorInitializationContext context) { context.RegisterPostInitializationOutput(...); // 特性注入代码 var fieldInfos context.SyntaxProvider.ForAttributeWithMetadataName( AttributeName, static (node, _) node is FieldDeclarationSyntax, static (ctx, _) { if (ctx.TargetSymbol is not IFieldSymbol field) { return null; } var type field.ContainingType; return new FieldToGenerate( type.Name, type.ContainingNamespace.ToDisplayString(), IsPartial(type), field.Name, GetPropertyName(field, ctx.Attributes), field.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); }); } }GetPropertyName的逻辑分两步如果用户在特性里显式指定了PropertyName就用指定的名字否则默认把字段名转成Pascal命名比如_userName转成UserName。这里要注意源生成器面对的是真实的C#代码任何字符串映射逻辑都要考虑边界情况比如字段名全是下划线、字段名已经是Pascal风格等等。private static string GetPropertyName(IFieldSymbol field, ImmutableArrayAttributeData attributes) { foreach (var attr in attributes) { foreach (var namedArg in attr.NamedArguments) { if (namedArg.Key PropertyName namedArg.Value.Value is string name !string.IsNullOrWhiteSpace(name)) { return name; } } } var trimmed field.Name.TrimStart(_); if (trimmed.Length 0) { return field.Name; } return char.ToUpperInvariant(trimmed[0]) trimmed.Substring(1); }2.4 按类聚合数据并注册输出字段级别的数据拿完之后还需要按类聚合因为同一个类的多个字段要生成到同一个partial类文件里。Collect方法会把所有字段信息收集成一个不可变数组然后我们再按类分组。这里有一个增量生成器非常关键的细节分组后的模型类必须实现基于值的相等性比较。因为增量生成器判断“这一步要不要重算”靠的是上一次的结果和这次的结果是否相等而不是引用是否相同。如果你的模型类用默认的引用比较那每次编译都会认为输入变了缓存全部失效增量生成器就名存实亡。private sealed record FieldToGenerate( string ClassName, string Namespace, bool IsPartial, string FieldName, string PropertyName, string TypeName); private sealed record PropertyToGenerate( string FieldName, string PropertyName, string TypeName); private sealed record ClassToGenerate( string TypeName, string Namespace, bool IsPartial, ImmutableArrayPropertyToGenerate Properties) { public bool Equals(ClassToGenerate? other) { if (other is null) { return false; } if (!string.Equals(TypeName, other.TypeName, StringComparison.Ordinal) || !string.Equals(Namespace, other.Namespace, StringComparison.Ordinal) || IsPartial ! other.IsPartial) { return false; } if (Properties.Length ! other.Properties.Length) { return false; } for (var i 0; i Properties.Length; i) { if (!Properties[i].Equals(other.Properties[i])) { return false; } } return true; } public override int GetHashCode() { unchecked { var hash 17; hash hash * 31 TypeName.GetHashCode(); hash hash * 31 Namespace.GetHashCode(); hash hash * 31 IsPartial.GetHashCode(); foreach (var property in Properties) { hash hash * 31 property.GetHashCode(); } return hash; } } }聚合逻辑和RegisterSourceOutput注册逻辑如下var classInfos fieldInfos .Where(static info info is not null) .Collect() .Select(static (fields, _) { return fields .GroupBy(static f (f.ClassName, f.Namespace)) .Select(static g { var first g.First(); return new ClassToGenerate( first.ClassName, first.Namespace, first.IsPartial, g.OrderBy(static f f.FieldName, StringComparer.Ordinal) .Select(static f new PropertyToGenerate(f.FieldName, f.PropertyName, f.TypeName)) .ToImmutableArray()); }) .ToImmutableArray(); }); context.RegisterSourceOutput(classInfos, static (spc, classes) { foreach (var cls in classes) { if (!cls.IsPartial) { spc.ReportDiagnostic(Diagnostic.Create( new DiagnosticDescriptor( AN0001, AutoNotify 目标类必须声明为 partial, 类型 {0} 标有 [AutoNotify]但未声明为 partial源生成器无法补充代码, NotifyGenerator, DiagnosticSeverity.Error, isEnabledByDefault: true), Location.None, cls.TypeName)); continue; } spc.AddSource( $AutoNotify_{cls.Namespace}_{cls.TypeName}.g.cs, SourceText.From(GenerateSource(cls), Encoding.UTF8)); } });生成代码的模板不需要太复杂关键是把类型的完全限定名带对避免using缺失导致编译失败。我使用SymbolDisplayFormat.FullyQualifiedFormat得到的类型名是global::System.String这种形式放进生成的代码里无论命名空间多复杂都不会错。另外生成文件头部加上// 和#nullable enable让IDE知道这是生成代码也让可空引用类型行为不受用户项目开关影响。3. 增量生成器核心管线拆解为什么它能“只重算该重算的部分”3.1 ForAttributeWithMetadataName用声明式查询代替手动遍历在ForAttributeWithMetadataName出现之前大多数人写源生成器要用CreateSyntaxProvider手动遍历语法树先找所有字段声明再对每个字段调用SemanticModel.GetDeclaredSymbol最后还要逐个检查特性列表。这套写法不是不行但有两个问题一是代码啰嗦二是无法利用Roslyn内部的缓存机制。ForAttributeWithMetadataName把“查找所有使用某特性的节点”这个高频操作做成了框架级能力。Roslyn编译器会为特性名建立索引匹配更快而且生成器API内部会缓存属性匹配结果。对我们开发者来讲代码也从“过程式遍历”变成了“声明式查询”。要提醒一点第一个参数attributeName要传特性的完全限定名不是不带Attribute后缀的短名也不是只传类名。比如我们的特性全名是NotifyGenerator.AutoNotifyAttribute。如果拼错生成器会静默地匹配不到任何节点且不会报错排查起来非常难受。3.2 Provider的流水线操作Transform、Where、Collect、Select理解增量生成器关键要分清两种ProviderIncrementalValuesProvider和IncrementalValueProvider。前者表示“多个独立的输出流”比如扫描到的几百个字段每个字段是一个元素后者表示“一个聚合的输出”比如最终需要生成的类集合整体是一个结果。我们的流水线正好走了这条路径ForAttributeWithMetadataName返回IncrementalValuesProvider 每个字段一个元素Where过滤掉nullCollect把多个值聚合成一个不可变数组变成IncrementalValueProviderImmutableArray Select做分组合并最终得到一个IncrementalValueProviderImmutableArray 。每一步的输入输出都是不可变对象且都参与增量缓存。从设计角度讲这很像函数式编程里的纯函数管道输入相同输出必然相同只要输入没有变框架就不重复执行这个函数。所以写transform时一定要保持纯度不要在函数里写静态缓存、读写文件、访问外部变量。3.3 RegisterSourceOutput代码产物与DiagnosticRegisterSourceOutput是管线的终点它有两个作用一是调用AddSource把源代码文本加入编译单元二是调用ReportDiagnostic向编译器报告问题。代码产物和诊断信息在增量生成器里地位是平等的都作为输出被缓存。AddSource有两个参数提示名HintName和SourceText。HintName必须全局唯一我习惯用类型名加命名空间拼接避免不同命名空间下同名类产出冲突。如果两个生成的源文件HintName重复编译器会直接报错所以宁可多加一点信息到文件名里。Diagnostic这块很多初学者容易忽略。生成器并不是只能闷头生成代码它还可以像分析器一样告诉用户哪里写错了。比如我们的partial类检查如果用户忘了把类声明为partial就报一个AN0001错误编译器会把这个错误显示在错误列表里用户甚至可以通过IDE的问题窗格直接看到消息。这在拖到正式项目里时非常有用。3.4 缓存与比较器增量生成器的灵魂增量生成器性能提升的关键就是Provider管线上每一步携带的缓存。Roslyn在每次编译时比较这一步的输入和上一次的输入如果值相等直接复用上一次的输出不执行Transform或Select。这要求管线上所有中间类型都实现“值相等”。最常见也最隐蔽的坑就是数组或集合字段没有做逐元素比较。比如ClassToGenerate里的Properties是ImmutableArray ImmutableArray本身是结构体它的Equals默认比较的是元素引用相等而不是内容相等。如果某个字段的值没变只是重新创建了一个对象引用不同Equals返回false缓存就会失效。所以我在上面的代码里重写了Equals和GetHashCode逐项比较Properties数组内容。调试这个问题的办法很朴素在Transform或Select函数入口打一个Debugger.Launch看它是否在每次编译时都被命中。如果被命中次数太多大概率是比较器有问题。这是我自己踩了好几次坑之后总结出来的经验。4. 调试、查看生成代码与性能红线4.1 让生成器在VS里“停下来”源生成器是在编译过程中运行的普通的断点根本没机会命中因为生成器代码跑在编译器的进程里。最直接的调试方式是用Debugger.Launch()在Initialize方法开头写上它会弹出一个“选择要调试的实例”窗口。但我强烈建议不要裸写Debugger.Launch否则每次编译都弹窗队友一定会恨你。我习惯用环境变量控制if (Environment.GetEnvironmentVariable(DEBUG_NOTIFY_GENERATOR) 1) { System.Diagnostics.Debugger.Launch(); }这样平时编译完全无感想调试时在系统环境变量里加一个DEBUG_NOTIFY_GENERATOR1重新编译就能进入调试器。记得调试完删掉环境变量。查看生成的代码也有两个入口。一是在VS里打开解决方案资源管理器点击“显示所有文件”展开依赖项下面的分析器节点每个生成器对应的.g.cs文件都在那里可以直接打开查看。二是在磁盘上找obj目录具体路径大概是obj/Debug/net8.0/generated/NotifyGenerator/NotifyGenerator.AutoNotifyGenerator/AutoNotify_DemoApp_UserViewModel.g.cs。有时候VS不刷新直接看磁盘会更准。4.2 性能红线四种必须避免的操作增量生成器不是银弹写法不对照样会拖慢编译。我总结了几条红线都是从大型项目里踩出来的经验第一不要在RegisterSourceOutput里做重活。RegisterSourceOutput应该只负责把已经算好的结果转成SourceText并AddSource任何语义分析、类型映射都应该在之前的Select阶段完成。否则每次编译都会把这些逻辑重新执行一遍增量缓存形同虚设。第二不要在Transform里访问Compilation对象上的全局数据比如Compilation.SyntaxTrees、Compilation.GetSymbolsWithName等等。这会让下游的缓存彻底失效因为整个Compilation一变所有依赖它的步骤都要重算。如果实在要访问编译全局信息要用CompilationProvider显式声明依赖让框架明确这是一条独立的缓存分支。第三不要在生成器里做IO操作和网络操作。生成器跑在编译进程中编译器要同时处理大量其他分析任务你的IO一旦阻塞整个团队的编译都要跟着遭殃。如果需要给生成器传配置用AnalyzerConfigOptions不要读文件。第四不要依赖静态状态。生成器可能被编译器并行调用实例字段和静态字段在不同编译之间存在不可预知的复用方式。正确的做法是保持无状态所有数据只存在管线的value里。4.3 从Demo到生产诊断与健壮性演示版本为了可读性省掉了不少边界处理实战中至少要补三类健壮性问题。第一嵌套类。如果AutoNotify用在嵌套类上生成代码里的类型名需要拼接外层类型否则生成的partial类找不到对应定义。处理方式是用containing type链逐层构造比如Outer.Inner并且为每一层生成partial声明。第二名称冲突。如果用户类已经手写了PropertyChanged事件或者某个字段名转换后生成了和已有属性同名的成员生成的代码就会编译报错。正规做法是加一个诊断规则检测到冲突时给用户一个明确的错误提示而不是让编译器报一个不知所云的重复定义错误。第三泛型类。带泛型参数的类生成partial定义时需要带上同名泛型参数否则类型参数对不上。这些场景不难但都需要在生成本身加测试用例。我经常被人问现在有CommunityToolkit.Mvvm的[ObservableProperty]为什么还要自己写源生成器我的看法是学习写源生成器的过程本质上是在学习“如何让编译器帮你写代码”。你掌握的不是某个Api的用法而是一整套解放重复劳动的思路。理解了基本管线将来做依赖注入注册、Mapster映射、API Client生成都是同一套模型。5. 常见问题与排查技巧实录现象可能原因解决办法生成器没有任何输出Analyzer项目引用配置错误检查是否有OutputItemTypeAnalyzer和ReferenceOutputAssemblyfalse编译报CS8034分析器程序集无法加载Microsoft.CodeAnalysis版本与SDK不匹配把生成器里的Roslyn包版本对齐到本机SDK对应的版本改了字段但生成的代码没变化增量缓存比较器没有按值比较检查所有模型类的Equals和GetHashCode实现生成文件里类型找不到缺少using或类型名没带全名用SymbolDisplayFormat.FullyQualifiedFormat输出类型名提示xxx成员已经存在用户代码和生成代码产生了同名成员增加诊断规则在生成前拦截冲突场景生成器抛异常导致编译失败边界用例没有处理用Debugger.Launch调试把异常堆栈打出来CS8034是我见过的最高频错误尤其当你把生成器项目从旧的Roslyn 3.x升级到4.x时。解决办法说起来很简单把你的SDK对应的Roslyn版本和生成器项目引用的Microsoft.CodeAnalysis.CSharp包版本对齐。比如本机是.NET 8 SDK对应Roslyn 4.8那生成器项目就引用4.8.x本机是.NET 9 SDK就引用4.12.x。跨版本匹配容易出幺蛾子。至于字段改了但生成代码不变多数是缓存比较器写错了而不是生成器没跑。排查方法很简单在Select函数里打一个临时Debugger.Launch看它是否每次编译都中断。如果中断次数明显少于预期说明上游某一步被缓存复用了再顺着管线逐个排查哪个类型的Equals没写对。我个人在实际操作中还有一个习惯先把所有中间模型类定义成record然后用内置的比较器如果发现缓存总是失效再手工重写Equals和GetHashCode。这样大部分情况都能快速定位问题。最后分享一个小技巧生成器里的诊断信息顺手把属性名或者类型名拼进消息文本里Debug时候会省很多事。因为编译器的错误列表会直接显示出来你一眼就能看出是哪个类的哪个成员出了问题不用再翻日志猜来猜去。这个内容后续其实还可以继续扩展比如用同样的管线给枚举生成扩展方法、给接口生成装饰器类或者给ASP.NET Core的Endpoint生成强类型客户端。原理都一样后面的路就靠你自己踩了。
返回列表