C# Roslyn 编译器平台实战:源生成器、分析器与代码修补

Roslyn 是微软对 C# / VB.NET 编译器的开源重写,它不再是一个黑箱------而是将编译过程完全暴露为 API。你可以介入编译的几乎每一个阶段:在语法树层面分析代码、报告诊断信息、自动修复问题,甚至在编译期间凭空生成新的源代码。

本文涵盖 Roslyn 生态中最实用的三大扩展点:源生成器(Source Generator)分析器(Analyzer)代码修补(Code Fix Provider) ,从项目创建、日常使用到调试技巧,逐一展开。


一、核心概念速览

在深入之前,有必要厘清三者的定位:

扩展点 ## 作用 ### 介入时机 ### 输出
源生成器 在编译时生成新的 C# 源文件 编译期间,语法树已就绪但尚未进行语义分析 新增的 .cs 文件,参与后续编译
分析器 检查代码并报告诊断(警告/错误/信息) 编译期间或编辑器实时分析时 Diagnostic 对象(带 Squiggly line)
代码修补 为分析器报告的诊断提供自动修复方案 用户在编辑器中触发 Quick Action(Ctrl+.) 修改后的文档(语法树变换)

三者的关系通常是:分析器发现问题 → 代码修补解决问题;源生成器则独立地生成代码以减少手写样板。


二、源生成器(Source Generator)

2.1 什么是源生成器

源生成器在编译期间运行,读取当前编译的语法树和语义信息,然后向编译中追加全新的源文件。典型场景包括:自动生成序列化代码、根据配置生成 DTO、为接口生成代理实现、AOP 风格的代码织入等。

与传统的 T4 模板不同,源生成器是编译管线的一等公民------不需要额外的构建步骤,IDE 也能实时感知生成的代码。

2.2 创建源生成器项目

项目模板方式:

powershell 复制代码
dotnet new install Microsoft.CodeAnalysis.CSharp.SourceGenerators.ProjectTemplates
dotnet new sourcegenerator -n MyGenerators

手动创建:

powershell 复制代码
dotnet new classlib -n MyGenerators -f netstandard2.0

然后修改 .csproj:

xml 复制代码
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <!-- 源生成器必须是 netstandard2.0,因为它运行在编译器进程内 -->
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
  </ItemGroup>

</Project>

关键约束:源生成器的目标框架必须是 netstandard2.0,因为它被加载到编译器进程中运行,而编译器运行在 .NET Framework 或 .NET Core 上,需要最大兼容性。

2.3 编写一个源生成器

推荐实现 IIncrementalGenerator 接口(增量式生成器,性能更好):

csharp 复制代码
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using System.Text;
// 注意要添加Generator特性
[Generator]
public class AutoLogGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 第一步:语法过滤------找出所有带 [AutoLog] 特性的方法声明
        var methodDeclarations = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                fullyQualifiedName: "MyApp.AutoLogAttribute",
                predicate: (node, _) => node is MethodDeclarationSyntax,
                transform: (ctx, _) => (MethodDeclarationSyntax)ctx.TargetNode)
            .Where(m => m is not null);

        // 第二步:注册输出------为每个方法生成日志包装代码
        context.RegisterSourceOutput(methodDeclarations, (spc, method) =>
        {
            var className = method.Parent is ClassDeclarationSyntax c ? c.Identifier.Text : "Unknown";
            var methodName = method.Identifier.Text;
            var parameters = method.ParameterList.Parameters;

            var sb = new StringBuilder();
            sb.AppendLine("// <auto-generated/>");
            sb.AppendLine("using System;");
            sb.AppendLine();
            sb.AppendLine($"partial class {className}");
            sb.AppendLine("{");
            sb.AppendLine($"    partial void On{methodName}Enter()");
            sb.AppendLine("    {");
            sb.AppendLine($"        Console.WriteLine("[Enter] {className}.{methodName}");");
            sb.AppendLine("    }");
            sb.AppendLine();
            sb.AppendLine($"    partial void On{methodName}Exit()");
            sb.AppendLine("    {");
            sb.AppendLine($"        Console.WriteLine("[Exit] {className}.{methodName}");");
            sb.AppendLine("    }");
            sb.AppendLine("}");

            spc.AddSource($"{className}.{methodName}.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
        });
    }
}

