SonarQube自定义Java规则实战:构建、部署与避坑指南
简介一份围绕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.xmlMaven 构建配置依赖和打包方式都在这里必改版本要按 SonarQube 平台调整src/main/java规则实现与规则定义类必改写自己的检查逻辑src/test/java规则单元测试建议改每加一条规则补一个测试build.sh编译、测试、复制插件目录的脚本可改路径按实际环境调整README.md / README.en.md中英文使用说明只读先看这里再做.idea / .imlIDEA 项目配置可保留不影响打包targetMaven 构建输出不需要可以删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.version17/java.version !-- 这两个版本号以源码 README 为准不同 SonarQube 对应不同 sonar-java API -- sonar.version9.9/sonar.version packaging.version1.1.0.220/packaging.version /propertiesjava.version决定了你本地的 JDK 版本。SonarQube 9.x 平台要求 Java 17 起跑但编译规则插件时用它做主版本就行。sonar.version是 sonar-java-plugin 的版本这一项要和你的 SonarQube 平台版本对应不能随手填一个最新版否则运行时会NoClassDefFoundError。packaging.version是sonar-packaging-maven-plugin的版本它会负责在打包时生成 sonar-plugin 描述文件让 SonarQube 认识你这是个插件。依赖方面核心依赖只有一个dependency groupIdorg.sonarsource.java/groupId artifactIdsonar-java-plugin/artifactId version${sonar.version}/version scopeprovided/scope /dependencyscopeprovided是关键打包时不要把这个插件依赖塞进最终的 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 -DskipTestsfalse 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_DIRset -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 文件就会调用一次scanFileBaseTreeVisitor是遍历器它按照 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 可能为 null3.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注册表里叫 BSonarQube 在界面上能显示规则但扫描时就是不出问题。规则库里可以连续repo.createRule(...)加多条每条都是独立规则。为了让 SonarQube 能够加载这个定义类还要有一个插件入口类实现Plugin接口把定义类和规则类注册进去。这类代码通常在资源包里已经有了后面构建部署时我会再提。提示createRepository的 key 不要和官方仓库 key 重复否则会出现重复定义警告导致你的规则不生效。4. 构建、打包、部署怎么让 SonarQube 真正加载这条规则4.1 sonar-packaging-maven-plugin 做了打包时最关键的一件事普通 Maven 的 package 只会生成一个普通 jarSonarQube 不认。要让 SonarQube 在启动时识别并加载规则jar 里必须有一个META-INF/sonar-plugin.properties或等价的描述信息声明插件 key、插件类名和依赖的官方插件。这个文件大部分靠sonar-packaging-maven-plugin自动生成。pom.xml 里一个典型的插件配置如下build plugins plugin groupIdorg.sonarsource.sonar-packaging-maven-plugin/groupId artifactIdsonar-packaging-maven-plugin/artifactId version${packaging.version}/version extensionstrue/extensions configuration pluginKeyjava-custom-rules/pluginKey pluginNameJava Custom Rules/pluginName pluginClasscom.example.rules.CustomJavaRulesPlugin/pluginClass /configuration /plugin /plugins /buildextensions要设为 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.class4.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.urlhttp://localhost:9000 \ -Dsonar.logintoken \ -Dsonar.projectKeyrule-check-demo \ -Dsonar.sourcessrc/main/java \ -Dsonar.java.binariestarget/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 五条高频踩坑记录现象、原因、解决我把实际开发里最容易翻车的五个情况整理成了一张表每一条都是先看现象再找原因最后给解法。#现象原因解决1SonarQube 的规则页看不到新规则插件 jar 没有放到extensions/plugins或者 pluginClass 加载失败检查插件目录和 jar 内容重启 SonarQube看web.log是否报错确认CustomJavaRulesPlugin已编译进 jar2规则页有规则但扫描不报任何问题规则 key 在Rule注解和RulesDefinition里不一致让两者完全一致并用curl http://localhost:9000/api/rules/search?rule_keyjava-custom-rules:NoSystemOut查询规则详情3扫描时直接抛NoClassDefFoundErrorpom 中 sonar-java-plugin 版本与服务器 SonarQube 不匹配或 scope 不是 provided按 README 或 SonarQube 版本对照表调整sonar.version把 dependency 的 scope 改为 provided4规则报了但定位到的代码行是错的使用了context.reportIssue(this, tree, ...)的整树重载定位到整个 statement改用context.reportIssue(this, tree.methodSelect(), ...)这类精确节点重载报告的行号会落到具体调用上5在 IDE 里单测能跑但在 SonarQube 里不触发扫描时缺少sonar.java.binaries类型解析返回 null导致规则里的 symbol 判空后直接 return在 sonar-scanner 命令里补上-Dsonar.java.binariestarget/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 写代码实际上是在给团队的编码规范写可执行的定语规则描述写得越具体团队踩坑就越少。希望帮到你。本文还有配套的精品资源点击获取