☰
Java老项目文件编码检查:GBK/UTF-8混存与BOM门禁
2026/10/27 13:22:11 网站建设 项目流程

简介:这是一份用 Java 编写的文件编码检查与转换工具源码,面向需要在 Windows、Linux 等不同平台间迁移文本、批量排查乱码或统一项目编码的开发者,也可作为理解字符集与 BOM 机制的示例工程。工具可自动识别 GBK、US-ASCII、ISO-8859-1,以及带 BOM 与不带 BOM 的 UTF-8、UTF-16BE/LE、UTF-32BE/LE 等格式,并支持在识别结果之间互相转换,适合处理中文文本、配置文件和跨系统导出数据中的编码不一致问题。压缩包共 27 个文件,以 23 个 Java 源文件为主体,另有 2 个 XML 配置分别用于 Maven 构建描述与代码格式化规则,以及 1 份说明文档和 1 个演示用 pptx,整体约 599KB,体量轻巧,可直接导入 IDE 阅读、调试与二次改造。已有 283 人学习下载,对编码检测与转换这一常见开发问题具备实际参考价值。

1. encodingchecker 是我给老 Java 项目写的一个文件编码检查器

起因很土:接手一个跑了七八年的 Java 后台,打开一个 Service 类,中文注释全是「锟斤拷」——编译能过、运行也没事,可一提交,Git diff 就整片全红,同一批文件里有人用 GBK 存、有人用 UTF-8 存,review 根本没法做。后来这类活我全交给 encodingchecker:一个纯 Java 实现的文件编码检查器。它做的事很窄——扫一个目录,逐个判断文本文件最可能的编码,把 GBK 与 UTF-8 混存、带 BOM 的 UTF-8、以及根本解不出来的坏文件挑出来,输出成能直接看的报告,并用退出码告诉流水线这次该不该拦下来。适合正在维护老 Java 项目、准备把整个仓库统一到 UTF-8、或者想给代码提交加一道编码守门的人。

2. 定位与技术选型:纯 JDK 打底的探测逻辑,凭什么敢下结论

2.1 检查的到底是什么:BOM、字节结构合法性、可解码性

先把一个容易被说糊的前提摆清楚:文件里没有“编码”这个字段,只有一串字节。所谓“这个文件是 GBK”,永远是一次推断,不是一次读取。检查器能拿到的证据只有三类,可靠性从高到低排下来,正好对应三层判定。

第一类是 BOM。UTF-8 的EF BB BF、UTF-16LE 的FF FE、UTF-16BE 的FE FF、UTF-32 的FF FE 00 00和00 00 FE FF,这些是实打实写在文件头里的字节,命中就基本没有争议,唯一要小心的是前缀重叠(后面 3.2 会讲)。

第二类是字节结构的合法性。UTF-8 的编码规则很严:ASCII 之外,首字节只能是110xxxxx、1110xxxx、11110xxx,后续字节必须是10xxxxxx,而且长度要对得上。这意味着任意一段 GBK 中文,只要字节序列里出现了不满足这个模式的位置,就可以直接排除 UTF-8。反过来不一定成立,因为 GBK 双字节的取值空间和 UTF-8 的合法序列有交集,短文本撞上的概率并不低。

第三类是统计特征,也是最虚的一层。GBK 的双字节区是首字节81-FE、尾字节40-FE(去掉7F),拿这个区间去套,能算出一个“双字节配对比例”。它只在文件里有较多中文时才靠得住,纯 ASCII 或者只有三五个汉字的文件,这一层的结论基本等于抛硬币。

所以整个探测器的设计原则是:能用结构判的绝不用统计,能判“不确定”的绝不硬给结论。检查器最怕的不是漏报,是把一个正常的 UTF-8 文件标成 GBK,然后有人照着报告去批量转码——那就是一场灾难。老项目里跑一遍这种批量操作,血泪经验足够写满一屏。

2.2 技术栈与依赖取向:JDK 8 起步,零第三方运行时依赖

