如何为 C# 和 Java 实现自定义 SonarQube 插件

zeee 2 2025-12-28

一、为什么需要自定义 SonarQube 插件

1.1 SonarQube 内置规则的局限

SonarQube 内置了大量通用代码质量规则(空指针、SQL 注入、代码异味等),但在实际工程中,团队往往有特定于业务或架构的编码规范,这些是通用规则无法覆盖的。例如:

  • 安全合规:生产环境禁止暴露 Swagger/OpenAPI 文档、禁止硬编码数据库连接串
  • 架构约束:Service 层不允许直接调用 Repository 以外的数据访问方式、禁止跨模块循环依赖
  • 团队规范:日志必须使用统一的 Logger 工厂、异常处理必须包含错误码
  • 框架特定:自研框架的 API 使用姿势检查、配置项合法性校验

这些规则高度依赖上下文,无法期望 SonarQube 官方逐一支持。

1.2 自定义插件的价值

将团队编码规范写成 SonarQube 插件后,可以获得:

能力 说明
自动化检测 每次 CI/CD 自动运行,无需人工 Code Review
零侵入 不修改业务代码,不引入额外依赖
统一管控 通过 Quality Profile 集中管理规则的开关和严重级别
可追溯 Issue 关联到具体文件和行号,支持 API 查询
多语言覆盖 C#、Java、Go、Python 等均可扩展

1.3 两种语言,两条路线

SonarQube 服务端是 Java 应用,插件必须是 .jar 格式。这对 Java 项目来说很自然,但 C# 项目怎么办?

C# 插件(间接路线)Java 插件(直接路线)dotnet pack→ .nupkgC# 源码实现 Roslyn Analyzersonarqube-roslyn-sdk→ plugin.jarmvn packagesonar-packaging-maven-pluginJava 源码实现 Plugin APIplugin.jarSonarQubeextensions/plugins/

本文以一个实际案例——检测生产环境 Swagger 暴露风险——分别演示这两条路线的完整实现。

二、插件开发全景

2.1 SonarQube 插件运行机制

理解插件的运行机制是开发的前提:

代码分析服务端启动构建 AST(语法树)SonarScanner扫描源码执行 Check(规则检测逻辑)生成 Issue(文件+行号+消息)注册 Rules(RulesDefinition)加载 plugin.jarQuality Profile激活规则存入数据库展示在 Dashboard

一个 SonarQube 插件至少需要实现:

组件 Java 对应类 C# 对应类 职责
插件入口 Plugin — (SDK 自动生成) 注册所有扩展点
规则定义 RulesDefinition — (SDK 自动生成) 声明规则 ID、名称、描述、严重级别
检测逻辑 IssuableSubscriptionVisitor DiagnosticAnalyzer 遍历语法树,匹配模式,报告 Issue

2.2 规则设计原则

