DataOutputStream.writeUTF 写出的不是普通 UTF-8 文本
Java两端用writeUTF和readUTF通信一直正常,换成别的语言直接按UTF-8读,字符串却坏了。问题往往不在网络丢包,而在方法名里的UTF省略了关键限定:这里是Java的modified UTF-8格式。
它还有一个两字节长度头,长度表示后续编码字节数,不是字符数。空字符与补充平面字符的编码方式也与标准UTF-8不同。只在文档里写“字符串用UTF-8”,不足以让另一端正确实现这套协议。
用Java 17保存下面代码为 ModifiedUtf.java,运行 java ModifiedUtf.java。测试字符串包含A、空字符和一个表情,普通中文未必能暴露的差异会直接出现在字节里。
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
public class ModifiedUtf {
static byte[] wire(String value) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (DataOutputStream out = new DataOutputStream(bytes)) { out.writeUTF(value); }
return bytes.toByteArray();
}
public static void main(String[] args) throws Exception {
String text = "A\0\ud83d\ude00";
byte[] data = wire(text);
System.out.println("utf8=" + HexFormat.of().formatHex(text.getBytes(StandardCharsets.UTF_8)));
System.out.println("wire=" + HexFormat.of().formatHex(data));
try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(data))) {
System.out.println("roundtrip=" + in.readUTF().equals(text));
}
try { wire("a".repeat(65536)); throw new AssertionError(); }
catch (UTFDataFormatException expected) { System.out.println("ascii_limit=REJECTED"); }
try { wire("中".repeat(21846)); throw new AssertionError(); }
catch (UTFDataFormatException expected) { System.out.println("multibyte_limit=REJECTED"); }
}
}
输出:
utf8=4100f09f9880
wire=000941c080eda0bdedb880
roundtrip=true
ascii_limit=REJECTED
multibyte_limit=REJECTED
多出来的不只是一段长度头
标准UTF-8里,NUL可用一个零字节表示;modified UTF-8采用不同形式。补充平面字符在Java字符串中由代理对表示,这套格式又分别编码相应码元,因此字节数与标准UTF-8不相同。
从输出可以看见,wire前面还有长度字段。把整段wire交给普通UTF-8解码器,先把二进制头当成正文就已经错了;即使跳过两字节头,也不能保证剩余payload是标准UTF-8。协议边界与字符编码是两项都要处理的内容。
readUTF能还原原值,只证明匹配的Java读写方法遵循同一格式。它没有证明这个格式等于HTTP、JSON文件或其他语言库所说的UTF-8。跨语言对接时,给出准确格式与字节样本,比双方各自贴一段“UTF8编码”代码更直接。
65535 限制约束的是编码字节
超过长度表示范围时,writeUTF会失败。不是只有六万多个字符才会触发:一个字符占多少编码字节取决于内容。大量多字节字符可能在Java字符串长度还没有到65535时就已经超出。
因此不能用 text.length() <= 65535 作为充分校验。若保留此协议,需要按modified UTF-8规则确认可编码长度,或明确处理写入失败。对不受控输入,还应有更小的业务预算,不应把格式理论上限直接当作可接受请求规模。
另一端读取长度以后也必须先限制资源,再分配和消费正文。读到了一个长度字段不等于对方真的发送了足够数据,也不等于这份输入值得被接收。截断、超时和非法字节各自要有失败路径,不能把后续解析异常统一当成空字符串。
新协议为什么更适合显式定义
如果无需兼容已有writeUTF数据,可以把协议写得更直白:使用标准UTF-8得到字节,长度头采用明确字节序与宽度,先校验允许长度,再发送字节。这样其他语言只需实现已约定的分帧与标准字符编码。
不过这是一项协议迁移,不是单端性能优化。已有接收方仍按readUTF读,新发送方改成标准UTF-8加任意长度头,就会立刻不兼容。需要版本标识、双读阶段或独立端点,逐步核对历史数据与新数据。存量文件也不能靠“尝试解码成功”猜是哪种版本。
若数据实际上是文本文件或HTTP JSON,通常不应为了Java方便再套这一层二进制字符串格式。让文本格式负责边界和转义,避免一段普通日志需要另一端先猜长度头。
还要防止把另一种方法当替代品
writeBytes会按每个字符的低位写字节,并不是“按UTF-8写所有字符”;writeChars又是另一种按字符写两字节的形式。方法名字相近,不代表可交换。正确替代应该从协议要求出发,再选对应API。
本例同时打印标准UTF-8、wire与回读结果,就是为了让三层证据对应起来。只用ASCII做连通测试,很多格式差异会暂时藏起来;加入NUL、非ASCII、补充字符与长度边界,才更接近真实兼容检查。写清楚字节协议,才能知道某一端升级时究竟改了什么。