☰
simian重复代码检测实战:阈值调参、避坑与流水线门禁
2026/10/27 5:08:29 网站建设 项目流程

简介:面向开发团队、代码质量审查人员及构建工程师的 Simian 代码重复检测工具包,包内版本为 2.3.35,主要用于查找源代码中的重复代码段,帮助识别复制粘贴式编程与冗余实现,降低维护成本并提升可维护性,适合需要引入重复代码检查的 Java 项目及多语言代码库在选型、归档和资料查阅时使用。压缩包共 59 个文件,约 3.43MB,以 38 个 HTML 文档为主体,另含 DLL 运行库、JAR 与 EXE 组件、图像文件,以及 DTD、XSL 等报告与文档定义,并附 PDF、TXT 许可说明和日志归档。已有 1420 人学习或下载。包内站点文档涉及特性介绍、安装说明、更新记录与客户案例,配合 javadoc 参考,可帮助读者了解检测机制、报告结构及多语言支持范围;可执行组件与运行库也为评估和查阅 Simian 提供了较完整的资料基础。

1. 接手一个老业务仓库,最贵的不是没人写文档,是同一段逻辑在四个类里各抄了一遍

改需求时漏改两处,测试环境跑得通,线上对账差三分钱,最后翻出三段几乎一模一样的 if-else。这时候比起拉着人肉 review 一行行扫,我一般先扔一个 simian 进去:一个 jar、一条命令,几分钟给你一份“哪两个文件的哪几行到哪几行长得一样”的清单。simian 是跑在 JVM 上的重复代码检测工具,按行和结构比对源文件,支持 Java、C#、C++、Python 等一批语言,靠阈值控制灵敏度,没有服务端也没有数据库,落地成本极低。它适合两类人:要接手老仓库做重构、摸清历史包袱的,和想把重复率卡进流水线、防止代码越写越像复读机的。下面按我第一次跑通它的顺序写,参数怎么调、输出怎么读、以及几个让我白跑半天的坑。

2. 第一次跑通 simian:从 java -jar 到一份能看的重复块清单

真正拦住新人的往往不是“不会用”,而是第一次跑出来一片空白,或者一屏几千行重复。先把地基打平:环境、命令、输出三件事按顺序过一遍,后面调参才有参照物。

2.1 把 jar 和环境摆到仓库里,别放在个人目录

simian 是单文件 jar,只要机器上有 JRE 就能跑,不用安装、不用起服务。我一般把它放到仓库的tools/simian/下,跟代码一起提交,理由很实在:不同人本机各下一份,版本不一样,同一条命令跑出来的重复块数量能差出两成,评审时谁也说服不了谁。放仓库里,版本跟着分支走,出问题还能回溯。

# 确认 JVM 可用,能打印出版本号就行 java -version # jar 放在仓库固定位置,所有脚本从这里取 ls -lh tools/simian/simian.jar

参数说明上没什么玄学,java -version只用来确认 JRE 在 PATH 里;jar 的路径建议做成变量,后面写 CI 脚本时只改一处。注意别把 jar 塞进src目录,否则它自己会被扫进去,二进制文件按行切分后产生的“重复”纯属噪音。

环境就绪后,第一件事不是找项目代码,而是让它把用法打出来:

# 不带任何参数直接运行,大多数构建会把选项列表打到标准输出或标准错误 java -jar tools/simian/simian.jar 2>&1 | head -40

这一步很关键。simian 的开关在不同构建里有拼写差异,尤其是各类-ignore*开关和报告格式的名字,网上抄来的命令十有八九对不上。以本机打印出来的列表为准,后面所有命令都从这份 usage 里抄,能省掉一半“命令没报错但结果不对”的排查时间。我吃过一次亏:照着一篇旧文章写了-ignoreComments,工具压根不认识这个参数,直接忽略继续跑,结果跟我预期的过滤效果完全相反,白分析了两小时。

2.2 一条最小可用命令,把三个参数拆开看

先别管过滤、格式、退出码,用最小组合跑通一次,拿到“有东西”的正反馈:

