Repository Wiki
BeyondDimension/SteamTools

源代码生成器(SettingsGenerator)

源代码生成器(SettingsGenerator)是 SteamTools 仓库中 BD.WTTS.Generators 项目内的一个 Roslyn Source Generator,它在 C# 编译期间扫描带有 [SettingsGeneration] 标记的类型声明,并为每个类型生成一个 {TypeName}.g.cs 源文件注入到编译单元中。

Purpose and Scope

本文档完整讲解 SettingsGenerator 的实现机制,包括:

  • BD.WTTS.Generators Roslyn 组件项目的结构与打包方式
  • SettingsGenerationReceiver 语法接收器的候选类型筛选逻辑
  • SettingsGenerator 的 Initialize / Execute 两阶段执行模型
  • 生成产物(.g.cs)的形态与源码中遗留的替代实现
  • 失败模式、边界情况、性能与扩展点

留给兄弟页面的内容: 同一项目中还存在另一个生成器 AttributeGenerator.cs(属性生成器),其实现细节不在本页范围内;证书生成器 CertGenerator.cs 与二维码生成器 QRCodeHelper.Net.Codecrete.QrCodeGenerator.cs 属于业务功能而非 Roslyn 源生成器,亦不属于本页主题。

Overview

SettingsGenerator 的设计意图是:在编译期自动发现应用程序中标注了 [SettingsGeneration] 的设置(Settings)类型,并根据这些类型的字段信息自动生成代码,从而避免为设置类手写重复的样板代码。

其运行位置非常特殊——不在应用程序进程内,而是在 Roslyn 编译器(csc / IDE 进程)内部执行。这决定了它的几个关键约束:

  1. 目标框架必须是 netstandard2.0:源生成器程序集被加载进编译器进程,只能使用编译器宿主兼容的 API 面。
  2. 不能阻塞编译:Execute 中接受 CancellationToken,需要快速完成。
  3. 两阶段执行:先用 ISyntaxReceiver 在语法层面做廉价的候选筛选,再在 Execute 中通过语义模型(SemanticModel)确认,避免为每个语法节点构建昂贵的语义绑定。

当前实现处于实验/脚手架阶段:Execute 生成的 .g.cs 内容仅是逐字段的注释行(字段名 + 字段类型的清单),并未产出真正的属性访问代码。源文件中保留了三段被注释掉的替代实现(全量语法树扫描方案与 HelloFrom 主方法模板),记录了该生成器的设计演进路径,详见后文「遗留代码与设计演变」。

Architecture

Loading diagram...

上图展示整个架构的数据流向:

  • 使用方项目在编译时被 Roslyn 解析为 Compilation(含多棵 SyntaxTree)。
  • SettingsGenerator.Initialize 通过 RegisterForSyntaxNotifications 把 SettingsGenerationReceiver 注册进编译流水线。
  • 编译器遍历每个语法节点时回调 OnVisitSyntaxNode;接收器只做纯语法级判断(节点是 TypeDeclarationSyntax 且特性名匹配 SettingsGeneration),命中则加入 Candidates 列表。
  • 编译后期调用 SettingsGenerator.Execute:生成器把 Candidates 中的语法声明通过 SemanticModel.GetDeclaredSymbol 解析为 ITypeSymbol,遍历其 IFieldSymbol 成员,用 StringBuilder 拼出源码。
  • 最终 context.AddSource("{TypeName}.g.cs", ...) 把生成文本作为"内存中的附加源文件"注入编译——它是真实参与编译的语法树,不是磁盘文件。

核心执行流程

阶段一:Initialize —— 注册语法接收器

Roslyn 在生成器生命周期的最开始调用 Initialize。SettingsGenerator 在此注册了它的语法通知器:

