原创

Files.readString 读对了 UTF-8,首列为什么还是多一个字符

CSV第一列明明叫 id,程序却找不到这个键。把字段打印到日志里,肉眼看起来也完全一样。这时值得检查的不是Map大小写,而是文件开头有没有UTF-8 BOM。

UTF-8 BOM的三个字节是EF BB BF。Java 17用UTF-8读取时,这三个字节可以被正确解码为U+FEFF;正确解码不意味着自动从业务文本中移除它。于是首列可能实际上是U+FEFF加上id,String.equals当然不等于普通id。

下面用真实临时文件核对这一点。保存为 Utf8Bom.java,使用JDK 17执行 java Utf8Bom.java,不依赖编辑器怎样保存编码。

import java.nio.charset.StandardCharsets;
import java.nio.file.*;
public class Utf8Bom {
    static String withoutInitialBom(String text) {
        return !text.isEmpty() && text.charAt(0) == '\ufeff' ? text.substring(1) : text;
    }
    public static void main(String[] args) throws Exception {
        Path file = Files.createTempFile("utf8-bom-", ".csv");
        try {
            byte[] body = "id,name\n1,alice\n".getBytes(StandardCharsets.UTF_8);
            byte[] all = new byte[3 + body.length];
            all[0] = (byte)0xef; all[1] = (byte)0xbb; all[2] = (byte)0xbf;
            System.arraycopy(body, 0, all, 3, body.length); Files.write(file, all);
            String text = Files.readString(file, StandardCharsets.UTF_8);
            System.out.println("first=" + Integer.toHexString(text.charAt(0)));
            System.out.println("raw_key=" + text.substring(0, text.indexOf(',')).equals("id"));
            String clean = withoutInitialBom(text);
            System.out.println("clean_key=" + clean.substring(0, clean.indexOf(',')).equals("id"));
            String inside = "a\ufeffb";
            System.out.println("inside_preserved=" + withoutInitialBom(inside).equals(inside));
        } finally { Files.deleteIfExists(file); }
    }
}

输出:

first=feff
raw_key=false
clean_key=true
inside_preserved=true

先看字符值,不要只看终端画面

first=feff 比一段看起来正常的日志更有诊断价值。不可见字符可能不占可见位置,复制、字体和日志展示还可能进一步遮住差异。对首列或配置键,可以临时记录转义后的字符值和长度,再按敏感数据要求限制输出范围。

本例读取的是UTF-8文件,BOM仍作为一个字符进入字符串。它与“用错误字符集读取而乱码”不是同一种问题。把读取字符集改来改去,可能让普通中文也坏掉,却没有解决文件格式对起始标记的约定。

UTF-16则有字节序标记与解码器选择的另一层语义,不能根据UTF-8实验直接断言所有编码的BOM都会以同样方式保留。排查时应先保留原始头部字节,确认实际输入格式,再讨论文本层修正。

起始标记的处理必须有明确作用域

示例函数只在第零个字符等于U+FEFF时移除一次。它不扫描整个字符串。文件正文中出现相同字符时,是否有意义、是否允许,应由内容协议判断,不能因为它在开头可作标记,就把所有位置都删掉。

同理,不应为了解决首列问题无条件trim整个输入。trim和strip处理的是各自定义的空白集合,它们也会改变业务原本可能需要保留的首尾空白。即使某种清洗碰巧让样本通过,它仍不是针对BOM的精确规则。

处理位置也应固定。如果文件读取层已剥离标记,CSV解析层就不必再从每个字段移除;若只在字段层修补第一个键,其他使用同一文本的入口可能继续遇到问题。最容易维护的方式,是在文件格式适配边界完成一次,后续解析收到的都是已确定规则的文本。

大文件不必先整体读成 String

这个程序为了突出问题使用Files.readString,小样本完全够用。实际导入大文件时,可以在字符Reader开头探测一个字符,必要时用PushbackReader回放不属于标记的字符,再交给真正的解析器。不要为了移除三个字节,把整份文件额外复制几次。

如果按字节探测,要正确处理不足三个字节的输入,并保留不匹配的前缀。一次read调用也不保证凑齐整个头部,尤其当输入来自网络或包装流时。把探测器写成“读一次三个字节,不够就忽略”会丢掉合法短文件内容。

BOM并不能证明文件剩余内容一定合法UTF-8,更不能证明它是可信CSV。仍需有编码错误策略、体积和行长限制,以及具体CSV语法处理。只有去掉标记,首列就能识别,只说明这一个失败原因已经被定位。

迁移旧清单时也要留意键冲突。两条原来分别带和不带U+FEFF的键,清理以后可能合并;直接覆盖会掩盖数据异常。先列出碰撞并按业务决定,比对所有键运行一次全局替换安全得多。

这类问题适合用一个精确样本收口:带BOM的文件、不带BOM的文件、只有BOM的文件、正文中包含U+FEFF的文件。断言头部按约定处理、正文按原值保留。看得见的列名只是展示,真正参与比较的是那一串字符。

正文到此结束
评论插件初始化中...
Loading...
本文目录