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 实验实例来测试:
- 在分析器项目的 Debug 配置中,设置 "Start external program" 为 devenv.exe 的路径。
- 在命令行参数中添加 /rootsuffix Exp。
- 按 F5 启动,会打开一个新的 VS 窗口(实验实例)。
- 在实验实例中打开消费方项目,分析器将在此实例中运行,主 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 来观察生成器的实际输出。