在消费方项目中定义触发特性:

csharp 复制代码
namespace MyApp;

[AttributeUsage(AttributeTargets.Method)]
public class AutoLogAttribute : Attribute { }

2.4 在项目中引用源生成器

消费方项目的 .csproj 中这样引用:

xml 复制代码
<ItemGroup>
  <ProjectReference Include="..\MyGenerators\MyGenerators.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false" />
</ItemGroup>

OutputItemType="Analyzer" 是关键------它告诉编译器将此程序集作为分析器/生成器加载,而非普通引用。ReferenceOutputAssembly="false" 表示不将其作为运行时依赖。

编译后,生成的文件会参与编译。在 Visual Studio 中,你可以在解决方案资源管理器的 "Dependencies → Analyzers → MyGenerators" 节点下看到生成的文件。


三、分析器(Analyzer)

3.1 什么是分析器

分析器对代码进行实时检查,当发现不符合约定的模式时,向编辑器报告一条诊断信息(Diagnostic),表现为代码下方的波浪线。分析器可以检查命名规范、禁止使用某些 API、强制架构约定、发现潜在 Bug 等。

3.2 创建分析器项目

使用 VS 模板(推荐):

在 Visual Studio 中搜索模板 "Analyzer with Code Fix (.NET Standard)"。这会同时创建分析器、代码修补和测试三个项目。

手动创建:

powershell 复制代码
dotnet new classlib -n MyAnalyzers -f netstandard2.0

.csproj 与源生成器基本一致,同样需要 Microsoft.CodeAnalysis.CSharp 包和目标框架 netstandard2.0。

3.3 编写一个分析器

以下分析器检查所有 public 方法是否包含 XML 文档注释,缺少则报告警告:

csharp 复制代码
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class MissingDocCommentAnalyzer : DiagnosticAnalyzer
{
    // 定义诊断描述
    public static readonly DiagnosticDescriptor Rule = new(
        id: "MY001",
        title: "公开方法缺少文档注释",
        messageFormat: "方法 '{0}' 缺少 XML 文档注释",
        category: "Documentation",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "所有 public 方法都应包含 /// 文档注释以提高可维护性。");

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
        => ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        // 性能最佳实践:并发执行 + 跳过生成代码
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();

        // 注册语法节点回调------只关心方法声明
        context.RegisterSyntaxNodeAction(AnalyzeMethod, SyntaxKind.MethodDeclaration);
    }

    private static void AnalyzeMethod(SyntaxNodeAnalysisContext context)
    {
        var method = (MethodDeclarationSyntax)context.Node;

        // 只检查 public 方法
        if (!method.Modifiers.Any(SyntaxKind.PublicKeyword))
            return;

        // 检查是否存在三斜杠文档注释
        var trivia = method.GetLeadingTrivia();
        var hasDocComment = trivia.Any(t =>
            t.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) ||
            t.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia));

        if (!hasDocComment)
        {
            var diagnostic = Diagnostic.Create(
                Rule,
                method.Identifier.GetLocation(),
                method.Identifier.Text);
            context.ReportDiagnostic(diagnostic);
        }
    }
}

3.4 分析器的引用方式

与源生成器完全相同:

xml 复制代码
<ProjectReference Include="..\MyAnalyzers\MyAnalyzers.csproj"
                  OutputItemType="Analyzer"
                  ReferenceOutputAssembly="false" />

如果分析器以 NuGet 包形式发布,消费方可以这样引用:

xml 复制代码
<PackageReference Include="MyAnalyzers" Version="1.0.0">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

引用后,编辑器会实时显示诊断波浪线,编译时也会在输出中报告对应的警告或错误。

3.5 通过 .editorconfig 或 .globalconfig 配置严重级别

消费方可以覆盖分析器默认的严重级别:

ini 复制代码
# .editorconfig
[*.cs]
dotnet_diagnostic.MY001.severity = error