# 最小调用:阈值 6,只扫 java 文件,路径给到源码根 java -jar tools/simian/simian.jar \ -threshold=6 \ -includes='**/*.java' \ src/main/java

三个参数各管一件事,混在一起就调不明白。

-threshold=6是灵敏度旋钮,含义是“连续多少个有效行相同才算一处重复”。填 6 意味着只要六行长得一样就报。这个值偏激进,第一次摸底用它可以先看看量级,真拿来当门禁会把一堆 try-catch 包装和三行日志模板全报出来。

-includes='**/*.java'是文件过滤。这里必须加引号,原因在第五章会细说——不加引号,shell 会尝试自己展开这个通配符,很多终端默认不递归,最后传进去的是字面量或者一堆错路径,工具扫了个寂寞。如果不写-includes,它会把目录下所有文本文件都当源文件比对,.properties、.xml、.sql混在一起,配置文件的模板化程度本来就高,报出来的重复能把人淹没。

src/main/java是扫描根。这个路径决定了“重复”的边界:只给一个模块,你看到的是模块内的重复;给整个仓库,能看到跨模块复制。两种都有用,但别在同一次分析里混——跨模块的重复往往意味着公共能力放错了位置,模块内的重复更像是赶工期留下的复制粘贴,处理优先级完全不同。

跑通之后立刻做一件事:只扫单个包,验证行号能对上。

# 缩小范围验证:只扫一个包,输出应该能在编辑器里直接跳过去 java -jar tools/simian/simian.jar \ -threshold=6 \ -includes='**/*.java' \ src/main/java/com/demo/order

把报出来的行号在编辑器里打开看一眼,确认那几行确实是重复的。这一步能提前暴露路径写错、编码不一致、扫描根给高了一层之类的问题,比后面拿着一份错位的报告做重构靠谱得多。

2.3 输出怎么读:Found、between lines 和“有效行”

默认格式下,一个重复块长这样:

Found 14 duplicate lines in the following files: between lines 62 and 75 of src/main/java/com/demo/order/OrderService.java between lines 118 and 131 of src/main/java/com/demo/order/OrderBatchService.java

第一行的数字是这一组重复块涉及的总行数,不是“重复了几次”。下面每一行是一个出现位置,between lines A and B给出起止行。一组里通常有两条位置,代码在三处以上重复就会继续往下列,所以别默认“一组就是两处”。

真正容易读错的是“有效行”这个概念。阈值卡的是有效行,不是物理行:空行、纯注释行,以及单独成行的花括号这类没有语义的行,一般不计入阈值统计。所以你会看到threshold=8报出来的块只有五六行物理行——这不是 bug,是算了两行注释在外面。理解这一点,后面调阈值才不会瞎猜。

摸完格式,先做一次量级统计,别急着看内容:

# 先看总量级:有多少个重复块,比看单个块有用 java -jar tools/simian/simian.jar \ -threshold=8 \ -includes='**/*.java' \ src/main/java > build/dup.txt 2>&1 # 块的个数 grep -c '^Found ' build/dup.txt # 重复行总数,判断这仓库到底是"有点重复"还是"复制粘贴驱动开发" awk '/^Found /{s+=$2} END{print s}' build/dup.txt

参数说明:grep -c数的是块数,awk累加的是行数。这两个数字的比值很有信息量——块数少但行数巨大,说明存在整类复制的历史包袱;块数多但每块就七八行,说明是零散的样板代码,抽公共方法的收益更高。我一般先用这两个数决定要不要往下投入时间,比值不对就换个模块再扫。

3. 把阈值调到能干活:threshold 与忽略开关的取舍

第一次扫出来的报告通常没法直接看,要么全是噪音,要么干净得可疑。调参的本质是在“漏报”和“误报”之间找一条能干活的分界线,而这条线在业务代码、基础设施代码、生成代码里完全不同。

3.1 threshold 从 6 调到 20,报的东西完全不是一回事

阈值不是越高越好,它改变的是“你看到的是什么类型的问题”。

