多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SonarQube Java自定义规则开发实战:从插件搭建到避坑指南

SonarQube Java自定义规则开发实战:从插件搭建到避坑指南 简介sonar-java-custom-rules.zip 面向使用 SonarQube 进行 Java 静态代码分析的开发者与团队提供一套可集成到持续集成流程中的自定义检测规则用于在标准规则集之外补充项目特定的编码规范检查帮助在开发早期发现代码异味、潜在缺陷与复杂度问题。压缩包共 485 个文件约 747.36MB以 xml 配置、java 源码、json 数据、jar 依赖与 class 编译产物为主另含 html 文档、md 说明及 sh 构建脚本覆盖规则实现、依赖管理与构建部署的完整链路。资源内含 IntelliJ IDEA 工程文件、Maven 的 pom.xml 与 src 源码目录便于读者直接阅读规则实现逻辑、理解插件工程结构并参考 README 完成本地构建与规则扩展。目前已有 592 人学习下载适合希望深入 SonarJava 自定义规则开发、提升代码质量管理能力的中高级 Java 工程师参考。1. 从 sonar-java-custom-rules.zip 说起为什么通用规则总差一口气团队代码库里有一类问题SonarQube 自带规则永远抓不到某个内部框架的 API 必须成对调用、某个注解只能出现在特定方法签名上、日志里禁止拼接敏感字段。这些约束写不进通用规则只能自己写 Java 自定义规则。sonar-java-custom-rules.zip这个包名指向的正是这条路径——用 Java 写 SonarQube 插件把团队私有的编码规约变成可扫描、可阻断、可统计的规则。它解决的不是“有没有静态扫描”而是“扫描器懂不懂我们自己的规矩”。适合两类人一是被重复代码评审拖住的 Tech Lead二是想把架构约束固化进 CI 的中间件维护者。下面按“能跑起来 → 能写对 → 能避坑”的顺序拆开讲。2. 环境与骨架把自定义规则插件跑通的最小闭环2.1 先确认版本矩阵别让依赖打架SonarQube 的插件 API 版本和 Java 运行时版本是强绑定的这是最容易翻车的地方。常见做法是先锁定三件事SonarQube 服务端版本、sonar-plugin-api版本、编译用的 JDK 版本。以当前主流组合为例服务端 9.x 对应插件 API 9.x编译 JDK 用 11 或 17。如果服务端是 10.x插件 API 也要跟着上 10.x否则插件装上去会直接报Unsupported API version。组件推荐版本说明SonarQube 服务端9.9 LTS长期支持插件生态稳sonar-plugin-api9.9.0.229必须与服务端主版本一致Java 编译版本11服务端 9.x 的运行时基线sonar-java-plugin7.16提供 Java 语法树解析能力Maven3.8构建插件包提示不要用 JDK 21 去编译面向 9.x 服务端的插件字节码版本过高会导致类加载失败报错信息通常很隐晦只显示NoClassDefFoundError。2.2 用 Maven 搭出插件骨架sonar-java-custom-rules.zip解压后通常是一个 Maven 工程核心是pom.xml和规则实现类。如果从零搭最小pom.xml需要声明sonar-plugin-api、sonar-java-plugin和打包插件。project modelVersion4.0.0/modelVersion groupIdcom.example.sonar/groupId artifactIdcustom-java-rules/artifactId version1.0.0/version packagingsonar-plugin/packaging properties maven.compiler.source11/maven.compiler.source maven.compiler.target11/maven.compiler.target sonar.api.version9.9.0.229/sonar.api.version /properties dependencies !-- SonarQube 插件 APIprovided 作用域服务端已自带 -- dependency groupIdorg.sonarsource.sonarqube/groupId artifactIdsonar-plugin-api/artifactId version${sonar.api.version}/version scopeprovided/scope /dependency !-- Java 语法树解析provided 作用域 -- dependency groupIdorg.sonarsource.java/groupId artifactIdsonar-java-plugin/artifactId version7.16.0.30901/version scopeprovided/scope /dependency /dependencies build plugins plugin groupIdorg.sonarsource.sonar-packaging-maven-plugin/groupId artifactIdsonar-packaging-maven-plugin/artifactId version1.21.0.505/version extensionstrue/extensions configuration pluginClasscom.example.sonar.CustomRulesPlugin/pluginClass pluginKeycustomjavarules/pluginKey pluginNameCustom Java Rules/pluginName /configuration /plugin /plugins /build /project这段配置的关键点packaging必须是sonar-plugin否则打出来的 jar 不会被服务端识别为插件两个依赖都用provided因为服务端运行时已经包含这些类打进去反而会引起类冲突。pluginClass指向插件入口类pluginKey是插件在服务端的唯一标识后续规则仓库名会用到。2.3 插件入口类与规则注册插件入口类实现Plugin接口在getExtensions里注册规则定义和规则实现。package com.example.sonar; import org.sonar.api.Plugin; public class CustomRulesPlugin implements Plugin { Override public void define(Context context) { // 注册规则仓库仓库 key 与规则定义中的 repository key 对应 context.addExtension(CustomRulesDefinition.class); // 注册规则实现类每个规则一个类 context.addExtension(NoSensitiveLogRule.class); } }define方法里每加一个addExtension就多一个可被扫描器加载的组件。规则定义类负责描述规则的元信息名称、描述、严重级别、标签规则实现类负责真正的语法树遍历。两者分离是 SonarQube 插件的标准结构不要合并成一个类否则规则元信息无法在服务端 UI 正确展示。2.4 规则定义类把元信息写清楚package com.example.sonar; import org.sonar.api.server.rule.RulesDefinition; import org.sonar.api.server.rule.RulesDefinitionAnnotationLoader; public class CustomRulesDefinition implements RulesDefinition { public static final String REPOSITORY_KEY custom-java; public static final String REPOSITORY_NAME Custom Java Rules; Override public void define(Context context) { NewRepository repository context .createRepository(REPOSITORY_KEY, java) .setName(REPOSITORY_NAME); // 用注解加载器扫描规则类上的 Rule 注解 new RulesDefinitionAnnotationLoader().load(repository, NoSensitiveLogRule.class); repository.done(); } }createRepository的第二个参数java表示这些规则作用于 Java 语言。RulesDefinitionAnnotationLoader会读取规则类上的Rule注解把 key、name、description、priority 等元信息自动填充。如果不用注解加载器就得手动repository.createRule(...)逐个设置代码量大且容易漏字段。2.5 打包与部署验证# 在工程根目录执行跳过测试加快首次验证 mvn clean package -DskipTests # 产物在 target 下形如 custom-java-rules-1.0.0.jar ls target/*.jar # 复制到服务端插件目录路径以实际部署为准 cp target/custom-java-rules-1.0.0.jar $SONAR_HOME/extensions/plugins/ # 重启服务端使插件生效 $SONAR_HOME/bin/linux-x64/sonar.sh restart打包成功后jar 里必须包含META-INF/MANIFEST.MF其中Plugin-Class和Plugin-Key字段由打包插件自动写入。部署后登录服务端进入“规则”页面搜索custom-java仓库能看到注册的规则即表示插件加载成功。如果看不到先查服务端日志logs/sonar.log常见原因是pluginClass写错或依赖作用域不是provided。3. 写一条能用的规则从语法树节点到问题报告3.1 理解 Java 语法树节点类型SonarJava 把 Java 源码解析成语法树规则实现就是遍历这棵树在特定节点上做判断。常用节点类型包括MethodInvocationTree方法调用、AnnotationTree注解、VariableTree变量声明、LiteralTree字面量。写规则前先用工具看清目标代码对应哪些节点否则会对着错误的节点写逻辑扫描时一条问题都报不出来。常见做法是先用 SonarJava 提供的sonar-java-plugin里的测试工具或者直接在 IDE 里用语法树查看插件观察。以“日志中禁止拼接敏感字段”为例目标代码是log.info(user password)对应节点是MethodInvocationTree其参数里包含BinaryExpressionTree字符串拼接拼接的右操作数是IdentifierTree名字叫password。3.2 规则实现类的标准结构package com.example.sonar; import org.sonar.api.batch.fs.InputFile; import org.sonar.api.batch.sensor.SensorContext; 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.*; Rule(key NoSensitiveLog) public class NoSensitiveLogRule extends BaseTreeVisitor implements JavaFileScanner { private JavaFileScannerContext context; // 敏感字段名列表实际项目可从配置读取 private static final String[] SENSITIVE {password, token, secret}; Override public void scanFile(JavaFileScannerContext context) { this.context context; // 从语法树根节点开始遍历 scan(context.getTree()); } Override public void visitMethodInvocation(MethodInvocationTree tree) { // 先判断是不是日志调用 if (isLogCall(tree)) { for (ExpressionTree arg : tree.arguments()) { if (arg.is(Tree.Kind.PLUS) || arg.is(Tree.Kind.PLUS_ASSIGNMENT)) { checkBinaryExpression((BinaryExpressionTree) arg); } } } // 继续遍历子节点不要漏掉嵌套调用 super.visitMethodInvocation(tree); } private boolean isLogCall(MethodInvocationTree tree) { String method tree.symbol().name(); return info.equals(method) || debug.equals(method) || warn.equals(method); } private void checkBinaryExpression(BinaryExpressionTree tree) { ExpressionTree right tree.rightOperand(); if (right.is(Tree.Kind.IDENTIFIER)) { String name ((IdentifierTree) right).name(); for (String s : SENSITIVE) { if (name.toLowerCase().contains(s)) { // 报告问题指定节点位置和消息 context.reportIssue(this, tree, 日志中禁止拼接敏感字段: name); } } } } }BaseTreeVisitor提供了所有节点的默认遍历逻辑只需覆写关心的visitXxx方法。scanFile是JavaFileScanner接口的入口由扫描器在分析每个文件时调用。context.reportIssue是报告问题的唯一出口第一个参数传this让框架知道是哪个规则报的第二个参数是语法树节点决定问题在代码中的位置第三个是消息文本。3.3 参数说明与可调项参数/字段作用调整建议Rule(key)规则唯一标识全局唯一不要和内置规则重名SENSITIVE数组敏感字段名单实际项目改为从Settings读取支持动态配置isLogCall判断识别日志方法按团队日志框架调整方法名集合reportIssue节点问题定位传tree定位到拼接表达式传tree.parent()可定位到整条语句super.visitMethodInvocation继续遍历必须调用否则嵌套调用会被漏掉注意reportIssue的第二个参数如果传了null问题会挂到文件级别UI 上无法定位到具体行排查体验很差。始终传一个具体的语法树节点。3.4 本地测试规则是否生效写完规则不要直接扔到服务端试先在本地用单元测试验证。SonarJava 提供了JavaCheckVerifier工具类可以针对一段示例代码断言规则是否在预期位置报出问题。package com.example.sonar; import org.junit.jupiter.api.Test; import org.sonar.java.checks.verifier.CheckVerifier; class NoSensitiveLogRuleTest { Test void shouldReportWhenPasswordConcatenated() { // 示例代码中 // Noncompliant 标记预期报问题的行 CheckVerifier.newVerifier() .onFile(src/test/files/NoSensitiveLog.java) .withCheck(new NoSensitiveLogRule()) .verifyIssues(); } }测试文件NoSensitiveLog.java里这样写class Sample { void log(String password) { System.out.println(user password); // Noncompliant System.out.println(userok); // Compliant } }CheckVerifier会解析测试文件运行规则然后比对// Noncompliant标记的位置和实际报出的问题位置是否一致。不一致就测试失败能快速定位是规则逻辑写错还是节点选错。这套验证流程是写自定义规则时最省时间的环节比反复重启服务端试要快一个数量级。4. 避坑与排查自定义规则最容易翻车的五个地方4.1 插件装上但规则不出现现象jar 放进插件目录、重启服务端后规则列表里搜不到自定义仓库。原因通常是RulesDefinition没有在插件入口类里注册或者createRepository的 language 参数写成了Java大写而服务端只认小写java。解决检查define方法里是否addExtension(CustomRulesDefinition.class)并确认 language 参数全小写。4.2 规则能出现但扫描不报问题现象规则在 UI 上可见但扫描项目后一条问题都没有。原因多半是规则实现类没有注册到插件入口或者scanFile里没有调用scan(context.getTree())。另一个常见原因是visitMethodInvocation里忘了调super导致遍历在第一个方法调用后就停了。解决在入口类里确认规则实现类已addExtension并在每个覆写的visit方法末尾调用super。4.3 报错位置偏移或指向错误行现象问题报出来了但定位到的行号和实际代码差了几行。原因是reportIssue传的节点是子节点而 UI 显示的是该节点所在行。如果传的是BinaryExpressionTree它可能跨多行定位就会偏。解决传更精确的节点比如IdentifierTree或者用context.reportIssue(this, tree.firstToken(), ...)指定 token 位置。4.4 依赖作用域写错导致类冲突现象服务端启动时报NoSuchMethodError或ClassCastException指向 SonarJava 内部类。原因是sonar-java-plugin依赖没有用provided被打进了插件 jar和服务端自带的版本冲突。解决确认pom.xml里sonar-plugin-api和sonar-java-plugin都是provided打包后用unzip -l检查 jar 里是否包含org/sonar/plugins/java目录有就是打进去了。4.5 规则在增量扫描时漏报现象全量扫描能报出问题但增量扫描只扫改动文件时漏报。原因是规则实现里用了跨文件的全局状态或者依赖了SensorContext里不稳定的缓存。解决规则实现保持无状态所有判断只依赖当前文件的语法树如果确实需要跨文件信息改用Sensor而非JavaFileScanner并在execute里统一处理。5. 进阶让规则可配置、可度量、可演进规则写多了之后硬编码的敏感字段名单会变成维护负担。更好的做法是把名单抽到规则参数里在服务端 UI 上可改改完不用重新打包插件。SonarQube 的规则参数通过RuleProperty注解声明规则实现里用Settings读取。import org.sonar.check.RuleProperty; Rule(key NoSensitiveLog) public class NoSensitiveLogRule extends BaseTreeVisitor implements JavaFileScanner { // 默认值服务端 UI 可覆盖 RuleProperty( key sensitiveFields, description 逗号分隔的敏感字段名列表, defaultValue password,token,secret ) public String sensitiveFields password,token,secret; private String[] sensitiveArray; Override public void scanFile(JavaFileScannerContext context) { // 每次扫描前解析参数支持 UI 动态修改 this.sensitiveArray sensitiveFields.split(,); scan(context.getTree()); } // 其余逻辑不变判断时用 sensitiveArray }RuleProperty的key是参数在服务端的标识defaultValue是未配置时的兜底值。规则实现里不要用static字段存参数因为同一个规则实例可能被多个扫描任务复用静态字段会串数据。每次scanFile重新解析一次开销可以忽略。参数化之后规则的度量也要跟上。服务端会自动统计每条规则的报出次数、涉及文件数、技术债。如果某条规则长期零报出要么是规则写错了要么是团队代码确实干净两种情况都值得回头看。我一般会每月拉一次规则命中排行把零命中的规则挑出来复查避免规则仓库里堆一堆“僵尸规则”。还有一个容易被忽略的点规则的严重级别不要一刀切设成Blocker。自定义规则刚上线时建议先设成Info或Minor观察一段时间确认误报率可接受后再逐步升级。直接上Blocker会导致 CI 频繁阻断团队很快就会要求关掉整条规则反而失去了固化的意义。这是我在多个项目里踩过的血泪教训——规则的可信度是靠低误报攒出来的不是靠高严重级别吓出来的。最后说一个验证规则长期有效的小技巧给每条规则配一个最小复现用例放在测试目录里随插件一起版本管理。每次改规则逻辑先跑一遍用例确认预期报出位置没变。这样即使半年后回来改规则也能快速确认没有破坏原有行为。希望帮到你。本文还有配套的精品资源点击获取
返回列表