无论用哪种语言实现,好的规则应遵循:

  1. 精确匹配:用语义分析而非字符串匹配,避免误报
  2. 合理排除:提供"安全出口"(如 C# 的 #if DEBUG、Java 的 @Profile("dev")
  3. 清晰消息:Issue 消息应说明"检测到了什么"和"应该怎么修"
  4. 唯一标识:规则 Key 全局唯一,便于 API 查询和 CI 集成

三、Java 插件开发

Java 是 SonarQube 的"一等公民",插件开发最为直接。

3.1 项目结构

my-sonar-plugin/
├── pom.xml
└── src/main/java/com/example/sonar/
    ├── MyPlugin.java              # 插件入口
    ├── MyRulesDefinition.java     # 规则注册
    ├── MyCheckRegistrar.java      # 检查注册器
    ├── RulesList.java             # 规则列表
    └── MyCheck.java               # 检测逻辑(可有多个)

3.2 Maven 配置

核心是 <packaging>sonar-plugin</packaging>sonar-packaging-maven-plugin

<packaging>sonar-plugin</packaging>

<dependencies>
    <!-- SonarQube Plugin API(provided,由 SonarQube 运行时提供) -->
    <dependency>
        <groupId>org.sonarsource.api.plugin</groupId>
        <artifactId>sonar-plugin-api</artifactId>
        <version>10.14.0.2599</version>
        <scope>provided</scope>
    </dependency>
    <!-- SonarQube Java 分析器(provided,提供 AST 访问 API) -->
    <dependency>
        <groupId>org.sonarsource.java</groupId>
        <artifactId>sonar-java-plugin</artifactId>
        <version>8.8.0.37665</version>
        <type>sonar-plugin</type>
        <scope>provided</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.sonarsource.sonar-packaging-maven-plugin</groupId>
            <artifactId>sonar-packaging-maven-plugin</artifactId>
            <version>1.23.0.740</version>
            <extensions>true</extensions>
            <configuration>
                <pluginKey>myplugin</pluginKey>
                <pluginClass>com.example.sonar.MyPlugin</pluginClass>
                <requiredForLanguages>java</requiredForLanguages>
                <skipDependenciesPackaging>true</skipDependenciesPackaging>
            </configuration>
        </plugin>
    </plugins>
</build>

⚠️ 两个依赖必须为 provided——它们由 SonarQube 运行时提供,打包进插件会导致类冲突。

3.3 插件入口

只需注册两个扩展点——规则定义(服务端)和检查注册器(分析端):

public class MyPlugin implements Plugin {
    @Override
    public void define(Context context) {
        context.addExtension(MyRulesDefinition.class);   // 服务端:注册规则元数据
        context.addExtension(MyCheckRegistrar.class);     // 分析端:注册检查逻辑
    }
}

3.4 规则定义

@Rule 注解自动读取元数据,注册到自定义仓库:

public class MyRulesDefinition implements RulesDefinition {
    public static final String REPOSITORY_KEY = "myrepo";

    @Override
    public void define(Context context) {
        NewRepository repository = context
            .createRepository(REPOSITORY_KEY, "java")
            .setName("My Custom Rules");

        for (Class<?> checkClass : RulesList.getChecks()) {
            Rule annotation = checkClass.getAnnotation(Rule.class);
            if (annotation != null) {
                NewRule rule = repository.createRule(annotation.key());
                rule.setName(annotation.name());
                rule.setHtmlDescription(annotation.description());
                rule.setSeverity(annotation.priority().name());
                rule.addTags("custom");
            }
        }
        repository.done();
    }
}

3.5 检查注册器 + 规则列表

// 注册器:将检查类关联到规则仓库
public class MyCheckRegistrar implements CheckRegistrar {
    @Override
    public void register(RegistrarContext ctx) {
        ctx.registerClassesForRepository(
            MyRulesDefinition.REPOSITORY_KEY,
            RulesList.getMainChecks(),    // 主代码检查
            RulesList.getTestChecks());   // 测试代码检查(可为空)
    }
}

// 规则列表:集中管理所有检查类
public final class RulesList {
    public static List<Class<? extends JavaCheck>> getMainChecks() {
        return Arrays.asList(MyCheck.class);
    }
    public static List<Class<? extends JavaCheck>> getTestChecks() {
        return Collections.emptyList();
    }
}

3.6 检测逻辑(以 Swagger 检测为例)

Java 检查基于 IssuableSubscriptionVisitor——声明感兴趣的 AST 节点类型,框架自动回调:

@Rule(
    key = "SWAGGER001",
    name = "检测到项目中启用了 Swagger",
    description = "生产环境不应启用 Swagger/OpenAPI 文档",
    priority = Priority.MAJOR)
public class SwaggerUsageCheck extends IssuableSubscriptionVisitor {

    private static final Set<String> SWAGGER_ENABLE_ANNOTATIONS = new HashSet<>(Arrays.asList(
        "EnableSwagger2", "EnableSwagger2WebMvc", "EnableOpenApi", "EnableKnife4j"));

    private static final Set<String> SWAGGER_CONFIG_TYPES = new HashSet<>(Arrays.asList(
        "Docket", "GroupedOpenApi", "OpenAPI", "SwaggerParser", "BeanConfig"));

    private static final List<String> SWAGGER_IMPORT_PREFIXES = Arrays.asList(
        "io.swagger.", "springfox.", "org.springdoc.", "com.github.xiaoymin.");

    private static final Set<String> DEV_PROFILE_VALUES = new HashSet<>(Arrays.asList(
        "dev", "development", "local", "test", "!prod", "!production"));

    @Override
    public List<Tree.Kind> nodesToVisit() {
        // 声明需要访问的语法树节点类型
        return Arrays.asList(
            Tree.Kind.CLASS, Tree.Kind.IMPORT, Tree.Kind.METHOD,
            Tree.Kind.NEW_CLASS, Tree.Kind.METHOD_INVOCATION);
    }

    @Override
    public void visitNode(Tree tree) {
        switch (tree.kind()) {
            case IMPORT: checkImport((ImportTree) tree);     break;
            case CLASS:  checkClass((ClassTree) tree);       break;
            case METHOD: checkMethod((MethodTree) tree);     break;
            // ... 其他节点类型
        }
    }
}

检测 import 语句——匹配 Swagger 相关包前缀:

private void checkImport(ImportTree importTree) {
    String importName = getFullImportName(importTree);
    if (importName == null) return;

    for (String prefix : SWAGGER_IMPORT_PREFIXES) {
        if (importName.startsWith(prefix)) {
            reportIssue(importTree,
                String.format("检测到引入了 Swagger 相关依赖: '%s'", importName));
            return;
        }
    }
}

检测类注解——带 @Profile("dev") 排除逻辑:

private void checkClass(ClassTree classTree) {
    List<AnnotationTree> annotations = classTree.modifiers().annotations();

    // 排除条件:@Profile("dev") 表示仅开发环境启用
    if (hasDevProfileAnnotation(annotations)) return;

    for (AnnotationTree annotation : annotations) {
        String name = getAnnotationSimpleName(annotation);
        if (name != null && SWAGGER_ENABLE_ANNOTATIONS.contains(name)) {
            reportIssue(annotation,
                String.format("检测到启用 Swagger 的注解: @%s", name));
        }
    }
}

检测 @Bean 方法返回 Swagger 配置类型

private void checkMethod(MethodTree methodTree) {
    if (hasDevProfileAnnotation(methodTree.modifiers().annotations())) return;

    boolean isBeanMethod = methodTree.modifiers().annotations().stream()
        .anyMatch(a -> "Bean".equals(getAnnotationSimpleName(a)));

    if (isBeanMethod) {
        String returnType = getTypeSimpleName(methodTree.returnType());
        if (returnType != null && SWAGGER_CONFIG_TYPES.contains(returnType)) {
            reportIssue(methodTree.simpleName(),
                String.format("检测到 Swagger 配置 Bean, 返回类型: %s", returnType));
        }
    }
}

3.7 构建

直接使用 Maven 打包即可,sonar-packaging-maven-plugin 会自动生成符合 SonarQube 规范的 .jar

mvn clean package -DskipTests
# 输出: target/myplugin-1.0.0.jar

但在实际 CI/CD 或团队协作中,建议编写构建脚本来处理以下问题:

  1. Maven 环境检测:CI 服务器可能没有预装 Maven
  2. 自动下载:在无 Maven 的环境中自动下载并使用本地副本
  3. 统一命名:通过 pom.xml<finalName> 控制输出 jar 名称

完整构建脚本(PowerShell)

$ErrorActionPreference = 'Stop'

$Root     = $PSScriptRoot
$PomPath  = Join-Path $Root 'pom.xml'
$ToolsDir = Join-Path $Root 'tools'

# ---------- 检测或下载 Maven ----------
$MavenVersion = '3.9.9'
$MavenUrl     = "https://dlcdn.apache.org/maven/maven-3/$MavenVersion/binaries/apache-maven-$MavenVersion-bin.zip"
$LocalMavenDir = Join-Path $ToolsDir "maven\apache-maven-$MavenVersion"
$LocalMvn      = Join-Path $LocalMavenDir 'bin\mvn.cmd'

# 优先使用系统 Maven
$mvnCmd = Get-Command mvn -ErrorAction SilentlyContinue
if ($mvnCmd) {
    $mvn = $mvnCmd.Source
    Write-Host "使用系统 Maven: $mvn" -ForegroundColor Green
}
# 其次使用本地 Maven
elseif (Test-Path $LocalMvn) {
    $mvn = $LocalMvn
    Write-Host "使用本地 Maven: $mvn" -ForegroundColor Green
}
# 都没有则自动下载
else {
    Write-Host "未找到 Maven,自动下载 v$MavenVersion ..." -ForegroundColor Yellow
    $zipPath = Join-Path $ToolsDir "maven-$MavenVersion.zip"
    New-Item -ItemType Directory -Path (Join-Path $ToolsDir 'maven') -Force | Out-Null
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    Invoke-WebRequest -Uri $MavenUrl -OutFile $zipPath -UseBasicParsing
    Expand-Archive -Path $zipPath -DestinationPath (Join-Path $ToolsDir 'maven') -Force
    Remove-Item $zipPath -Force
    $mvn = $LocalMvn
}

# ---------- 设置 JAVA_HOME(如需要) ----------
if (-not $env:JAVA_HOME) {
    $javaPath = Get-Command java -ErrorAction SilentlyContinue
    if ($javaPath) {
        # 从 java.exe 路径推导 JAVA_HOME
        $env:JAVA_HOME = (Get-Item $javaPath.Source).Directory.Parent.FullName
    }
}

# ---------- 构建 ----------
Write-Host '=== Building SonarQube Java Plugin ===' -ForegroundColor Cyan
Push-Location $Root
try {
    & $mvn clean package -DskipTests -f $PomPath
    if ($LASTEXITCODE -ne 0) { throw 'Maven build failed.' }
} finally {
    Pop-Location
}

# ---------- 输出 ----------
$jar = Get-ChildItem (Join-Path $Root 'target') -Filter '*.jar' |
    Where-Object { $_.Name -notlike '*-sources*' } |
    Select-Object -First 1

if (-not $jar) { throw 'No .jar found in target/' }
Write-Host "`nPlugin .jar: $($jar.FullName)" -ForegroundColor Green
Write-Host '将此文件复制到 SonarQube extensions/plugins/ 目录即可。' -ForegroundColor Yellow

通过 pom.xml 控制 jar 命名

<build> 中设置 <finalName> 可以自定义输出名称(支持引用 Maven 变量):

<build>
    <finalName>${project.artifactId}-plugin-${project.version}</finalName>
    <!-- ... plugins ... -->
</build>

这样 mvn package 直接输出 myplugin-plugin-1.0.0.jar,无需后续重命名。

四、C# 插件开发

C# 无法直接编写 SonarQube 插件(因为 SonarQube 是 Java 平台),需要借助 Roslyn Analyzer + sonarqube-roslyn-sdk 间接实现。

4.1 技术路线

dotnet packRoslynSonarQubePluginGeneratorRoslyn Analyzer(C# DiagnosticAnalyzer).nupkgSonarQube plugin.jar部署到 SonarQube

核心思路:先写一个标准的 Roslyn Analyzer(它本身就能在 dotnet build 时运行),再用 sonarqube-roslyn-sdk 把它包装成 SonarQube 可识别的 .jar 插件。

4.2 项目配置

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <!-- 必须是 netstandard2.0,Roslyn Analyzer 的运行时要求 -->
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>

    <!-- NuGet 包元数据,sonarqube-roslyn-sdk 依赖这些信息 -->
    <PackageId>my-analyzer</PackageId>
    <PackageVersion>1.0.0</PackageVersion>
    <IncludeBuildOutput>false</IncludeBuildOutput>
  </PropertyGroup>

  <!-- 关键:将 DLL 放入 NuGet 的 analyzers/dotnet/cs 目录 -->
  <ItemGroup>
    <None Include="$(OutputPath)\$(AssemblyName).dll"
          Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
  </ItemGroup>

  <ItemGroup>
    <!-- Roslyn 版本不能超过 4.9.x,否则 SDK v4.0 无法识别 -->
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
  </ItemGroup>
</Project>

⚠️ 两个硬性约束

  • TargetFramework 必须为 netstandard2.0
  • Microsoft.CodeAnalysis.CSharp ≤ 4.9.x(sonarqube-roslyn-sdk v4.0 的上限)

4.3 检测逻辑(以 Swagger 检测为例)

Roslyn Analyzer 的核心是 DiagnosticAnalyzer

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class SwaggerUsageAnalyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "SWAGGER001";

    private static readonly ImmutableHashSet<string> SwaggerMethodNames =
        ImmutableHashSet.Create(StringComparer.Ordinal,
            "AddSwaggerGen", "UseSwagger", "UseSwaggerUI");

    private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
        DiagnosticId,
        title:           "检测到非 DEBUG 条件下启用 Swagger",
        messageFormat:   "检测到在非 DEBUG 条件下启用 Swagger: '{0}'",
        category:        "Usage",
        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.RegisterCompilationStartAction(compilationCtx =>
        {
            // 缓存 #if DEBUG 区间,避免对同一文件重复计算
            var debugSpanCache = new ConcurrentDictionary<SyntaxTree, ImmutableArray<TextSpan>>();

            compilationCtx.RegisterSyntaxNodeAction(
                ctx => AnalyzeInvocation(ctx, debugSpanCache),
                SyntaxKind.InvocationExpression);
        });
    }
}

