Roslyn 实战指南:用 .NET 编译器平台实现代码自动分析

Roslyn 实战指南:用 .NET 编译器平台实现代码自动分析

zeee 1 2025-12-28

一、什么是 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 语法结构 ClassDeclarationMethodDeclarationInvocationExpression
标记(Token) SyntaxToken 最小的词法单元 publicvoidMyMethod()
琐碎内容(Trivia) SyntaxTrivia 空白、注释、预处理指令 // 注释#if DEBUG、换行符

以一行代码为例:

app.UseSwagger();

对应的语法树(简化):

ExpressionStatement
└── InvocationExpression              ← "app.UseSwagger()"
    ├── MemberAccessExpression        ← "app.UseSwagger"
    │   ├── IdentifierName "app"
    │   ├── DotToken "."
    │   └── IdentifierName "UseSwagger"
    └── ArgumentList                  ← "()"
        ├── OpenParenToken "("
        └── CloseParenToken ")"

💡 可以使用 Roslyn Syntax VisualizerSharpLab.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 要做的:检测 AddSwaggerGenUseSwaggerUseSwaggerUI 这三个方法调用,如果它们不在 #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 提供 SyntaxTreeSemanticModelDiagnosticAnalyzer 等全部 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 = y
  • ObjectCreationExpressionnew Foo()
  • ClassDeclarationclass MyClass { }
  • MethodDeclarationvoid MyMethod() { }
  • UsingDirectiveusing 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 块内"——你需要自己计算。

思路

  1. 遍历文件中所有预处理指令(#if#elif#else#endif
  2. 追踪嵌套关系
  3. 当遇到 #if DEBUG,记录"DEBUG 区间开始"
  4. 当遇到对应的 #elif / #else / #endif,记录"DEBUG 区间结束"
  5. 最终得到一个 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 完整实现

分析器内部的逻辑流程:

InvocationExpressionSimpleAssignmentNoYesNoYesNoYesYesNoYesNoRoslyn 编译引擎遍历每个语法节点节点类型?取得方法符号(SemanticModel)取得属性名方法名在检测列表中?跳过在 #if DEBUG区间内?属性名在检测列表中?赋值为 true?在 #if DEBUG区间内?✅ 安全不报警⚠️ 报告诊断SWAGGER001

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 把编译器从黑盒变成了白盒,让我们能用代码去分析代码。核心思路就三步:

  1. 定义规则DiagnosticDescriptor 声明 ID、消息、严重级别
  2. 注册回调RegisterSyntaxNodeAction 告诉引擎"我关心哪些语法节点"
  3. 分析报告 — 在回调中用语法树 + 语义模型判断,命中则 ReportDiagnostic

掌握了这个模式,你可以把任何编码规范——安全合规、架构约束、团队惯例——变成编译器级别的自动化检查,在代码提交之前就发现问题。


# csharp