原创

UTF-8 坏字节怎样处理:替换、忽略与严格解码的不同后果

new String(bytes, UTF_8) 返回了一个字符串,只能说明这批字节被转换成了可用的 Java 对象,不能说明输入原本就是合法 UTF-8。错误字节可能已经被替换成替代字符,原始信息也就丢了。

展示日志时,尽量显示后续文字有时合理;导入账号、文件标识、签名原文时,静默替换就可能改变身份。尤其不能先宽松解码,再检查字符串里有没有乱码符号,以此代替严格字节校验:替代字符也可能本来就是输入中的合法内容。

Java 17 的 CharsetDecoder 可以明确选择坏输入策略。下面把三种策略、分块时半个字符的保留、文件结尾的截断序列放进同一个程序。代码不读取外部文件,所有字节都直接列出,结果可以逐项核对。

一段坏输入会变成三种不同结果

输入是 ASCII 字节 a、非法 UTF-8 字节 FF、ASCII 字节 b。选择 REPLACE 会保留两边文字并插入替代字符;选择 IGNORE 会直接丢掉错误输入;选择 REPORT 则把错误交给调用者。

丢掉坏字节不是“修复为正确编码”。例如两个原始标识分别包含不同非法字节,忽略后都可能变成 ab。替换同样可能把不同输入合成相同字符串。这对展示未必致命,对唯一键和身份判断却是实质改变。输入协议要求合法 UTF-8 时,应先拒绝坏字节,再讨论业务格式。

CharsetDecoder 默认的错误策略是报告错误,但建议在关键入口显式写出,方便审查。它与带 Charset 参数的 String 构造器采取的行为不能混为一谈。把一种入口换成另一种入口,正是需要重新检查失败协议的地方。

完整实验:坏字节和跨块字符一起核对

保存为 DecodePolicy.java,使用 JDK 17 运行,不需要任何依赖。

import java.nio.*;
import java.nio.charset.*;
import java.util.*;

public class DecodePolicy {
    static String decode(byte[] bytes, CodingErrorAction action)
            throws CharacterCodingException {
        return StandardCharsets.UTF_8.newDecoder()
                .onMalformedInput(action).onUnmappableCharacter(action)
                .decode(ByteBuffer.wrap(bytes)).toString();
    }

    static String chunks(List<byte[]> parts) throws CharacterCodingException {
        CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
                .onMalformedInput(CodingErrorAction.REPORT)
                .onUnmappableCharacter(CodingErrorAction.REPORT);
        ByteBuffer input = ByteBuffer.allocate(8);
        CharBuffer output = CharBuffer.allocate(4);
        StringBuilder text = new StringBuilder();
        for (byte[] part : parts) {
            if (part.length > input.remaining()) throw new IllegalArgumentException("chunk too large");
            input.put(part).flip();
            for (;;) {
                CoderResult result = decoder.decode(input, output, false);
                output.flip(); text.append(output); output.clear();
                if (result.isError()) result.throwException();
                if (result.isUnderflow()) break;
            }
            input.compact(); // Preserve a multibyte sequence that has not finished.
        }
        input.flip();
        for (;;) {
            CoderResult result = decoder.decode(input, output, true);
            output.flip(); text.append(output); output.clear();
            if (result.isError()) result.throwException();
            if (result.isUnderflow()) break;
        }
        for (;;) {
            CoderResult result = decoder.flush(output);
            output.flip(); text.append(output); output.clear();
            if (result.isError()) result.throwException();
            if (result.isUnderflow()) break;
        }
        return text.toString();
    }

    public static void main(String[] args) throws Exception {
        byte[] broken = {'a', (byte) 0xff, 'b'};
        System.out.println("String=" + new String(broken, StandardCharsets.UTF_8));
        System.out.println("REPLACE=" + decode(broken, CodingErrorAction.REPLACE));
        System.out.println("IGNORE=" + decode(broken, CodingErrorAction.IGNORE));
        try {
            decode(broken, CodingErrorAction.REPORT);
            throw new AssertionError("expected malformed input");
        } catch (MalformedInputException expected) {
            System.out.println("REPORT=" + expected.getClass().getSimpleName());
        }
        System.out.println("chunks=" + chunks(List.of(
                new byte[]{(byte) 0xe4}, new byte[]{(byte) 0xb8, (byte) 0xad})));
        try {
            chunks(List.of(new byte[]{(byte) 0xe4}));
            throw new AssertionError("expected truncated sequence");
        } catch (MalformedInputException expected) {
            System.out.println("truncated=" + expected.getClass().getSimpleName());
        }
    }
}