检测方法调用——通过语义模型精确匹配(而非字符串匹配):

private static void AnalyzeInvocation(
    SyntaxNodeAnalysisContext ctx,
    ConcurrentDictionary<SyntaxTree, ImmutableArray<TextSpan>> cache)
{
    var invocation = (InvocationExpressionSyntax)ctx.Node;

    // 通过语义模型解析方法符号,确保精确匹配
    var symbolInfo = ctx.SemanticModel.GetSymbolInfo(invocation);
    var method = symbolInfo.Symbol as IMethodSymbol
        ?? symbolInfo.CandidateSymbols.FirstOrDefault() as IMethodSymbol;

    if (method is null || !SwaggerMethodNames.Contains(method.Name))
        return;

    // 排除条件:位于 #if DEBUG 区间内不报警
    var debugSpans = cache.GetOrAdd(invocation.SyntaxTree,
        tree => GetDebugDirectiveSpans(tree, ctx.CancellationToken));

    if (IsInDebugSpan(invocation.Span, debugSpans))
        return;

    ctx.ReportDiagnostic(
        Diagnostic.Create(Rule, invocation.GetLocation(), method.Name));
}

#if DEBUG 区间计算——通过栈结构追踪条件编译指令嵌套:

private static ImmutableArray<TextSpan> GetDebugDirectiveSpans(
    SyntaxTree tree, CancellationToken ct)
{
    var root = tree.GetRoot(ct);
    var directives = root.DescendantTrivia(descendIntoTrivia: true)
        .Where(t => t.IsDirective)
        .Select(t => t.GetStructure())
        .OfType<DirectiveTriviaSyntax>()
        .OrderBy(d => d.SpanStart);

    var spans = new List<TextSpan>();
    var stack = new Stack<(bool IsDebug, int Start)>();

    foreach (var directive in directives)
    {
        switch (directive)
        {
            case IfDirectiveTriviaSyntax ifDir:
                stack.Push((IsDebugCondition(ifDir.Condition), ifDir.Span.End));
                break;
            case ElifDirectiveTriviaSyntax elifDir:
                if (stack.Count > 0) {
                    var prev = stack.Pop();
                    if (prev.IsDebug)
                        spans.Add(TextSpan.FromBounds(prev.Start, elifDir.SpanStart));
                    stack.Push((IsDebugCondition(elifDir.Condition), elifDir.Span.End));
                }
                break;
            case ElseDirectiveTriviaSyntax elseDir:
                if (stack.Count > 0) {
                    var prev = stack.Pop();
                    if (prev.IsDebug)
                        spans.Add(TextSpan.FromBounds(prev.Start, elseDir.SpanStart));
                    stack.Push((false, elseDir.Span.End));
                }
                break;
            case EndIfDirectiveTriviaSyntax endifDir:
                if (stack.Count > 0) {
                    var prev = stack.Pop();
                    if (prev.IsDebug)
                        spans.Add(TextSpan.FromBounds(prev.Start, endifDir.SpanStart));
                }
                break;
        }
    }
    return spans.ToImmutableArray();
}

