按 UTF-8 字节预算保留文本前缀:代理项、码点与省略号
一个字段最多容纳八个 UTF-8 字节,输入是 A中😀B。按 Java 字符串长度截八个位置,没有限制住字节;先编码再取前八个字节,碰到另一个预算又可能切断多字节字符。要返回合法文本前缀,必须同时知道字符边界与编码长度。
这不是“中文一律三个字节”的问题。ASCII、其他文字和补充平面字符的长度不同;Java String 的下标又按 UTF-16 代码单元计数,一个 emoji 可能占两个 char。本文做一个明确的小工具:接受合法 UTF-16 字符串,返回在预算内最长的完整码点前缀,并明确告诉调用方是否截断。
截断合同先于实现
这里的预算指原始 UTF-8 字节,不含 BOM、JSON 引号、转义、压缩或传输头。结果保留原字符串的连续前缀,不跳过中间放不下的字符去凑后面的空间。例如 A 和中占四字节,下一个 emoji 需要四字节;预算五时,应停在 A中,不能略过 emoji 把末尾 B 接上。
输入若含孤立代理项,整条请求失败。即使坏字符位于已经超出预算的尾部,也照样拒绝,这样函数不会一会儿充当校验器、一会儿悄悄忽略坏输入。为了使全量校验成本有界,本例另外限制字符串最多一百万个 char;这是工具的输入策略,可以按实际字段收窄。
最后一个边界需要先承认:完整码点不等于完整的视觉字符。字母加组合重音、肤色修饰符、旗帜和零宽连接符序列,可能由多个码点构成。本文保护编码合法性,若产品要求视觉字符不拆开,还需要按字素簇规则分段,不能把这个函数直接命名成“完整字符截断”。
从 UTF-16 走到字节预算
Java 17 中不需要额外依赖。保存下面程序为 Utf8Prefix.java,执行 java Utf8Prefix.java:
import java.nio.ByteBuffer;
import java.nio.charset.*;
import java.util.Objects;
public class Utf8Prefix {
private static final int MAX_INPUT_CHARS = 1_000_000;
private static final int MAX_BUDGET = 1_000_000;
record Prefix(String text, int bytes, boolean truncated) {}
static Prefix take(String source, int budget) {
Objects.requireNonNull(source, "source");
if (budget < 0 || budget > MAX_BUDGET) throw new IllegalArgumentException("invalid budget");
if (source.length() > MAX_INPUT_CHARS) throw new IllegalArgumentException("input too long");
int end = 0, used = 0;
boolean accepting = true;
for (int i = 0; i < source.length();) {
char first = source.charAt(i);
int cp, next;
if (Character.isHighSurrogate(first)) {
if (i + 1 == source.length() || !Character.isLowSurrogate(source.charAt(i + 1)))
throw new IllegalArgumentException("unpaired surrogate at " + i);
cp = Character.toCodePoint(first, source.charAt(i + 1));
next = i + 2;
} else {
if (Character.isLowSurrogate(first))
throw new IllegalArgumentException("unpaired surrogate at " + i);
cp = first;
next = i + 1;
}
int bytes = cp <= 0x7f ? 1 : cp <= 0x7ff ? 2 : cp <= 0xffff ? 3 : 4;
if (accepting && bytes <= budget - used) {
used += bytes;
end = next;
} else accepting = false;
i = next;
}
return new Prefix(source.substring(0, end), used, end < source.length());
}
static String marked(String source, int budget) {
Prefix full = take(source, budget);
if (!full.truncated()) return full.text();
int markerBytes = 3; // UTF-8 for U+2026.
if (budget < markerBytes) throw new IllegalArgumentException("marker does not fit");
return take(source, budget - markerBytes).text() + "\u2026";
}
public static void main(String[] args) throws Exception {
String value = "A中😀B";
for (int budget = 0; budget <= 9; budget++) {
Prefix p = take(value, budget);
byte[] encoded = p.text().getBytes(StandardCharsets.UTF_8);
StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(encoded));
if (encoded.length != p.bytes() || encoded.length > budget || !value.startsWith(p.text()))
throw new AssertionError("prefix invariant");
System.out.println(budget + " => [" + p.text() + "], bytes=" + p.bytes()
+ ", truncated=" + p.truncated());
}
String marked = marked(value, 8);
System.out.println("marked=[" + marked + "], bytes=" + marked.getBytes(StandardCharsets.UTF_8).length);
System.out.println("combining=[" + take("e\u0301", 1).text() + "]");
try { take("ok\uD800", 1); }
catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
try { marked(value, 2); }
catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
}
}
运行结果:
0 => [], bytes=0, truncated=true
1 => [A], bytes=1, truncated=true
2 => [A], bytes=1, truncated=true
3 => [A], bytes=1, truncated=true
4 => [A中], bytes=4, truncated=true
5 => [A中], bytes=4, truncated=true
6 => [A中], bytes=4, truncated=true
7 => [A中], bytes=4, truncated=true
8 => [A中😀], bytes=8, truncated=true
9 => [A中😀B], bytes=9, truncated=false
marked=[A中…], bytes=7
combining=[e]
unpaired surrogate at 2
marker does not fit
预算从零到九,正好越过 ASCII、汉字和 emoji 的几个边界。三字节预算仍只能保留 A;四到七都保留 A中;八可以加入 emoji;九才容纳末尾 B。程序没有为了用满预算而跳过字符,所以返回字节数允许小于预算。
每一步还把结果重新编码,用实际 UTF-8 数组长度验证报告的 bytes,并用严格解码器检查编码结果。没有以输出里是否出现问号来猜测成功。问号本来就是合法字符,替换字符也可能是用户确实输入的内容;测试应该验证编码合同与前缀关系,而不是禁掉某个看起来像错误的符号。
为什么不直接循环 char
高代理项必须紧跟低代理项,两者组成一个补充码点;孤立的高项和低项都被拒绝。合法 BMP 码点按范围占一至三字节,补充码点占四字节。代码只有在完成代理项判断以后,才据此计算 UTF-8 长度,避免把两个 char 当成两段各自合法的字符。
普通 String.getBytes(UTF_8) 会使用编码器定义的替换行为处理某些非法输入,因此它适合最后编码合法结果,却不应该承担这里“必须拒绝孤立代理项”的校验合同。另一种实现是显式使用 REPORT 的 CharsetEncoder,分块写入受限缓冲区;那条路线同样可行,但需要正确处理剩余输入、输出空间不足与结束状态。
本例一次扫描完整输入,只在预算允许的连续区间更新 end。accepting 一旦变成 false,就不会重新打开,但扫描仍继续完成合法性检查。最终只构造一个 substring,避免每读一个字符就重新编码整段前缀造成重复工作。没有给出具体性能倍数;这里能直接从循环说明的是扫描次数与对象创建位置。
预算和输入上限都限定为一百万,所以 used 与差值计算不会接近 int 上界。若工具改成处理超大流,应重新设计长整型计数、分块编码和结果输出,不应仅把一个常量改成 Integer.MAX_VALUE 后继续一次性保留字符串。
省略号也要占预算
显示层想加一个省略号时,不能先拿满八字节,再追加标点。U+2026 在 UTF-8 中需要三字节,示例 marked 先判断是否真的发生截断,只有需要标记时才预留三字节。八字节预算下输出 A中…,占七字节;余下一字节放不下下一个四字节码点,因此没有用满也合理。
预算少于三字节且需要截断时,这个接口明确失败。也可以设计成不加标记、返回空串或另给 truncated 字段,但必须写成产品合同。随意让省略号本身超长,会把“按字节截断”这个承诺破坏在最后一步。对于不允许截断的业务字段,应直接拒绝,而不是调用 marked 然后把改变后的值存回去。
标点如果换成三个 ASCII 点,预留字节数碰巧仍是三,但它与单个省略号的视觉、文本和检索语义都不同。标记字符串来自配置时,应先严格验证并计算它的实际编码长度,不能把中文标点、图标和 HTML 实体都当作同一种三字节后缀。
规范化顺序也会影响字节预算。视觉上接近的预组合字母与字母加组合标记,可能具有不同的码点数和 UTF-8 长度。如果业务规定先做 NFC 等规范化,应先完成规范化和输入上限检查,再对最终要保存的文本计算预算。截断后又执行会改变编码长度的转换,会让先前的计数失去对应对象。
这里选择逐字保留原始前缀,所以没有悄悄做大小写转换或规范化。是否把两种视觉相同的文本视为同一个标识,是另一项业务决定;字节工具只承诺它接收到的字符串。测试同样应检查返回值确实是输入的前缀,不能只比较显示截图,否则文本被替换、重排甚至跳过时,也可能被误认成截断正确。
视觉边界和存储边界怎样分工
输出中的 combining 行把 e 与组合重音分开,故意展示码点边界的限制。若是 UI 摘要,应先用满足目标 Unicode 版本的字素分段方案找出候选边界,再计算每个完整片段的 UTF-8 长度;不能假设某个旧运行时的字符迭代器一定覆盖所有新 emoji 序列。
如果接收端限制的是数据库 VARCHAR 字符数,还要核对数据库实际编码、字段单位和校验方式,别把它自动转换成同一个字节预算。索引前缀长度、请求体大小和页面展示长度也各自拥有不同单位。把它们合在一个名叫 maxLength 的参数里,调用方迟早会传错含义。
JSON 输出尤其容易误会。原文字符编码后只有几个字节,转义形式可能更长;本函数对原始字符串的保证,不等于对序列化后整份报文长度的保证。若上游协议限制整包字节,应在最终编码边界检查,或者为结构字段建立独立预算。不要在底层文本工具里猜它之后会进入哪一种外层格式。
日志预览、消息摘要和短展示可以选择允许截断;签名输入、对象标识和用户提交的正式名称则通常需要原值或明确拒绝。相同算法用于不同位置,业务结果可能完全不同。函数返回 truncated 字段,正是为了让调用方不能只拿到一段字符串,却不知道自己已经改变了数据。