简介:一份围绕SonarQube定制Java静态检查规则的完整工程包,面向需要为团队或项目扩展代码质量检测能力的Java开发、QA及DevOps人员。包内包含自定义规则源码、测试样例、Maven构建配置、IntelliJ项目文件及Git版本库元数据,共485个文件,涵盖java源码、xml配置、json、jar依赖、class编译产物、html报告等类型,压缩后约747MB,结构符合标准Maven工程布局,便于二次开发与部署。核心价值在于:可对照样例与构建脚本理解如何编写、打包并接入SonarQube服务器,实现针对特定编码规范或业务约束的个性化检测;随附的README与.git记录还能帮助还原项目演进过程,适合参考规则开发流程。目前已有591人浏览学习,可作为Sonar自定义规则入门的参考资料。
1. 默认规则不够用之后:sonar-java-custom-rules 这个包到底能做什么
很多团队把 SonarQube 接入 CI 之后都会卡在同一个地方:自带规则够多,但自己要卡的规范总缺一条,比如禁止直接使用 System.out.println、要求 Controller 返回值必须统一包装。sonar-java-custom-rules.zip 就是为这种场景准备的可编译 Maven 工程:里面有规则实现、规则注册、测试用例和 build.sh,不是空讲概念,而是把“团队规范如何变成静态检查”这条路走通。适合正在维护 SonarQube 平台的工程师,也适合想通过具体代码理解 Java 静态分析 API 的开发者;拿到包之后不用从零搭工程,改一改规则类就能落地到项目里。
2. 先看资源结构再动手:pom.xml 是钥匙,build.sh 是传送门
2.1 解压之后先建目录地图:src、target、.iml、.idea 都是什么
拿到压缩包先别急着改代码,我一般会先解压,把构建产物清掉,再看目录结构:
unzip sonar-java-custom-rules.zip -d sonar-rules cd sonar-rules tree -L 2 -I 'target|.git|.idea'-I参数用来忽略 target、.git、.idea 这些不重要的目录,让视线集中在源码上。如果压缩包里保留了 target,说明作者打压缩包之前做过构建,这种包拖回本地后最好先跑一次mvn clean,否则第一次编译遇到“过期类文件”容易误判成环境问题。
解压后你会看到pom.xml、build.sh、README.md、README.en.md、src这些内容。pom.xml是 Maven 的构建入口,所有依赖和打包方式都由它定义;build.sh是一个把编译、复制插件目录串起来的脚本,适合不熟悉 Maven 的人一键执行。src/main/java下放规则实现,src/test/java下放规则的单测,这一点和普通 Java Maven 工程没有差别。
资源包里还有.iml和.idea目录,说明原始工程是在 IntelliJ IDEA 里开发的。.iml是模块描述文件,.idea是工作区配置,它们对 SonarQube 运行时没有影响,但如果你也用 IDEA,可以直接用 Open Project 打开整个目录,省去重新配 JDK 和 Maven 的麻烦。.git目录则说明这个工程用 Git 管理,解压后会作为独立 Git 仓库存在。
| 路径 | 作用 | 要不要改 |
|---|---|---|
| pom.xml | Maven 构建配置,依赖和打包方式都在这里 | 必改,版本要按 SonarQube 平台调整 |
| src/main/java | 规则实现与规则定义类 | 必改,写自己的检查逻辑 |
| src/test/java | 规则单元测试 | 建议改,每加一条规则补一个测试 |
| build.sh | 编译、测试、复制插件目录的脚本 | 可改,路径按实际环境调整 |
| README.md / README.en.md | 中英文使用说明 | 只读,先看这里再做 |
| .idea / .iml | IDEA 项目配置 | 可保留,不影响打包 |
| target | Maven 构建输出 | 不需要,可以删 |
2.2 为什么自定义规则要单独打 jar,而不是改 SonarQube 自带的 Java 插件
SonarQube 的插件机制本身很简单:把写好的 jar 放进extensions/plugins,重启服务后它就出现在插件列表里。所以有同学会想,干脆直接改官方 Java 插件源码,把新规则添加进去,重新编译一个插件。这个思路在本地实验可以,但线上问题很大:官方插件在 SonarQube 升级时会被新版覆盖,你的规则改动全丢了;而且官方插件打包复杂,内部 API 跨越式升级会让你每个版本都要重改一次。
自定义规则插件的推荐做法是:利用 SonarQube 暴露的公共 API,写一个独立的插件 jar,在运行时和官方 Java 插件配合。官方 Java 插件会遍历代码生成语法树,你的规则类像一个监听器,在语法树节点上挂 hook,拿到信息后通过JavaFileScannerContext上报问题。这样升级官方插件不影响自定义规则,只要 API 版本匹配,规则 jar 可以持续复用。
这也是这个压缩包存在的价值:它已经帮你把公共 API 的调用方式、依赖配置、打包插件都配好了,你只需要在src里写自己的规则实现。相比从零开始搭工程,这个包至少省掉半天查依赖的时间。从目录结构看,它大概率沿用 SonarQube 官方自定义规则示例的组织方式,这对后面参考官方文档很有帮助。
2.3 pom.xml 三个版本核对点:Java、SonarQube、打包插件
打开 pom.xml,不要急着看业务代码,先看三个 property 和依赖。我一般会把 pom 里的版本段整理成下面这样检查:
<properties> <java.version>17</java.version> <!-- 这两个版本号以源码 README 为准,不同 SonarQube 对应不同 sonar-java API --> <sonar.version>9.9</sonar.version> <packaging.version>1.1.0.220</packaging.version> </properties>java.version决定了你本地的 JDK 版本。SonarQube 9.x 平台要求 Java 17 起跑,但编译规则插件时用它做主版本就行。sonar.version是 sonar-java-plugin 的版本,这一项要和你的 SonarQube 平台版本对应,不能随手填一个最新版,否则运行时会NoClassDefFoundError。packaging.version是sonar-packaging-maven-plugin的版本,它会负责在打包时生成 sonar-plugin 描述文件,让 SonarQube 认识你这是个插件。
依赖方面,核心依赖只有一个:
<dependency> <groupId>org.sonarsource.java</groupId> <artifactId>sonar-java-plugin</artifactId> <version>${sonar.version}</version> <scope>provided</scope> </dependency>scope=provided是关键:打包时不要把这个插件依赖塞进最终的 jar,因为 SonarQube 运行时已经加载了官方 Java 插件。如果你把依赖打进去,轻则 jar 变大,重则同一个类出现在两个 classloader 里,规则加载直接失败。很多人在自定义规则插件上翻车,就是这个 scope 写成了 compile。
2.4 build.sh 把编译、测试、拷贝插件串成一条命令
资源包里的 build.sh 在不同项目里略有差异,但核心动作基本逃不开三步:mvn clean package编译,把产出 jar 拷到 SonarQube 的插件目录,然后重启平台。常见写法是这样的:
#!/usr/bin/env bash set -euo pipefail mvn clean package -DskipTests=false JAR_FILE=$(ls target/*.jar | grep -v sources | head -n 1) SONAR_PLUGIN_DIR=${SONAR_HOME:-./sonarqube}/extensions/plugins cp "$JAR_FILE" "$SONAR_PLUGIN_DIR/" echo "Plugin copied to $SONAR_PLUGIN_DIR"set -euo pipefail保证脚本中间任何一步失败都会直接退出,不会出现“编译挂了但脚本继续复制旧 jar”的情况。ls target/*.jar | grep -v sources是为了排除源码包,只拿编译出的可执行 jar。SONAR_PLUGIN_DIR允许你通过环境变量指定 SonarQube 的安装目录,如果没配就用相对路径,对本地开发很方便。
注意 build.sh 里通常不会包含重启 SonarQube 的动作。这是因为加载插件必须在服务启动前完成,重启后插件才生效。如果你用的是 Docker 部署的 SonarQube,需要改成docker cp把 jar 复制到容器里的/opt/sonarqube/extensions/plugins,然后 restart 容器。这个细节资源包 README 里一般会写,用之前先看一眼。
注意:如果 build.sh 里的
SONAR_HOME没有设置,脚本会尝试在当前目录下找sonarqube/extensions/plugins,本地开发时建议显式导出SONAR_HOME,不要赌相对路径。
3. 自定义规则核心:在语法树访问器里写出你的第一个 Java 检查
3.1 JavaFileScanner + BaseTreeVisitor:一条规则的两半
SonarJava 的规则实现通常由两个角色拼起来:JavaFileScanner是入口,SonarQube 每扫描一个 Java 文件,就会调用一次scanFile;BaseTreeVisitor是遍历器,它按照 Java 语法树结构,把所有方法调用、字段访问、类声明、注解都拆成节点,并给每一个节点留了回调方法。
规则类的常规写法是让一个类同时实现JavaFileScanner并继承BaseTreeVisitor。在scanFile里用scan(context.getTree())启动遍历,之后你只需要覆写感兴趣的回调,比如visitMethodInvocation、visitNewClass、visitAnnotation。这样做的好处是把“文件入口”和“语法树遍历”合并成一个类,代码量小,排查也方便。官方示例模板也是这个结构。
3.2 规则实例:禁止 System.out.println
下面这条规则是自定义 Java 规则里最常见的入门案例:禁止直接打印。完整逻辑放在src/main/java里的一个单独类中。
package com.example.rules; import org.sonar.check.Rule; import org.sonar.plugins.java.api.JavaFileScanner; import org.sonar.plugins.java.api.JavaFileScannerContext; import org.sonar.plugins.java.api.tree.BaseTreeVisitor; import org.sonar.plugins.java.api.tree.MethodInvocationTree; @Rule(key = "NoSystemOut") public class NoSystemOutRule extends BaseTreeVisitor implements JavaFileScanner { private JavaFileScannerContext context; @Override public void scanFile(JavaFileScannerContext context) { this.context = context; scan(context.getTree()); } @Override public void visitMethodInvocation(MethodInvocationTree tree) { if ("System.out.println".equals(tree.methodSelect().toString())) { context.reportIssue(this, tree, "不要直接使用 System.out.println,请改用日志框架。"); } super.visitMethodInvocation(tree); } }scanFile里把 context 存下来,供后续上报问题使用;scan(context.getTree())触发整棵语法树的遍历。visitMethodInvocation会在每个方法调用点触发,tree.methodSelect().toString()直接把源码里的调用前缀转成字符串,比如System.out.println。匹配到目标之后,reportIssue就上报一条问题,参数this表示是这条规则报的,tree是定位到代码上的节点范围。
这里有个容易被忽略的细节:methodSelect().toString()匹配的是源码文本,如果团队习惯写成System . out . println,这种带空格的写法就匹配不上了。所以更稳的办法是拿tree.methodSelect().symbol().type()去判断真实类型,这也就是下一节的内容。
3.3 从字符串匹配升级到类型判断:symbol 的用法
只靠字符串匹配的规则是脆的。你想检测某个自定义类的方法调用,比如所有UserService.getUser()都必须先做权限校验,源码里可能写成this.userService.getUser(),也可能写成service.getUser(),这时字符串匹配就不靠谱了。
正确做法是拿到方法的符号(symbol),通过符号找到所属类型,再判断类型全名。下面是一个判断“是否调用了java.util.ArrayList构造器”的片段:
@Override public void visitNewClass(NewClassTree tree) { if (tree.identifier().symbol().type() == null) { super.visitNewClass(tree); return; } String fullName = tree.identifier().symbol().type().fullyQualifiedName(); if ("java.util.ArrayList".equals(fullName)) { context.reportIssue(this, tree.identifier(), "请直接用 List 接 ArrayList,避免暴露具体实现。"); } super.visitNewClass(tree); }tree.identifier().symbol()拿到构造器对应的符号,symbol().type()再拿到这个构造器所属的类类型。这里要先判空,因为符号解析在部分场景下可能返回 null,比如代码本身有编译错误,或者正在扫描的上下文没有完整 classpath。fullyQualifiedName()返回全限定名,用这种完整名判断基本不会误报。
| 方式 | 优点 | 缺点 |
|---|---|---|
toString()匹配源码片段 | 直观、零依赖 | 空格、换行等格式变化都会导致匹配失败 |
symbol().type().fullyQualifiedName()匹配全限定名 | 稳定,能识别真实类型 | 需要 classpath 完整,否则 symbol 可能为 null |
3.4 注册规则:RulesDefinition 与 @Rule 注解的配合
有了规则类还不够,SonarQube 还需要知道这条规则的元数据:名称、描述、严重级别、规则 key。这个工作通过RulesDefinition接口完成。
package com.example.rules; import org.sonar.api.server.rule.RulesDefinition; public class MyJavaRulesDefinition implements RulesDefinition { private static final String REPOSITORY_KEY = "java-custom-rules"; @Override public void define(Context context) { NewRepository repo = context.createRepository(REPOSITORY_KEY, "java").setName("My Java Custom Rules"); repo.createRule("NoSystemOut") .setName("No System.out.println") .setSeverity("MAJOR") .setHtmlDescription("禁止直接使用 System.out.println,请使用日志框架。"); repo.done(); } }createRepository的第一个参数是仓库 key,第二个参数是语言,Java 必须是"java"。规则 key 要和规则类上@Rule(key = "NoSystemOut")保持一致,这是新手最容易踩的坑:类里叫 A,注册表里叫 B,SonarQube 在界面上能显示规则,但扫描时就是不出问题。规则库里可以连续repo.createRule(...)加多条,每条都是独立规则。
为了让 SonarQube 能够加载这个定义类,还要有一个插件入口类实现Plugin接口,把定义类和规则类注册进去。这类代码通常在资源包里已经有了,后面构建部署时我会再提。
提示:
createRepository的 key 不要和官方仓库 key 重复,否则会出现重复定义警告,导致你的规则不生效。
4. 构建、打包、部署:怎么让 SonarQube 真正加载这条规则
4.1 sonar-packaging-maven-plugin 做了打包时最关键的一件事
普通 Maven 的 package 只会生成一个普通 jar,SonarQube 不认。要让 SonarQube 在启动时识别并加载规则,jar 里必须有一个META-INF/sonar-plugin.properties或等价的描述信息,声明插件 key、插件类名和依赖的官方插件。这个文件大部分靠sonar-packaging-maven-plugin自动生成。
pom.xml 里一个典型的插件配置如下:
<build> <plugins> <plugin> <groupId>org.sonarsource.sonar-packaging-maven-plugin</groupId> <artifactId>sonar-packaging-maven-plugin</artifactId> <version>${packaging.version}</version> <extensions>true</extensions> <configuration> <pluginKey>java-custom-rules</pluginKey> <pluginName>Java Custom Rules</pluginName> <pluginClass>com.example.rules.CustomJavaRulesPlugin</pluginClass> </configuration> </plugin> </plugins> </build>extensions要设为 true,这样 Maven 生命周期才会被包装成 SonarQube 插件打包流程。pluginClass指向一个实现了org.sonar.api.Plugin的入口类,SonarQube 在启动时通过这个类找到你注册的 RulesDefinition 和扫描规则。这个入口类里通常做这样一件事:
package com.example.rules; import org.sonar.api.Plugin; public class CustomJavaRulesPlugin implements Plugin { @Override public void define(Context context) { context.addExtensions(MyJavaRulesDefinition.class, NoSystemOutRule.class); } }addExtensions接收规则定义和规则类本身,SonarQube 会实例化它们并纳入自己的扩展体系。如果漏掉这一步,就算 jar 复制到了插件目录,SonarQube 也不会加载任何规则。检查一个插件 jar 是否正常,可以用jar tf看里面有没有描述文件和入口类:
jar tf target/java-custom-rules-1.0.jar | grep -E "META-INF|Plugin.class"4.2 build.sh 的两种服务方式:本地目录与 Docker 容器
本地安装的话,build.sh 执行完后把 jar 复制到 SonarQube 安装目录下的extensions/plugins,然后重启 SonarQube 服务。这里有一个必须记住的坑:要重启的不是 web 进程,而是整个 SonarQube 服务。插件只会在启动阶段扫描,运行期热加载是不存在的。
如果是 Docker 部署,build.sh 里的cp命令就没用了,因为容器里的路径和宿主机隔离。我一般会把构建和复制拆开,先在本机跑mvn clean package,再执行:
docker cp target/java-custom-rules-1.0.jar sonarqube:/opt/sonarqube/extensions/plugins/ docker restart sonarqubedocker cp的目标路径要看具体镜像。SonarQube 官方镜像的插件目录通常是/opt/sonarqube/extensions/plugins,如果你的容器是用sonarqube:lts起的,路径基本一致。复制完之后可以使用docker logs -f sonarqube查看启动日志,确认没有加载异常。
注意:Docker 容器重启后插件目录里的文件会被容器层保留,但升级容器时
docker cp的内容会丢失,建议在 Dockerfile 里用 COPY 固化安装步骤,避免每次重建容器都要手动复制。
4.3 用 sonar-scanner 扫一个小项目验证规则
插件装好只是第一步,还要验证规则真的会在扫描时触发。我习惯新建一个只含两个类的最小 Java 项目,一个类里故意写System.out.println,另一个类是干净的,然后用 sonar-scanner 跑一次本地分析。
sonar-scanner \ -Dsonar.host.url=http://localhost:9000 \ -Dsonar.login=<token> \ -Dsonar.projectKey=rule-check-demo \ -Dsonar.sources=src/main/java \ -Dsonar.java.binaries=target/classessonar.java.binaries必须指定编译后的 class 文件夹,否则 sonar-java 在解析符号时拿不到类型信息,规则很可能直接不执行。扫描结束后到 SonarQube 界面的 Issues 页,项目名选择rule-check-demo,如果规则生效,你会看到一条No System.out.println的问题记录,定位到对应的代码行。
如果界面上看不到,先别急着怀疑规则代码,去服务器的logs/web.log和logs/ce.log找NoSystemOut相关输出。接下来一章我会专门写排查路径。
5. 避坑:自定义规则从编译通过到真的生效,五个问题要先排查
5.1 五条高频踩坑记录:现象、原因、解决
我把实际开发里最容易翻车的五个情况整理成了一张表,每一条都是先看现象,再找原因,最后给解法。
| # | 现象 | 原因 | 解决 |
|---|---|---|---|
| 1 | SonarQube 的规则页看不到新规则 | 插件 jar 没有放到extensions/plugins,或者 pluginClass 加载失败 | 检查插件目录和 jar 内容,重启 SonarQube,看web.log是否报错;确认CustomJavaRulesPlugin已编译进 jar |
| 2 | 规则页有规则,但扫描不报任何问题 | 规则 key 在@Rule注解和RulesDefinition里不一致 | 让两者完全一致,并用curl http://localhost:9000/api/rules/search?rule_key=java-custom-rules:NoSystemOut查询规则详情 |
| 3 | 扫描时直接抛NoClassDefFoundError | pom 中 sonar-java-plugin 版本与服务器 SonarQube 不匹配,或 scope 不是 provided | 按 README 或 SonarQube 版本对照表调整sonar.version,把 dependency 的 scope 改为 provided |
| 4 | 规则报了,但定位到的代码行是错的 | 使用了context.reportIssue(this, tree, ...)的整树重载,定位到整个 statement | 改用context.reportIssue(this, tree.methodSelect(), ...)这类精确节点重载,报告的行号会落到具体调用上 |
| 5 | 在 IDE 里单测能跑,但在 SonarQube 里不触发 | 扫描时缺少sonar.java.binaries,类型解析返回 null,导致规则里的 symbol 判空后直接 return | 在 sonar-scanner 命令里补上-Dsonar.java.binaries=target/classes,并确保扫描前先执行mvn compile |
第一行值得多解释一句。有些人在本地解压后直接修改规则,然后mvn package拿到 jar,却忘了把它复制到 SonarQube 的插件目录,单纯跑sonar-scanner只是客户端分析,不会自动把插件装到服务器上。插件加载是服务器侧的动作,和扫描客户端是两个进程。
第三行的版本问题其实在 pom.xml 里最容易埋雷。sonar-java 的 API 在不同大版本之间会有方法签名变化,如果你用 SonarQube 9.9 平台,却把sonar.version填成 10.x,运行时的类可能还是 9.9 的旧类,自然找不到新方法。反过来,API 版本太旧而平台太新,也会出现方法被删除导致的NoSuchMethodError。
5.2 通用排查路径:从插件列表到扫描报告
如果上面五条都没覆盖到你的问题,我一般按下面的顺序排查,不猜,只看证据。
第一步,确认插件被 SonarQube 加载。进入 Administration > Marketplace,或者直接访问http://localhost:9000/api/plugins/installed,看列表里有没有Java Custom Rules。没有就检查插件目录和 jar 是否完整。
第二步,查看启动日志。web.log会记录插件加载阶段的报错,ce.log记录扫描任务执行期的报错。用tail -f盯着这两个文件,重启一次 SonarQube,报错信息直接告诉你是类找不到还是仓库注册失败。
第三步,用一个小项目复现。不要拿线上大项目验证,干扰因素太多。最小项目能编译、能扫描,把问题隔离到“规则本身”和“平台配置”之间。如果最小项目能出问题,那八成是规则实现细节;如果最小项目也不出,就要怀疑是不是大项目里还有其他模块覆盖了这条规则。
第四步,检查是否被其它规则重复或掩盖。SonarQube 默认会按规则条件显示问题,如果规则质量配置为隐藏,或者仓库没有和质量配置关联,扫描到了也不会显示。确认你用的是默认 quality profile,并且该规则没有被显式排除。
6. 进阶:把自定义规则放进质量门禁,并用单元测试保护它
6.1 用 JavaCheckVerifier 写规则的单测
规则会越写越多,如果只靠 SonarQube 扫描来验证,每次改一个规则都要重启服务,效率太低。更好的做法是把规则核心逻辑放在独立类里,用 SonarJava 提供的测试辅助类跑一遍语法树。在src/test/java里加上下面这个测试类:
package com.example.rules; import org.junit.Test; import org.sonar.java.checks.verifier.JavaCheckVerifier; public class NoSystemOutRuleTest { @Test public void should_report_system_out() { JavaCheckVerifier.verify( "src/test/resources/NoSystemOut.java", new NoSystemOutRule()); } }JavaCheckVerifier.verify会读取测试资源里的 Java 文件,交给规则类扫描,然后和文件中用// Noncompliant标记的行做对比。测试文件里只需要保留最小触发场景,不要放无关代码,否则排错时不好定位。这个习惯能帮你把规则逻辑从 SonarQube 平台上拆出来,IDE 里直接跑 JUnit,一次能省三分钟重启 SonarQube 的时间。
6.2 在质量门禁里把关键规则设为 Blocker
自定义规则跑通之后,下一步是把它们真正卡到开发流程里。进入 Quality Profiles,把规则仓库里的NoSystemOut勾选为 Blocker,再把这个质量配置绑定到目标项目上。之后只要有人提交包含 System.out.println 的代码,SonarQube 的 Quality Gate 就会判定失败,流水线在 Merge Request 阶段就能拦住。
这里要注意质量门禁的失效场景:如果团队使用的质量配置不是默认的,新规则不会自动出现在里面。我一般会把自定义规则也加到 SonarQube 的默认质量配置里,并且把规则描述写清楚,告诉开发者为什么不能这么写、应该怎么改。好的自定义规则不只是约束,更是一份活的编码规范。
从那以后我每次写完规则都强制走一遍“本地单测→打包→装插件→扫最小项目”这个闭环,确认没有把问题带到线上环境。自定义规则看起来是给 SonarQube 写代码,实际上是在给团队的编码规范写可执行的定语,规则描述写得越具体,团队踩坑就越少。希望帮到你。
本文还有配套的精品资源,点击获取