4.4 Roslyn 规则清单文件

Roslyn 要求项目中包含 AnalyzerReleases.Unshipped.md,否则会产生编译警告:

### New Rules

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
SWAGGER001 | Usage | Warning | 检测非 DEBUG 条件下启用 Swagger

4.5 构建:从 C# 到 .jar

C# 的构建比 Java 多一层转换——先打成 NuGet 包,再用 sonarqube-roslyn-sdk 转为 .jar

dotnet builddotnet pack → .nupkg清除 SDK 缓存RoslynSonarQubePluginGenerator/a:PackageIdplugin.jar重命名为统一格式

完整构建脚本(PowerShell)

$ErrorActionPreference = 'Stop'

# ---------- 配置 ----------
$PackageId      = 'my-analyzer'           # 与 .csproj 中的 <PackageId> 一致
$Configuration  = 'Release'
$SdkVersion     = '4.0'                   # sonarqube-roslyn-sdk 版本
$SdkZipName     = "SonarQube.Roslyn.SDK-$SdkVersion.zip"
$SdkDownloadUrl = "https://github.com/SonarSource/sonarqube-roslyn-sdk/releases/download/$SdkVersion/$SdkZipName"

$Root           = $PSScriptRoot
$CsprojPath     = Join-Path $Root 'MyAnalyzer.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 as 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 $SdkZipName
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    Invoke-WebRequest -Uri $SdkDownloadUrl -OutFile $zipPath -UseBasicParsing
    Expand-Archive -Path $zipPath -DestinationPath $SdkDir -Force
    Remove-Item $zipPath -Force
    # Windows 安全策略可能阻止下载的 exe,需要解锁
    Get-ChildItem $SdkDir -File | ForEach-Object {
        Unblock-File $_.FullName -ErrorAction SilentlyContinue
    }
    if (-not (Test-Path $GeneratorExe)) {
        throw 'RoslynSonarQubePluginGenerator.exe not found after extracting SDK.'
    }
}
Write-Host "Generator: $GeneratorExe" -ForegroundColor Green