csharp
1public void Initialize(GeneratorInitializationContext context) 2{ 3 // No initialization required for this 4 context.RegisterForSyntaxNotifications(() => new SettingsGenerationReceiver()); 5}

Source: SettingsGenerator.cs

RegisterForSyntaxNotifications 接受一个工厂委托,Roslyn 会创建接收器实例并在语法遍历阶段反复回调它。注释 "No initialization required for this" 表明该生成器无需做任何额外初始化(例如加载附加文件或初始化缓存)。

阶段二:OnVisitSyntaxNode —— 语法级候选筛选

SettingsGenerationReceiver 是一个 sealed class,实现了(Roslyn 经典的)ISyntaxReceiver 接口:

csharp
1public sealed class SettingsGenerationReceiver : ISyntaxReceiver 2{ 3 public const string AttributeName = "SettingsGenerationAttribute"; 4 5 public List<TypeDeclarationSyntax> Candidates { get; } = new List<TypeDeclarationSyntax>(); 6 7 public void OnVisitSyntaxNode(SyntaxNode syntaxNode) 8 { 9 if (syntaxNode is TypeDeclarationSyntax typeDeclarationSyntax) 10 { 11 foreach (var attributeList in 12 typeDeclarationSyntax.AttributeLists) 13 { 14 foreach (var attribute in attributeList.Attributes) 15 { 16 if (attribute.Name.ToString() == "SettingsGeneration" || 17 attribute.Name.ToString() == AttributeName) 18 { 19 this.Candidates.Add(typeDeclarationSyntax); 20 } 21 } 22 } 23 } 24 } 25}

Source: SettingsGenerationReceiver.cs

关键设计点:

  • TypeDeclarationSyntax 而非 ClassDeclarationSyntax:意味着 [SettingsGeneration] 可以标注在 class、struct、interface、record 等任意类型声明上,筛选范围更宽。
  • 纯字符串匹配特性名:attribute.Name.ToString() == "SettingsGeneration" || ... == "SettingsGenerationAttribute" 同时接受短名与全名两种写法(C# 允许 [SettingsGeneration] 与 [SettingsGenerationAttribute] 等价标注)。这一步刻意不做语义解析,因为在遍历每个语法节点时构建符号的成本极高;字符串比较是零成本的。代价是若存在同名但不同命名空间的特性会误报,误报在阶段三由语义层兜底纠正(见下文)。
  • Candidates 是简单的 List<T>:没有去重。若同一个类型声明被多次回调(在正常编译中一个语法节点只回调一次,所以实际不会重复),列表可能出现重复项。

阶段三:Execute —— 语义解析与代码生成

Execute 是生成器真正产出代码的地方。完整控制流如下:

csharp
1public void Execute(GeneratorExecutionContext context) 2{ 3 // retreive the populated receiver 4 if (!(context.SyntaxReceiver is SettingsGenerationReceiver receiver)) 5 return; 6 7 var compilation = context.Compilation; 8 9 // loop over the candidate fields, and keep the ones that are actually annotated 10 var symbols = new List<ITypeSymbol>(); 11 foreach (var decl in receiver.Candidates) 12 { 13 var model = compilation.GetSemanticModel(decl.SyntaxTree); 14 if (model.GetDeclaredSymbol(decl, context.CancellationToken) is ITypeSymbol symbol) 15 { 16 symbols.Add(symbol); 17 } 18 } 19 20 foreach (var symbol in symbols) 21 { 22 var code = new StringBuilder(); 23 // 遍历 settings 类型的所有属性。 24 foreach (var field in symbol.GetMembers().OfType<IFieldSymbol>()) 25 { 26 // 获取属性名和属性类型。 27 var propertyName = field.Name; 28 var propertyType = field.Type.ToDisplayString(); 29 30 // 生成代码,打印属性值。 31 code.AppendLine($"// \"{propertyName}: {0}\", type:{propertyType}"); 32 } 33 context.AddSource($"{symbol.Name}.g.cs", SourceText.From(code.ToString(), Encoding.UTF8)); 34 } 35}

Source: SettingsGenerator.cs

逐步解读这一控制流:

  1. 接收器类型守卫:context.SyntaxReceiver is SettingsGenerationReceiver receiver 使用模式匹配做类型与空值双重检查。若接收器类型不符(理论上不可能,除非 Initialize 被改动),直接静默返回——生成器不抛异常,保证编译不被中断。
  2. 候选 → 符号:对每个候选 TypeDeclarationSyntax,先取其所属 SyntaxTree 的 SemanticModel,再调用 GetDeclaredSymbol(decl, context.CancellationToken) 得到 ITypeSymbol。传入 CancellationToken 使语义查询可被编译器取消,这是长会话 IDE 场景下(用户快速连续修改代码触发重编译)的响应性保障。
  3. 字段遍历:symbol.GetMembers().OfType<IFieldSymbol>() 只筛选字段(含静态字段、常量),不包括属性(IPropertySymbol)或事件。源码中的中文注释写的是"遍历 settings 类型的所有属性",但代码实际操作的是 IFieldSymbol——注释与实现存在轻微不一致,读者应以代码为准。
  4. 拼接生成文本:对每个字段,写入一行 // "字段名: {0}", type:完整类型名。field.Type.ToDisplayString() 输出带命名空间的完整类型字符串(例如 System.String、System.Collections.Generic.List<System.String>)。
  5. 注入编译:context.AddSource($"{symbol.Name}.g.cs", SourceText.From(...)) 以类型名 + .g.cs 作为 HintName 添加源文件。.g.cs 后缀是源生成器约定的命名方式,IDE 会将其折叠显示在原始类型下方。

端到端时序

Loading diagram...

生成的代码形态

假设某类型声明为:

csharp
1[SettingsGeneration] 2public partial class AppSettings 3{ 4 private string _theme; 5 private int _fontSize; 6}

则编译期间会注入名为 AppSettings.g.cs 的源文件,其完整内容为(逐字段三行注释):

csharp
// "_theme: {0}", type:string // "_fontSize: {0}", type:int

注意三个事实:

  1. 生成内容全部是注释行,不含任何可执行语句,因此对编译结果无实质影响——这也印证了该生成器目前处于脚手架/验证阶段,主要价值在于验证「发现类型 → 解析字段 → 注入源文件」这条流水线是否贯通。
  2. 源码中的格式串 $"// \"{propertyName}: {0}\"" 里的 {0} 是字面量(未使用 {0} 之外的插值槽),打印属性值的意图来自注释掉的旧实现(Console.WriteLine("{propertyName}: {{0}}", settings.{propertyName}))。
  3. AddSource 的 HintName 必须全局唯一,此实现仅用 symbol.Name(不含命名空间)——若两个不同命名空间存在同名类型,会触发 Roslyn 的 hint 重复异常,详见「失败模式」。

遗留代码与设计演变

SettingsGenerator.cs 第 50–100 行保留了三段被注释的替代实现,是理解该生成器设计意图的关键证据:

方案 A(第 50–79 行)—— 不用接收器的全量扫描:在 Execute 内直接遍历 context.Compilation.SyntaxTrees,对每棵树的每个 ClassDeclarationSyntax 取 GetDeclaredSymbol,再逐个调用 GetAttributes() 查找 AttributeClass.ToDisplayString() == "YourNamespace.SettingsGenerationAttribute"。这是语义级精确匹配(能区分不同命名空间的同名特性),但代价是为每个类声明构建符号——被当前"接收器 + 语法级预筛"方案替代。该方案的注释还提到了 compilation.GetTypeByMetadataName("YourNamespace.Settings") 用于解析目标设置类型。

方案 B(第 81–100 行)—— 官方 Hello World 模板:GetEntryPoint → 按 mainMethod.ContainingNamespace / ContainingType.Name 生成 public static partial class ... { static partial void HelloFrom(string name) => ... }。这是 Roslyn 源生成器教程的标准示例代码,佐证本文件是从官方模板起步开发的。

当前保留的方案(接收器方案)在生产 Roslyn 源生成器中是性能最优的形态:语法遍历阶段零语义开销,语义绑定只发生在少数候选上。

项目打包与依赖配置

生成器项目的 .csproj 决定它如何被消费:

xml
1<Project Sdk="Microsoft.NET.Sdk"> 2 <PropertyGroup> 3 <TargetFramework>netstandard2.0</TargetFramework> 4 <ImplicitUsings>enable</ImplicitUsings> 5 <Nullable>enable</Nullable> 6 <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> 7 <IsPackable>true</IsPackable> 8 <IsRoslynComponent>true</IsRoslynComponent> 9 </PropertyGroup> 10 11 <ItemGroup> 12 <Compile Remove="..\AssemblyInfo.cs"></Compile> 13 <Compile Remove="..\ImplicitUsings.BCL.cs"></Compile> 14 <Compile Remove="..\ImplicitUsings.Common.cs"></Compile> 15 </ItemGroup> 16 17 <ItemGroup> 18 <PackageReference Include="Microsoft.CodeAnalysis.Analyzers"> 19 <PrivateAssets>all</PrivateAssets> 20 <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> 21 </PackageReference> 22 <PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" /> 23 </ItemGroup> 24</Project>

Source: BD.WTTS.Generators.csproj

逐项解读设计意图:

配置项值为什么
TargetFrameworknetstandard2.0Roslyn 编译器宿主兼容的最低公分母 API 面;源生成器运行于编译器进程内
EnforceExtendedAnalyzerRulestrue强制启用扩展分析器规则(如禁止某些反射 API),保证生成器可在编译器内安全运行
IsRoslynComponenttrue使 dotnet pack 时在 nupkg 中标记为 Roslyn 组件,供引用方按分析器/生成器方式加载
Microsoft.CodeAnalysis.CSharp + PrivateAssets="all"—编译器平台 API 只在编译本项目时使用,绝不随生成器程序集传递给使用方,避免与编译器自带的 Roslyn 版本冲突
Microsoft.CodeAnalysis.Analyzers + PrivateAssets="all"—生成器专属分析规则(如 RS1030 禁止阻塞等),同样不传递
Compile Remove="..\AssemblyInfo.cs" 等—排除仓库级共享源文件(src/AssemblyInfo.cs、ImplicitUsings.*.cs 会被目录遍历默认引入),因为 netstandard2.0 项目引入这些 .NET 6+ 风格文件会导致编译失败

ImplicitUsings/Nullable 均为 enable,因此 SettingsGenerator.cs 顶部未显式 using System;(List<>、StringBuilder 来自隐式导入),也未写可空标注。

API Reference

SettingsGenerator.Initialize(GeneratorInitializationContext context) : void

参数:

  • context(GeneratorInitializationContext):生成器初始化上下文

行为: 调用 context.RegisterForSyntaxNotifications(() => new SettingsGenerationReceiver()) 注册语法接收器工厂。Roslyn 对每次编译各创建一次接收器实例。

SettingsGenerator.Execute(GeneratorExecutionContext context) : void

参数:

  • context(GeneratorExecutionContext):携带 Compilation、SyntaxReceiver、CancellationToken 与 AddSource 能力的执行上下文

行为: 从接收器取候选 → 语义解析为 ITypeSymbol → 遍历 IFieldSymbol → 为每个类型 AddSource("{TypeName}.g.cs", ...)。

异常: 代码本身不显式抛出;Roslyn 宿主会捕获生成器异常并在 IDE「Error List / 生成器输出」中报告(编译不因生成器崩溃而整体失败,但该生成器产物缺失)。

SettingsGenerationReceiver.OnVisitSyntaxNode(SyntaxNode syntaxNode) : void

参数:

  • syntaxNode(SyntaxNode):编译器遍历到的语法节点

行为: 若节点为 TypeDeclarationSyntax 且其任一特性的名称字符串等于 "SettingsGeneration" 或 "SettingsGenerationAttribute",将该类型声明加入 Candidates。

状态: Candidates 为公开可变 List<TypeDeclarationSyntax>,初始为空列表。

常量 SettingsGenerationReceiver.AttributeName

值为 "SettingsGenerationAttribute",定义目标特性的长名约定。

失败模式、边界情况与并发

场景行为影响
接收器类型不匹配is 模式匹配失败,静默 return无产物,编译正常
候选声明无对应符号(如残缺语法)GetDeclaredSymbol 返回 null,被 is ITypeSymbol 过滤跳过单个类型跳过
不同命名空间存在同名类型HintName 仅用 symbol.Name,出现重复 X.g.csRoslyn 报诊断(hint 必须唯一),需改为 symbol.ToDisplayString() 全名编码
同名不同命名空间的特性接收器字符串匹配会产生误报候选;Execute 未做二次特性校验与方案 A 相比缺少语义兜底,可能为非预期类型生成文件
编译被取消GetDeclaredSymbol 传入 context.CancellationToken语义查询中途取消,生成器随编译终止
IDE 增量重编译接收器每次编译重新实例化,Candidates 重建状态天然隔离,无跨编译污染
并发Roslyn 对同一接收器实例的 OnVisitSyntaxNode 回调为串行;Execute 中共享的仅局部 List/StringBuilder无共享可变状态,无线程安全问题

性能与运维要点

  • 热点在语义绑定:GetSemanticModel + GetDeclaredSymbol 是最昂贵操作,但仅对通过接收器预筛的少数候选执行,复杂度 O(候选数) 而非 O(全部类型数)。
  • StringBuilder 复用范围:在 foreach (var symbol in symbols) 外层每次 new 一个,多个类型间不复用;对当前"仅注释行"的产物无所谓,若未来生成真实代码应考虑复用或 SourceText 池化。
  • 无缓存:未使用 IncrementalGenerator(IIncrementalSourceGenerator),每次编译全量重跑。对该生成器的规模而言可接受;若后续字段数量巨大或引用 AnalyzerConfigOptionsProvider,迁移到增量管道是标准演进路径。
  • 调试:可在生成器项目上设置 Debug/launchSettings.json 以 devenv 或 dotnet build 为启动进程进行调试;生成的文件可通过 EmitCompilerGeneratedFiles MSBuild 属性落盘检查。

扩展点

  • 替换生成模板:Execute 中 code.AppendLine(...) 一行即生成逻辑的全部所在,改为拼出真正的 partial class 属性/序列化代码即可让生成器实用化(参考被注释的方案 A 中 Console.WriteLine("{propertyName}: {{0}}", settings.{propertyName}) 的意图)。
  • 支持属性成员:将 OfType<IFieldSymbol>() 换成或追加 IPropertySymbol 即可覆盖自动属性。
  • 精确特性匹配:在 Execute 中对每个 symbol.GetAttributes() 做二次校验(AttributeClass.ToDisplayString() 对比完整特性名),消除接收器字符串匹配的误报。
  • 唯一 HintName:用 symbol.ToDisplayString() 或「命名空间.类型名」编码 HintName,修复同名类型冲突。

Sources

(3 files)
src/BD.WTTS.Generators/Receiver