一、为什么需要自定义 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# 项目怎么办?
本文以一个实际案例——检测生产环境 Swagger 暴露风险——分别演示这两条路线的完整实现。
二、插件开发全景
2.1 SonarQube 插件运行机制
理解插件的运行机制是开发的前提:
一个 SonarQube 插件至少需要实现:
| 组件 | Java 对应类 | C# 对应类 | 职责 |
|---|---|---|---|
| 插件入口 | Plugin |
— (SDK 自动生成) | 注册所有扩展点 |
| 规则定义 | RulesDefinition |
— (SDK 自动生成) | 声明规则 ID、名称、描述、严重级别 |
| 检测逻辑 | IssuableSubscriptionVisitor |
DiagnosticAnalyzer |
遍历语法树,匹配模式,报告 Issue |
2.2 规则设计原则
无论用哪种语言实现,好的规则应遵循:
- 精确匹配:用语义分析而非字符串匹配,避免误报
- 合理排除:提供"安全出口"(如 C# 的
#if DEBUG、Java 的@Profile("dev")) - 清晰消息:Issue 消息应说明"检测到了什么"和"应该怎么修"
- 唯一标识:规则 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 或团队协作中,建议编写构建脚本来处理以下问题:
- Maven 环境检测:CI 服务器可能没有预装 Maven
- 自动下载:在无 Maven 的环境中自动下载并使用本地副本
- 统一命名:通过
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 技术路线
核心思路:先写一个标准的 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.0Microsoft.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:
完整构建脚本(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-analyzer→myanalyzer。如果需要精确控制 Key,建议PackageId直接使用无连字符的小写名称。
五、部署与使用
5.1 部署流程
# 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 begin 和 end 之间加上 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 插件的开发并不复杂,核心就三步:
- 定义规则——声明规则 ID、名称、严重级别
- 实现检测——遍历语法树,匹配目标模式,报告 Issue
- 打包部署——生成
.jar,放入extensions/plugins/,激活规则
Java 走直接路线(Plugin API),C# 走间接路线(Roslyn Analyzer + sonarqube-roslyn-sdk),最终都生成 .jar 部署到 SonarQube。
掌握这套方法后,可以将任何团队编码规范(安全合规、架构约束、框架规范等)写成自动化检测规则,让 Code Review 中的"人盯人"变成"机器自动查"。