# ========== Step 4: Clear SDK NuGet cache ==========
# ⚠️ 关键步骤!SDK 会将 nupkg 缓存到临时目录。
# 如果不清除,更新版本后仍会使用旧缓存,导致生成的 jar 包含旧代码。
Write-Host '=== Step 4: Clear SDK cache ===' -ForegroundColor Cyan
$sdkCache = Join-Path $env:TEMP '.sonarqube.sdk'
if (Test-Path $sdkCache) {
    Remove-Item $sdkCache -Recurse -Force -ErrorAction SilentlyContinue
    Write-Host 'SDK cache cleared.' -ForegroundColor Yellow
}

# ========== Step 5: Generate SonarQube plugin .jar ==========
Write-Host '=== Step 5: Generate SonarQube plugin .jar ===' -ForegroundColor Cyan

# 删除旧的 jar 文件
Get-ChildItem $Root -Filter '*-plugin-*.jar' | Remove-Item -Force

# 将本地 nupkgs 目录作为 NuGet feed 传给 SDK
$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 to unified format ==========
# SDK 生成的 jar 名称格式不可控,这里统一重命名
# 从 nupkg 文件名提取版本号(如 my-analyzer.1.0.0.nupkg → 1.0.0)
$pkgVersion = $nupkg.BaseName -replace '^.*?\.(\d+\.\d+\.\d+.*)$', '$1'
$targetJarName = "myanalyzer-plugin-$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
    $jar = Get-Item $targetJarPath
}

