一、什么是 Roslyn
Roslyn(正式名称 .NET Compiler Platform)是 C# 和 Visual Basic 的开源编译器,同时也是一套编译器即服务(Compiler as a Service) API。
传统编译器是一个黑盒——源码进去,二进制出来,中间过程不可见。Roslyn 把这个黑盒打开了:
┌──────────────────────────────────────────────────────────┐
│ 传统编译器(黑盒) │
│ 源码 ───→ [???] ───→ IL/二进制 │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Roslyn(白盒) │
│ 源码 ───→ 语法树 ───→ 语义模型 ───→ IL/二进制 │
│ ↓ ↓ │
│ 可查询 可查询 │
│ 可遍历 可分析 │
│ 可修改 可推理 │
└──────────────────────────────────────────────────────────┘
Roslyn 让你能够在编译过程中:
- 读取源码的完整语法树(Syntax Tree)
- 查询每个符号的类型、定义位置、引用关系(Semantic Model)
- 生成诊断(警告/错误),就像编译器内置规则一样
- 修改语法树并生成新代码(Code Fix / Source Generator)
二、Roslyn 的核心架构
2.1 语法树(Syntax Tree)
语法树是源码的完全忠实表示——包括每个空格、注释、换行。它由三种元素组成:
| 元素 | 类 | 说明 | 示例 |
|---|---|---|---|
| 节点(Node) | SyntaxNode |
语法结构 | ClassDeclaration、MethodDeclaration、InvocationExpression |
| 标记(Token) | SyntaxToken |
最小的词法单元 | public、void、MyMethod、(、) |
| 琐碎内容(Trivia) | SyntaxTrivia |
空白、注释、预处理指令 | // 注释、#if DEBUG、换行符 |
以一行代码为例:
app.UseSwagger();
对应的语法树(简化):
ExpressionStatement
└── InvocationExpression ← "app.UseSwagger()"
├── MemberAccessExpression ← "app.UseSwagger"
│ ├── IdentifierName "app"
│ ├── DotToken "."
│ └── IdentifierName "UseSwagger"
└── ArgumentList ← "()"
├── OpenParenToken "("
└── CloseParenToken ")"
💡 可以使用 Roslyn Syntax Visualizer 或 SharpLab.io 在线查看任意代码的语法树。
2.2 语义模型(Semantic Model)
语法树只告诉你"代码长什么样",语义模型告诉你"代码是什么意思":
| 语法树能告诉你 | 语义模型能告诉你 |
|---|---|
这里调用了一个名为 UseSwagger 的方法 |
这个方法属于 Microsoft.AspNetCore.Builder.SwaggerBuilderExtensions 类 |
变量名是 x |
x 的类型是 int,定义在第 10 行 |
右侧是 EnableSwagger |
这是一个 const bool,值为 true |
// 语法层面:只知道方法名
var methodName = invocation.Expression; // "app.UseSwagger"
// 语义层面:知道完整的方法签名、所属类型、返回值等
var symbol = semanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol;
symbol.Name; // "UseSwagger"
symbol.ContainingType; // "SwaggerBuilderExtensions"
symbol.ReturnType; // "IApplicationBuilder"
2.3 语法树 vs 语义模型:什么时候用哪个
| 场景 | 用语法树 | 用语义模型 |
|---|---|---|
| 检查方法名 | ✅ 快速但可能误报 | ✅ 精确匹配 |
检查 #if DEBUG |
✅ 只在 Trivia 中 | ❌ 语义模型不含预处理信息 |
| 判断变量类型 | ❌ 语法树不知道类型 | ✅ |
| 判断常量值 | ❌ | ✅ GetConstantValue() |
| 检测未使用的 using | ❌ | ✅ 需要引用解析 |
最佳实践:先用语法树快速筛选(低成本),再用语义模型精确验证(高成本)。
三、Roslyn 的使用场景
Roslyn 的能力远不止写 Analyzer。以下是主要使用场景:
3.1 代码分析(Diagnostic Analyzer)
在编译时自动检测代码问题,报告警告或错误。
场景举例:
├── 检测生产环境暴露 Swagger(本文示例)
├── 检测硬编码的数据库连接串
├── 检测未 await 的异步调用
├── 强制日志必须使用统一 Logger
└── 禁止在 Controller 中直接访问 DbContext
3.2 代码修复(Code Fix Provider)
配合 Analyzer,在 IDE 中提供"一键修复"功能(灯泡图标💡)。
场景举例:
├── 将 Swagger 调用自动包裹进 #if DEBUG
├── 将硬编码字符串提取为配置
└── 自动添加缺失的 null 检查
3.3 代码生成(Source Generator)
在编译时根据已有代码自动生成新代码,无需 T4 模板。
场景举例:
├── 自动生成 DTO 映射代码
├── 根据接口自动生成实现类
├── 根据枚举生成 switch 表达式
└── 自动生成序列化/反序列化方法
3.4 代码重构工具
分析+修改语法树,批量重构现有代码。
场景举例:
├── 批量重命名命名空间
├── 将 var 替换为显式类型(或反之)
├── 将旧式 API 调用迁移到新 API
└── 自动提取方法/内联变量
3.5 文档/指标工具
读取语法树和语义模型,提取代码信息。
场景举例:
├── 自动生成 API 文档
├── 统计代码复杂度(圈复杂度)
├── 检测代码依赖关系图
└── 统计每个类的方法数和行数
四、手把手实战:编写 Roslyn Analyzer
本节以检测生产环境 Swagger 暴露风险为完整示例,从零开始实现一个 Roslyn DiagnosticAnalyzer。
4.1 需求分析
在 ASP.NET 项目中,Swagger 通常这样启用:
// Program.cs
builder.Services.AddSwaggerGen(); // 注册 Swagger 服务
app.UseSwagger(); // 启用 Swagger 中间件
app.UseSwaggerUI(); // 启用 Swagger UI
安全的做法是将其包裹在 #if DEBUG 中:
#if DEBUG
builder.Services.AddSwaggerGen();
app.UseSwagger();
app.UseSwaggerUI();
#endif
我们的 Analyzer 要做的:检测 AddSwaggerGen、UseSwagger、UseSwaggerUI 这三个方法调用,如果它们不在 #if DEBUG 区间内,就报出警告。
同时还要处理一个扩展场景——团队自己封装的配置类:
new DidaAllInOneOptions(args) { UseSwaggerInRelease = true } // 也应该报警
4.2 创建项目
项目文件 (.csproj)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- ❶ 必须是 netstandard2.0,这是 Roslyn Analyzer 的运行时要求 -->
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<!-- ❷ 启用 Analyzer 扩展规则检查(推荐) -->
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<!-- NuGet 包元数据 -->
<PackageId>dolphin-cs</PackageId>
<PackageVersion>1.3.0</PackageVersion>
<Title>Dolphin Swagger Usage Analyzer</Title>
<Authors>Dida</Authors>
<Description>Roslyn analyzer that detects Swagger usage not wrapped in #if DEBUG.</Description>
<PackageTags>analyzers;roslyn;swagger;sonarqube</PackageTags>
<!-- ❸ NuGet 打包配置:不包含编译输出(DLL 在下面单独指定路径) -->
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<DevelopmentDependency>true</DevelopmentDependency>
<NoPackageAnalysis>true</NoPackageAnalysis>
<IncludeBuildOutput>false</IncludeBuildOutput>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>
<!-- ❹ 关键:将 DLL 放入 NuGet 的 analyzers/dotnet/cs 目录
NuGet 消费者安装此包后,DLL 会自动作为 Analyzer 加载 -->
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll"
Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
<ItemGroup>
<!-- Roslyn Analyzer 开发辅助包(提供编译时检查) -->
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- ❺ Roslyn API,这是核心依赖 -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
</ItemGroup>
</Project>
关键配置说明:
| 标记 | 配置项 | 为什么 |
|---|---|---|
| ❶ | netstandard2.0 |
Roslyn 运行时(包括 VS、dotnet build、SonarScanner)要求 Analyzer DLL 是 netstandard2.0 |
| ❷ | EnforceExtendedAnalyzerRules |
让编译器检查你的 Analyzer 代码是否遵循最佳实践 |
| ❸ | IncludeBuildOutput=false |
不把 DLL 放到 NuGet 的 lib/ 目录(它不是普通类库) |
| ❹ | PackagePath="analyzers/dotnet/cs" |
NuGet 约定路径,消费者安装此包后 DLL 自动作为 Analyzer 加载 |
| ❺ | Microsoft.CodeAnalysis.CSharp |
提供 SyntaxTree、SemanticModel、DiagnosticAnalyzer 等全部 API |
规则清单文件
Roslyn 要求项目中包含 AnalyzerReleases.Unshipped.md(否则编译有警告),用来声明所有规则:
### New Rules
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
SWAGGER001 | Usage | Warning | 检测非 DEBUG 条件下启用 Swagger
还需要一个空的 AnalyzerReleases.Shipped.md(发布后将规则从 Unshipped 移到 Shipped)。
4.3 第一步:定义规则
每个 Analyzer 至少要声明一个 DiagnosticDescriptor——它描述了规则的 ID、标题、消息模板、严重级别等:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
namespace Dolphin.SwaggerUsageAnalyzer.DotNet.Analyzers
{
[DiagnosticAnalyzer(LanguageNames.CSharp)] // ← 标记这是一个 C# 分析器
public class SwaggerUsageAnalyzer : DiagnosticAnalyzer
{
// 规则 ID,全局唯一,用于在 SonarQube / IDE 中标识此规则
public const string DiagnosticId = "SWAGGER001";
// 规则描述符
private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
id: DiagnosticId,
title: "检测到非 DEBUG 条件下启用 Swagger", // 在规则列表中显示
messageFormat: "检测到在非 DEBUG 条件下启用 Swagger: '{0}'", // {0} 是占位符,运行时填入具体方法/属性名
category: "Usage", // 规则分类
defaultSeverity: DiagnosticSeverity.Warning, // 默认严重级别
isEnabledByDefault: true, // 默认启用
description: "Swagger相关调用或属性赋值应在#if DEBUG条件编译块内, 避免在生产环境暴露API文档.");
// 告诉 Roslyn 引擎本 Analyzer 支持哪些规则
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(Rule);
理解 DiagnosticDescriptor 各字段:
| 字段 | 作用 | 示例值 |
|---|---|---|
id |
规则唯一标识符 | "SWAGGER001" |
title |
在规则列表中显示的标题 | "检测到非 DEBUG 条件下启用 Swagger" |
messageFormat |
Issue 消息模板,{0} 等占位符在报告时填入 |
"检测到在非 DEBUG 条件下启用 Swagger: '{0}'" |
category |
规则分类 | "Usage" / "Security" / "Performance" |
defaultSeverity |
严重级别 | Error / Warning / Info / Hidden |
isEnabledByDefault |
是否默认开启 | true |
description |
详细描述(悬停时显示) | 完整说明文本 |
4.4 第二步:注册分析回调
Initialize 方法是 Analyzer 的入口——在这里告诉 Roslyn 引擎"我对哪些语法节点感兴趣":
// ──────── 需要检测的 Swagger 方法名 ────────
private static readonly ImmutableHashSet<string> SwaggerMethodNames =
ImmutableHashSet.Create(StringComparer.Ordinal,
"AddSwaggerGen",
"UseSwagger",
"UseSwaggerUI");
// ──────── 需要检测的封装属性名(赋值为 true 时触发) ────────
private static readonly ImmutableHashSet<string> SwaggerPropertyNames =
ImmutableHashSet.Create(StringComparer.Ordinal,
"UseSwaggerInRelease");
public override void Initialize(AnalysisContext context)
{
// 不分析自动生成的代码(如 .g.cs 文件)
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
// 允许 Roslyn 并发调用此 Analyzer(提升大项目性能)
context.EnableConcurrentExecution();
// RegisterCompilationStartAction:在每次编译开始时回调
// 适合需要在分析前初始化共享状态的场景
context.RegisterCompilationStartAction(compilationContext =>
{
// 创建一个线程安全的缓存:每棵语法树 → 它的 #if DEBUG 区间列表
// 这样同一个文件被多个节点触发时,不必重复计算
var debugSpanCache = new ConcurrentDictionary<SyntaxTree, ImmutableArray<TextSpan>>();
// ── 注册回调 1:每遇到一个"方法调用表达式"就回调 ──
compilationContext.RegisterSyntaxNodeAction(
nodeContext => AnalyzeMethodInvocation(nodeContext, debugSpanCache),
SyntaxKind.InvocationExpression);
// ── 注册回调 2:每遇到一个"赋值表达式"就回调 ──
compilationContext.RegisterSyntaxNodeAction(
nodeContext => AnalyzePropertyAssignment(nodeContext, debugSpanCache),
SyntaxKind.SimpleAssignmentExpression);
});
}
Roslyn 提供的注册 API 一览:
| 注册方法 | 触发时机 | 适用场景 |
|---|---|---|
RegisterSyntaxNodeAction |
遇到指定类型的语法节点 | 检测特定代码模式(最常用) |
RegisterSymbolAction |
遇到指定类型的符号定义 | 检测类/方法/属性的声明 |
RegisterOperationAction |
遇到指定类型的操作 | 检测赋值、循环、条件等逻辑 |
RegisterSyntaxTreeAction |
每棵语法树分析完成 | 文件级别的检查(如文件头注释) |
RegisterCompilationStartAction |
编译开始 | 初始化共享状态 |
RegisterCompilationAction |
编译结束 | 跨文件汇总分析 |
RegisterCodeBlockAction |
遇到方法体 / 属性访问器等 | 方法级别的复杂度检查 |
💡
SyntaxKind枚举定义了所有可能的语法节点类型。常用的有:
InvocationExpression— 方法调用foo.Bar()SimpleAssignmentExpression— 赋值x = yObjectCreationExpression—new Foo()ClassDeclaration—class MyClass { }MethodDeclaration—void MyMethod() { }UsingDirective—using System;
4.5 第三步:实现检测逻辑
检测方法调用(核心检测)
这是最关键的检测逻辑——检查 AddSwaggerGen() / UseSwagger() / UseSwaggerUI() 是否在 #if DEBUG 之外:
private static void AnalyzeMethodInvocation(
SyntaxNodeAnalysisContext nodeContext,
ConcurrentDictionary<SyntaxTree, ImmutableArray<TextSpan>> debugSpanCache)
{
var invocation = (InvocationExpressionSyntax)nodeContext.Node;
// ┌─────────────────────────────────────────────────┐
// │ 第一步:通过语义模型解析方法符号 │
// │ 为什么不直接比较方法名字符串? │
// │ 因为用户代码中可能有同名但完全不相关的方法 │
// │ 语义模型能确认这个方法的完整签名和所属类型 │
// └─────────────────────────────────────────────────┘
var symbolInfo = nodeContext.SemanticModel.GetSymbolInfo(
invocation, nodeContext.CancellationToken);
var methodSymbol = symbolInfo.Symbol as IMethodSymbol
?? symbolInfo.CandidateSymbols.FirstOrDefault() as IMethodSymbol;
// 不是目标方法,直接返回
if (methodSymbol is null || !SwaggerMethodNames.Contains(methodSymbol.Name))
{
return;
}
// ┌─────────────────────────────────────────────────┐
// │ 第二步:检查是否在 #if DEBUG 区间内 │
// │ 如果在 DEBUG 区间内,说明生产环境不会执行,不报警 │
// └─────────────────────────────────────────────────┘
var debugSpans = debugSpanCache.GetOrAdd(
invocation.SyntaxTree,
tree => GetDebugDirectiveSpans(tree, nodeContext.CancellationToken));
if (IsInDebugSpan(invocation.Span, debugSpans))
{
return;
}
// ┌─────────────────────────────────────────────────┐
// │ 第三步:报告诊断 │
// │ Diagnostic.Create 的第三个参数填入 messageFormat │
// │ 的占位符 {0},这里填方法名 │
// └─────────────────────────────────────────────────┘
var diagnostic = Diagnostic.Create(
Rule, // 使用哪个规则
invocation.GetLocation(), // 报告位置(文件+行号+列号)
methodSymbol.Name); // 填入 messageFormat 的 {0}
nodeContext.ReportDiagnostic(diagnostic);
}
检测属性赋值(扩展检测)
处理团队自封装的配置类场景,如 UseSwaggerInRelease = true:
private static void AnalyzePropertyAssignment(
SyntaxNodeAnalysisContext nodeContext,
ConcurrentDictionary<SyntaxTree, ImmutableArray<TextSpan>> debugSpanCache)
{
var assignment = (AssignmentExpressionSyntax)nodeContext.Node;
// 提取赋值左侧的属性名
// 可能是 IdentifierName(UseSwaggerInRelease = true)
// 也可能是 MemberAccess(options.UseSwaggerInRelease = true)
string propertyName;
switch (assignment.Left)
{
case IdentifierNameSyntax identifier:
propertyName = identifier.Identifier.ValueText;
break;
case MemberAccessExpressionSyntax memberAccess:
propertyName = memberAccess.Name.Identifier.ValueText;
break;
default:
return;
}
// 不是目标属性,跳过
if (!SwaggerPropertyNames.Contains(propertyName))
{
return;
}
// 仅在赋值为 true 时报警
if (!IsAssignedTrue(assignment.Right, nodeContext.SemanticModel, nodeContext.CancellationToken))
{
return;
}
// 检查是否在 #if DEBUG 区间内
var debugSpans = debugSpanCache.GetOrAdd(
assignment.SyntaxTree,
tree => GetDebugDirectiveSpans(tree, nodeContext.CancellationToken));
if (IsInDebugSpan(assignment.Span, debugSpans))
{
return;
}
var diagnostic = Diagnostic.Create(Rule, assignment.GetLocation(), propertyName);
nodeContext.ReportDiagnostic(diagnostic);
}
/// <summary>
/// 判断赋值右侧是否为 true。
/// 支持字面量 true 和编译期常量 true。
/// </summary>
private static bool IsAssignedTrue(
ExpressionSyntax right,
SemanticModel semanticModel,
CancellationToken ct)
{
// 情况 1:字面量 true
if (right is LiteralExpressionSyntax literal
&& literal.IsKind(SyntaxKind.TrueLiteralExpression))
{
return true;
}
// 情况 2:常量表达式
// 例如 const bool EnableSwagger = true;
// UseSwaggerInRelease = EnableSwagger
var constantValue = semanticModel.GetConstantValue(right, ct);
if (constantValue.HasValue && constantValue.Value is bool boolValue)
{
return boolValue;
}
return false;
}
4.6 第四步:实现 #if DEBUG 区间检测
这是本 Analyzer 最复杂的部分——需要解析条件编译指令的嵌套结构。
为什么需要手动解析?
#if DEBUG 是预处理指令,在语法树中属于 Trivia(琐碎内容),不是普通的语法节点。Roslyn 不会帮你判断"某个节点是否在 #if DEBUG 块内"——你需要自己计算。
思路
- 遍历文件中所有预处理指令(
#if、#elif、#else、#endif) - 用栈追踪嵌套关系
- 当遇到
#if DEBUG,记录"DEBUG 区间开始" - 当遇到对应的
#elif/#else/#endif,记录"DEBUG 区间结束" - 最终得到一个
TextSpan列表——文件中所有"在 DEBUG 条件下的代码区间"
源码:
───────────────────────────────
1 │ builder.Services.AddSwaggerGen(); ← 不在 DEBUG 区间,报警 ⚠️
2 │
3 │ #if DEBUG ← DEBUG 区间开始
4 │ app.UseSwagger(); ← 在 DEBUG 区间,不报警 ✅
5 │ app.UseSwaggerUI(); ← 在 DEBUG 区间,不报警 ✅
6 │ #else
7 │ // release mode ← 不在 DEBUG 区间
8 │ #endif
9 │
10 │ app.UseSwagger(); ← 不在 DEBUG 区间,报警 ⚠️
───────────────────────────────
DEBUG 区间: [第3行末尾 ~ 第6行开头]
完整实现
/// <summary>
/// 判断给定的语法范围是否位于任一 #if DEBUG 区间内。
/// </summary>
private static bool IsInDebugSpan(TextSpan span, ImmutableArray<TextSpan> debugSpans)
{
foreach (var debugSpan in debugSpans)
{
if (debugSpan.Contains(span))
{
return true;
}
}
return false;
}
/// <summary>
/// 解析语法树中所有 #if DEBUG ... #endif 区间。
/// 通过栈结构正确处理嵌套的 #if / #elif / #else / #endif。
/// </summary>
private static ImmutableArray<TextSpan> GetDebugDirectiveSpans(
SyntaxTree tree,
CancellationToken cancellationToken)
{
var root = tree.GetRoot(cancellationToken);
// 收集所有预处理指令并按位置排序
var directives = root.DescendantTrivia(descendIntoTrivia: true)
.Where(trivia => trivia.IsDirective)
.Select(trivia => trivia.GetStructure())
.OfType<DirectiveTriviaSyntax>()
.OrderBy(d => d.SpanStart);
var spans = new List<TextSpan>();
var stack = new Stack<ConditionalState>();
foreach (var directive in directives)
{
switch (directive)
{
// ── 遇到 #if ──
// 压栈:记录"是否是 DEBUG 条件"和"这个分支的起始位置"
case IfDirectiveTriviaSyntax ifDirective:
stack.Push(new ConditionalState(
IsDebugCondition(ifDirective.Condition),
ifDirective.Span.End));
break;
// ── 遇到 #elif ──
// 弹出上一个分支:如果是 DEBUG,记录它的区间
// 压入新分支
case ElifDirectiveTriviaSyntax elifDirective:
if (stack.Count > 0)
{
var previous = stack.Pop();
if (previous.IsDebug)
{
spans.Add(TextSpan.FromBounds(
previous.BranchStart,
elifDirective.SpanStart));
}
stack.Push(new ConditionalState(
IsDebugCondition(elifDirective.Condition),
elifDirective.Span.End));
}
break;
// ── 遇到 #else ──
// 弹出上一个分支:如果是 DEBUG,记录它的区间
// #else 分支本身不算 DEBUG
case ElseDirectiveTriviaSyntax elseDirective:
if (stack.Count > 0)
{
var previous = stack.Pop();
if (previous.IsDebug)
{
spans.Add(TextSpan.FromBounds(
previous.BranchStart,
elseDirective.SpanStart));
}
stack.Push(new ConditionalState(false, elseDirective.Span.End));
}
break;
// ── 遇到 #endif ──
// 弹出最后一个分支:如果是 DEBUG,记录它的区间
case EndIfDirectiveTriviaSyntax endIfDirective:
if (stack.Count > 0)
{
var previous = stack.Pop();
if (previous.IsDebug)
{
spans.Add(TextSpan.FromBounds(
previous.BranchStart,
endIfDirective.SpanStart));
}
}
break;
}
}
return spans.ToImmutableArray();
}
/// <summary>
/// 判断预处理指令的条件表达式是否为 DEBUG。
/// 支持 #if DEBUG,排除 #if !DEBUG。
/// </summary>
private static bool IsDebugCondition(ExpressionSyntax condition)
{
if (condition is null)
{
return false;
}
// 条件中包含 DEBUG 标识符
var hasDebug = condition.DescendantNodesAndSelf()
.OfType<IdentifierNameSyntax>()
.Any(id => id.Identifier.ValueText == "DEBUG");
// 排除 !DEBUG(取反不算在 DEBUG 区间内)
var hasNegatedDebug = condition.DescendantNodesAndSelf()
.OfType<PrefixUnaryExpressionSyntax>()
.Any(p => p.OperatorToken.IsKind(SyntaxKind.ExclamationToken)
&& p.Operand is IdentifierNameSyntax id
&& id.Identifier.ValueText == "DEBUG");
return hasDebug && !hasNegatedDebug;
}
/// <summary>
/// 条件编译分支状态(用于栈式追踪嵌套)。
/// </summary>
private readonly struct ConditionalState
{
public ConditionalState(bool isDebug, int branchStart)
{
IsDebug = isDebug;
BranchStart = branchStart;
}
/// <summary>当前分支是否为 DEBUG 条件</summary>
public bool IsDebug { get; }
/// <summary>当前分支代码起始位置(指令行结束后)</summary>
public int BranchStart { get; }
}
}
}
4.7 代码结构全览
将上述所有部分组合起来,完整的文件结构如下:
MyAnalyzer/
├── MyAnalyzer.csproj # 项目文件(netstandard2.0)
├── AnalyzerReleases.Unshipped.md # 规则清单(未发布)
├── AnalyzerReleases.Shipped.md # 规则清单(已发布,初始为空)
└── Analyzers/
└── SwaggerUsageAnalyzer.cs # Analyzer 完整实现
分析器内部的逻辑流程:
4.8 性能优化要点
编写 Analyzer 时必须注意性能——它会在每次编译时运行,影响开发体验:
| 优化手段 | 实现方式 | 为什么 |
|---|---|---|
| 启用并发 | EnableConcurrentExecution() |
允许多个文件并行分析 |
| 缓存共享数据 | ConcurrentDictionary 缓存 DEBUG 区间 |
同一个文件被多个节点触发时不重复计算 |
| 先语法后语义 | 先检查方法名(语法),再查符号(语义) | 语义分析成本远高于语法分析 |
| 尽早返回 | 不匹配的节点立即 return |
减少不必要的分析开销 |
| 不分析生成代码 | ConfigureGeneratedCodeAnalysis(None) |
.g.cs 等文件无需检测 |
| 使用 ImmutableHashSet | 替代 List.Contains() |
O(1) 查找 vs O(n) 遍历 |
4.9 测试 Analyzer
方式一:直接引用到测试项目
在另一个 C# 项目中添加对 Analyzer 的引用:
<ItemGroup>
<ProjectReference Include="..\MyAnalyzer\MyAnalyzer.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
然后编写包含 Swagger 调用的代码,dotnet build 后即可在编译输出中看到警告。
方式二:使用 Roslyn 测试框架
安装 Microsoft.CodeAnalysis.CSharp.Analyzer.Testing:
using Microsoft.CodeAnalysis.Testing;
[Fact]
public async Task SwaggerCall_OutsideDebug_ShouldWarn()
{
var testCode = @"
class Program {
void Main() {
app.UseSwagger(); // 应该报警
}
}";
var expected = DiagnosticResult
.CompilerWarning("SWAGGER001")
.WithSpan(4, 9, 4, 25)
.WithArguments("UseSwagger");
await VerifyCS.VerifyAnalyzerAsync(testCode, expected);
}
五、构建与部署
5.1 作为 NuGet 包分发
编译 + 打包后,其他项目安装此 NuGet 包即可自动启用分析:
# 编译
dotnet build -c Release
# 打包成 NuGet
dotnet pack -c Release -o ./nupkgs --no-build
# 其他项目安装
dotnet add package dolphin-cs --source ./nupkgs
安装后,dotnet build 时 Analyzer 会自动运行——在 IDE 中也会实时显示波浪线提示。
5.2 集成到 SonarQube
如果想把 Analyzer 的结果展示在 SonarQube Dashboard 上,需要用 sonarqube-roslyn-sdk 将 .nupkg 转为 SonarQube 可识别的 .jar 插件:
完整构建脚本:
$ErrorActionPreference = 'Stop'
# ---------- 配置 ----------
$PackageId = 'dolphin-cs'
$Configuration = 'Release'
$SdkVersion = '4.0'
$SdkDownloadUrl = "https://github.com/SonarSource/sonarqube-roslyn-sdk/releases/download/$SdkVersion/SonarQube.Roslyn.SDK-$SdkVersion.zip"
$Root = $PSScriptRoot
$CsprojPath = Join-Path $Root 'Dolphin.SwaggerUsageAnalyzer.DotNet.csproj'
$NupkgDir = Join-Path $Root 'nupkgs'
$ToolsDir = Join-Path $Root 'tools'
$SdkDir = Join-Path $ToolsDir 'sonarqube-roslyn-sdk'
$GeneratorExe = Join-Path $SdkDir 'RoslynSonarQubePluginGenerator.exe'
# ========== Step 1: Build ==========
Write-Host '=== Step 1: Build ===' -ForegroundColor Cyan
dotnet build $CsprojPath -c $Configuration
if ($LASTEXITCODE -ne 0) { throw 'Build failed.' }
# ========== Step 2: Pack NuGet ==========
Write-Host '=== Step 2: Pack NuGet ===' -ForegroundColor Cyan
if (Test-Path $NupkgDir) { Remove-Item "$NupkgDir\*" -Force }
dotnet pack $CsprojPath -c $Configuration -o $NupkgDir --no-build
if ($LASTEXITCODE -ne 0) { throw 'Pack failed.' }
$nupkg = Get-ChildItem $NupkgDir -Filter '*.nupkg' | Select-Object -First 1
if (-not $nupkg) { throw "No .nupkg found in $NupkgDir" }
Write-Host "NuGet package: $($nupkg.FullName)" -ForegroundColor Green
# ========== Step 3: Ensure sonarqube-roslyn-sdk ==========
Write-Host '=== Step 3: Ensure sonarqube-roslyn-sdk ===' -ForegroundColor Cyan
if (-not (Test-Path $GeneratorExe)) {
Write-Host "Downloading sonarqube-roslyn-sdk v$SdkVersion ..."
if (-not (Test-Path $ToolsDir)) { New-Item -ItemType Directory $ToolsDir | Out-Null }
$zipPath = Join-Path $ToolsDir "SonarQube.Roslyn.SDK-$SdkVersion.zip"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri $SdkDownloadUrl -OutFile $zipPath -UseBasicParsing
Expand-Archive -Path $zipPath -DestinationPath $SdkDir -Force
Remove-Item $zipPath -Force
Get-ChildItem $SdkDir -File | ForEach-Object {
Unblock-File $_.FullName -ErrorAction SilentlyContinue
}
if (-not (Test-Path $GeneratorExe)) {
throw 'RoslynSonarQubePluginGenerator.exe not found after extracting SDK.'
}
}
# ========== Step 4: Clear SDK NuGet cache ==========
# ⚠️ 必须清除!否则 SDK 会使用旧缓存的 nupkg
$sdkCache = Join-Path $env:TEMP '.sonarqube.sdk'
if (Test-Path $sdkCache) {
Remove-Item $sdkCache -Recurse -Force -ErrorAction SilentlyContinue
}
# ========== Step 5: Generate .jar ==========
Write-Host '=== Step 5: Generate SonarQube plugin .jar ===' -ForegroundColor Cyan
Get-ChildItem $Root -Filter '*-plugin-*.jar' | Remove-Item -Force
$localFeed = "file:///$($NupkgDir.Replace('\', '/'))"
& $GeneratorExe /a:$PackageId /customnugetrepo:$localFeed
if ($LASTEXITCODE -ne 0) { throw 'Plugin generation failed.' }
$jar = Get-ChildItem $Root -Filter '*-plugin-*.jar' | Select-Object -First 1
if (-not $jar) { throw 'No .jar generated.' }
# ========== Step 6: Rename ==========
$pkgVersion = $nupkg.BaseName -replace '^.*?\.(\d+\.\d+\.\d+.*)$', '$1'
$targetJarName = "dolphincs-plugin-swagger001-$pkgVersion.jar"
$targetJarPath = Join-Path $Root $targetJarName
if ($jar.Name -ne $targetJarName) {
if (Test-Path $targetJarPath) { Remove-Item $targetJarPath -Force }
Rename-Item $jar.FullName $targetJarName
}
Write-Host "`nPlugin .jar: $targetJarPath" -ForegroundColor Green
六、Roslyn 常用 API 速查
| 需求 | API | 示例 |
|---|---|---|
| 获取方法符号 | SemanticModel.GetSymbolInfo() |
model.GetSymbolInfo(invocation).Symbol |
| 获取类型信息 | SemanticModel.GetTypeInfo() |
model.GetTypeInfo(expression).Type |
| 获取常量值 | SemanticModel.GetConstantValue() |
model.GetConstantValue(expr).Value |
| 遍历子节点 | SyntaxNode.DescendantNodes() |
root.DescendantNodes().OfType<ClassDeclarationSyntax>() |
| 遍历子标记 | SyntaxNode.DescendantTokens() |
root.DescendantTokens().Where(t => t.IsKind(...)) |
| 遍历 Trivia | SyntaxNode.DescendantTrivia() |
root.DescendantTrivia(descendIntoTrivia: true) |
| 查找祖先节点 | SyntaxNode.Ancestors() |
node.Ancestors().OfType<MethodDeclarationSyntax>() |
| 获取位置 | SyntaxNode.GetLocation() |
invocation.GetLocation() → 文件+行号+列号 |
| 获取文本 | SyntaxNode.ToString() |
invocation.ToString() → 源码文本 |
| 判断节点类型 | SyntaxNode.IsKind() |
node.IsKind(SyntaxKind.InvocationExpression) |
| 创建诊断 | Diagnostic.Create() |
Diagnostic.Create(rule, location, args) |
| 解析语法树 | CSharpSyntaxTree.ParseText() |
CSharpSyntaxTree.ParseText(code) |
| 创建编译 | CSharpCompilation.Create() |
CSharpCompilation.Create("test", new[] { tree }) |
七、常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| Analyzer 不生效 | TargetFramework 不是 netstandard2.0 |
必须使用 netstandard2.0 |
| NuGet 包安装后无效 | DLL 没有放到 analyzers/dotnet/cs 路径 |
检查 csproj 中的 PackagePath |
GetSymbolInfo 返回 null |
缺少引用的程序集 | 确保测试时提供完整的 MetadataReference |
| Analyzer 导致编译变慢 | 语义分析调用过于频繁 | 先用语法树过滤,再调用语义模型 |
#if DEBUG 检测不准 |
嵌套的条件编译未正确处理 | 使用栈结构追踪嵌套层级 |
| 并发异常 | 共享可变状态 | 使用 ConcurrentDictionary 或不可变数据结构 |
| sonarqube-roslyn-sdk 版本不兼容 | Roslyn 版本过高 | SDK v4.0 最高支持 Microsoft.CodeAnalysis.CSharp 4.9.x |
八、延伸阅读
| 资源 | 链接 |
|---|---|
| Roslyn 官方文档 | https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/ |
| Roslyn GitHub 仓库 | https://github.com/dotnet/roslyn |
| Syntax Visualizer 工具 | https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/syntax-visualizer |
| SharpLab(在线查看语法树) | https://sharplab.io/ |
| Roslyn Analyzer 教程 | https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/tutorials/how-to-write-csharp-analyzer-code-fix |
| sonarqube-roslyn-sdk | https://github.com/SonarSource/sonarqube-roslyn-sdk |
| SyntaxKind 完整枚举 | https://learn.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.csharp.syntaxkind |
九、总结
Roslyn 把编译器从黑盒变成了白盒,让我们能用代码去分析代码。核心思路就三步:
- 定义规则 —
DiagnosticDescriptor声明 ID、消息、严重级别 - 注册回调 —
RegisterSyntaxNodeAction告诉引擎"我关心哪些语法节点" - 分析报告 — 在回调中用语法树 + 语义模型判断,命中则
ReportDiagnostic
掌握了这个模式,你可以把任何编码规范——安全合规、架构约束、团队惯例——变成编译器级别的自动化检查,在代码提交之前就发现问题。