简介:EncodingChecker 是面向 Java 开发者的文件编码检查与转换工具源码包,重点解决中文文本在 GBK、UTF 系列及带 BOM 或不带 BOM 格式间识别不准、转换后乱码的问题,适合处理日志、配置、导入导出文件与跨平台文本兼容场景。压缩包共 27 个文件,以 23 个 Java 源码为主体,辅以 2 个 XML 配置、1 份说明文档和 1 份演示课件,整体约 599KB,目录轻量清晰,便于阅读源码、运行示例并理解编码判定与转换流程。该资源已有 283 人学习,价值在于提供可复用的编码检测思路、转换实现与项目构建配置,读者可据此掌握字符集及 BOM 差异的处理方法,并改造成命令行工具或集成到实际项目中。演示课件还可帮助梳理编码基础概念,结合源码理解异常处理与批量转换的工程化写法,并减少试错时间。
1. 满屏问号的那次排查,让我写了个文件编码检查器
前阵子帮同事看一个接手的老项目,IDE 打开application.properties,中文全是\u4e2d\u6587这种转义;再打开几个 Java 源文件,注释里的中文变成了问号,git diff更离谱——整个文件全删全增,明明只改了一行。折腾半小时才确认:问题不在代码,在编码。文件本身是 GBK,仓库约定是 UTF-8,IDE 一边按 UTF-8 读,一边又悄悄按 GBK 存,来回几次就成了玄学。这类问题的麻烦之处在于它不报错,编译器照过,运行时才在日志里冒出一串乱码。于是我用 Java 写了个命令行工具 encodingchecker,专门干一件事:扫描一个目录,判定每个文件的真实编码,输出报告,需要的话批量转成统一编码。它适合两类人:接手历史项目、准备统一仓库编码的后端开发;以及需要在构建流水线里卡一道"编码门禁"的工程团队。
2. 工程骨架:从 pom.xml 到 main 方法的分层
先把这个工具的整体骨架摆出来。它不依赖任何框架容器,就是一个 fat-jar 命令行程序,Java 17 编译,跑起来java -jar encodingchecker.jar -p ./src。分层思路很朴素:命令行层只负责解析参数,检测层只负责给出结论,扫描层负责把文件喂进来,转换层和报告层各自独立。这样拆的好处是,后面想把检测能力嵌到别的系统里,直接 new 一个检测器就行,不用管 CLI。
2.1 目录结构:四个包各管一件事
encodingchecker/ ├── pom.xml └── src/main/java/io/example/encodingchecker/ ├── Main.java # 入口,装配 picocli ├── cli/ │ ├── CheckCommand.java # 子命令:check / fix │ └── Options.java # 参数定义与校验 ├── detect/ │ ├── CharsetDetector.java # 检测调度:BOM -> 校验 -> 打分 │ ├── BomSniffer.java # BOM 嗅探 │ ├── Utf8Validator.java # UTF-8 字节序列校验 + 坏字节定位 │ └── DetectionResult.java # 检测结果值对象 ├── scan/ │ ├── FileScanner.java # 目录遍历与过滤 │ └── SampleReader.java # 大文件采样读取 ├── convert/ │ └── CharsetConverter.java # 转码 + 原子落盘 + 备份 └── report/ ├── ReportWriter.java # 报告接口 └── JsonReportWriter.java # JSON 实现,另有 CsvReportWriter这个结构里最值得留意的是detect包被切成了四个类。很多人写检测工具会把逻辑全塞进一个Detector里,几百行 if-else,最后没人敢改。拆开之后,BOM 嗅探是纯静态方法,UTF-8 校验是纯函数,打分器只依赖前两者的输出,单元测试极好写——给一段byte[],断言返回的Charset和置信度。scan和convert分开也是刻意的:扫描阶段只读不写,转换阶段才碰磁盘,这样--dry-run模式天然成立。
2.2 依赖清单与入口:三个 jar 撑起全部功能
pom.xml里没有惊喜,依赖控制在四个以内,避免工具本身变成一个依赖黑洞。
<properties> <maven.compiler.release>17</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <!-- 命令行解析:注解式声明参数,自动生成 help --> <dependency> <groupId>info.picocli</groupId> <artifactId>picocli</artifactId> <version>4.7.5</version> </dependency> <!-- 编码探测兜底:对 GBK/Shift_JIS 等有多字节统计模型 --> <dependency> <groupId>com.github.albfernandez</groupId> <artifactId>juniversalchardet</artifactId> <version>2.4.0</version> </dependency> <!-- 报告序列化 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.1</version> </dependency> </dependencies>入口类只做两件事:注册子命令、把退出码透传给 shell。退出码在流水线场景里比日志重要得多,这点后面第 6 章会展开。
public final class Main { public static void main(String[] args) { CommandLine cmd = new CommandLine(new CheckCommand()) .addSubcommand("fix", new FixCommand()) .setCaseInsensitiveEnumValuesAllowed(true); // execute 返回业务退出码:0 通过 / 1 存在不匹配 / 2 参数错误 int code = cmd.execute(args); System.exit(code); } }cmd.execute的返回值来自IExecutionStrategy,默认情况下 picocli 会把Callable的返回值当退出码。这里约定 0 表示全部文件符合预期,1 表示存在编码不合规的文件,2 表示参数或 IO 异常。参数setCaseInsensitiveEnumValuesAllowed让--target utf-8和--target UTF-8都能过,少一次用户骂街。
2.3 命令行参数:把"检查"和"修复"拆成两种模式
参数设计上我坚持一个原则:默认行为必须是只读。用户第一次跑,绝不允许动他的文件。
| 参数 | 作用 | 默认值 |
|---|---|---|
-p, --path | 扫描根目录,必填 | 无 |
-i, --include | 扩展名白名单,逗号分隔 | java,properties,xml,md,txt |
-e, --exclude | 跳过的目录名,可重复 | target,.git,node_modules |
-t, --target | 期望编码,用于判定"是否合规" | UTF-8 |
--fix | 实际执行转码,需配合--backup | false |
--backup | 转码前备份为.bak | true |
--sample-size | 大文件采样字节数 | 262144 |
--threads | 并发线程数 | CPU 核数 |
--min-confidence | 低于该置信度只告警不判失败 | 0.85 |
--report | 报告输出路径,支持.json/.csv | 控制台 |
--fail-on-mismatch | 存在不合规文件时返回退出码 1 | true |
--fix单独做成子命令而不是一个开关,是我踩过坑之后的决定。早期版本里-p ./src --fix一不小心就误伤了整个目录,后来改成fix子命令,且必须显式写--backup,多敲几个字符换来的是不用给自己准备后悔药。--min-confidence这一项也很关键:编码检测本质上是概率判断,短文件、全 ASCII 文件的结果天然不可靠,用一个阈值把"不确定"和"确定错误"区分开,报告才有可读性。
3. 检测内核:BOM、字节校验与候选集打分三连
编码检测没有银弹,所有成熟实现都是分层判断加启发式打分。这里的策略是三层:第一层看 BOM,命中即定论;第二层做 UTF-8 严格校验,因为 UTF-8 的字节序列约束最强,能通过就是强证据;第三层对剩下的候选(GBK、UTF-16 无 BOM、ISO-8859-1)做统计打分,取最高分。每层都会写进DetectionResult.reason字段,出问题时能一眼看出是哪一层下的结论,不至于变成黑匣子。
3.1 BOM 嗅探:先判长 BOM 再判短 BOM
BOM 是唯一能给出确定性答案的证据,但判断顺序错了会直接翻车。
public final class BomSniffer { // 注意顺序:UTF-32LE 的 BOM 以 FF FE 开头,和 UTF-16LE 前缀冲突, // 必须把长 BOM 排在前面,否则 UTF-32LE 会被误判成 UTF-16LE private static final byte[][] BOMS = { {(byte) 0xFF, (byte) 0xFE, (byte) 0x00, (byte) 0x00}, // UTF-32LE {(byte) 0x00, (byte) 0x00, (byte) 0xFE, (byte) 0xFF}, // UTF-32BE {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}, // UTF-8 {(byte) 0xFF, (byte) 0xFE}, // UTF-16LE {(byte) 0xFE, (byte) 0xFF} // UTF-16BE }; private static final Charset[] CHARSETS = { Charset.forName("UTF-32LE"), Charset.forName("UTF-32BE"), StandardCharsets.UTF_8, StandardCharsets.UTF_16LE, StandardCharsets.UTF_16BE }; /** 返回命中的编码,未命中 BOM 返回 null,bomLength 通过数组回传 */ public static Charset sniff(byte[] head, int len, int[] bomLength) { for (int i = 0; i < BOMS.length; i++) { byte[] bom = BOMS[i]; if (len < bom.length) { continue; } boolean match = true; for (int k = 0; k < bom.length; k++) { if (head[k] != bom[k]) { match = false; break; } } if (match) { bomLength[0] = bom.length; return CHARSETS[i]; } } return null; } }这段代码的关键不在循环,而在BOMS数组的排列顺序。FF FE 00 00和FF FE是前缀关系,如果按"短的在前"匹配,一个 UTF-32LE 文件会被判成 UTF-16LE,后面的解码全错。参数head是文件头部字节数组,len是实际读到的长度(小文件可能读不满),bomLength用数组回传是因为 Java 没有多返回值,返回编码的同时要告诉调用方跳过几个字节,否则转码后文件开头会多出一段看不见的垃圾。命中 BOM 时置信度直接给 1.0,理由字段写BOM:UTF-8之类,报告里一眼可查。
3.2 UTF-8 合法性校验:用 CharsetDecoder 报错,用手写扫描定位
没有 BOM 的文件里,UTF-8 是最值得优先验证的编码,因为它的字节约束严格,误判率低。
public final class Utf8Validator { /** 严格校验:任何非法字节序列都返回 false */ public static boolean isValid(byte[] buf, int len) { CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) // 非法序列直接抛异常 .onUnmappableCharacter(CodingErrorAction.REPORT); try { decoder.decode(ByteBuffer.wrap(buf, 0, len)); return true; } catch (CharacterCodingException e) { return false; } } /** 定位第一个非法字节的偏移,用于报告里给出精确位置 */ public static int firstBadOffset(byte[] buf, int len) { int i = 0; while (i < len) { int b = buf[i] & 0xFF; if (b < 0x80) { i++; continue; } int need, min; if ((b & 0xE0) == 0xC0) { need = 1; min = 0x80; } else if ((b & 0xF0) == 0xE0) { need = 2; min = 0x800; } else if ((b & 0xF8) == 0xF0) { need = 3; min = 0x10000; } else return i; // 0x80-0xBF 或 0xF8+ 都不合法 if (i + need >= len) return -1; // 采样边界截断,不算错 int cp = b & (0x3F >> need); for (int k = 1; k <= need; k++) { int c = buf[i + k] & 0xFF; if ((c & 0xC0) != 0x80) return i; // 续字节必须是 10xxxxxx cp = (cp << 6) | (c & 0x3F); } if (cp < min) return i; // 过长编码,如 C0 80 if (cp >= 0xD800 && cp <= 0xDFFF) return i; // 代理区不能单独出现 if (cp > 0x10FFFF) return i; i += need + 1; } return -1; } }isValid用 JDK 自带解码器配合REPORT策略,逻辑最少,出错就抛CharacterCodingException。但它的缺点也很明显:只告诉你"不合法",不告诉你在哪。firstBadOffset是手写扫描,逐字节还原 UTF-8 的编码规则,返回第一个坏字节的下标。参数说明:need是续字节个数,min是对应长度的最小码点,用来拦过长编码;i + need >= len这个判断专门处理采样边界——采样恰好把一个多字节字符切成两半时,不能判成非法,返回 -1 表示"这段数据不足以定论"。实践中这份定位信息价值很高,一个 20MB 的日志文件报"UTF-8 不合法"毫无意义,报"第 1048573 字节处非法"就能直接dd过去看现场。
3.3 候选打分:GBK、UTF-16、ISO-8859-1 谁排前面
UTF-8 校验失败后,进入打分环节。
public static DetectionResult score(Path file, byte[] buf, int len) { double gbk = scoreGbk(buf, len); // 0.0 ~ 1.0 double utf16 = scoreUtf16NoBom(buf, len); // 靠零字节分布 double latin1 = 0.2; // 任何字节序列都合法,永远垫底 // 出现高位字节才考虑 GBK,纯 ASCII 时 GBK 和 UTF-8 等价,不参与比较 double best = Math.max(gbk, Math.max(utf16, latin1)); Charset cs; String reason; if (best == gbk) { cs = Charset.forName("GB18030"); // 用超集承接,兼容 GBK/GB2312 reason = String.format("双字节序列合法率=%.3f", gbk); } else if (best == utf16) { cs = StandardCharsets.UTF_16LE; // 无 BOM 时 LE 更常见 reason = String.format("偶数字节零值占比=%.3f", utf16); } else { cs = StandardCharsets.ISO_8859_1; reason = "兜底:无更强证据"; } return new DetectionResult(file, cs, (float) best, reason, len); } /** GBK 打分:统计双字节对是否落在合法区间 */ private static double scoreGbk(byte[] buf, int len) { int pairs = 0, valid = 0; for (int i = 0; i < len; i++) { int b = buf[i] & 0xFF; if (b < 0x80) continue; // ASCII 部分不计入分母 if (i + 1 >= len) break; int c = buf[i + 1] & 0xFF; pairs++; // 首字节 0x81-0xFE,次字节 0x40-0xFE 且非 0x7F if (b >= 0x81 && b <= 0xFE && c >= 0x40 && c <= 0xFE && c != 0x7F) { valid++; i++; // 吃掉次字节 } } return pairs == 0 ? 0.0 : (double) valid / pairs; }打分不是"谁高选谁"这么简单,还有两条隐含规则必须写进代码。第一条:scoreGbk的分母只统计高位字节对,纯 ASCII 文件pairs为 0,直接返回 0,避免"全英文文件被猜成 GBK"的滑稽结论。第二条:UTF-16 无 BOM 的判定靠零字节分布——英文为主的 UTF-16LE 文本,奇数位几乎全是 0x00,这个特征很稳;但只要文件里中文占比高,零字节就消失,特征失效,所以这条分支置信度上限我压在 0.6,绝不会把它判成"确定"。最后那个latin1 = 0.2是兜底:ISO-8859-1 对任意字节序列都"合法",天生适合接盘,但绝不能让它赢,否则所有 GBK 文件都会被判成西文。这个 0.2 不是拍脑袋,是保证它一定小于任何有实际证据的候选分。
4. 扫描与并发:几万个文件怎么在十秒内出报告
检测单个文件很快,真正耗时的是 I/O。一个中型仓库动辄两三万个文件,其中大量是二进制资源、构建产物、依赖缓存。扫描层的目标很明确:不该读的文件一个字节都别读,该读的文件别读第二遍。
4.1 目录遍历:walkFileTree 与符号链接的坑
public List<Path> scan(Path root, Set<String> includes, Set<String> excludes) throws IOException { List<Path> files = new ArrayList<>(); Files.walkFileTree(root, EnumSet.noneOf(FileVisitOption.class), Integer.MAX_VALUE, new SimpleFileVisitor<>() { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { String name = dir.getFileName() == null ? "" : dir.getFileName().toString(); // 目录级剪枝:命中排除名单就不下去,比逐文件过滤快一个数量级 return excludes.contains(name) ? FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE; } @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { if (!attrs.isRegularFile()) return FileVisitResult.CONTINUE; // 跳过设备/管道 if (attrs.size() == 0) return FileVisitResult.CONTINUE; // 空文件无需检测 if (attrs.size() > 200L * 1024 * 1024) return FileVisitResult.CONTINUE; String name = file.getFileName().toString(); int dot = name.lastIndexOf('.'); if (dot > 0 && includes.contains(name.substring(dot + 1).toLowerCase())) { files.add(file); } return FileVisitResult.CONTINUE; } @Override public FileVisitResult visitFileFailed(Path file, IOException exc) { // 权限不足或无权限的软链,记录后继续,不中断整次扫描 return FileVisitResult.CONTINUE; } }); return files; }三个细节。EnumSet.noneOf(FileVisitOption.class)表示不跟随符号链接,这是故意的——仓库里指向外部目录的软链一旦跟随,轻则重复扫描,重则扫进/proc这种无限深度的地方,跑一晚上出不来。preVisitDirectory里做目录级剪枝,target、node_modules、.git整个子树直接不进,比读进来再判断快得多,这在node_modules动辄十几万文件的场景下是决定性的。visitFileFailed必须重写,默认实现会直接抛异常终止遍历,而真实仓库里总有那么几个无权限文件,一个坏文件不该毁掉整次扫描。至于attrs.size()的两个判断,空文件没有讨论编码的意义,超大文件(这里卡 200MB)通常是数据库转储或日志包,检测成本高且结果不可信,直接跳过并在报告里标注SKIPPED。
4.2 采样读取:大文件只读头尾若干块
public byte[] readSample(Path file, int sampleSize) throws IOException { long size = Files.size(file); try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) { if (size <= sampleSize) { ByteBuffer buf = ByteBuffer.allocate((int) size); while (buf.hasRemaining() && ch.read(buf) > 0) { /* 读满或读完 */ } return buf.array(); } // 大文件:取头部 3/4 采样量 + 尾部 1/4,覆盖注释头和日志尾部 int headLen = sampleSize * 3 / 4; int tailLen = sampleSize - headLen; ByteBuffer head = ByteBuffer.allocate(headLen); while (head.hasRemaining() && ch.read(head) > 0) { /* ... */ } ByteBuffer tail = ByteBuffer.allocate(tailLen); ch.position(size - tailLen); while (tail.hasRemaining() && ch.read(tail) > 0) { /* ... */ } byte[] result = new byte[headLen + tailLen]; System.arraycopy(head.array(), 0, result, 0, headLen); System.arraycopy(tail.array(), 0, result, headLen, tailLen); return result; } }采样策略比"读前 64KB"要讲究。只读头部会遇到一个典型场景:某个 Java 文件前面 3000 行全是英文注释,中文只在第 5000 行出现,头部采样下来全是 ASCII,结论全是"无法判定"。加上尾部采样能覆盖大部分日志和长文件的情况,命中率明显提升。参数sampleSize默认 256KB,够容纳约 8 万个中文字符,对判定来说统计意义已经足够。ch.position(size - tailLen)做随机读,不会把整个文件流式读完,内存占用恒定。返回值拼接后会丢掉中间的断点信息,所以Utf8Validator里那个i + need >= len的边界判断才必要——采样拼接处必然是"断裂"的,不能把断裂当非法。
4.3 并发:固定线程池 + 有序汇总
public List<DetectionResult> detectAll(List<Path> files, int threads) throws Exception { ExecutorService pool = Executors.newFixedThreadPool(threads, r -> { Thread t = new Thread(r, "enc-check-" + UUID.randomUUID()); t.setDaemon(true); // 主线程退出时不阻塞 JVM return t; }); try { // 提交顺序与结果顺序一致,报告才能稳定 diff List<Future<DetectionResult>> futures = new ArrayList<>(files.size()); for (Path f : files) { futures.add(pool.submit(() -> detectOne(f))); } List<DetectionResult> results = new ArrayList<>(files.size()); for (int i = 0; i < futures.size(); i++) { try { results.add(futures.get(i).get()); } catch (ExecutionException e) { // 单文件失败降级为 UNKNOWN,不中断整批 results.add(DetectionResult.unknown(files.get(i), e.getCause())); } } return results; } finally { pool.shutdownNow(); } }用固定线程池而不是parallelStream,理由很实在:parallelStream底层是 ForkJoinPool,遇到阻塞 I/O 会占着工作线程,容易把公共池打满,而且顺序不保证——报告顺序乱跳,git diff时每次都全变,根本没法用。这里按提交顺序futures.get(i),既并发又保序。threads默认取Runtime.getRuntime().availableProcessors(),但纯 I/O 任务其实可以开到 2 倍核数,磁盘是 NVMe 时收益明显;机械盘则相反,线程多了只会让磁头来回寻道,此时--threads 2更合适。线程设成 daemon 是为了防止某个文件卡在异常 I/O 上导致 JVM 不退出,配合finally里的shutdownNow兜底。
5. 避坑与排查:五类编码判定翻车现场
编码检测的不确定性决定了它一定会有误判,区别只在于你知不知道它会在哪误判。下面五条是实际跑仓库时最常撞上的,每条都按现象、原因、解决三步记。
5.1 判定环节的三类误判
第一条:纯英文文件被判成"不符合 UTF-8"。
现象:--target UTF-8模式下,一批 README、配置文件报不合规,打开看全是英文。原因:检测器对纯 ASCII 文件没有强证据,某些候选打分接近,输出了 GBK 或 ISO-8859-1,于是和期望编码比对失败。解决:在CharsetDetector入口加一条短路——采样数据里所有字节都小于 0x80 时,直接返回US-ASCII,并在比较逻辑里把 ASCII 视为 UTF-8 的子集放行。这条规则我一开始没加,导致第一版报告里 40% 是误报,被同事笑了很久。
第二条:GBK 文本恰好是合法 UTF-8,被判成 UTF-8。
现象:一个 GBK 编码的 Java 文件,里面只有"中文"两个字,检测结果是 UTF-8,置信度还挺高。原因:两个字对应 4 个字节,其中某些组合恰好满足 UTF-8 的多字节结构,样本越短,巧合概率越高。解决:对采样长度小于 512 字节的文件,把置信度乘以 0.5 的惩罚系数,低于--min-confidence时报告里标记为UNCERTAIN而不是直接判失败。这是概率问题,压不住,只能承认它不确定。业务上更靠谱的做法是给这类文件加白名单。
第三条:无 BOM 的 UTF-16 文件被判成二进制或 GBK。
现象:某个导出文件里全是\0和乱码字符,工具报"可能是 GB18030"。原因:UTF-16 的零字节特征只在西方文本里明显,中文占比一高,零字节分布被打散,scoreUtf16NoBom拿不到高分,反而被 GBK 的合法性得分压过去。解决:在打分前加一道"零字节占比"预判——如果采样中 0x00 的比例超过 15%,优先尝试 UTF-16 解码并验证解码后是否全是可打印字符,通过就直接定论,不再进 GBK 分支。
5.2 转换与运行环节的两类问题
第四条:转码之后换行符变了,BOM 也没了。
现象:--fix转完,git diff显示每一行都是修改,明明内容没动。原因:用new String(bytes, charset)加getBytes(target)做转换,字符串层面看起来没问题,但原文件里的\r\n在读入时被按行拆过又拼回来,或者被Files.writeString按平台默认行尾重写。解决:转换全程走字节,不经过字符串切割。
public void convert(Path src, Charset from, Charset to, boolean backup) throws IOException { byte[] raw = Files.readAllBytes(src); // 按源编码严格解码,遇到非法字节直接失败,绝不静默替换成 '?' CharsetDecoder dec = from.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT); CharBuffer chars = dec.decode(ByteBuffer.wrap(raw)); ByteBuffer out = to.newEncoder().encode(chars); if (backup) { Files.copy(src, src.resolveSibling(src.getFileName() + ".bak"), StandardCopyOption.REPLACE_EXISTING); } // 先写临时文件再原子替换,中途崩溃不会留下半个文件 Path tmp = Files.createTempFile(src.getParent(), ".enc-", ".tmp"); Files.write(tmp, toBytes(out)); Files.move(tmp, src, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); }参数说明:dec的两个REPORT策略是这套代码的安全阀,任何无法映射的字节都会抛异常而不是被替换成?——转码最怕的就是"成功了一半",文件看起来正常,实际上丢了几十个字符。tmp加ATOMIC_MOVE保证原子性,同一分区内move是原子操作,进程被杀也不会留下损坏文件。备份用resolveSibling放在同目录,方便用户直接对比。
第五条:Windows 控制台输出全是问号。
现象:Linux 上跑得好好的报告,在 Windows 的 cmd 里打印出来中文乱码。原因:不是文件编码问题,是标准输出的字符集。JDK 18 之前System.out用的是平台默认编码,中文 Windows 上是 GBK,遇到 UTF-8 字符就变问号。解决:启动参数加-Dstdout.encoding=UTF-8,或者干脆不在控制台打中文,报告统一写文件,控制台只输出统计数字和路径。我后来的做法是把--report的默认值从"控制台"改成encoding-report.json,控制台只打一行汇总,问题直接消失。
6. 二次开发:把检查器接进构建流水线与自定义规则
工具写完自己用是一回事,让它在团队里长期活着是另一回事。临时跑一次的报告没人看,只有卡在提交和构建环节,编码问题才不会再堆积。
6.1 自定义规则:把"必须 UTF-8"变成一条可执行断言
最常被提的需求是"我们仓库规定所有 Java 文件必须 UTF-8,但 properties 允许 ISO-8859-1"。硬编码在检测器里改起来麻烦,抽一个规则接口更合适。
@FunctionalInterface public interface EncodingRule { Verdict check(DetectionResult result); } // 按扩展名分派不同期望编码,落在配置里而不是代码里 public class ExtensionRule implements EncodingRule { private final Map<String, Charset> expected = Map.of( "java", StandardCharsets.UTF_8, "properties", StandardCharsets.ISO_8859_1, "md", StandardCharsets.UTF_8 ); private final Charset fallback; public ExtensionRule(Charset fallback) { this.fallback = fallback; } @Override public Verdict check(DetectionResult r) { String name = r.file().getFileName().toString(); int dot = name.lastIndexOf('.'); String ext = dot > 0 ? name.substring(dot + 1).toLowerCase() : ""; Charset want = expected.getOrDefault(ext, fallback); // 置信度不足时降级为 WARN,避免把不确定当错误 if (r.confidence() < 0.85f) { return new Verdict(Level.WARN, "置信度不足:" + r.reason()); } return r.charset().equals(want) ? new Verdict(Level.PASS, "ok") : new Verdict(Level.FAIL, "期望 " + want + ",实际 " + r.charset()); } }接口只有一个方法,实现类可以随便加:白名单路径规则、文件大小规则、生成代码目录豁免规则。Verdict用Level三态而不是布尔值,是因为"不确定"和"确定错误"必须分开——前者只告警,后者才让流水线红。r.charset().equals(want)这里要注意Charset.equals比较的是规范名,GBK和GB18030不相等,如果团队里两种写法混用,得先Charset.forName归一化再比。
6.2 接进流水线:退出码、报告与增量检查
退出码的语义在设计时就要钉死,否则 CI 脚本没法写判断。
| 退出码 | 含义 | 流水线动作 |
|---|---|---|
| 0 | 全部文件符合规则 | 继续 |
| 1 | 存在 FAIL 级别的不合规文件 | 中断,附报告 |
| 2 | 参数错误或根目录不存在 | 中断,检查脚本 |
| 3 | 扫描过程中出现未恢复的 IO 异常 | 中断,人工介入 |
流水线里的调用通常长这样,先全量检查,把报告存成构件,失败时直接把 JSON 里的failures数组打印出来:
#!/usr/bin/env bash set -o pipefail java -jar encodingchecker.jar check \ -p ./src/main \ -i java,xml,properties,md \ -t UTF-8 \ --min-confidence 0.9 \ --report build/encoding-report.json code=$? if [ "$code" -eq 1 ]; then # 只打印不合规文件,避免日志刷屏 jq -r '.failures[] | "\(.file) expected=\(.expected) actual=\(.actual)"' \ build/encoding-report.json | head -n 50 exit 1 fi exit "$code"全量扫描在几万文件的仓库上通常要十几秒,放进每次提交的钩子里偏重。增量方案是只检查git diff --name-only出来的文件列表:给 CLI 加一个--file-list参数,从 stdin 读路径,跳过目录遍历。这个改动很小,FileScanner里加一个分支,把walkFileTree换成Files.readAllLines(stdin)即可,但体验上的差别很大——提交时只检查改动的三个文件,几百毫秒就结束,没人会想着去绕过它。
我在这个工具上最实在的一条教训是:第一版把--fix做成了默认可用,某次在别人的机器上跑,备份开关被人关掉,一个目录的 GBK 文件全转成了 UTF-8,其中几个本来就用 ISO-8859-1 存的 properties 直接废掉,只能从 git 里捞回来。从那以后我每次写会修改文件的工具,都强制让"写"和"读"走两条完全独立的代码路径,转换前必须落备份、必须原子替换、必须先跑一遍 dry-run 看报告。希望这些坑你不用再踩一遍,工具拿去跑之前,先在--dry-run下看一遍报告,比什么都强。
本文还有配套的精品资源,点击获取