# ========== Done ==========
Write-Host "`nPlugin .jar: $($jar.FullName)" -ForegroundColor Green
Write-Host '将此文件复制到 SonarQube extensions/plugins/ 目录即可。' -ForegroundColor Yellow

构建流程要点

步骤 说明 常见问题
dotnet build 编译 Analyzer DLL 确保 netstandard2.0 + Roslyn ≤ 4.9.x
dotnet pack 将 DLL 打包到 analyzers/dotnet/cs --no-build 避免重复编译
清除 SDK 缓存 删除 %TEMP%\.sonarqube.sdk 不清除会使用旧版本缓存
RoslynSonarQubePluginGenerator 读取 nupkg 生成 jar /customnugetrepo 指向本地目录
重命名 统一 jar 输出名称 SDK 自动命名不可控

⚠️ SDK NuGet 缓存是最常见的坑:修改了代码并重新 dotnet pack,但 SDK 仍然使用之前缓存的 nupkg,导致生成的 jar 不包含最新代码。务必在每次构建前清除 %TEMP%\.sonarqube.sdk 目录。

⚠️ Repository Key 自动转换:SDK 会将 PackageId 转为小写并去除连字符作为 Repository Key。例如 my-analyzermyanalyzer。如果需要精确控制 Key,建议 PackageId 直接使用无连字符的小写名称。