执行:

java DecodePolicy.java

核对结果为:

String=a�b
REPLACE=a�b
IGNORE=ab
REPORT=MalformedInputException
chunks=中
truncated=MalformedInputException

终端需要能够显示 UTF-8;终端字体不支持替代字符时,它可能看起来像空方框,这不改变字符串中的字符值。真正要做自动断言时,可以比较码点或转义值,不应依赖截图里这个符号画得像不像问号。

REPORT 的错误也不要原样变成面向用户的英文堆栈。导入任务可以将其转换成明确的“输入不符合 UTF-8 编码”错误,并按任务协议决定整批失败还是记录该输入失败。只要业务没有允许部分接收,就不能捕获异常以后悄悄继续使用前半段内容。

一个字符拆到两块,不是输入损坏

汉字中的 UTF-8 字节是 E4 B8 AD。程序把第一块设成 E4,第二块设成 B8 AD。如果对两块分别调用 new String,每块都不是独立完整的字符序列;宽松入口会产生替代字符,严格入口则会误以为输入坏了。

网络和文件块边界没有义务对齐字符边界。正确做法是保留上一块末尾尚未完成的字节,拼上下一块,再让同一个解码器继续处理。示例中负责保留这些字节的是 ByteBuffer.compact(),不是把每个块先变成字符串再拼接。

每一轮先 flip,让解码器读取已写入的部分;解码结束后 compact,把未消费字节移到缓冲区前部,后面的空间继续接收新数据。输出缓冲区满时返回 OVERFLOW,先取走已有字符,再继续解码同一批剩余字节。输入暂时不足则得到 UNDERFLOW,它在非结尾阶段可以表示“等下一块”,并不自动意味着失败。

这份实现为教学使用,输入缓冲区只有八字节,而且明确拒绝放不下的输入块。若改成读取真实流,需要让读操作直接利用缓冲区剩余空间,或拆分大块继续送入,不能删掉检查后期待 ByteBuffer 自动增长。输出则汇总到 StringBuilder,只适合有界文本;大文件应把已解码字符写给下游 Writer,并单独设置字节数、字符数和行长限额。

到了 EOF,必须告诉解码器这就是结尾

只收到 E4 还没拿到后两个字节,在流中途可以等待;到了 EOF,它就已经是一段截断输入。程序通过最后一次 decode(..., true) 传达这个事实,所以 truncated 得到 MalformedInputException。

如果每次都传 false,或者读完输入后直接取走已有输出,这类截断可能没有在预期位置被拒绝。结束解码以后还按状态机调用 flush,让有内部状态的解码器完成收尾。即便当前 UTF-8 样本不需要额外输出,通用解码流程仍应表达完整生命周期,而不是靠这个字符集的偶然简单省略收尾。

同一个 decoder 不适合被多个线程并发使用,也不能把上一份独立文件的未完成状态带到下一份文件里。最简单的边界是每次独立输入创建一个 decoder;需要复用时,必须在新的解码操作前 reset,并确保没有并发调用。线程安全的 Charset 对象与有状态的 CharsetDecoder 对象,不能因为名字相近就按同一种方式缓存。

严格 UTF-8 也不是全部文本校验

解码通过以后,文本仍可能包含控制字符、不可见字符、合法但业务禁止的符号,也可能存在 Unicode 归一化差异。那是字符层和业务层的规则,应在成功解码后继续检查。严格解码只回答字节序列是否符合指定编码,不替业务选择允许的账号字符集。

反过来,遇到错误也不能自动断言文件一定是另一种编码,更不能依次尝试多个字符集,挑一个没有报错的就当正确结果。有些字符集能为很多字节给出映射,成功解码不构成格式识别证据。输入应该带有明确编码约定;历史数据确需迁移时,保留原始字节和判定依据,单独处理歧义记录。

选择错误策略的依据是输入的用途。用于机器判断的标识、结构化导入和协议字段,通常更需要保留“无效”这个状态;面向人的不完整日志可以容忍替代字符,但也应留下解码失败的信号。不要让一段方便的字符串构造调用,在没有业务决定的情况下替你丢掉输入信息。

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