threshold典型命中适合的阶段
6(偏灵敏)三四行的 try-catch 包装、日志模板、简单转换初次摸底,只看趋势和总量
8 到 10一个完整的校验片段、参数拼装、SQL 条件构造日常门禁的常用区间
12 到 15跨类的业务流程复制排重构优先级,噪音明显下降
20 以上整个方法体甚至整个类的复制找历史包袱,量少但每一条都值得动手

我的习惯是先跑 8 拿到基线,再跑 15 看“真正值得动手的有多少”。如果 15 还能报出几十处,说明这仓库的重复不是零星问题,而是有系统性原因——常见的是公共能力没沉淀,或者两套业务分支平行维护。这种情况下逐个抽方法治标不治本,得先跟人聊清楚为什么会走到这一步。

阈值还跟语言特性有关。Java 里一个像样的方法体压缩一下也有十几行,8 到 10 是比较自然的档位;但对样板代码密集的语言,6 就已经很吵了。别死记数字,拿本仓库的历史已知重复去校准:找一段你确定是复制粘贴的代码,看它在哪个阈值下消失,那个值就是你的上限参考。

3.2 忽略开关:抓“改了变量名的复制”最有效,也最容易翻车

simian 有一批-ignore*开关,作用是比对前把某些元素归一化,让“结构相同但名字不同”的代码也能匹配上。这是它最有价值的能力——真正难缠的重复从来不是原样照抄,而是改了变量名、换了类型、调了字段顺序的那种,肉眼看半天看不出问题。

常见的几个方向:

  • 忽略标识符:变量名、方法名归一化。开了之后,两个类里结构相同但命名不同的方法会被判为重复。这是抓“改个名就当自己写过了”的利器。
  • 忽略字面量和数字:字符串、数值归一化。适合配置密集、常量多的代码,也适合那种一个模板套十遍的初始化逻辑。
  • 忽略字符串内容:只比对结构,适合多语言资源处理、报表模板这类代码。

风险也很直接:一开标识符忽略,getter/setter、DTO 转换、异常包装、Builder 这些天然结构雷同的代码会成片报。我见过一次,开了忽略标识符之后报告从 60 块涨到 900 多块,团队里没人愿意看了,工具直接废掉。

比较稳的做法是分两轮:第一轮不开忽略开关,拿到“字面重复”的清单,这部分几乎不用讨论,抽就完了;第二轮再开忽略,专门看结构重复,并且一定要配合排除规则把 DTO、VO、实体类目录挡在外面。两轮报告分开放,别混在一个文件里,否则评审时你说不清哪条是真问题。

# 第一轮:字面重复,噪音最低,结论最硬 java -jar tools/simian/simian.jar \ -threshold=8 -includes='**/*.java' \ src/main/java > build/dup-literal.txt 2>&1 # 第二轮:开标识符忽略,看结构级重复 java -jar tools/simian/simian.jar \ -threshold=8 -includes='**/*.java' -ignoreIdentifiers \ src/main/java > build/dup-structural.txt 2>&1

参数说明:两轮唯一的差别是-ignoreIdentifiers。开关的具体拼写以本机 usage 为准,先跑一次不带参数的调用确认。两份报告分开存,后面做趋势对比时也有基线。

3.3 用包含和排除把生成代码、三方库、测试 fixture 挡在外面

这一步的收益比调阈值大得多。生成代码天然高度重复——ORM 映射、协议桩代码、前端根据接口定义生成的请求封装,这些东西重复是设计使然,报出来纯属浪费注意力。

扫描根给到src/main/java只能挡住一部分,generated、target、build这些目录经常跟源码混在一起,靠路径参数切不干净。我的习惯是路径参数给到源码根,再用排除规则做第二道闸:

# 排除生成目录和第三方代码,报告立刻干净一半 java -jar tools/simian/simian.jar \ -threshold=10 \ -includes='**/*.java' \ -excludes='**/generated/**' \ src/main/java > build/dup-clean.txt 2>&1