也可以完全禁用某条规则:

ini 复制代码
dotnet_diagnostic.MY001.severity = none

四、代码修补(Code Fix Provider)

4.1 什么是代码修补

代码修补为分析器报告的诊断提供自动修复方案。用户在编辑器中将光标放在波浪线上,按下 Ctrl+.(Quick Actions),就能看到修复建议,确认后自动修改代码。

4.2 编写代码修补

继续上面的例子------为缺少文档注释的方法自动生成注释骨架:

csharp 复制代码
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddDocCommentCodeFix)), Shared]
public class AddDocCommentCodeFix : CodeFixProvider
{
    public sealed override ImmutableArray<string> FixableDiagnosticIds
        => ImmutableArray.Create("MY001");

    public sealed override FixAllProvider GetFixAllProvider()
        => WellKnownFixAllProviders.BatchFixer;

    public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
    {
        var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken);
        var diagnostic = context.Diagnostics.First();
        var span = diagnostic.Location.SourceSpan;

        // 找到对应的方法声明节点
        var method = root?.FindToken(span.Start)
            .Parent?.AncestorsAndSelf()
            .OfType<MethodDeclarationSyntax>()
            .FirstOrDefault();

        if (method is null) return;

        context.RegisterCodeFix(
            CodeAction.Create(
                title: "添加 XML 文档注释",
                createChangedDocument: ct => AddDocCommentAsync(context.Document, method, ct),
                equivalenceKey: "AddDocComment"),
            diagnostic);
    }

    private static async Task<Document> AddDocCommentAsync(
        Document document,
        MethodDeclarationSyntax method,
        CancellationToken cancellationToken)
    {
        // 构造 /// <summary> 注释文本
        var paramLines = method.ParameterList.Parameters
            .Select(p => $"/// <param name="{p.Identifier.Text}"></param>");

        var hasReturn = method.ReturnType.ToString() != "void";

        var commentText = "/// <summary>\n/// \n/// </summary>\n"
            + string.Join("\n", paramLines)
            + (hasReturn ? "\n/// <returns></returns>" : "")
            + "\n";

        var trivia = SyntaxFactory.ParseLeadingTrivia(commentText);
        var newMethod = method.WithLeadingTrivia(
            method.GetLeadingTrivia().InsertRange(0, trivia));

        var root = await document.GetSyntaxRootAsync(cancellationToken);
        var newRoot = root!.ReplaceNode(method, newMethod);

        return document.WithSyntaxRoot(newRoot);
    }
}

关键要点:

FixableDiagnosticIds 声明此修补对应哪些诊断 ID(这里是 MY001)。GetFixAllProvider() 返回 BatchFixer,让用户可以一次性修复整个项目中的所有同类问题。RegisterCodeFixesAsync 中注册修复动作,实际修改通过操作语法树完成。

4.3 代码修补的引用

代码修补与分析器在同一个程序集中,引用方式一致。通常分析器和代码修补打包在一起发布。


五、调试技巧

调试 Roslyn 扩展比普通应用更特殊,因为生成器和分析器运行在编译器进程中(而非你的应用进程)。以下是几种实用的调试方法。

5.1 使用 Debugger.Launch() 断点

最直接的方式------在生成器或分析器代码中插入:

csharp 复制代码
#if DEBUG
if (!System.Diagnostics.Debugger.IsAttached)
    System.Diagnostics.Debugger.Launch();
#endif

编译时,系统会弹出对话框让你选择一个调试器实例附加。附加后就能命中后续断点。

注意:这会让编译过程暂停等待调试器附加,只应在开发期间使用。

5.2 Visual Studio 实验实例(Experimental Hive)

如果你在 Visual Studio 中开发,可以创建一个 VSIX 项目来打包分析器,然后启动一个 VS 实验实例来测试:

  1. 在分析器项目的 Debug 配置中,设置 "Start external program" 为 devenv.exe 的路径。
  2. 在命令行参数中添加 /rootsuffix Exp。
  3. 按 F5 启动,会打开一个新的 VS 窗口(实验实例)。
  4. 在实验实例中打开消费方项目,分析器将在此实例中运行,主 VS 实例保持调试状态。