五、部署与使用

5.1 部署流程

复制到plugin.jarextensions/plugins/重启 SonarQubeQuality Profile激活规则✅ 就绪
# 1. 复制 jar 到 SonarQube 插件目录(示例:Docker 部署)
docker cp myanalyzer-plugin-1.0.0.jar sonarqube:/opt/sonarqube/extensions/plugins/

# 2. 重启 SonarQube
docker restart sonarqube

# 3. 在 SonarQube Web UI 激活规则
#    Quality Profiles → 选择语言 → 搜索规则 Key → Activate

⚠️ 必须激活规则! 插件部署后,规则默认是未激活状态,需要在 Quality Profile 中手动激活才会生效。

5.2 CI/CD 集成

部署完成后,在 CI 流水线中正常运行 SonarScanner 即可,无需修改业务代码:

# .NET 项目
dotnet sonarscanner begin /k:"ProjectKey" /d:sonar.host.url="https://sonarqube.example.com"
dotnet build
dotnet sonarscanner end

# Java 项目(Maven)
mvn sonar:sonar -Dsonar.host.url=https://sonarqube.example.com

5.3 通过 API 查询检测结果

# 查询项目是否触发了指定规则
GET /api/issues/search?componentKeys={projectKey}&rules={repoKey}:{ruleKey}

# 查看已注册的自定义规则
GET /api/rules/search?q={ruleKey}

六、Java vs C# 插件开发对比

Java C#
开发语言 Java C#
核心 API IssuableSubscriptionVisitor DiagnosticAnalyzer
AST 访问 声明 nodesToVisit() + visitNode() 注册 SyntaxNodeAction
规则定义 @Rule 注解 + RulesDefinition DiagnosticDescriptor + AnalyzerReleases.md
排除条件 @Profile("dev") 注解 #if DEBUG 条件编译
打包方式 mvn package (sonar-packaging-maven-plugin) dotnet pack → sonarqube-roslyn-sdk
产物 直接生成 .jar .nupkg.jar(两步)
Repository Key 在代码中显式定义 由 SDK 从 PackageId 自动生成
版本约束 无特殊限制 Roslyn ≤ 4.9.x(SDK v4.0)

七、踩坑记录

问题 原因 解决方案
sonarqube-roslyn-sdk 报 Roslyn 版本不兼容 SDK v4.0 最高支持 4.9.x Microsoft.CodeAnalysis.CSharp 降级到 4.9.2
扫描后未发现任何 Issue 规则未在 Quality Profile 中激活 在 SonarQube UI 中手动激活规则
.NET CI 扫描无结果 CI 脚本中缺少 dotnet build sonarscanner beginend 之间加上 dotnet build
Java 编译报 非法字符: '\ufeff' 编辑器写入了 UTF-8 BOM 确保 Java 源码为无 BOM 的 UTF-8 编码
SDK 生成的 Key 与预期不同 SDK 自动去除 PackageId 中的连字符 统一使用无连字符命名,或接受 SDK 的转换规则
Java 插件依赖冲突 sonar-plugin-api 打包进了 jar 确保依赖 scope 为 provided
C# 更新代码后 jar 不变 SDK 使用了 %TEMP%\.sonarqube.sdk 中的旧缓存 构建前删除 %TEMP%\.sonarqube.sdk 目录
CI 环境无 Maven 服务器未预装 Maven 构建脚本中加入自动检测+下载逻辑
Windows 下载的 exe 无法执行 Windows 安全策略阻止了来自网络的文件 使用 Unblock-File 解除锁定

八、总结

自定义 SonarQube 插件的开发并不复杂,核心就三步:

  1. 定义规则——声明规则 ID、名称、严重级别
  2. 实现检测——遍历语法树,匹配目标模式,报告 Issue
  3. 打包部署——生成 .jar,放入 extensions/plugins/,激活规则

Java 走直接路线(Plugin API),C# 走间接路线(Roslyn Analyzer + sonarqube-roslyn-sdk),最终都生成 .jar 部署到 SonarQube。

掌握这套方法后,可以将任何团队编码规范(安全合规、架构约束、框架规范等)写成自动化检测规则,让 Code Review 中的"人盯人"变成"机器自动查"。


# swagger # sonarqube