参数说明:-excludes接受通配模式,多个模式的分隔方式不同构建可能不一样,先按单个模式跑通,再扩展。**/generated/**匹配任意层级下名为 generated 的目录及其所有内容,这是最常用的一条。

需要挡的清单,我一般固定这几类:生成代码目录、第三方源码拷贝(很多项目会把某个库的源码直接放进来改)、测试资源目录、示例和 demo 目录、历史遗留的old/backup目录。挡掉这些之后,同样的阈值报出来的数量往往能降一半以上,剩下的才是业务代码里真该讨论的部分。

还有一类特殊:注解处理器和模板文件生成的源码,编译后才出现,源目录里看不到。这种要么在生成目录上排除,要么干脆把生成阶段挪到扫描之后。别指望工具能分辨“这是生成的所以不算”,它只看文本。

4. 塞进流水线:退出码、报告格式和一层薄脚本

工具放在本机偶尔跑一次,价值有限;真正能改变团队习惯的是把它挂进流水线,让重复率变成每次提交都能看到的一个数字。但直接挂上去大概率出事,要么永远绿(等于没挂),要么全员红(最后被注释掉)。

4.1 -failOnDuplication 与退出码:让门禁真的能卡住

默认情况下,哪怕报出一千处重复,simian 也会正常退出,退出码是 0。这在本地分析时是好事,在流水线里就是灾难——构建永远成功,谁也不会去看报告。打开失败开关后,一旦发现超过阈值的重复就以非零码退出,构建才会真的红。

# 发现重复就返回非零退出码,流水线上才会红 java -jar tools/simian/simian.jar \ -threshold=10 -includes='**/*.java' \ -failOnDuplication=true \ src/main/java > build/dup.txt 2>&1 echo "退出码: $?"

参数说明:-failOnDuplication的取值和拼写各构建略有差异,先跑 usage 确认。这里有个容易被忽略的区分:非零退出码既可能是“发现了重复”,也可能是“jar 没找到、路径写错、内存不够”。脚本里必须能把这两种情况分开,否则构建红了没人知道是代码问题还是环境问题。

区分方法很土但有效:看报告文件有没有内容。运行失败时输出文件通常是空的或者只有一行错误。这个判断后面写进脚本里。

4.2 输出格式:给人看的和给脚本吃的要分开

默认的纯文本格式对人友好,但要用脚本处理就得写一堆字符串截取。simian 支持切换输出格式,其中有一类是结构化格式,字段清晰,适合喂给后续的统计和看板;还有一类是给编辑器做跳转用的,输出里带文件路径和行号,配合编辑器能直接点开。

我的用法是双份:纯文本进构建日志,人出了问题直接翻;结构化格式落成文件,作为构建产物存起来,跑一段时间后能画出重复率曲线。曲线比单次数字有说服力得多——某次提交后重复块数量陡增,八成是有人整段复制了一个类,这种信号比 code review 里的感觉靠谱。

# 人看的版本进日志 java -jar tools/simian/simian.jar -threshold=10 -includes='**/*.java' \ -formatter=plain src/main/java > build/dup.txt 2>&1 # 机器处理的版本单独落盘,长期存档 java -jar tools/simian/simian.jar -threshold=10 -includes='**/*.java' \ -formatter=xml src/main/java > build/dup.xml 2>&1

参数说明:-formatter的可选值以 usage 为准,先跑一次确认名字。别在同一次调用里既想要人看的又想要机器读的——两次调用的开销在小仓库上可以忽略,在大仓库上也就多几十秒,比写解析器便宜。

注意一个细节:结构化格式的字段名同样可能随构建变化。如果你打算长期存这份数据做趋势,先在脚本里做一层字段映射,别让下游看板直接依赖原始字段名,否则升个版本全套报表就废了。

4.3 一层薄脚本:把退出码、报告和日志捏在一起

直接用一行命令挂流水线,早晚会碰到“构建红了但不知道为啥”。包一层脚本,把该判断的都判断掉:

#!/usr/bin/env bash # check-duplicates.sh:流水线里调用,参数是扫描根目录 set -uo pipefail JAR="${SIMIAN_JAR:-tools/simian/simian.jar}" THRESHOLD="${SIMIAN_THRESHOLD:-10}" ROOT="${1:-src/main/java}" OUT_DIR="build/simian" mkdir -p "$OUT_DIR" java -jar "$JAR" \ -threshold="$THRESHOLD" \ -includes='**/*.java' \ -excludes='**/generated/**' \ -formatter=plain \ -failOnDuplication=true \ "$ROOT" > "$OUT_DIR/duplicates.txt" 2> "$OUT_DIR/error.log" rc=$? # 非零退出且报告为空,说明是运行失败,不是发现重复 if [ "$rc" -ne 0 ] && [ ! -s "$OUT_DIR/duplicates.txt" ]; then echo "[simian] 运行失败,查看 $OUT_DIR/error.log" >&2 exit 2 fi blocks=$(grep -c '^Found ' "$OUT_DIR/duplicates.txt" || true) echo "[simian] 阈值 ${THRESHOLD},重复块 ${blocks} 个,报告 $OUT_DIR/duplicates.txt" # 0 表示干净,1 表示有重复,2 表示工具本身出错 exit "$rc"

几个参数的作用:SIMIAN_JAR和SIMIAN_THRESHOLD走环境变量,不同分支可以给不同阈值,不用改脚本;ROOT允许只扫某个模块,大仓库分模块并行跑比一次全量快得多;退出码 0/1/2 三态分开,流水线配置里就能针对不同情况做不同处理——1 是业务问题,2 是环境问题,报警发给不同的人。

set -uo pipefail里没加-e,是有意的:这个脚本需要拿到 java 的退出码自己判断,加了-e会在 java 失败时直接退出,后面的判断逻辑永远走不到。这是我在别的脚本里踩过的坑,抄过来的时候记得一起看。

还有一个实践细节:门禁刚上线时不要直接卡人。先跑两周只报告不失败,把基线记下来,看看波动范围,再定阈值。上来就红,团队的应对方式一定是找你把检查注释掉,而不是去改代码。

5. 避坑与排查:输出为空、误报成片和流水线卡不住的五个原因

下面五条都是真实撞过的,每条按现象、原因、解决写,遇到类似症状可以直接对号入座。

5.1 报告一片空白:不是代码干净,是文件根本没被扫到

现象:命令没报错,退出码 0,输出文件里一个Found都没有,但你知道这个模块肯定有复制粘贴。

原因:八成是-includes的通配符被 shell 提前展开了。不加引号时,**/*.java在很多终端里不会递归展开,或者被展开成当前目录下的有限几个文件,甚至原样传进去当字面量。工具收到的是一堆错误路径,扫不到文件,自然一声不吭。

解决:模式一律加单引号,让它原样传给 jar。然后做一次最小验证——把扫描根指到一个你手上有三个 java 文件的小目录,确认能扫出东西。如果加了引号仍然为空,检查大小写和层级:-includes='*.java'和-includes='**/*.java'在不同路径下匹配范围不一样,从宽到窄试一遍,能定位到问题在哪。

5.2 一开忽略标识符就报出上千条:误报淹没了真问题

现象:加上忽略标识符的开关之后,重复块从几十条涨到上千条,翻两页全是 getter、setter、构造函数和对象转换。

原因:忽略标识符之后,结构相同的代码就等同了。实体类、DTO、VO、Builder 这些东西本身就是批量生成的模板,结构必然雷同,判定重复在数学上没错,但没有任何重构价值。

解决:三步走。第一,把这些目录整体排除掉,别让它们进比对池。第二,阈值往上抬一档,8 抬到 12,能刷掉大部分短样板。第三,如果确实要保留结构级比对,把它做成一份独立报告,不进流水线门禁,只作为重构季的参考资料。两套结果混在一起看,最后一定是没人看。

5.3 流水线永远绿:退出码被管道吃掉了

现象:明明报了重复,构建照样成功,翻日志能看到Found 30 duplicate lines,但检查步骤就是绿的。

