简介:Simian 是一款经典的代码重复检测工具,面向 Java、C#、C/C++、JavaScript 等多语言项目,用于在持续集成流程中发现冗余代码与复制粘贴片段,帮助开发者降低维护成本。这份资源收录了 Simian 2.3.35 的完整发行组件,共 59 个文件,压缩包仅 3.43MB;其中包含可直接运行的 jar、exe 程序,配套的 simian.dtd 与 simian.xsl 管理报告格式,另有 JDK 1.5 安装日志、官方文档(更新日志、特性、安装指南、客户案例等)以及大量 javadoc HTML 页面,适合需要研究旧版工具行为或自行集成检测流程的开发者参考。资源包文件类型以 38 个 HTML、5 个 DLL、5 个 GIF 为主,还有 PDF 许可协议与 CSS、PNG 等辅助素材,整体结构完整。目前已有 1416 人下载学习,对于想快速上手 Simian、了解其报告输出和持续集成用法的读者,这是一份小而全的离线资料包。
1. simian(代码重复检测工具):先看它如何抓住“改头换面”的重复代码
接手某个遗留系统时,我在评审单里看到两段 300 多行的逻辑几乎一模一样,只有变量名不同、缩进不一样,肉眼排查花了大半天。simian(代码重复检测工具)就是为这类场景准备的:它不按文本直接比对,而是把源码拆成 token 流,在文件之间找“结构重复”的片段,再按阈值输出重复块位置,缩进、换行、注释这些表面差异基本不影响结论。
它是经典的代码判重工具,支持 Java、C#、JavaScript、TypeScript 等主流语言,参数既可以用命令行传,也可以写进配置文件统一管理,输出支持文本、XML、CSV 三种格式。老项目用它量化重复债务,新项目放进持续集成当质量门禁,都很顺手。适合被复制粘贴坑过的开发者,也适合想给团队立一套代码规矩的技术负责人。
2. simian 的判重原理与工具选型:为什么它比文本 diff 更可靠
文本 diff 只能告诉你“这两行长得像”,遇到改过缩进、拆行、换了变量名的重复代码就会漏掉。simian 的做法是先做词法切分,把源码变成一串 token,再在 token 序列里找重复片段,这一层抽象让它能识别“结构层”的复制粘贴,而不是受排版干扰。
2.1 从字符 diff 到 token 指纹:simian 如何识别“改过缩进”的重复
两张代码表面完全不同,逻辑结构却一样,是复制粘贴最常见的形态。看下面这对文件:
// A.java public int add(int x, int y) { return x + y; } // B.java public int sum(int a, int b) { return a + b; }如果拿文本 diff 去比对,两个文件可能没有任何一行完全相同;但 simian 会把各自切成 token 序列:public、int、IDENTIFIER、(、IDENTIFIER、,……再加上若干字面量 token。后半段return x + y;和return a + b;在 token 类型上是同构的,是否判重取决于标识符和字面量规则怎么开。常见做法是把“忽略标识符”“忽略字符串值”“忽略数字”这些开关按团队规范打开,这样只要结构一致就会被点名。
这里的关键点是 threshold 的单位是 token 数,不是行数。连续出现多少个 token 的重复片段才算问题,由你定,默认经验值在 6 到 10 之间。阈值越低误报越多,调太高又会漏掉短小的重复块,具体数值后面单独说。
正因为判断发生在 token 层,注释、空行、空格、换行全都不参与匹配,这两段代码即使一个从 80 列格式改成炫酷菱形缩进,结果也相同。这是 simian 和grep、文本 diff 类方案的本质区别,也是它这么多年还在被用的原因。
2.2 和常见候选工具对比:单 jar 和跨语言是它最大的优势
谈到重复代码检测,通常绕不开几个选择,我把使用体验放在一起看:
| 候选 | 运行方式 | 多语言覆盖 | 输出与门禁友好度 |
|---|---|---|---|
| simian(本资源) | 单个 Java jar | 常见企业语言大多覆盖 | text/xml/csv,门禁场景可直接读取退出码 |
| 某 Java 静态分析平台的 CPD 组件 | 和该平台一起运行 | 覆盖广但平台较重 | 报告丰富、图形化好,跑全量时内存占用偏高 |
| 某个 Node 生态的重复检测工具 | Node 命令行 | 前端技术栈为主 | JSON/HTML 展示漂亮,增量扫描脚本多 |
我一般会这样选:如果团队原本就在用那套 Java 静态分析平台,CPD 能顺手拿到重复报表;如果项目是纯前端或 Node 生态,那个 Node 工具的可视化更愉悦。但放到企业内部后端项目里,仓库里既有 Java 又有 JavaScript,还可能混着 SQL、XML 模板,simian 的优势就出来了:一个 jar 包、一条命令行,CI 里加步骤没有额外环境依赖。这也是我把它当第一选择的原因,而不是因为它功能列表比谁多。
2.3 安装与首次运行:拿 jar 包跑通第一条命令
simian 的产物是一个可执行 jar,前提是本机已装好 Java 运行时。下载后把 jar 放在项目根目录的tools/子目录里,首次扫描建议用最朴素的一条命令验证环境:
java -jar tools/simian.jar -includes="**/*.java" -threshold=6 -reportFormat=text这里-includes="**/*.java"表示只扫描所有 Java 源文件,通配符走 ant 风格,能递归匹配子目录;-threshold=6是连续 6 个 token 重复就报告;-reportFormat=text让人直接读终端输出。我给.java套了双引号,防止 shell 先把通配符展开成一层目录名,这个习惯建议一直保留。
跑通之后你会看到两类输出:有重复块时会列出源文件、起始行和结束位置;没有重复时静默通过,只打印统计摘要。如果项目是第一次做这种扫描,6 这个阈值通常会报出一批短片段,先别急着调低,看两三条报告确认文件路径是否合理、排除规则对不对,再进入下一步配置。
3. 命令行参数与输出格式:把调优后的规则固化成“门禁”
上一章的命令能跑通但不够用,真正的项目需要把 exclude 目录、语言指定、报告文件都定下来。这章把参数逐个拆开,最后落成一个工程内通用的配置。
3.1 核心选项逐个拆解:includes、excludes、threshold、language
常用参数其实就七个,记住它们就能覆盖大部分场景:
| 选项 | 作用 | 常用值参考 |
|---|---|---|
-includes | 指定参与扫描的文件模式 | src/**,test/** |
-excludes | 排除目录或文件 | **/generated/**,**/target/** |
-threshold | 连续重复 token 数下限 | 6 到 10,历史老代码可以先 10 |
-language | 强制指定语言解析规则 | java、csharp、javascript |
-failOnDuplicates | 发现重复时返回非零退出码 | true |
-reportFormat | 输出格式 | text/xml/csv |
-reportFile | 报告写入路径 | build/reports/simian/simian.xml |
一个生产环境可用的扫描命令大概是这个样子:
java -jar tools/simian.jar \ -includes="src/**,test/**" \ -excludes="**/generated/**,**/target/**" \ -threshold=8 \ -language=java \ -failOnDuplicates=true \ -reportFormat=xml \ -reportFile=build/reports/simian/simian.xml逐段解释:src/**,test/**把业务代码和测试代码都纳入扫描,测试里也有大量复制粘贴,不能漏;excludes里generated和target是 MyBatis 生成类、构建插件的产物目录,不排除会带来海量假重复;threshold=8是我在 Java 项目里常用的起始值,比 6 少一半误报,又不会放过有明显复制痕迹的段落;language=java避免个别后缀奇怪的源文件被当成别的语言解析;failOnDuplicates=true让有重复时进程返回非零码,CI 里才能做门禁。
注意:老项目首次全量扫描,阈值建议从 10 起跳。一次跑出来几千个重复块对团队没有正向激励,先拿到一个能看的基线,再逐步下调到 8、6,看重复数增长曲线,找到一个“开始明显变陡”的位置作为阈值。
3.2 输出格式:文本、XML 与 CSV 各管一段
text 格式适合人眼快速定位,CSV 方便脚本做聚合,XML 适合给持续集成解析。实际项目里我习惯一次性输出 XML,再由脚本转摘要。先看 CSV 命令怎么出:
java -jar simian.jar -includes="**/*.java" -threshold=8 -reportFormat=csv -reportFile=simian.csv拿到 CSV 后可以用 Python 快速统计重复块最多的文件,定位“重灾区”:
import csv from collections import Counter counter = Counter() with open("simian.csv", newline="", encoding="utf-8") as f: for row in csv.reader(f): # 第一列一般是源文件路径,首行可能有表头,先跳过 if not row or row[0].startswith("source") or row[0].startswith("file"): continue path = row[0] counter[path] += 1 for path, count in counter.most_common(10): print(f"{count:4d} {path}")这段脚本的逻辑是逐行读 CSV,取第一列文件路径做计数。不同版本的 simian 列名略有差异,第一次运行时先打印两行看下表头,再决定跳过条件,比我这里写死的字符串更可靠。sorted 之后你就能知道哪些文件是复制粘贴的重灾区,通常也是后续重构优先级最高的地方。
3.3 用配置文件固化规则:让团队跑的是同一套标准
命令行参数一多就有人记错、漏传,结果别人报 20 个重复块,你报 200 个,对不上。simian 支持把参数写进配置文件,一行一个key=value:
includes=src/**,test/** excludes=**/generated/**,**/target/** threshold=8 language=java failOnDuplicates=true reportFormat=xml reportFile=build/reports/simian/simian.xml然后一条命令读取:
java -jar tools/simian.jar -config=config/simian.properties配置文件提交到代码仓库,同事拉下来直接用同一套规则,后续调阈值也只在文件里改一处。这个文件就是团队的“后悔药”,上次扫描用的什么参数,翻一下记录就知道,不会再出现“为什么我这儿结果和你不一样”的争论。
4. 把 simian 接入现有流程:报告落地与 CI 门禁
命令行跑通只是一个起点,真正让重复率受控的是把它编进持续集成。这章给出我在持续集成平台里加重复检测步骤的写法,以及报告怎么解析成团队看得懂的摘要。
4.1 在持续集成任务里写一个重复扫描步骤
先明确退出码的含义:-failOnDuplicates=true时,没有重复返回 0,有重复返回非 0,参数错误也可能返回非 0。所以脚本里不能简单用set -e一棍子打死,要分支处理。下面是一个通用 bash 示例:
set -e # 先清掉上次构建的旧报告,避免误读 rm -f build/reports/simian/simian.xml || true # 执行扫描,保存退出码供后续判断 set +e java -jar tools/simian.jar -config=config/simian.properties status=$? set -e if [ $status -eq 0 ]; then echo "simian: no duplicate blocks found" elif [ $status -eq 1 ]; then echo "simian: found duplicate blocks, check the report" # 需要硬性门禁时,这里可以决定是否继续: # exit 1 else echo "simian: scan error or invalid config, exit code $status" exit $status fi # 把报告归档为构建产物,方便打开定位 mkdir -p artifacts cp build/reports/simian/simian.xml artifacts/simian.xml这段的关键点是先set +e拿到真实退出码,再恢复set -e,避免有重复块时脚本被中途打断,连报告都来不及存档。status的三种分支也顺手把“参数错误”和“发现重复”区分开,排查时不用猜。
我通常把这一步放在单元测试之后、制品构建之前。原因很简单:重复检测跑得太早,大量代码还没定型,误报会干扰节奏;跑得太晚,改代码的成本已经上去了。放在测试后意味着语法和功能是通的,这时候谈重复率,团队接受度最高。
4.2 解析报告:把 XML 转成一条可读的构建摘要
XML 报告包含重复块的详细位置,但太长不适合直接贴在构建日志里。我习惯加一段解析脚本,只抽取关键数字和 Top 10 文件:
import xml.etree.ElementTree as ET from collections import Counter tree = ET.parse("build/reports/simian/simian.xml") root = tree.getroot() # simian 不同版本的节点名略有差异,这里兼容常见写法 blocks = [] for node in root.iter(): if node.tag.lower() in ("duplicate", "repeat", "block"): blocks.append(node) print(f"total duplicate blocks: {len(blocks)}") # 统计每个源文件被重复命中的次数 files = Counter() for blk in blocks: for item in blk.iter(): tag = item.tag.lower() if "sourcefile" in tag or "file" in tag: if item.text: files[item.text.strip()] += 1 for path, cnt in files.most_common(10): print(f"{cnt:4d} {path}")逻辑说明:先遍历 XML 找重复块节点,再嵌套遍历子节点找文件名。因为不同版本的 simian 节点名有出入,我按sourcefile或file关键字模糊匹配,而不是写死某个标签。跑一次之后把实际打印的节点结构看一眼,再决定要不要收紧条件。
这段脚本不进代码仓库也没有关系,但项目里至少要在构建脚本里保留一个调用它的步骤,让重复块“总量”这个数字出现在构建页面上。数据只有被看见,才会被重视。
5. 避坑:simian 使用中的五个高频翻车现场
下面几条全是实际用出来的血泪经验。每一条都按“现象、原因、解决”写清楚,遇到类似问题可以直接对着处理。
5.1 threshold 设成 4,重复结果多到没人看
现象:命令跑完输出几百个重复块,告警刷屏,代码评审时没人愿意打开报告。 原因:连续 4 个 token 在 Java 里太常见了,return null;、if (x == null)、throw new RuntimeException都轻松撞上,大量“非典型重复”被标记出来。 解决:把 threshold 先提到 8 或 10,拿到一份不那么刺眼的基线;之后每次降 2 再跑,观察重复块数量的增幅。如果从 8 降到 6 时数量暴涨三倍,说明当前仓库的合理阈值是 8,再低只会制造噪音。这个“突变点”比我拍脑袋定的数字可靠得多。
5.2 没排除 build/target,结果重复率虚高
现象:扫描报告里重复块集中在target/generated-sources或build目录,业务代码反而干干净净。 原因:-includes写得太宽或者根本没写,构建产物被当作源码扫进来,生成的代码天然和源码高度相似。 解决:把排除目录补全:-excludes=**/target/**,**/build/**,**/generated/**,**/node_modules/**。这两个目录一定不能省,否则报告数据完全失真,且会让团队对工具的信任度迅速下降。
5.3 failOnDuplicates 的退出码方向用反
现象:CI 任务要么永远红,要么永远绿,和有没有重复毫无关系。 原因:不少脚本写成java -jar simian.jar ... || true,把“有重复返回非零”和“执行失败”混为一谈;或者反过来,认为返回 0 才是有问题。 解决:像 4.1 那样先保存退出码再分支。记住语义:没有重复返回 0,有重复返回非零,参数错误也可能非零。想只用报告判断的话,就解析 XML 后看重复块数量,而不是只看退出码。
5.4 多语言仓库混扫,模板文件被当成同一种语言
现象:PHP 和 HTML 模板、Java 和 JSP 混在一起,报告里出现了“跨语言重复”的诡异片段。 原因:simian 按扩展名推断语言,遇到.tpl、.jsp、.vue这类不标准后缀时,可能落到错误的解析器,token 切分规则一变,误报就来了。 解决:按语言拆成多次扫描:先扫 Java,再单独扫 JavaScript,每个场景用-language强制指定;对模板类文件可以单独建一个 exclude 列表,或者把它们放到单独的扫描任务里用文本类规则处理。
5.5 大仓库扫描直接内存溢出
现象:命令跑了一半报OutOfMemoryError,或者干脆卡死不动。 原因:默认 JVM 堆太小,几百万行代码的仓库全量扫描时,token 流和重复匹配都占内存,堆被耗尽。 解决:给 JVM 分配大堆:JAVA_OPTS="-Xmx2g" java -jar tools/simian.jar ...;同时收窄-includes到真正的源码目录,把node_modules、第三方依赖、生成代码先排除掉。仓库实在太大,按模块分多次跑,最后合并报告,比单次全量扫完要稳得多。
6. 进阶用法:用 simian 做持续重构的度量标尺
6.1 把重复量记录成趋势表
限制重复代码不只是靠某一次扫描,而是让重复量随时间可见。我用一个小脚本把每次扫描的重复 token 数追加到一个 CSV,每周自动跑一次,形成趋势线:
import xml.etree.ElementTree as ET from datetime import date tree = ET.parse("build/reports/simian/simian.xml") root = tree.getroot() # 兼容不同版本的 summary 写法,找 attribute 里含 duplicate 和 token 的值 duplicate_token = 0 for node in root.iter(): attrs = {k.lower(): v for k, v in node.attrib.items()} for key, val in attrs.items(): if "duplicate" in key and "token" in key: duplicate_token = max(duplicate_token, int(val)) with open("duplicate-trend.csv", "a", encoding="utf-8") as f: f.write(f"{date.today().isoformat()},{duplicate_token}\n")每周看一眼这个数字,比任何口号都直观。它让我意识到“复制粘贴不是一次重构能解决的,但趋势向下就意味着团队在改变习惯”。
6.2 只扫描本次改动文件,避免老历史淹没新问题
全量扫描会把多年积攒的老重复债全部翻出来,新改动在里面完全不显眼。另一种常见做法是和版本控制配合,扫描本次提交涉及的文件:
changed=$(git diff --name-only HEAD~1 -- '*.java') if [ -n "$changed" ]; then java -jar tools/simian.jar -threshold=8 $changed fi注意$changed如果有文件路径带空格,展开后会被切分,稳妥的做法是循环逐个传入,或者把 CI 的 working directory 控制得足够规整。这种做法适合“渐进式治理”:新代码不允许制造新的重复,老债务另开专项处理,两者互不干扰。从那以后我每次接手老项目,第一件事就是把 simian 配好,阈值先放到 10 跑一遍留个基线报告,之后的每个迭代都对比上周的重复量。看到数字往下走,比自己嘴上说“注意代码质量”有说服力得多,希望帮到你。
本文还有配套的精品资源,点击获取