1. 编译期元编程的两种形态
一句话总结: 源生成器在编译期把新代码加进编译单元,分析器在编译期把问题报给 IDE 与构建,二者共享 Roslyn 的语法与语义模型,是 .NET 生态最强大的元编程入口。
.NET 的编译期元编程分成两条线。源生成器(Source Generator) 读取已有代码,向编译过程注入新的 C# 源文件,典型用途是自动生成序列化器、映射代码、注册表与 DTO。分析器(Analyzer) 读取同样的代码,但不改代码,只产出 诊断(Diagnostic),配合 代码修复(Code Fix) 在 IDE 里一键改错。
两者的共同底座是 Roslyn 编译器平台。Roslyn 把编译过程拆成可观察的 API:语法树、语义模型、符号、操作树。生成器与分析器都是「编译器的插件」,随编译一起跑。
| 形态 | 输出 | 运行时机 | 典型场景 |
|---|---|---|---|
| 源生成器 | 新的 .cs 源文件 | 编译前(生成阶段) | 序列化器、DTO、注册代码 |
| 分析器 | Diagnostic 报告 | 编译中与 IDE 中 | 代码规范、性能告警、安全规则 |
| 代码修复 | 文本变更 | IDE 快速修复 | 一键替换写法、补全参数 |
| 增量生成器 | 增量缓存的新源文件 | 增量编译 | 大规模项目里的低开销生成 |
避坑: 生成器与分析器都以
netstandard2.0为目标框架,不能引用运行时的net8.0库。需要引用 Roslyn 包(Microsoft.CodeAnalysis.CSharp)并设置PrivateAssets="all",避免把编译器程序集带进使用者的输出目录。
2. ISourceGenerator 与 IIncrementalGenerator
一句话总结:
ISourceGenerator每次编译都全量重跑,IIncrementalGenerator用管道与不可变数据模型只重跑受影响的部分,新项目应一律选后者。
ISourceGenerator 是初代 API,接口只有 Initialize 与 Execute 两个方法。Execute 拿到整个 GeneratorExecutionContext,自己遍历语法树、拼字符串、AddSource。问题是:任何一处代码变动都会让整个生成器重跑,在大项目里直接拖慢增量编译。
IIncrementalGenerator(.NET 6+)把生成过程建模成一条管道:从语法提供器出发,经过转换、过滤、合并,最后输出源文件。每个阶段的结果都被缓存,只有输入变化的部分才重新计算。
// 初代写法:每次编译全量执行
[Generator]
public class LegacyGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context) { }
public void Execute(GeneratorExecutionContext context)
{
// 遍历所有语法树,代价高
foreach (var tree in context.Compilation.SyntaxTrees)
{
// ... 拼代码
}
context.AddSource("Generated.g.cs", "// ...");
}
}
// 增量写法:管道 + 缓存
[Generator]
public class IncrementalGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// 语法提供器:只挑带特性的类
var candidates = context.SyntaxProvider
.ForAttributeWithMetadataName(
"MyLib.GenerateDtoAttribute",
predicate: (node, _) => node is ClassDeclarationSyntax,
transform: (ctx, ct) => GetModel(ctx, ct))
.Where(m => m is not null);
// 收集后生成
context.RegisterSourceOutput(candidates.Collect(), (spc, models) =>
{
var source = Emit(models!);
spc.AddSource("Dtos.g.cs", source);
});
}
}
| 维度 | ISourceGenerator | IIncrementalGenerator |
|---|---|---|
| 缓存 | 无,全量重跑 | 管道级增量缓存 |
| 推荐度 | 遗留代码 | 新项目唯一选择 |
| 数据模型 | 任意可变对象 | 应使用 record/不可变 |
| 大项目开销 | 高 | 低 |
避坑: 增量管道里的中间数据必须是值相等的不可变类型。若 transform 返回一个普通 class(引用相等),缓存永远命中不了,增量退化为全量。用
record或实现IEquatable<T>,并且不要在模型里持有ISymbol、SyntaxNode这类对象——它们把整个编译单元钉在内存里,是增量生成器最常见的内存泄漏源。
3. 语法模型与语义模型
一句话总结: 语法模型回答「代码长什么样」,语义模型回答「这个名字指什么」,两者配合才能可靠地识别类型、泛型与继承关系。
SyntaxNode、SyntaxToken、SyntaxTrivia 构成语法树。语法树是只读的,任何修改都通过工厂方法返回新树。语法层面能拿到类名、成员名、特性写法,但拿不到「这个 Id 属性是 int 还是 Guid」。
SemanticModel 补上这一层。通过 GetDeclaredSymbol、GetSymbolInfo、GetTypeInfo 可以在某个语法节点上解析出符号与类型。符号(INamedTypeSymbol、IMethodSymbol)带有完整的类型信息与继承链。
private static DtoModel? GetModel(GeneratorAttributeSyntaxContext ctx, CancellationToken ct)
{
if (ctx.TargetSymbol is not INamedTypeSymbol type) return null;
var ns = type.ContainingNamespace.IsGlobalNamespace
? null
: type.ContainingNamespace.ToDisplayString();
// 语义模型:把每个属性解析成类型
var props = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.Select(p => new PropModel(
p.Name,
p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)))
.ToArray();
return new DtoModel(ns, type.Name, props);
}
private sealed record PropModel(string Name, string Type);
private sealed record DtoModel(string? Namespace, string Name, PropModel[] Props);
| API | 层次 | 能回答的问题 |
|---|---|---|
SyntaxNode / SyntaxToken | 语法 | 名字、修饰符、特性位置 |
SemanticModel.GetSymbolInfo | 语义 | 这个调用指向哪个方法 |
INamedTypeSymbol | 语义 | 基类、接口、类型参数 |
IOperation | 语义(更抽象) | 表达式在语义上做了什么 |
避坑: 不要用字符串比较去判断类型名,
ToDisplayString()的结果受命名空间别名与全局限定影响。判断类型用SymbolEqualityComparer.Default.Equals(a, b),取类型全名用ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)再剥掉global::前缀。分析器里同样如此——字符串匹配会在using别名、嵌套类型上翻车。
4. DiagnosticAnalyzer 与 CodeFixProvider
一句话总结: 分析器通过
RegisterSyntaxNodeAction等回调产出Diagnostic,代码修复通过CodeFixProvider提供改写方案,二者以DiagnosticId关联。
分析器的入口是 DiagnosticAnalyzer.Initialize,在里面注册「对什么节点、什么符号感兴趣」。回调里做判断,命中规则就 ReportDiagnostic。规则描述放在 DiagnosticDescriptor 里,包含 Id、标题、消息模板、类别、默认严重级别。
代码修复是另一套机制:CodeFixProvider 声明自己能修哪些 Id,IDE 弹出灯泡时调用 RegisterCodeFixesAsync,在里面把语法树改掉并返回新的 Document。
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class SyncOverAsyncAnalyzer : DiagnosticAnalyzer
{
public static readonly DiagnosticDescriptor Rule = new(
id: "MYLIB001",
title: "避免同步等待异步方法",
messageFormat: "方法 {0} 返回 Task,请使用 await 而非 .Result",
category: "Performance",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
=> ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterOperationAction(Analyze, OperationKind.PropertyReference);
}
private static void Analyze(OperationAnalysisContext context)
{
var prop = (IPropertyReferenceOperation)context.Operation;
if (prop.Property.Name is not ("Result" or "Wait")) return;
var type = prop.Instance?.Type;
if (type is null || type.Name != "Task") return;
context.ReportDiagnostic(Diagnostic.Create(Rule, prop.Syntax.GetLocation(), "Task"));
}
}
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SyncOverAsyncFix))]
[Shared]
public class SyncOverAsyncFix : CodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds
=> ImmutableArray.Create(SyncOverAsyncAnalyzer.Rule.Id);
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync();
var node = root!.FindNode(context.Span);
context.RegisterCodeFix(
CodeAction.Create(
title: "改用 await",
createChangedDocument: ct => ReplaceWithAwaitAsync(context.Document, node!, ct),
equivalenceKey: "AwaitInsteadOfResult"),
context.Diagnostics.First());
}
private static async Task<Document> ReplaceWithAwaitAsync(
Document doc, SyntaxNode node, CancellationToken ct)
{
var root = await doc.GetSyntaxRootAsync(ct);
var awaitExpr = SyntaxFactory.AwaitExpression((ExpressionSyntax)node);
return doc.WithSyntaxRoot(root!.ReplaceNode(node, awaitExpr));
}
}
| 关注点 | 分析器 | 代码修复 |
|---|---|---|
| 入口 | Initialize 注册动作 | RegisterCodeFixesAsync |
| 输出 | Diagnostic | CodeAction / Document |
| 关联键 | DiagnosticDescriptor.Id | FixableDiagnosticIds |
| 严重级别 | DiagnosticSeverity | 无(继承诊断) |
避坑: 分析器运行在编译与 IDE 里,任何异常都会被静默吞掉,表现为「规则不生效」而不是报错。调试分析器必须附加到
dotnet build或VBCSCompiler进程。另外要显式调用ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None),否则会对生成代码反复告警;EnableConcurrentExecution()也必须开,否则在大解决方案里会明显变慢。
5. 生成序列化与 DTO 代码
一句话总结: 用源生成器产出序列化与 DTO 代码,可以把反射开销搬到编译期,同时天然兼容 Native AOT 与裁剪。
System.Text.Json 从 .NET 6 起支持 源生成序列化:给 JsonSerializerContext 打上 [JsonSerializable(typeof(T))],生成器会为每个类型产出专用的读写代码。相比反射,它更快、内存更省,而且没有反射,因此能在 AOT 与裁剪下工作。
除了直接用官方生成器,也可以自己写生成器,为标记类型产出 DTO 映射、批量注册代码等。
// 官方源生成序列化上下文
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(List<OrderDto>))]
public partial class AppJsonContext : JsonSerializerContext { }
// 使用生成好的上下文(无反射)
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.OrderDto);
var back = JsonSerializer.Deserialize(json, AppJsonContext.Default.OrderDto);
// 自定义生成器:为 [GenerateMapper] 类型产出映射方法
// 生成结果示例(Generated.g.cs)
namespace MyApp;
public static partial class OrderMapper
{
public static OrderDto ToDto(this Order source) => new()
{
Id = source.Id,
Total = source.Total,
CreatedAt = source.CreatedAt,
};
}
| 方案 | 运行时开销 | AOT 友好 | 可维护性 |
|---|---|---|---|
| 反射序列化 | 高(首次更明显) | 否 | 好 |
| 源生成序列化 | 低 | 是 | 好 |
| 手写映射 | 最低 | 是 | 差(易漏字段) |
| 表达式树映射 | 中 | 部分 | 好 |
避坑: 源生成序列化要求类型在编译期静态可知。
object、dynamic、开放泛型、只在运行时才出现的类型都无法被生成。多态场景要显式声明派生类型或使用JsonDerivedType。另外生成器产出的代码默认不参与你的格式化,调试时通过<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>把文件落盘查看。
6. 性能与增量缓存
一句话总结: 生成器的性能瓶颈几乎总在「每编译都重跑」与「持有语法/符号引用」,用增量管道加纯值模型就能解决。
生成器与编译同进程运行,跑得慢就等于拖慢每一次编译。三个最常见的问题:
第一,全量执行。初代生成器每次编译遍历所有语法树。改用 ForAttributeWithMetadataName 可以只命中带特性的声明,Roslyn 内部对它有专门的语法索引优化。
第二,中间模型不可比。返回引用类型导致缓存永不命中。
第三,持有符号引用。模型里存 ISymbol 会让整个编译单元无法被回收。
// 反例:模型持有 ISymbol,内存与缓存双重灾难
private sealed class BadModel
{
public ISymbol Symbol { get; init; } = null!; // 钉住整个编译单元
}
// 正例:只保留纯值,且用 record 获得值相等
private sealed record GoodModel(string Name, string FullTypeName, string? Namespace);
// 正例:把昂贵的语义计算放在 transform 里,缓存会自动复用
var models = context.SyntaxProvider
.ForAttributeWithMetadataName("MyLib.GenerateDtoAttribute",
(n, _) => n is ClassDeclarationSyntax,
(ctx, ct) => BuildPureModel(ctx, ct))
.Where(m => m is not null)
.Collect(); // 收集后再统一生成
| 症状 | 根因 | 修法 |
|---|---|---|
| 增量编译变慢 | 全量遍历语法树 | 用 ForAttributeWithMetadataName |
| 缓存不命中 | 模型是引用相等 | 改成 record |
| 内存持续增长 | 模型持有 ISymbol | 只存字符串与值 |
| IDE 卡顿 | 未开并发执行 | EnableConcurrentExecution() |
| 生成结果不稳定 | 遍历顺序随机 | 生成前显式排序 |
避坑: 生成器输出必须确定性——同样的输入产出完全一样的文本。字典遍历顺序、
HashSet迭代顺序在不同进程里可能不同,会让增量构建认为输出变了而反复重编。生成前对集合显式OrderBy,是让构建稳定的关键小动作。
7. 打包与分发
一句话总结: 生成器与分析器都通过 NuGet 分发,关键是放进
analyzers/dotnet/cs目录、声明PrivateAssets、并把依赖正确传递。
生成器/分析器项目本质是一个 netstandard2.0 类库,但打包方式与普通库不同:DLL 必须落在 NuGet 包的 analyzers/dotnet/cs 路径下,使用者引用时才不会被当作普通依赖复制到输出。
<!-- 生成器项目 .csproj 关键片段 -->
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IncludeBuildOutput>false</IncludeBuildOutput>
<IsRoslynComponent>true</IsRoslynComponent>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp"
Version="4.8.0" PrivateAssets="all" />
<None Include="$(OutputPath)\$(AssemblyName).dll"
Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
<!-- 使用者项目:按需引入,避免传递到下游 -->
<ItemGroup>
<PackageReference Include="MyLib.Generators" Version="1.0.0"
PrivateAssets="all" />
</ItemGroup>
| 打包要点 | 说明 |
|---|---|
| 目标框架 | netstandard2.0 |
| 输出路径 | analyzers/dotnet/cs |
| 依赖 | Roslyn 包一律 PrivateAssets="all" |
| 使用者引用 | PrivateAssets="all" 阻断传递 |
| 调试 | EmitCompilerGeneratedFiles + CompilerGeneratedFilesOutputPath |
| 验证 | 用 Microsoft.CodeAnalysis.Testing 写单测 |
避坑: 生成器引用的第三方库不会自动传给使用者,运行时可能
FileNotFoundException。解决办法是把依赖也打进analyzers/dotnet/cs,或改用Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing在 CI 里验证真实编译结果。另一个坑是版本:生成器用高版本 Roslyn 编译,使用者用低版本 SDK 时可能加载失败,应把 Roslyn 版本对齐到支持的最低 SDK。
8. 总结
| 环节 | 要点 |
|---|---|
| 两种形态 | 生成器改代码,分析器报问题,共享 Roslyn 底座 |
| 生成器选型 | 新项目一律用 IIncrementalGenerator |
| 语法与语义 | 语法看形状,语义看类型,类型比较用符号相等器 |
| 分析器 | Initialize 注册回调,产出 Diagnostic,配套 CodeFixProvider |
| 生成代码 | 序列化与 DTO 搬到编译期,天然兼容 AOT |
| 性能 | 增量管道 + 纯值模型 + 确定性输出 |
| 分发 | analyzers/dotnet/cs + PrivateAssets="all" |
源生成器与分析器把「运行时才做的事」提前到编译期:反射序列化变成生成代码,团队规范变成可执行的诊断,重复样板变成自动产出。代价是构建变慢与调试变难,所以增量缓存与确定性输出必须从第一版就设计进去。理解 Roslyn 的语法与语义模型之后,下一步是理解运行时的内存与 IO 模型——这正是 Span<T>、Memory<T> 与 Pipelines 的舞台。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。