原因:命令写成了java -jar ... | tee build/dup.txt。管道里$?拿到的是tee的退出码,不是 java 的,除非开了pipefail。另一个常见原因是应用了失败开关但值写成了false,或者拼写有误,工具不认这个参数,静默忽略继续跑。

解决:先开set -o pipefail,再把输出重定向到文件而不是走管道,最后单独判断退出码。另外一定要在本机手动跑一次命令,把echo $?打出来看一眼,确认发现重复时确实是非零。指望在流水线上验证这件事,反馈周期太长,一次调试十分钟,很容易放弃。

5.4 报告里全是第三方代码和构建产物:扫描根给高了

现象:打开报告,前几十条都在vendor/、node_modules/、target/、build/目录下,重复行数上万,跟自己写的代码一行关系都没有。

原因:扫描根直接给了仓库根目录。第三方库和构建产物里本来就有大量相似代码,尤其是一些脚手架生成的代码,复制比例极高。

解决:扫描根压到源码目录,再用排除规则补第二道闸。排除清单建议写成脚本里的常量,而不是每次手敲,否则总有漏的时候。可以顺手把-includes也收紧,只认业务代码的扩展名。改完之后重复行数往往会掉到原来的十分之一,这时候看到的才是自己的问题。

5.5 报告刚出来代码已经变了:行号对不上,还拿旧报告改代码

现象:照着报告里的行号去编辑器里找,发现那里的代码完全不同,或者文件已经合并、重命名。

原因:报告是某个时间点的快照,代码是活的。分支合并、格式化工具跑一遍、文件挪个位置,行号就全变了。拿着一份过期的报告做重构,比不看报告还危险。

解决:报告必须跟 commit 哈希一起存档,文件名里带上短哈希和时间戳。真要动手改,重新跑一次再改,别复用昨天的结果。重构窗口比较长的时候,我一般会把当次报告的重复块挑出前十条,抄成一个清单,逐条改、逐条划掉,改完再跑一遍对比数量,形成闭环。

6. 把重复块收敛成可执行的清单:一份 awk 汇总加三条分类判断

原始报告是按块罗列的,一屏一屏往下翻很难做决策。我一般先把它压成一张排序表,看清哪些文件对之间的重复最重:

# 把纯文本报告压成“重复行数 + 文件对”的排序表 awk ' /^Found / { n = $2; pair = ""; hit = 0; next } /between lines/ { i = index($0, " of ") f = substr($0, i + 4) gsub(/[ \t]+$/, "", f) pair = (hit == 0) ? f : pair " <-> " f hit++ if (hit == 2) { printf "%4d\t%s\n", n, pair; hit = 0 } } ' build/simian/duplicates.txt | sort -rn | head -20

逻辑说明:/^Found /遇到新块时把行数存进n并重置状态;/between lines/从of后面截文件名,第二处出现时把这一对打出来。参数上没什么可调的,head -20决定看多少条,我一般先看前二十。

要清楚它的局限:一组重复如果涉及三处以上,第三处会被丢掉,只保留前两个文件名。做决策够用,别拿它当精确统计。

拿到这张表,真正花时间的是分类。我按三条判断走:同一个文件内部的重复,说明这个类职责太杂,先拆类再抽方法,优先级最高;跨模块、跨服务的重复,往往意味着公共能力放错了层,得先定归属再动手,别在错误的模块里抽公共方法,抽完两边都要依赖它,耦合更糟;主代码和测试代码之间的重复,基本可以忽略,重复的测试数据构造反而比强行抽象好维护。

排序之后别一次改十处。改一处,重跑一次,看着数字往下掉,这个过程本身就能说服团队继续投入——从 63 块降到 40 块,比任何口头论证都有力。

从那以后我每次接手老仓库,第一件事都是把 simian 跑一遍,把报告和 commit 哈希一起存进docs/dup/目录,改完再跑一次对比。跳过这一步直接开工,后面一定会为同一个逻辑改了两处漏了第三处返工,血泪经验。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询