技术选型上我做了一个很克制的决定:JDK 自带java.nio.charset打底,运行时零第三方依赖。原因不复杂——这工具最终是要塞进别人的构建流程里跑的,最好是一个 jar 丢进去就能用,不要拖一串传递依赖,也别在别人mvn dependency:tree里添乱。

常见做法是引一个字符集探测库来提准确率,这确实是省事的路子,探测库内部做了大量的频率统计和语言模型,对短文本的命中率比手写规则高一截。但代价是包体积上去、行为变成黑匣子,出问题的时候你没法解释“为什么这个文件被判成 Big5”。这个项目面向的场景是 Java 代码库,中文为主、编码种类就 UTF-8 / GBK / ISO-8859-1 三种,规则法够用,而且每一条判定都能在报告里说清理由——对做仓库迁移的人来说,“为什么”比“是什么”更重要。

具体到版本:源码级别按 Java 8 编译,因为很多老项目的构建机还停在 8;构建用 Maven,打包出一个带Main-Class的可执行 jar;测试用 JUnit 5,重点覆盖编码探测的边界用例。命令行参数不引 picocli、commons-cli 这类框架,手写一个循环解析就够了,参数统共十来个,加框架反而是负担。

<properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties>

这段没写依赖,是因为dependencies里除了测试作用域的 JUnit,确实什么都没有。project.build.sourceEncoding这一行别省——本项目的源码文件自己也得是 UTF-8,否则打包出来的 class 里常量字符串就是乱的,那时候查起来才是真的玄学。

2.3 目录结构:从入口到探测器再到报告

包结构按“读入 → 判定 → 输出”三段切,一个包只干一件事,加新探测器的时候不用改别的地方:

encodingchecker/ ├── pom.xml └── src/ ├── main/java/com/example/encodingchecker/ │ ├── EncodingChecker.java # main 入口,串流程、定退出码 │ ├── cli/ │ │ ├── Options.java # 参数模型(路径、扩展名、阈值) │ │ └── OptionsParser.java # 手写参数解析 │ ├── scan/ │ │ └── FileScanner.java # 目录遍历 + 剪枝 + 大小上限 │ ├── detect/ │ │ ├── BomDetector.java # 第一层:BOM │ │ ├── StrictDecoder.java # 第二层:严格解码 │ │ ├── GbkHeuristic.java # 第三层:GBK 双字节统计 │ │ ├── EncodingDetector.java # 三层组合,出最终结论 │ │ └── Detection.java # 结论模型(编码 + 置信度 + 理由) │ └── report/ │ ├── Finding.java # 单个文件的检查结果 │ └── ReportWriter.java # text / csv / json 三种输出 └── test/java/com/example/encodingchecker/ └── detect/ # 边界用例:纯 ASCII、短中文、BOM、坏字节

detect包里Detection这个模型值得单拎出来说:它带的不是单纯的Charset,而是charset + confidence + reason三元组。confidence只有HIGH / LOW / UNKNOWN三档,reason是一句人话,比如“BOM 命中 UTF-8”“UTF-8 严格解码失败,GBK 配对率 0.97”。报告里带上理由,别人拿着 CSV 去找人对接时才说得清,不然一句“这文件是 GBK”没人服气。

scan和report分离还有个实际好处:文件遍历是最容易出平台差异的一段(权限、符号链接、长路径),报告输出是最容易被改的一段(有人要 CSV 进 Excel,有人要 JSON 进看板)。两者解耦之后,加一种输出格式只动ReportWriter,不碰遍历逻辑。

3. 核心链路拆解:一次扫描从遍历目录到写出报告

3.1 文件遍历与过滤:别把 target 和 .git 扫进来

遍历用Files.walkFileTree而不是Files.walk,就为了能在preVisitDirectory里做子树剪枝。这个区别看着小,实际影响很大:一个中等规模的 Java 项目,target/下几千个.class、.jar,.git/下几百 MB 的 pack 文件,全扫进来不但慢,报告还会瞬间被“坏文件”淹没。