5.3 单元测试

这是最推荐的开发方式------为分析器和生成器编写单元测试,无需启动编译器进程即可验证行为。

测试项目设置:

xml 复制代码
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.2" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing" Version="1.1.2" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
    <PackageReference Include="xunit" Version="2.7.0" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.7" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\MyAnalyzers\MyAnalyzers.csproj" />
  </ItemGroup>

</Project>

分析器测试示例:

csharp 复制代码
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using Xunit;

public class MissingDocCommentAnalyzerTests
{
    [Fact]
    public async Task PublicMethod_WithoutDocComment_ReportsWarning()
    {
        var code = """
            public class MyClass
            {
                public void {|#0:DoSomething|}() { }
            }
            """;

        var expected = new DiagnosticResult("MY001", DiagnosticSeverity.Warning)
            .WithLocation(0)
            .WithArguments("DoSomething");

        var test = new CSharpAnalyzerTest<MissingDocCommentAnalyzer, DefaultVerifier>
        {
            TestCode = code,
            ExpectedDiagnostics = { expected },
        };

        await test.RunAsync();
    }

    [Fact]
    public async Task PublicMethod_WithDocComment_NoDiagnostic()
    {
        var code = """
            public class MyClass
            {
                /// <summary>Does something.</summary>
                public void DoSomething() { }
            }
            """;

        var test = new CSharpAnalyzerTest<MissingDocCommentAnalyzer, DefaultVerifier>
        {
            TestCode = code,
        };

        await test.RunAsync();
    }
}

代码修补测试示例:

csharp 复制代码
[Fact]
public async Task CodeFix_AddsDocComment()
{
    var code = """
        public class MyClass
        {
            public void {|#0:DoSomething|}() { }
        }
        """;

    var fixedCode = """
        public class MyClass
        {
            /// <summary>
            /// 
            /// </summary>
            public void DoSomething() { }
        }
        """;

    var expected = new DiagnosticResult("MY001", DiagnosticSeverity.Warning)
        .WithLocation(0)
        .WithArguments("DoSomething");

    var test = new CSharpCodeFixTest<MissingDocCommentAnalyzer, AddDocCommentCodeFix, DefaultVerifier>
    {
        TestCode = code,
        FixedCode = fixedCode,
        ExpectedDiagnostics = { expected },
    };

    await test.RunAsync();
}

源生成器测试示例:

csharp 复制代码
[Fact]
public async Task Generator_ProducesPartialMethods()
{
    var source = """
        using MyApp;
        
        [AutoLog]
        public partial class MyService
        {
            [AutoLog]
            public partial void Process() { }
        }

        namespace MyApp
        {
            [System.AttributeUsage(System.AttributeTargets.Method)]
            public class AutoLogAttribute : System.Attribute { }
        }
        """;

    var test = new CSharpSourceGeneratorTest<AutoLogGenerator, DefaultVerifier>
    {
        TestState =
        {
            Sources = { source },
            GeneratedSources =
            {
                // 验证生成的文件内容
                (typeof(AutoLogGenerator), "MyService.Process.g.cs", 
                    SourceText.From(ExpectedGeneratedCode, Encoding.UTF8)),
            },
        },
    };

    await test.RunAsync();
}

运行 dotnet test 就能验证所有行为,断点调试与普通单元测试无异。

5.4 查看生成器输出

开发期间,可以让生成器将中间结果写到磁盘以便检查:

csharp 复制代码
#if DEBUG
System.IO.File.WriteAllText(
    $@"C:\temp\generator{methodName}.cs",
    generatedCode);
#endif

在消费方项目中也可以配置将生成文件输出到磁盘:

xml 复制代码
<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

编译后,所有生成的文件会出现在 Generated/ 目录下,可以直接打开查看。

5.5 常见调试陷阱

生成器未被加载: 确保消费方项目引用了 OutputItemType="Analyzer"。如果用 NuGet 包,检查包的 analyzers/dotnet/cs/ 目录结构是否正确。

