前言:Sonar 是什么?是敌人。
第一次接触 SonarQube 的程序员,通常经历以下心路历程:
这就是 90% 团队的 Sonar 使用故事。
本文目标:带你真正搞懂 Sonar 在做什么,让你从「被 Sonar 支配的恐惧」变成「支配 Sonar 的从容」。
一、SonarQube 是什么?
SonarQube 是一个持续代码质量检测平台,能对你的代码进行静态分析,找出:
简单来说:Sonar 就是一个不会累、不会客气、不会看你脸色的老严格 Code Reviewer,而且他同时能读完整个项目所有代码。
Sonar 的核心概念
| 概念 | 含义 | 严重程度 | 通俗解释 |
|---|---|---|---|
| Bug | 明确会导致错误的代码 | 🔴 Blocker / Critical | 这里一定会出问题 |
| Vulnerability | 安全漏洞 | 🔴 Critical | 黑客会从这里进来 |
| Code Smell | 坏味道,降低可维护性 | 🟡 Major / Minor | 能跑,但很臭 |
| Security Hotspot | 需要人工审查的安全点 | 🔵 需 Review | 可能有问题,人工判断 |
| Debt | 技术债(修复所需时间估算) | - | 欠下的债,迟早要还 |
| Coverage | 单测覆盖率 | - | 你的测试有多可信 |
二、安装和接入
2.1 本地快速启动(Docker)
不想折腾安装包,直接 Docker 一把梭:
# 启动 SonarQube(Community 版免费)
docker run -d \
--name sonarqube \
-p 9000:9000 \
-e SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true \
sonarqube:lts-community
# 等待约 1 分钟启动完成后访问
# http://localhost:9000
# 默认账号密码:admin / admin(第一次登录会强制改密码)⚠️ 生产环境请配置外部数据库(PostgreSQL),别用内置的 H2,否则哪天容器一删,所有历史数据全没了,就像你的技术债从未存在过一样——但它其实存在。
2.2 Maven 项目接入
<!-- pom.xml 添加插件 -->
<plugin>
<groupId>org.sonarsource.scanner.maven</groupId>
<artifactId>sonar-maven-plugin</artifactId>
<version>3.10.0.2594</version>
</plugin># 执行扫描
mvn clean verify sonar:sonar \
-Dsonar.projectKey=my-payment-service \
-Dsonar.projectName="My Payment Service" \
-Dsonar.host.url=http://localhost:9000 \
-Dsonar.token=your_token_here
# 执行完后打开浏览器,迎接你的审判2.3 Gradle 项目接入
// build.gradle
plugins {
id "org.sonarqube" version "4.4.1.3373"
id "jacoco" // 覆盖率必须配合 JaCoCo
}
sonar {
properties {
property "sonar.projectKey", "my-service"
property "sonar.host.url", "http://localhost:9000"
property "sonar.token", System.getenv("SONAR_TOKEN")
}
}
// 跑测试并生成覆盖率报告,然后再 Sonar 扫描
// ./gradlew test jacocoTestReport sonar2.4 CI/CD 集成(GitHub Actions)
# .github/workflows/sonar.yml
name: SonarQube Analysis
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
sonarqube:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 必须拉完整历史,Sonar 需要 blame 信息
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Cache SonarQube packages
uses: actions/cache@v3
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
- name: Build and analyze
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
run: mvn -B verify sonar:sonar
# PR 不过 Quality Gate,自动阻断合并
- name: Check Quality Gate
uses: sonarsource/sonarqube-quality-gate-action@master
timeout-minutes: 5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}集成后的 CI 流程:
三、Quality Gate:那道不得不过的门
Quality Gate 是 Sonar 最核心的概念——一组判断代码能不能上线的质量标准。默认的 Sonar Way 标准如下:
重点:Sonar Way 默认只检查「新代码」,不是全量。这是非常人性化的设计——存量技术债不会一夜之间全变成你的阻碍,但新写的代码必须符合标准。
自定义 Quality Gate
不同项目有不同要求,自定义 Gate 是常规操作:
Administration → Quality Gates → Create
支付核心服务(严格版):
✅ 新增 Bug = 0
✅ 新增漏洞 = 0
✅ 新代码覆盖率 >= 85%
✅ 新代码重复率 <= 2%
✅ 新增 Blocker/Critical Code Smell = 0
前端工具服务(宽松版):
✅ 新增 Bug = 0
✅ 新增漏洞 = 0
✅ 新代码覆盖率 >= 60%
✅ 新代码重复率 <= 5%四、常见 Issue 类型和修复姿势
4.1 Bug 类:这些是真的会挂的
空指针没检查(最高频):
// ❌ Sonar 报错:A "NullPointerException" could be thrown
public String getAccountName(String accountId) {
Account account = accountRepository.findById(accountId);
return account.getName(); // account 可能是 null!
}
// ✅ 修复方式一:Optional
public Optional<String> getAccountName(String accountId) {
return accountRepository.findById(accountId)
.map(Account::getName);
}
// ✅ 修复方式二:明确抛业务异常
public String getAccountName(String accountId) {
return accountRepository.findById(accountId)
.orElseThrow(() -> new AccountNotFoundException(accountId))
.getName();
}资源未关闭:
// ❌ Sonar 报错:Close this "InputStream"
public byte[] readFile(String path) throws IOException {
InputStream is = new FileInputStream(path);
return is.readAllBytes(); // 如果这里抛异常,is 永远不会关闭
}
// ✅ 修复:try-with-resources,自动关闭
public byte[] readFile(String path) throws IOException {
try (InputStream is = new FileInputStream(path)) {
return is.readAllBytes();
}
}条件永为真/假:
// ❌ Sonar 报错:Remove this expression which always evaluates to "true"
String status = "ACTIVE";
if (status != null && status.equals("ACTIVE")) { // status 是字面量,不可能为 null
doSomething();
}
// ✅ 修复
if ("ACTIVE".equals(status)) {
doSomething();
}4.2 Vulnerability 类:安全问题,一个都不能忍
硬编码密码(最容易被扫出来,也最尴尬):
// ❌ Sonar 报错:Hard-coded credentials are security-sensitive
public class DatabaseConfig {
private static final String PASSWORD = "Admin@123456"; // 全公司都能看到你的密码了
private static final String API_KEY = "sk-abcdef123456"; // 你的 API Key 已经泄露
}
// ✅ 修复:从环境变量或配置中心读取
@Value("${db.password}")
private String dbPassword;
// 或者
String apiKey = System.getenv("OPENAI_API_KEY");SQL 注入风险:
// ❌ Sonar 报错:Change this code to not construct SQL queries directly
public List<Account> findByName(String name) {
String sql = "SELECT * FROM account WHERE name = '" + name + "'";
// 如果 name = "' OR '1'='1",你的数据库就裸奔了
return jdbcTemplate.query(sql, accountRowMapper);
}
// ✅ 修复:参数化查询
public List<Account> findByName(String name) {
String sql = "SELECT * FROM account WHERE name = ?";
return jdbcTemplate.query(sql, accountRowMapper, name);
}不安全的随机数:
// ❌ Sonar 报错:Use a cryptographically strong random number generator
import java.util.Random;
public String generateToken() {
Random random = new Random(); // 可预测,不安全
return String.valueOf(random.nextLong());
}
// ✅ 修复:用 SecureRandom
import java.security.SecureRandom;
public String generateToken() {
SecureRandom secureRandom = new SecureRandom();
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}4.3 Code Smell 类:不影响运行,但让人头疼
认知复杂度过高(Cognitive Complexity):
这是 Sonar 独有的指标,比圈复杂度更贴近人类阅读体验。每个嵌套、每个逻辑分支都会增加复杂度分数:
// ❌ Sonar 报错:Refactor this method to reduce its Cognitive Complexity from 24 to 15
public String processTransaction(Transaction tx) {
if (tx != null) { // +1
if (tx.getAmount() != null) { // +2(嵌套加权)
if (tx.getAmount().compareTo(BigDecimal.ZERO) > 0) { // +3
if (tx.getType() == TransactionType.CREDIT) { // +4
if (tx.getStatus() != TransactionStatus.CANCELLED) { // +5
// 真正的逻辑在第五层...
for (String tag : tx.getTags()) { // +6
if (tag.startsWith("VIP")) { // +7
return "VIP_CREDIT";
} else if (tag.startsWith("CORP")) { // +8
return "CORP_CREDIT";
}
}
return "STANDARD_CREDIT";
}
}
}
}
}
return "INVALID";
}
// ✅ 修复:卫语句提前返回,拆分方法,降低嵌套
public String processTransaction(Transaction tx) {
if (!isValidTransaction(tx)) return "INVALID";
if (tx.getStatus() == TransactionStatus.CANCELLED) return "INVALID";
if (tx.getType() != TransactionType.CREDIT) return "INVALID";
return resolveTransactionTag(tx.getTags());
}
private boolean isValidTransaction(Transaction tx) {
return tx != null
&& tx.getAmount() != null
&& tx.getAmount().compareTo(BigDecimal.ZERO) > 0;
}
private String resolveTransactionTag(List<String> tags) {
return tags.stream()
.filter(tag -> tag.startsWith("VIP") || tag.startsWith("CORP"))
.findFirst()
.map(tag -> tag.startsWith("VIP") ? "VIP_CREDIT" : "CORP_CREDIT")
.orElse("STANDARD_CREDIT");
}方法参数过多(Parameter Smells):
// ❌ Sonar 报错:Method has 8 parameters, which is greater than 7 authorized
public TransferResult transfer(
String fromIban, String toIban, BigDecimal amount, String currency,
String description, String reference, LocalDate valueDate, Boolean urgent) {
...
}
// ✅ 修复:封装成请求对象
public TransferResult transfer(TransferRequest request) {
...
}
// TransferRequest 用 Builder 构建,清晰、可扩展、不怕加字段
TransferRequest.builder()
.fromIban("AE07...")
.toIban("AE07...")
.amount(new BigDecimal("1000"))
.currency("AED")
.description("Rent payment")
.build();五、覆盖率:那个让人又爱又恨的数字
覆盖率是 Sonar 里最容易引发团队争论的指标。
5.1 用 JaCoCo 生成覆盖率报告
<!-- pom.xml -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal> <!-- 测试前埋点 -->
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal> <!-- 测试后生成报告 -->
</goals>
</execution>
</executions>
</plugin>5.2 排除不需要统计覆盖率的类
有些类天然不适合写单测(DTO、枚举、配置类、自动生成的代码),强行覆盖是浪费生命:
<!-- sonar-project.properties 或 pom.xml -->
<sonar.coverage.exclusions>
**/dto/**,
**/entity/**,
**/config/**,
**/constant/**,
**/*Config.java,
**/*Properties.java,
**/*Application.java,
**/generated/**
</sonar.coverage.exclusions>5.3 写一个真正有价值的单测
覆盖率高不等于测试质量高。很多人为了拉覆盖率,写出这种「僵尸测试」:
// ❌ 僵尸测试:覆盖率 100%,但啥也没验证
@Test
public void testCalculateFee() {
FeeService feeService = new FeeService();
feeService.calculateFee(new BigDecimal("1000"), "AED");
// 没有任何 assert,方法跑了就算通过
// 这种测试存在的唯一意义是拉覆盖率
}
// ✅ 有效测试:验证行为,覆盖边界
@Test
public void calculateFee_standardAmount_returnsCorrectFee() {
// Given
BigDecimal amount = new BigDecimal("1000");
FeeCalculationRequest request = FeeCalculationRequest.builder()
.amount(amount)
.currency("AED")
.accountType(AccountType.STANDARD)
.build();
// When
FeeResult result = feeService.calculateFee(request);
// Then
assertThat(result.getFeeAmount())
.isEqualByComparingTo(new BigDecimal("5.00")); // 0.5% fee
assertThat(result.getFeeType()).isEqualTo(FeeType.PERCENTAGE);
}
@Test
public void calculateFee_zerAmount_throwsIllegalArgumentException() {
// 边界值:金额为零
assertThatThrownBy(() -> feeService.calculateFee(
FeeCalculationRequest.builder().amount(BigDecimal.ZERO).build()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Amount must be positive");
}六、压制 Issue:合理使用 @SuppressWarnings
有些 Issue 是 Sonar 的误报,或者有充分理由不修。这时候可以压制,但要留下理由,不然三个月后你自己都不知道为什么压制了:
// ❌ 沉默式压制,不负责任
@SuppressWarnings("java:S2077")
public List<Account> rawQuery(String sql) {
return jdbcTemplate.query(sql, rowMapper);
}
// ✅ 负责任的压制:说明原因
@SuppressWarnings("java:S2077")
// NOSONAR: SQL 由内部系统动态生成,非用户输入,无注入风险。
// 参考:https://our-wiki/internal-query-builder-security-review
public List<Account> rawQuery(String sql) {
return jdbcTemplate.query(sql, rowMapper);
}常见可以压制的规则:
| 规则 | 场景 | 压制理由 |
|---|---|---|
java:S2077 | 动态 SQL | SQL 来源可信,非用户输入 |
java:S1135 | TODO 注释 | 已记录到 Jira,有跟进计划 |
java:S3776 | 复杂度高 | 业务逻辑本身复杂,拆分反而降低可读性 |
java:S106 | System.out.println | 启动脚本或工具类,无日志框架 |
java:S2259 | 可能空指针 | 上下文保证非空,Sonar 无法推断 |
⚠️ 压制不是逃避,是最后手段。 每个 @SuppressWarnings 都应该是一次有据可查的决策,而不是偷懒的借口。七、Sonar 使用的正确姿势
用了这么多年 Sonar,总结出一套真正有效的接入策略:
最重要的一条原则
不要让 Sonar 变成「形式主义」。
八、真实场景问题排查
8.1 覆盖率明明跑了测试,Sonar 上还是 0%?
排查顺序:
# 1. 确认 JaCoCo 报告生成了
ls target/site/jacoco/jacoco.xml # Maven
ls build/reports/jacoco/test/jacocoTestReport.xml # Gradle
# 2. 确认 Sonar 能找到报告路径
mvn sonar:sonar \
-Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml
# 3. 如果是多模块项目,需要聚合报告
# 在父 pom.xml 中配置 jacoco-aggregate8.2 PR 时 Sonar 分析卡住不动?
# 常见原因:没设超时
- name: Check Quality Gate
uses: sonarsource/sonarqube-quality-gate-action@master
timeout-minutes: 5 # 加这个!不然可能无限等待
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}8.3 本地和 CI 扫描结果不一致?
# 本地确保和 CI 用一样的参数
mvn clean verify sonar:sonar \
-Dsonar.branch.name=$(git branch --show-current) \
-Dsonar.pullrequest.key=$PR_NUMBER \ # PR 模式下必须指定
-Dsonar.pullrequest.branch=$HEAD_BRANCH \
-Dsonar.pullrequest.base=$BASE_BRANCH九、Sonar 全家桶速查表
| 工具 | 用途 | 适用场景 |
|---|---|---|
| SonarQube | 自托管服务端 | 私有部署,数据不出公司 |
| SonarCloud | 云端 SaaS 版 | 开源项目免费,省去运维 |
| SonarLint | IDE 插件 | 本地实时检测,提交前发现问题 |
| sonar-scanner | 命令行扫描器 | 非 Maven/Gradle 项目 |
强烈推荐装 SonarLint!在 IDE 里实时提示,比等 CI 跑完再看报告效率高十倍。IDEA、VS Code、Eclipse 都支持,连接到 SonarQube/SonarCloud 后规则自动同步。
结语:和 Sonar 和平共处
接触 Sonar 的程序员通常会经历三个阶段:
Sonar 不是你的敌人,它是那个永远不会累、永远不会客气的队友——它指出的每一个问题,都是某种程度上值得认真对待的信号。
当然,也有一些 Issue 是误报,一些规则不适合你们的场景,一些覆盖率要求脱离现实。这时候该压制压制,该调整调整,该和团队讨论讨论。工具是为人服务的,不是反过来。
最重要的是:别让 Sonar 的报告成为一个没人看的仪表盘。
那才是真正的放弃。