public final class FileScanner { /** 这些目录整棵跳过,不要试图进去再过滤 */ private static final Set<String> SKIP_DIRS = new HashSet<>(Arrays.asList( ".git", ".svn", ".hg", "target", "build", "out", "node_modules", ".idea", ".gradle")); /** 单文件上限,默认 4MB;超过的直接跳过,避免整读进内存 */ private final long maxBytes; public List<Path> scan(Path root) throws IOException { List<Path> result = new ArrayList<>(); Files.walkFileTree(root, new SimpleFileVisitor<Path>() { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { Path name = dir.getFileName(); if (name != null && SKIP_DIRS.contains(name.toString())) { return FileVisitResult.SKIP_SUBTREE; // 关键:剪枝,不是 continue } return FileVisitResult.CONTINUE; } @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { if (!attrs.isRegularFile()) return FileVisitResult.CONTINUE; if (attrs.size() > maxBytes) return FileVisitResult.CONTINUE; if (attrs.size() == 0) return FileVisitResult.CONTINUE; // 空文件没信息量 result.add(file); return FileVisitResult.CONTINUE; } @Override public FileVisitResult visitFileFailed(Path file, IOException exc) { // 单个文件读不了(权限、被占用、路径过长)不该中断整次扫描 return FileVisitResult.CONTINUE; } }); return result; } }

SKIP_DIRS是这个类里最需要按项目实际情况调的一项,尤其是混合仓库——前端目录下的node_modules里塞着几万个文件,扫进去毫无意义。maxBytes默认 4MB,超过就跳过:编码检查对超大文件没有收益,反倒是Files.readAllBytes一次性读进内存最容易 OOM。

preVisitDirectory返回SKIP_SUBTREE和返回CONTINUE的差别必须记牢:前者是“这棵子树整个不要”,后者是“目录本身跳过但继续往下走”。很多人都在这里犯过错误:在visitFile里判断父目录名然后continue,结果文件是过滤掉了,遍历的开销一点没省。

visitFileFailed那个覆写也不是可有可无的。默认实现会把IOException往上抛,Windows 上遇到超长路径或者被别的进程占用的文件,整次扫描就直接中断了——报告出一半,人还以为扫完了。返回CONTINUE至少能把剩下的文件跑完,跳过的记录另存一份。

3.2 三层判定:BOM 先看,严格解码兜底,统计打分收尾

三层判定的顺序不能变,第一层最硬,第三层最软。

第一层 BOM,逐个比对文件头字节:

public final class BomDetector { public static final class Hit { public final Charset charset; // null 表示没命中 public final int length; // BOM 占用的字节数 Hit(Charset charset, int length) { this.charset = charset; this.length = length; } } public static Hit detect(byte[] head, int len) { if (len >= 3 && (head[0] & 0xFF) == 0xEF && (head[1] & 0xFF) == 0xBB && (head[2] & 0xFF) == 0xBF) { return new Hit(StandardCharsets.UTF_8, 3); } // UTF-32 必须排在 UTF-16 前面,前缀重叠,顺序错了就全判成 UTF-16LE if (len >= 4 && (head[0] & 0xFF) == 0xFF && (head[1] & 0xFF) == 0xFE && (head[2] & 0xFF) == 0x00 && (head[3] & 0xFF) == 0x00) { return new Hit(Charset.forName("UTF-32LE"), 4); } if (len >= 4 && (head[0] & 0xFF) == 0x00 && (head[1] & 0xFF) == 0x00 && (head[2] & 0xFF) == 0xFE && (head[3] & 0xFF) == 0xFF) { return new Hit(Charset.forName("UTF-32BE"), 4); } if (len >= 2 && (head[0] & 0xFF) == 0xFF && (head[1] & 0xFF) == 0xFE) { return new Hit(StandardCharsets.UTF_16LE, 2); } if (len >= 2 && (head[0] & 0xFF) == 0xFE && (head[1] & 0xFF) == 0xFF) { return new Hit(StandardCharsets.UTF_16BE, 2); } return new Hit(null, 0); } }

& 0xFF是为了把byte提升成 0~255 的整数再比较,Java 的byte是有符号的,0xEF直接写进byte就是负数,不转一下比较出来的结果是错的。length返回出去,后面读内容时要从这个偏移开始,否则 BOM 会被当成正文的第一个字符,写回的时候又叠一层,越转越乱。

第二层严格解码,这是整个检查器的核心。new String(bytes, charset)这条路看着简单,其实是陷阱:它默认用REPLACE策略,遇到非法字节不报错,静默替换成\uFFFD。用它做判断,永远“解码成功”,检查器自己就变成了黑匣子。

public final class StrictDecoder { /** * 严格解码:有一个字节不合法就返回 null。 * 注意是从原始 byte[] 的 off 开始、长度 len,避开 BOM。 */ public static Charset match(byte[] data, int off, int len, Charset cs) { if (len <= 0) return null; CharsetDecoder decoder = cs.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) // 结构非法就抛 .onUnmappableCharacter(CodingErrorAction.REPORT); // 无法映射也抛 try { decoder.decode(ByteBuffer.wrap(data, off, len)); return cs; } catch (CharacterCodingException e) { return null; } } }

CodingErrorAction.REPORT是这一层的开关,两个都要设。onMalformedInput管字节结构不对(比如 UTF-8 里蹦出个孤立的0x80),onUnmappableCharacter管字节结构对但字符表里没有——GBK 里这种情况少见,换成 ISO-8859-1 之类就多了。两个都设成REPORT,decode才会真的抛CharacterCodingException,而不是悄悄把坏字节换成替换符。

第三层是 GBK 的双字节配对统计,只在结构层面判不出来的时候用:

public final class GbkHeuristic { /** 返回 GBK 双字节的配对比例,0.0 ~ 1.0;没有非 ASCII 字节时返回 0 */ public static double confidence(byte[] b, int off, int len) { int i = off, pairs = 0, bad = 0, end = off + len; while (i < end) { int c = b[i] & 0xFF; if (c < 0x80) { i++; continue; } // ASCII,跳过 if (i + 1 >= end) { bad++; break; } // 落单的尾字节 int t = b[i + 1] & 0xFF; if (c >= 0x81 && c <= 0xFE && t >= 0x40 && t <= 0xFE && t != 0x7F) { pairs++; i += 2; } else { bad++; i++; // 配不上对,往后挪一个字节继续 } } int total = pairs + bad; return total == 0 ? 0.0 : (double) pairs / total; } }

0x7F那个例外是 GBK 的规范细节:尾字节区间是40-FE,但7F被排除掉了,不判这一下,遇到含7F的序列会误配。bad++之后只挪一个字节而不是两个,是因为配不上的时候你不确定哪个字节是问题字节,前移一位重新配对比一次跳过两字节更保守。

三层合起来的判定逻辑,写成一张表比一堆if好读:

条件结论置信度
BOM 命中对应编码HIGH
全是 ASCII 字节ASCII 兼容HIGH
UTF-8 严格解码通过,且 GBK 配对率 < 0.6UTF-8HIGH
UTF-8 严格解码失败,GBK 配对率 ≥ 0.9GBKHIGH
两者都成立(短文本、纯中文 GBK 偶合)不下结论LOW
有效字节数 <--min-bytes不下结论UNKNOWN
两种都解不通BINARYHIGH

最后一行值得单说:两边都解不通的文件,八成压根不是文本,是图片、jar、或者被加密过的配置。把它标成BINARY而不是报错,扫描一个混合目录才不会被二进制文件刷屏。

3.3 结果模型与报告输出:文本、CSV 和退出码

报告是给人看的,退出码是给机器看的,两者分开设计。人看的部分要能直接定位,机器看的部分只需要一个数字。

public final class ReportWriter { public static void write(List<Finding> findings, Options opt, PrintStream out) { switch (opt.reportFormat) { case "csv": out.println("path,expected,detected,confidence,reason,bytes"); for (Finding f : findings) { out.printf("%s,%s,%s,%s,\"%s\",%d%n", escape(f.path), opt.expect, f.charsetName, f.confidence, escape(f.reason), f.size); } break; default: // text for (Finding f : findings) { out.printf("%-8s %-10s %-8s %s (%s)%n", f.confidence, f.charsetName, f.size, f.path, f.reason); } } } /** 路径里的反斜杠和引号,CSV 里不转义会直接把列错开 */ private static String escape(String s) { return s.replace("\\", "\\\\").replace("\"", "\"\""); } }

out.printf里的%-8s是左对齐定宽,text 报告对不齐就没法快速扫。CSV 分支的escape不是可选项:Windows 路径里的反斜杠、文件名里的引号,不处理会让 Excel 打开时整张表错位,后面拿去对比基线就全废了。

退出码在入口类里定,只有三个取值:

int exit = 0; for (Finding f : findings) { if ("UNKNOWN".equals(f.confidence)) continue; // 不确定的不拦 if (!f.charsetName.equalsIgnoreCase(opt.expect)) { // 期望 UTF-8 却检出别的 if ("any".equals(opt.failOn)) { exit = 1; break; } if ("bom".equals(opt.failOn) && f.hasBom) { exit = 1; break; } } } System.exit(exit); // 0 = 干净,1 = 有问题,2 = 参数/IO 错误

UNKNOWN不参与拦截是有意的:短文件本来就判不准,因为判不准就把构建打红,用不了两天大家就会给这个检查器加个|| true,从此形同虚设。failOn给三档也是这个思路——刚接入的时候设成bom,只拦最容易改的那一类,团队适应了再收紧到any。

4. 跑起来:构建、参数详解与接进流水线

4.1 从源码到可执行 jar

Maven 打包,只配一个maven-jar-plugin写Main-Class,不需要 shade,因为确实没有第三方运行时依赖:

<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <configuration> <archive> <manifest> <mainClass>com.example.encodingchecker.EncodingChecker</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build>

mainClass写全限定名,写错的话java -jar会报no main manifest attribute,看着像打包失败,其实就是这一个字符串的问题。构建命令和最小验证:

mvn -q clean package # 产出 target/encodingchecker.jar,先拿自己的 src 目录试一下 java -jar target/encodingchecker.jar --path ./src # 期望的输出大致是这样,一行一个异常文件 # LOW GBK 1204 src/main/java/.../LegacyDao.java (UTF-8 严格解码失败,GBK 配对率 0.97) # HIGH UTF-8-BOM 311 src/main/resources/app.properties (BOM 命中,建议去掉)

第一次跑先不要加--fail-on any,先看报告长什么样。拿自己项目跑一遍,大概率会发现几个你自己都不知道是 GBK 的文件——这种“原来这儿还埋着一个”的感觉,比看十篇编码科普都管用。

4.2 参数逐个说清与几种典型用法

参数不多,但每一个都会影响结论,尤其是阈值那几个:

参数默认值作用
--path当前目录扫描根目录,可重复传多个
--expectUTF-8期望编码,检出其他编码即视为异常
--include内置文本扩展名白名单只扫这些扩展名,逗号分隔,如java,xml,properties
--allfalse不看扩展名,所有文件都当文本试(慎用)
--min-bytes64有效字节少于该值的文件判 UNKNOWN,不给结论
--max-bytes4194304单文件上限,超过直接跳过
--reporttext输出格式:text/csv
--out标准输出报告落盘路径
--fail-onnone退出码策略:none/bom/any

--min-bytes是这几个里最该调的一个。默认 64 是按“两行注释的量”估的,含十几个汉字的 GBK 文件在 64 字节以上,统计层才有足够样本。项目里如果有一堆几行的配置文件,把它提到 256 更稳,代价是更多文件进 UNKNOWN,得人工扫一眼。

--expect不只是 UTF-8。迁移中途经常要反向确认“还有多少文件没转”,这时候把--expect设成 GBK,报告出来的就是剩余待办清单,比写脚本 find 一遍靠谱。

几个典型用法,直接抄:

# 1. 摸清家底:全仓库扫一遍,CSV 落盘,不拦构建 java -jar encodingchecker.jar --path . --report csv --out build/encoding-baseline.csv # 2. 只看 Java 和配置文件,把结果打到终端 java -jar encodingchecker.jar --path ./src --include java,xml,properties # 3. 迁移后验收:期望 UTF-8,检出别的就返回 1 java -jar encodingchecker.jar --path ./src --expect UTF-8 --fail-on any # 4. 反过来查:还剩多少 GBK 没转 java -jar encodingchecker.jar --path ./src --expect GBK --report csv --out build/todo.csv

第 1 条是整个流程的起点,先落一份基线。没有基线就动手批量转码,转完你都不知道是变好了还是变坏了——这个项目存在的意义有一半就在这份基线文件上。

4.3 接进构建流程:失败即拦截

接流水线的姿势很简单,jar 丢进项目里,构建脚本里加一行:

#!/usr/bin/env bash set -euo pipefail # 工具 jar 跟代码一起进仓库,避免每次构建去下载 JAR="tools/encodingchecker.jar" # 第一阶段:全量出一份报告,方便事后追溯,这一步永远不拦 java -jar "$JAR" --path src --report csv --out "build/encoding-$(date +%Y%m%d).csv" || true # 第二阶段:只拦"确定有问题"的,UNKNOWN 放过 java -jar "$JAR" --path src --expect UTF-8 --fail-on any --min-bytes 128

|| true加在第一阶段是有讲究的:报告阶段失败不该阻断构建,但也不能让脚本因为set -e直接退出。第二阶段才是真正的门禁,--min-bytes 128把短文件排除在外,减少误报带来的扯皮。

刚上线的时候别急着把--fail-on any打开。老仓库一次性打红几百个文件,结果一定是有人去改脚本加|| true,然后这个门禁就永远失效了。合理的节奏是:第一周只出报告不拦,第二周改成--fail-on bom只拦 BOM,等 BOM 清零了再上any。每一步都有明确的清理目标,团队才愿意配合。

5. 避坑与排查:编码检查最容易翻车的五个地方

5.1 全是 UTF-8 的目录,报告里冒出一堆 GBK

现象:一个确定全是 UTF-8 的模块,跑出来有十几个文件被判成 GBK,打开看内容完全正常。

原因:这些文件普遍很小,只有一两行中文注释。GBK 的双字节区间和 UTF-8 的合法序列有交集,十几个汉字这个量级,撞上合法 UTF-8 结构的概率不低,统计层给出的配对率又接近 1.0,于是被判成 GBK。

解决:把--min-bytes提到 128 或 256,让短文件进 UNKNOWN 而不是硬给结论。真正要处理的是“文件多且长”,短文件人工看一眼的成本远低于误判后批量转码的成本。

5.2 自己写的验证代码永远返回“解码成功”

现象:调试时用new String(bytes, "UTF-8")试了一下,没抛异常,就以为文件是 UTF-8,结果检查器结论相反。

原因:String的这组构造器内部用的是REPLACE策略,非法字节被静默替换成\uFFFD,永远不会抛CharacterCodingException。判断“能不能解码”用它是无效的,等于自己给自己造了个黑匣子。

解决:一律走CharsetDecoder+CodingErrorAction.REPORT。要确认替换符有没有出现,也可以解码后indexOf('\uFFFD'),但那是事后补救,不如一开始就让它抛。

5.3 扫完一个 Java 项目,报告里几千个“二进制文件”

现象:--all跑一遍,target/下的.class、.jar全被标成 BINARY,报告几万行,真正的问题文件被埋了。

原因:遍历时只做了文件级过滤,没有对目录剪枝。visitFile里continue只是不收集这个文件,遍历照样钻进target/把每个子目录走了一遍。

解决:在preVisitDirectory里判断目录名,返回SKIP_SUBTREE。同时别轻易用--all,默认走扩展名白名单,需要全量的时候再显式打开。

5.4 带 BOM 的 UTF-8,Java 编译没问题,别的工具全炸

现象:某个.properties或者脚本文件,Java 里读着正常,但被 shell 脚本、JSON 解析器、前端构建工具读到就报奇怪的语法错误。

原因:UTF-8 的 BOM 是三个字节EF BB BF,Java 的InputStreamReader会把它当 BOM 处理掉,但很多工具不认,直接当成正文的第一个字符,于是第一行的 key 前面多了个不可见字符,匹配全失败。

解决:报告里把 BOM 单独标出来,--fail-on bom专门拦这一类。清理 BOM 不要用编辑器一个个开,写个几行的脚本从偏移 3 开始重新写一遍就行,比手工可靠得多。

5.5 Windows 上扫到一半就退出,报告只有前几十行

现象:在开发机上跑没事,到某台 Windows 构建机上跑到一半进程就结束了,退出码还不是 0/1/2。

原因:SimpleFileVisitor的visitFileFailed默认实现会把IOException往外抛,遇到超长路径、被占用的文件、权限不足的目录,整个walkFileTree直接中断。

解决:覆写visitFileFailed返回CONTINUE,把跳过的路径单独记到一个skipped列表里,最后在报告尾部打一行“跳过 N 个文件”。跳过是正常的,静默跳过才是问题。

6. 二次开发:把它改成团队里真正拦得住的门禁

这个工具的骨架很小,改起来不费劲,但改之前得想清楚一件事:它是“发现问题”的工具,不是“修复问题”的工具。往这个方向加功能,比堆更多探测算法有价值。

改造方向落点大致工作量
增量检查,只查本次变更的文件Options加--changed-from,读一份文件清单半天
--fix自动转 UTF-8新增Transcoder,复用已有探测结果一天
输出 HTML / SARIFReportWriter加一个分支半天
项目级白名单读.encodingchecker配置,路径前缀匹配半天

增量检查是最值得先做的。全量扫一遍大仓库要几十秒,接在提交钩子上没人受得了;改成只查变更文件,一秒内出结果,才有人愿意让它挡在提交前面。实现上就是让FileScanner接受一个外部传入的路径列表,跳过walkFileTree那一段。

--fix要小心,核心就几行,但每一行都有讲究:

/** 按已探测到的编码读出,以 UTF-8 写回;先写临时文件再替换,避免写一半崩了 */ public static void toUtf8(Path src, Charset from, boolean stripBom) throws IOException { byte[] raw = Files.readAllBytes(src); int off = (stripBom && raw.length >= 3 && (raw[0] & 0xFF) == 0xEF && (raw[1] & 0xFF) == 0xBB && (raw[2] & 0xFF) == 0xBF) ? 3 : 0; // from 是 StrictDecoder 校验过的,这里用 String 才安全 String text = new String(raw, off, raw.length - off, from); byte[] out = text.getBytes(StandardCharsets.UTF_8); Path tmp = Files.createTempFile(src.getParent(), ".enc", ".tmp"); Files.write(tmp, out); Files.move(tmp, src, StandardCopyOption.REPLACE_EXISTING); }

from必须来自严格解码通过的结论,LOW和UNKNOWN的文件一律不许进这个函数——这是唯一的红线。stripBom单独做成参数而不是固定去掉,因为有些老系统确实依赖 BOM 识别文件。写临时文件再move是为了原子性,直接Files.write覆盖原文件,中途断电就是个半截文件,后悔药都没得吃。

还有一个容易忽略的点:不要用Files.readAllLines加Files.write的方式做转码。那套 API 会把行尾的空格、结尾的换行符、混合的 CRLF/LF 全部规整掉,转完码一看 git diff,整个文件都变了,真正的改动被淹没在里面,review 的人会想打人。按字节读、按字节写,中间只做编解码,其他一律不动。

接进流程的时候,我的习惯是先跑一次全量出基线,把结果提交进仓库,然后每次改动都拿新报告和基线 diff。基线里减少的行是修好的,增加的行是新引入的——这个对比比任何检查都直观。

从那以后我每次动老项目的编码,都先--report csv存一份基线,改完再 diff 两份报告,确认真的只剩我想改的那几个文件。编码这件事没有捷径,只有一份能对比的记录。希望帮到你。

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

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

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

立即咨询