源生成器与 Roslyn 分析器

深入 .NET 编译期元编程,覆盖 ISourceGenerator 与 IIncrementalGenerator 的差异、语法与语义模型的使用、DiagnosticAnalyzer 与 CodeFixProvider 的编写,以及自动生成序列化与 DTO 代码、增量缓存优化与 NuGet 打包分发。

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);
        });
    }
}
维度ISourceGeneratorIIncrementalGenerator
缓存无,全量重跑管道级增量缓存
推荐度遗留代码新项目唯一选择
数据模型任意可变对象应使用 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
输出DiagnosticCodeAction / Document
关联键DiagnosticDescriptor.IdFixableDiagnosticIds
严重级别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 的舞台。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排