netstandard2.0 限制: 生成器中不能使用 Span、IAsyncEnumerable 等较新 API,除非额外引用兼容包。遇到 MissingMethodException 或 TypeLoadException 时优先检查这一点。

增量生成器的缓存语义: IIncrementalGenerator 要求 transform 步骤是纯函数且结果可比较。如果你的中间结果类型没有正确实现 Equals 和 GetHashCode,生成器会不必要地重新运行,导致性能问题。建议使用 record 类型或手动实现值相等。

调试器无法附加: Debugger.Launch() 在 CI 环境下会卡死编译。务必用 #if DEBUG 保护,或改用条件断点 + 日志输出的方式。


六、项目结构最佳实践

一个完整的 Roslyn 扩展解决方案通常包含以下结构:

bash 复制代码
MyRoslynTools/
├── src/
│   ├── MyAnalyzers/              # 分析器 + 代码修补(netstandard2.0)
│   │   ├── MyAnalyzers.csproj
│   │   ├── MissingDocCommentAnalyzer.cs
│   │   └── AddDocCommentCodeFix.cs
│   ├── MyGenerators/             # 源生成器(netstandard2.0)
│   │   ├── MyGenerators.csproj
│   │   └── AutoLogGenerator.cs
│   └── MyLibrary/                # 消费方项目(net8.0)
│       ├── MyLibrary.csproj
│       └── MyService.cs
├── tests/
│   ├── MyAnalyzers.Tests/        # 分析器单元测试(net8.0)
│   │   └── MyAnalyzers.Tests.csproj
│   └── MyGenerators.Tests/       # 生成器单元测试(net8.0)
│       └── MyGenerators.Tests.csproj
└── MyRoslynTools.sln

将分析器和生成器分开为独立项目,因为它们的职责不同、更新节奏不同,测试也应该独立。


七、NuGet 打包与分发

将分析器打包为 NuGet 供其他团队使用时,.csproj 需要额外配置:

xml 复制代码
<PropertyGroup>
  <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
  <IncludeBuildOutput>false</IncludeBuildOutput>
  <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
  <DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>

<ItemGroup>
  <!-- 将编译输出放到 NuGet 包的 analyzers/dotnet/cs 目录 -->
  <None Include="$(OutputPath)$(AssemblyName).dll"
        Pack="true"
        PackagePath="analyzers/dotnet/cs"
        Visible="false" />
</ItemGroup>

消费方引用 NuGet 包后,分析器会自动加载,无需额外配置。


八、总结

Roslyn 编译器平台将 C# 编译过程从黑箱变为透明的、可编程的管线。源生成器消除了手写样板代码的痛苦,分析器让团队编码规范从文档变成了可执行的约束,代码修补则将修复方案自动化到一键完成。

从实践角度看,最重要的几个建议:始终使用 IIncrementalGenerator 而非旧版 ISourceGenerator;为分析器和生成器编写充分的单元测试,这是最高效的开发和调试方式;注意 netstandard2.0 的 API 限制;以及善用 EmitCompilerGeneratedFiles 来观察生成器的实际输出。

相关推荐
赫媒派1 小时前
Go 1.27 来了:泛型方法补齐,JSON 提速不踩坑
后端·go·敏捷开发
长栎1 小时前
99% 的人把桥接模式当策略模式用——抽象与实现分离,你做的不是同一件事
后端
长栎1 小时前
你激活了 sharp-skills 一个模块,但你的项目从来不是单点活儿
后端
Lcos1 小时前
Kubernetes Toleration 六种写法详解:从精确匹配到全部放行
后端
元界metalite1 小时前
MyBatis事务只能靠Transactional吗-MetaLite为何只保留编程式事务
后端
用户9479135811621 小时前
20260821_090153_LangGraph_生产落地的_5_个关键坑:从_State
后端
Csvn1 小时前
📊 SQL 入门 Day 21:表设计与约束
后端·sql
SamDeepThinking1 小时前
警惕那些很长时间没有编写任何代码、却在设计系统的人
java·后端·架构
aloha_1 小时前
基于Spring Boot + Vue 3的前后端一体化部署方案
后端