源代码生成器(SettingsGenerator)
源代码生成器(SettingsGenerator)是 SteamTools 仓库中 BD.WTTS.Generators 项目内的一个 Roslyn Source Generator,它在 C# 编译期间扫描带有 [SettingsGeneration] 标记的类型声明,并为每个类型生成一个 {TypeName}.g.cs 源文件注入到编译单元中。
Purpose and Scope
本文档完整讲解 SettingsGenerator 的实现机制,包括:
BD.WTTS.GeneratorsRoslyn 组件项目的结构与打包方式SettingsGenerationReceiver语法接收器的候选类型筛选逻辑SettingsGenerator的Initialize/Execute两阶段执行模型- 生成产物(
.g.cs)的形态与源码中遗留的替代实现 - 失败模式、边界情况、性能与扩展点
留给兄弟页面的内容: 同一项目中还存在另一个生成器 AttributeGenerator.cs(属性生成器),其实现细节不在本页范围内;证书生成器 CertGenerator.cs 与二维码生成器 QRCodeHelper.Net.Codecrete.QrCodeGenerator.cs 属于业务功能而非 Roslyn 源生成器,亦不属于本页主题。
Overview
SettingsGenerator 的设计意图是:在编译期自动发现应用程序中标注了 [SettingsGeneration] 的设置(Settings)类型,并根据这些类型的字段信息自动生成代码,从而避免为设置类手写重复的样板代码。
其运行位置非常特殊——不在应用程序进程内,而是在 Roslyn 编译器(csc / IDE 进程)内部执行。这决定了它的几个关键约束:
- 目标框架必须是
netstandard2.0:源生成器程序集被加载进编译器进程,只能使用编译器宿主兼容的 API 面。 - 不能阻塞编译:
Execute中接受CancellationToken,需要快速完成。 - 两阶段执行:先用
ISyntaxReceiver在语法层面做廉价的候选筛选,再在Execute中通过语义模型(SemanticModel)确认,避免为每个语法节点构建昂贵的语义绑定。
当前实现处于实验/脚手架阶段:Execute 生成的 .g.cs 内容仅是逐字段的注释行(字段名 + 字段类型的清单),并未产出真正的属性访问代码。源文件中保留了三段被注释掉的替代实现(全量语法树扫描方案与 HelloFrom 主方法模板),记录了该生成器的设计演进路径,详见后文「遗留代码与设计演变」。
Architecture
上图展示整个架构的数据流向:
- 使用方项目在编译时被 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 在此注册了它的语法通知器:
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 接口:
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 是生成器真正产出代码的地方。完整控制流如下:
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
逐步解读这一控制流:
- 接收器类型守卫:
context.SyntaxReceiver is SettingsGenerationReceiver receiver使用模式匹配做类型与空值双重检查。若接收器类型不符(理论上不可能,除非Initialize被改动),直接静默返回——生成器不抛异常,保证编译不被中断。 - 候选 → 符号:对每个候选
TypeDeclarationSyntax,先取其所属SyntaxTree的SemanticModel,再调用GetDeclaredSymbol(decl, context.CancellationToken)得到ITypeSymbol。传入CancellationToken使语义查询可被编译器取消,这是长会话 IDE 场景下(用户快速连续修改代码触发重编译)的响应性保障。 - 字段遍历:
symbol.GetMembers().OfType<IFieldSymbol>()只筛选字段(含静态字段、常量),不包括属性(IPropertySymbol)或事件。源码中的中文注释写的是"遍历 settings 类型的所有属性",但代码实际操作的是IFieldSymbol——注释与实现存在轻微不一致,读者应以代码为准。 - 拼接生成文本:对每个字段,写入一行
// "字段名: {0}", type:完整类型名。field.Type.ToDisplayString()输出带命名空间的完整类型字符串(例如System.String、System.Collections.Generic.List<System.String>)。 - 注入编译:
context.AddSource($"{symbol.Name}.g.cs", SourceText.From(...))以类型名 +.g.cs作为 HintName 添加源文件。.g.cs后缀是源生成器约定的命名方式,IDE 会将其折叠显示在原始类型下方。
端到端时序
生成的代码形态
假设某类型声明为:
1[SettingsGeneration]
2public partial class AppSettings
3{
4 private string _theme;
5 private int _fontSize;
6}则编译期间会注入名为 AppSettings.g.cs 的源文件,其完整内容为(逐字段三行注释):
// "_theme: {0}", type:string
// "_fontSize: {0}", type:int注意三个事实:
- 生成内容全部是注释行,不含任何可执行语句,因此对编译结果无实质影响——这也印证了该生成器目前处于脚手架/验证阶段,主要价值在于验证「发现类型 → 解析字段 → 注入源文件」这条流水线是否贯通。
- 源码中的格式串
$"// \"{propertyName}: {0}\""里的{0}是字面量(未使用{0}之外的插值槽),打印属性值的意图来自注释掉的旧实现(Console.WriteLine("{propertyName}: {{0}}", settings.{propertyName}))。 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 决定它如何被消费:
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
逐项解读设计意图:
| 配置项 | 值 | 为什么 |
|---|---|---|
TargetFramework | netstandard2.0 | Roslyn 编译器宿主兼容的最低公分母 API 面;源生成器运行于编译器进程内 |
EnforceExtendedAnalyzerRules | true | 强制启用扩展分析器规则(如禁止某些反射 API),保证生成器可在编译器内安全运行 |
IsRoslynComponent | true | 使 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.cs | Roslyn 报诊断(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为启动进程进行调试;生成的文件可通过EmitCompilerGeneratedFilesMSBuild 属性落盘检查。
扩展点
- 替换生成模板:
Execute中code.AppendLine(...)一行即生成逻辑的全部所在,改为拼出真正的 partial class 属性/序列化代码即可让生成器实用化(参考被注释的方案 A 中Console.WriteLine("{propertyName}: {{0}}", settings.{propertyName})的意图)。 - 支持属性成员:将
OfType<IFieldSymbol>()换成或追加IPropertySymbol即可覆盖自动属性。 - 精确特性匹配:在
Execute中对每个symbol.GetAttributes()做二次校验(AttributeClass.ToDisplayString()对比完整特性名),消除接收器字符串匹配的误报。 - 唯一 HintName:用
symbol.ToDisplayString()或「命名空间.类型名」编码 HintName,修复同名类型冲突。
Related Links
- SettingsGenerator.cs — 生成器主体实现
- SettingsGenerationReceiver.cs — 语法接收器实现
- BD.WTTS.Generators.csproj — Roslyn 组件项目配置
- 同项目兄弟页面:
AttributeGenerator(属性生成器)、CertGenerator(证书生成器,非 Roslyn 生成器)