简介:代码重复检测工具Simian的完整发布包与配套文档,专为Java、C#、C++及JavaScript等项目的开发者和质量保障人员准备,用于排查重复代码块、降低复制粘贴带来的维护负担。压缩包以gz格式打包,共59个文件,大小3.43MB,包含可直接运行的JAR和EXE程序、依赖的DLL库、PDF与TXT格式的许可证说明、HTML格式的帮助手册、CSS样式表、PNG和GIF界面素材,以及DTD和XSL报告格式定义,能够满足本地安装、命令行调用和报告解析等多类使用需求。目前已有1416人下载学习。借助这份资料,读者可以快速启动Simian的重复扫描,通过配置文件设定检测灵敏度与忽略规则,并将报告集成到Maven或Gradle构建流程中,作为持续集成质量门禁使用;同时,随包提供的官方文档和示例页面也能帮助新人理解各项参数含义,减少重复代码的引入。
1. simian(代码重复检测工具)在查什么:被复制三遍的代码,改一个 bug 要命
接手旧项目时最怕的不是看不懂,而是在三个不相关的文件里看到几乎一样的 50 行逻辑。改一处 bug,另外两处得跟着改,漏一处就是线上事故。代码重复是最隐蔽的技术债,评审时没人替你指出来,但它每天在消耗维护成本。simian(代码重复检测工具)就是专门做这件事的:扫描源代码,把超过指定长度的重复代码块找出来,输出报告,告诉你重复集中在哪个文件、哪几行。它解决的不只是"谁抄了谁"的问题,而是给重构提供一个可量化的起点。适合接手老项目、准备做结构性重构、以及想在 CI 里拦住新增重复代码的人。
2. simian 怎么找重复:从 Token 序列到重复块判定
2.1 为什么是 simian,而不是 diff 或 grep
先想清楚一个问题:代码重复为什么难发现?因为重复往往不是逐字相同的。A 同学在订单模块写了一轮校验逻辑,B 同学在支付模块复制过去后把变量名从 orderId 改成 payId,加了一行日志,缩进也可能不同。这时候用 grep 搜同一个字符串搜不到,用 diff 比对两个文件又会因为上下文不同而产生大量无关差异。人工评审更不可能把每个文件拼在一起逐行比对。simian 这类工具的做法是先不碰文本,而是把源码拆成语义上的最小单位 Token,再在 Token 序列里找重复子序列。变量名不同、字符串不同、空行缩进不同,只要结构骨架一样,它就能识别出来。
不用 AST 级别的克隆检测工具,是因为它们的集成成本高。很多静态分析平台具备克隆代码检测能力,但需要配置规则、起服务、维护插件,对一个刚开始重视重复问题的仓库来说太重了。simian 是单 jar 包、命令行工具,一条命令出报告,没有一堆依赖。它牺牲了 AST 级别的语义理解,换来的是极低的接入成本。如果你的目标只是"先摸清仓库里哪些地方在复制粘贴",它是性价比最高的切入点。
2.2 Token 化与规范化:三个步骤决定检测结果
simian 的核心流程分三步,弄清这三步,后面调参就不会靠猜。
第一步是词法拆解。simian 把每个源文件的内容切成 Token 流,不同语言用各自的词法规则切。对 Java 来说,public、class、void这类关键字是一个 Token,标识符是一个 Token,数字字面量是 Token,括号分号也各算一个 Token。空白、换行、注释在默认情况下不进入 Token 流,所以缩进风格不同、注释写得多或少,不会影响重复判定。这一步让"视觉上不一样"的代码在 Token 层面变得可比。
第二步是可选规范化。simian 提供几个开关,让你决定哪些 Token 差异可以被忽略。最常用的三个:-ignoreStrings忽略字符串字面量内容,比如"订单创建成功"和"支付创建成功"会被当成同一个 Token;-ignoreCharacters作用类似,针对单个字符;-ignoreVariableNames忽略变量名差异,orderId 和 payId 在比较时视为等价。注意这三个开关是独立的,默认都不开。实际项目里我至少会打开-ignoreVariableNames,否则复制代码时把变量名一改,工具就认为"不重复",检测就失去了意义。
第三步是滑动窗口匹配。simian 会设定一个阈值-threshold,表示"至少多少行代码才算重复"。它把所有 Token 流放在一起,在流上开一个候选窗口,向后搜索长度达到阈值的相同 Token 子序列,再把这个子序列尽量向两端扩展,直到找不到更多相同 Token 为止。扩展后的完整区间就是一个重复块。所以 threshold 越小,找到的候选越多,误报也越多;threshold 越大,查出来的是整段整块的复制,但小段重复会漏掉。
2.3 输出模型:重复组、文件位置与行长区间
理解 simian 的报告,要先理解它的组织模型。一个重复组(set)里包含多个代码位置,每个位置(block)有文件名和起始行号、结束行号。同一个重复块出现在三个文件里,你会看到三个 block,它们属于同一个 set。严谨的说法是"这组位置彼此两两重复",而不是"某文件重复了"。
text 格式下,输出大致长这样:
Found a duplicate of 12 lines in the following files: src/main/java/com/example/order/OrderValidator.java Lines: 42-53 src/main/java/com/example/pay/PayValidator.java Lines: 87-98行号区间是闭区间,也就是包含第 42 行和第 53 行。我在处理报告时更倾向于直接生成 XML,因为 XML 里 block 会带sourceFile、startLineNumber、endLineNumber属性,方便写脚本做增量对比和统计。text 格式适合人肉快速确认,XML 适合机器处理,这一点前后不要混。
牢记这个模型还有一个用处:当你看到某个文件出现在多个 set 里,说明它是重复源头的重灾区;当一个 set 里有 4 个 block,说明这段逻辑被复制了 4 份,重构时优先处理,收益最大。
3. 本地跑通 simian:最小命令与四个必调参数
3.1 环境准备:拿到 jar 包并确认可用
simian 是 Java 工具,机器上要有 Java 运行时。获取方式一般是两个:从项目发布渠道下载 jar 包,或者由团队统一放在制品库里。我建议把固定版本的 jar 提交到团队内部仓库,不要每个人各自下载,否则版本不一致会导致报告对不上,这在 CI 排查时会很痛苦。
拿到 jar 包后先验证环境,不要直接扫大项目:
java -version java -jar simian.jar -?第一条命令确认 JRE 版本够用,第二条确认 jar 包完整,并且会打印当前支持的参数列表。不同版本的参数措辞有细微差异,以实际帮助信息为准,不要背参数名,要会看帮助。
3.2 最小命令:扫一个 Java 目录
假设项目是常见的 Maven 结构,源码都在src/main/java下。最简单的检测命令如下:
java -jar simian.jar -includes="**/*.java" -threshold=6 .命令末尾的.表示从当前目录开始递归搜索,-includes限定只收集 Java 文件。-threshold=6的意思是少于 6 行的重复不考虑。为什么不设成更小?因为三五行的小重复大概率是框架模板,比如一个getter、一个空方法、一个 try-catch 骨架,这些噪声会淹没真正有重构价值的重复块。我的习惯是先设 6 跑一遍看整体规模,再根据报告决定往上还是往下调。
第一次跑完的预期是终端打印扫描文件数、重复组数和耗时。如果几秒就跑完,大概率是-includes没匹配上文件,一定要检查输出里的扫描文件数,确认不是 0。
3.3 输出格式:从 text 切到 XML
默认输出是 text,不加参数时 simian 会把重复块逐条打印在终端。文件一多就刷屏,而且不方便二次处理。我建议一上来就用 XML 输出,保留原始报告:
java -jar simian.jar -includes="**/*.java" -threshold=6 -reportFormat=xml > dup.xml-reportFormat=xml把报告写进标准输出,再用重定向落到dup.xml。如果当前版本支持-outputFile参数,直接用参数更省事。XML 报告里每个 block 的行号、文件路径都结构化保存,后面做基线对比、写统计脚本都靠它。
3.4 四个必调参数:先抄这一张表
参数不需要全背,真正影响结果的是下面四个。
| 参数 | 作用 | 我常用的值 |
|---|---|---|
-threshold | 最小重复行数 | 6 ~ 10 |
-ignoreVariableNames | 忽略变量名差异 | 开 |
-ignoreStrings | 忽略字符串字面量差异 | 开 |
-includes/-excludes | 文件过滤 | includes 按语言写,excludes 排除生成代码 |
-ignoreStrings很多人不敢开,担心把两个文案完全不同但结构一样的提示框当成重复。这个担心是多余的:重复检测的价值在于找结构复制,字符串内容不同是后续人工确认的事。关掉它,报告里全是"订单创建成功"和"支付创建成功"的一字之差,反而没法看。
-excludes必须把生成代码排除。常见的是target、build、以及代码生成器输出的 Java 文件。不排除的话,生成代码本身就高度重复,会占满报告前几十页,业务里的真重复被彻底淹没。我一般这样写:
java -jar simian.jar \ -includes="**/*.java" \ -excludes="**/target/**;**/build/**;**/generated/**" \ -threshold=6 \ -ignoreVariableNames=true \ -ignoreStrings=true \ -reportFormat=xml > dup.xml这里-excludes的多个模式用分号分隔,常见做法是把**/target/**这类中间层目录排除掉,注意通配符要保留两层**,否则可能在部分平台上匹配不到。
调参的过程不复杂。第一次扫描只看两个数字:报告总页数和 Top 重复文件。如果总页数超过几十页,说明阈值太低;如果报告短得离谱,先确认扫描文件数不为 0,再确认生成代码有没有混进来。连续调两轮,找到一个"报告能读完、且每一条都值得人工确认"的阈值,这就是你这个项目的基准配置。
4. 把 simian 接进 CI:让新增重复在合并前被拦住
4.1 CI 任务的最小形态
本地跑通只是第一步,真正有价值的是让每次构建自动检查新增重复。常见做法是在流水线的静态检查阶段加一个任务:下载 simian jar,执行全量扫描,把 XML 报告留档。最粗暴的门禁是加-failOnDuplication=true:一旦发现超过阈值的重复块,构建直接失败。但第一次接入时不要直接开启失败策略,先跑两周收集数据,摸清报告的基准体量,再决定阈值和例外名单。
下面是 CI 脚本里最常见的写法:
set -e java -jar simian.jar \ -includes="**/*.java" \ -excludes="**/generated/**;**/target/**" \ -threshold=8 \ -ignoreVariableNames=true \ -ignoreStrings=true \ -reportFormat=xml \ -failOnDuplication=false \ -outputFile=dup.xml说明几点。set -e保证 simian 异常退出时脚本立刻失败,排错时能看到是哪一步挂了。-failOnDuplication=false在前两周一直保持这个值,只产出报告不改节奏。-outputFile在有这个参数的版本里直接用,没有就重定向。CI 里最关键的还有一点:jar 包的版本必须锁死,用制品库里固定路径的产物,不要每次动态拉最新版,否则报告口径一直在漂移。
4.2 增量检测:只拦新增重复,不翻历史旧账
老仓库里积累了几年的重复代码一搜一大堆,直接 fail 会把整个队伍卡死。真正该拦的是"这次改动新增的重复"。增量检测常见做两种,我分别说清楚优缺点。
第一种是基线对比。在主干上保存一份报告作为数据基线,每次 MR 跑完本次扫描后,用脚本把本次结果和基线做差,新增的重复组才报错。这个方案稳定,不需要 simian 提供额外功能,只需要写一个解析 XML 的脚本。它的缺点是维护成本高,每次基线更新都要人工确认它是健康的。
第二种是只扫变更文件。从 git 里取本次变更的 Java 文件,把它们单独放进临时目录,或者直接通过-includes传进去,simian 只在这批文件里找重复。局限很明显:它只能发现"本次文件之间"或"本次文件内部"的重复,发现不了"新文件和老文件重复"。后者恰恰是复制粘贴最常见的来源。所以我从不单独用方案二,最多在方案一的报告里批量复核时辅助一下。
这里给一个解析 XML 的 Python 片段,模拟项目 X 里我用来做基线对比的脚本,你可以直接改路径用:
import xml.etree.ElementTree as ET tree = ET.parse("dup.xml") sets = tree.getroot().findall("set") for s in sets: blocks = s.findall("block") if len(blocks) < 2: continue locs = " | ".join( f"{b.get('sourceFile')}:{b.get('startLineNumber')}-{b.get('endLineNumber')}" for b in blocks ) print(locs)注意,不同版本 simian 的 XML 节点属性名可能略有不同,sourceFile、startLineNumber、endLineNumber是常见命名。跑一次后用less dup.xml看一下结构,确认属性名再批量跑,否则脚本会静默解析出一堆 None。
4.3 门禁阈值怎么定:用历史报告的中位数说话
门禁阈值不能拍脑袋。常见做法是看历史报告里的行长分布:把老报告里所有重复组的行长取出来算分布,取 P50 或 P60 作为阈值。如果历史数据里 50% 的重复组在 8 行以下,说明小重复是常态,那就把-threshold设为 8,只拦超过 8 行的大段重复。
第二步是把常见误报模式加进-excludes。这类误报很有规律:自动生成的 POJO、接口的默认实现、日志模板块。把这些路径提前排掉,报告里剩下的重复质量会显著提升。
第三步是允许部分存量文件豁免。对重复非常严重的文件,加一个"最差文件名单",这些文件允许继续存在,但禁止新增重复组。实现方式是在报告解析脚本里维护一个豁免文件列表,属于这些文件的 block 不参与 fail 判断。
所有这三个选择,都要写进 CI 脚本的注释里,写清楚为什么这么设、基线数据是哪一天采集的。否则半年后没人敢动这条规则,它就成了黑匣子,最后要么被绕过,要么被整个关掉。
5. simian 常见问题与排查:5 个翻车记录
5.1 历史代码全是重复,报告几千页,根本没法看
现象:第一次在旧项目上跑 simian,报告文件大得编辑器都打不开,Group 数量上万。
原因:两个问题叠加。第一是-threshold设得太低,三四行的模板代码全被识别成重复;第二是生成代码没有排除,target、build里的自动生成 Java 文件本身就高度雷同,占据了报告大半篇幅。
解决:先把-threshold提到 10,跑一遍看整体规模;同时把**/generated/**、**/target/**等路径写进-excludes。仍然太多就往 15 提,直到报告精简到两百页以内。这一步的目的不是消灭重复,而是让报告可读。
5.2 开了-ignoreVariableNames,重复组数量一点没降
现象:参数明明加了-ignoreVariableNames=true,报告里的重复组数量和之前差不多。
原因:最常见的情况是变量名确实被忽略了,但字符串内容差异没有忽略。两个结构相同的代码块,一个写"订单创建成功",一个写"支付创建成功",在字符串 Token 上就被判定为不同。另一个原因是项目里重复的是字段名或方法名,这些不是局部变量,不在该参数的覆盖范围内。
解决:同时打开-ignoreStrings=true组合使用。如果重复组的核心差异确实来自标识符命名,确认这些标识符是局部变量还是字段名。方法是拿一条具体报告做试验,改一组参数跑一次,对比同一段代码是否消失,比闷头猜快得多。
5.3 Windows 上能跑通,Linux CI 上扫描文件数为 0
现象:本地 Windows cmd 里执行命令没问题,同样的脚本放到 Linux 流水线上,输出显示扫描到 0 个文件。
原因:shell 对通配符的处理不同。在 Windows cmd 下,**/*.java由 simian 自己展开;在 Linux 下,如果-includes的值没有加引号,bash 会先尝试展开,找不到匹配就把空值传给 simian,最终匹配不到任何文件。
解决:在脚本里始终给-includes和-excludes的值加上引号,用单引号更保险,避免 shell 参与展开。例如:
java -jar simian.jar '-includes=**/*.java' '-threshold=6' .这个写法在两个平台上都稳。另外要注意路径分隔符,Windows 下反斜杠在双引号里会有转义问题,能正斜杠就正斜杠。
5.4 扫描大仓库超时或直接 OOM
现象:几千个文件的大仓库,simian 跑到一半就报OutOfMemoryError,或者把 CI 机器的 CPU 打满。
原因:simian 会把所有 Token 流放到内存里做窗口匹配,仓库越大内存占得越厉害。另外如果.xml、.json、.sql这类资源也被-includes兜了进来,文本量大,Tokenizer 全按源码处理,内存直接翻倍。
解决:第一,在-excludes里明确排除资源文件和非源码文件;第二,把仓库按模块拆开,一个模块一个扫描任务,报告分开出;第三,给 JVM 加大堆内存,命令开头加-Xmx4g。顺序不要反,先确认没混入资源文件,再考虑加内存,否则加 8G 也白搭。
5.5 同一份代码,不同机器跑出的报告不一样
现象:开发者在本地扫出重复组 30 组,CI 上扫出 28 组,两边对比半天,数量对不上。
原因:环境差异。常见的有三处:simian 的 jar 包版本不同,新版本词法规则有调整;Java 运行时版本不同,影响了文件读取的编码和字符处理方式;-includes里用的是相对路径,不同机器的工作目录不一致,扫描范围天然不同。
解决:把 jar 包固定到制品库的同一路径,加入校验;CI 里统一 JDK 镜像版本,本地开发也尽量用相同版本;脚本里写死基线目录的绝对路径,不依赖当前工作目录。排查顺序是先比版本,再比工作目录,最后比 JDK,命中率最高。
6. 进阶:把 simian 报告换算成重构收益
报告能看懂之后,下一步是让数据指导排期。我有个习惯:每次重构迭代前后各跑一次全量扫描,把重复行数的变化记录成表格,用趋势判断重构有没有真正生效。
统计重复行数时要注意一个陷阱:同一个重复组里有三个副本,逐 block 累加会把该段代码算三遍。下面的脚本保留的是粗粒度总和,理解它就好:
import xml.etree.ElementTree as ET tree = ET.parse("dup.xml") total = 0 for s in tree.getroot().findall("set"): blocks = s.findall("block") if len(blocks) < 2: continue for b in blocks: start = int(b.get("startLineNumber")) end = int(b.get("endLineNumber")) total += max(0, end - start + 1) print("raw total lines involved in duplicates:", total)实际可精简的行数大概等于total * (副本数 - 1) / 副本数,因为你保留一份就够了。这个数字可以直接放进迭代计划里,跟产品说"这一轮重构预计减少 800 行重复逻辑",比空谈代码质量有说服力得多。
优先级排序我一般不看总行数,只看跨模块重复。同一个模块内部的重复,通常是提取一个函数就能解决;跨模块的重复,往往意味着职责边界没划清,比如两个服务各写了一套权限校验。把dup.xml里sourceFile的前两级路径取出来做分组,同一个 set 里出现两个不同模块路径的,标记为高优先级。
我现在的习惯是固定每周五下午跑一次全量扫描,把新增重复组的数量记进表格,连续一个月下坡,就说明重构真的在生效;连续反复,就说明约定没落地。希望帮到你。
本文还有配套的精品资源,点击获取