本节目标:能根据协议特点选择一种成帧方式,并写出不吞半包、不放超长帧、不破坏调用方缓冲区的
FrameDecoder。
适用版本:JDK 25(Temurin 25.0.5+8);ByteBuffer、List<byte[]>。
承接的问题
4.1 解决了「字节怎么变成数字和字符」,但没有回答「一条消息到哪里结束」。这两个问题必须分开:UTF-8 能告诉你一个汉字占几个字节,却不能告诉你这条 JSON 已经写完了。
2.1 已经在 loopback 上看到,一次 22 字节的请求被 8 字节的缓冲区读成 8/8/7 三次;两次 write 也可能合并成一次 read。TCP 是字节流,它只保证字节有序到达,不保证任何消息边界。如果服务端把每次 read 的返回值当成一条消息,就会遇到两种错误:半包时解析到一半报 unexpected end of input,粘包时两条 JSON 连在一起解析失败。
linkd 的 /ws/echo 和自定义二进制协议都需要帧。帧格式决定了三件事:解码器什么时候可以交出一条消息、缓冲区要留多少字节、攻击者能不能用一个长度字段让你分配 2 GB 内存。本节把三种最常见的成帧方式写成可运行的 FrameDecoder,4.3 再把它们接进连接处理链路。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
核心机制一:FrameDecoder 的四条契约
linkd-core 的 FrameDecoder 只有一个方法:
package com.plume.linkd.core;
import java.nio.ByteBuffer;
import java.util.List;
/**
* 把读缓冲区里的字节切成完整帧。没有完整帧时返回空列表,
* 不消费任何字节;帧非法时抛运行时异常,由连接层决定是否关闭。
*/
public interface FrameDecoder {
List<byte[]> decode(ByteBuffer in);
}
分隔符和长度域解码器共用同一个上限异常。它继承 RuntimeException,消息里同时带声明长度和上限,方便日志直接定位:
package com.plume.linkd.core;
public final class FrameTooLargeException extends RuntimeException {
public FrameTooLargeException(int declared, int limit) {
super("frame length " + Integer.toUnsignedString(declared)
+ " exceeds limit " + limit);
}
}
这个签名很短,但隐含四条契约,任何一条破了都会在某个半包场景下出错:
- 只消费完整帧。 解码器可以移动
in.position(),但只能移到「已交付帧的末尾」。没有完整帧时,position必须停在帧的起始位置,让调用方下次接着读。 - 不完整不是错误。 半包返回空列表,不抛异常。异常只用于「这串字节永远不可能成为合法帧」,比如长度超过上限、分隔符超过上限。
- 一次可以返回多帧。 粘包时
decode要循环切到切不动为止,不能只返回第一帧。 - 返回拷贝,不返回切片。
byte[]不能是in.array()的视图,也不能是in.slice()背后的同一块内存。调用方下一步会compact()并继续read,复用那块内存;切片会让已经交付的帧内容被改写。
缓冲区在调用方和解码器之间的状态变化只有三步:
初始(写模式) read 之后 flip 之后 decode 之后 compact 之后
[ ] [data ][ ] [data ][ ] [frame][left][ ] [left ][ ]
pos=0 lim=cap pos=n lim=cap pos=0 lim=n pos=k lim=n pos=n-k lim=cap
decode 把 position 推到最后一帧的末尾,compact 把剩下的 left 搬到开头,下一次 read 从 left 后面继续写。解码器不负责 flip 和 compact,它只碰 position;这两个动作属于读循环,5.1 会展开。
核心机制二:定长帧
定长帧最简单:每 frameLength 个字节就是一条消息。解码器只需要在剩余字节够长时反复取:
package com.plume.linkd.core;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public final class FixedLengthFrameDecoder implements FrameDecoder {
private final int frameLength;
public FixedLengthFrameDecoder(int frameLength) {
if (frameLength <= 0) {
throw new IllegalArgumentException("frameLength must be > 0");
}
this.frameLength = frameLength;
}
@Override
public List<byte[]> decode(ByteBuffer in) {
List<byte[]> frames = new ArrayList<>();
while (in.remaining() >= frameLength) {
byte[] frame = new byte[frameLength];
in.get(frame);
frames.add(frame);
}
return frames;
}
}
while 而不是 if,是因为一次 read 可能带来三帧。循环结束时 remaining < frameLength,不足一帧的字节留在缓冲区里,position 停在最后一帧末尾,正好满足契约一。
定长帧的代价是填充。字段少的消息要补零到固定长度,带宽浪费;协议一旦要加字段,frameLength 就得改,新旧版本无法互通。它适合遥测采样、固定大小的心跳探针、硬件寄存器读取这类「每条记录长度本来就一样」的场景。linkd 只在协议测试里用它构造固定大小的探针,主协议不用。
定长帧没有显式的长度上限,frameLength 本身就是上限。这既是优点也是缺点:攻击者无法用一个字段让你分配超大内存,但他可以持续发不足一帧的字节,让缓冲区一直长——所以调用方仍然需要 MAX_BUFFER,4.3 会处理。
核心机制三:分隔符帧
分隔符帧用一个特殊字节标记消息结束,典型代表是 JSON Lines(每行一个 JSON,\n 分隔)和各类行式文本协议。解码器在缓冲区里找分隔符,找到就交付,找不到就等:
package com.plume.linkd.core;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public final class DelimiterFrameDecoder implements FrameDecoder {
private final byte delimiter;
private final int maxFrameLength;
public DelimiterFrameDecoder(byte delimiter, int maxFrameLength) {
if (maxFrameLength <= 0) {
throw new IllegalArgumentException("maxFrameLength must be > 0");
}
this.delimiter = delimiter;
this.maxFrameLength = maxFrameLength;
}
@Override
public List<byte[]> decode(ByteBuffer in) {
List<byte[]> frames = new ArrayList<>();
while (true) {
int start = in.position();
int limit = in.limit();
int delimiterIndex = -1;
for (int i = start; i < limit; i++) {
if (in.get(i) == delimiter) {
delimiterIndex = i;
break;
}
}
if (delimiterIndex < 0) {
if (in.remaining() > maxFrameLength) {
throw new FrameTooLargeException(in.remaining(), maxFrameLength);
}
return frames;
}
int length = delimiterIndex - start;
if (length > maxFrameLength) {
throw new FrameTooLargeException(length, maxFrameLength);
}
byte[] frame = new byte[length];
in.get(frame);
in.get();
frames.add(frame);
}
}
}
几个实现细节值得说明:
- 扫描用
in.get(i)绝对读,不移动position。只有确认分隔符存在、长度也合法之后,才用相对get消费帧和分隔符。中途抛异常时,position还在帧首,调用方至少知道数据没被吞掉。 - 单字节分隔符不会跨 read 被劈开,所以不需要「半个分隔符」的状态。如果协议用
\r\n双字节分隔符,就要处理第一次 read 只拿到\r的情况;这时要么保留一个字节的状态,要么把\r也当分隔符再跳过空行。本节只做单字节,把复杂度留给明确的协议。 \n\n会产生一个长度为 0 的帧。空帧是否合法是协议层的事:JSON Lines 通常忽略空行,linkd的文本通道也忽略;但二进制协议可能把空帧当心跳。解码器不替协议做决定。- 没有分隔符且
remaining > maxFrameLength时必须报错,否则对端只要一直发不含\n的字节,缓冲区就会无限增长。这是分隔符协议最容易被忽略的 DoS 面。
分隔符帧的前提是 payload 里不能出现分隔符。JSON Lines 能成立,是因为 JSON 字符串里的控制字符(包括换行)必须转义成 \n 两个字符,原始 0x0A 不会出现在 JSON 文本内部。换成任意二进制 payload,这个前提就没了:一张 JPEG 里随时可能有 0x0A,按行切必然切坏。二进制数据要么转义(COBS、base64),要么改用长度域。
核心机制四:长度域帧
长度域帧把消息长度写在消息前面。它是二进制协议最常用的方式:不需要转义,不做线性扫描,边界在 O(1) 内确定。linkd 的主协议用 4 字节大端长度开头:
package com.plume.linkd.core;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public final class LengthFieldFrameDecoder implements FrameDecoder {
private final int headerLength;
private final int maxFrameLength;
public LengthFieldFrameDecoder(int headerLength, int maxFrameLength) {
if (headerLength != Integer.BYTES) {
throw new IllegalArgumentException("only a 4-byte length header is supported");
}
if (maxFrameLength <= 0) {
throw new IllegalArgumentException("maxFrameLength must be > 0");
}
this.headerLength = headerLength;
this.maxFrameLength = maxFrameLength;
}
@Override
public List<byte[]> decode(ByteBuffer in) {
List<byte[]> frames = new ArrayList<>();
while (true) {
if (in.remaining() < headerLength) {
return frames;
}
int start = in.position();
in.mark();
int raw = readBigEndianInt(in, start);
in.position(start + headerLength);
long bodyLength = Integer.toUnsignedLong(raw);
if (bodyLength > maxFrameLength) {
throw new FrameTooLargeException(raw, maxFrameLength);
}
if (in.remaining() < bodyLength) {
in.reset();
return frames;
}
byte[] frame = new byte[(int) bodyLength];
in.get(frame);
frames.add(frame);
}
}
private static int readBigEndianInt(ByteBuffer in, int index) {
return ((in.get(index) & 0xff) << 24)
| ((in.get(index + 1) & 0xff) << 16)
| ((in.get(index + 2) & 0xff) << 8)
| (in.get(index + 3) & 0xff);
}
}
这段代码有四个关键点,每个都对应一个真实故障:
- 长度字段本身也可能半包。
remaining < 4时直接返回,不能先读两个字节再等。4.1 的手工移位只读完整的 4 个字节。 - 先校验上限,再分配。
bodyLength > maxFrameLength的检查在new byte[]之前。少了这一步,对端发0x7FFFFFFF,你就申请 2 GB,JVM 直接 OOM。检查必须用long比较,因为无符号长度最大可到 4294967295,已经超出int的正数范围。 - 负长度按无符号解释。
Integer.toUnsignedLong(0x80000000)是 2147483648,会命中上限。如果写成if (raw > max),raw是负数,检查直接通过,后面new byte[raw]抛NegativeArraySizeException,错误类型不对,定位也偏。 - 不够一帧时 reset 回帧头。 读长度前
mark(),把position推到 payload 起点;remaining < bodyLength时reset(),position回到帧首。这样调用方compact()后,未消费的字节仍然从帧首开始。
长度字段数的是它后面的字节数,不包含这 4 个字节自己;4.3 的 4 字节信封头也算在长度里。LengthFieldEncoder 写 payload.length,解码器按同一个口径读,两边不能一边算头一边不算,否则每一帧都会差 4 字节。
长度为 0 是合法的,表示空 payload。它可以用作心跳或「无参数命令」,但协议要明确区分「空帧」和「连接关闭」:前者是 frames 里一个长度为 0 的数组,后者是 read 返回 -1。
三种方式放在一起比较:
| 方式 | 边界来源 | 优点 | 代价 | linkd 用途 |
|---|---|---|---|---|
| 定长 | 固定字节数 | 实现最简单、无额外字节 | 必须填充、改字段即改协议 | 测试探针 |
| 分隔符 | 特殊字节 | 人类可读、易调试 | 需转义、要线性扫描 | 文本调试通道 |
| 长度域 | 头部整数 | 二进制安全、O(1) 定位 | 头部开销、必须设上限 | 主协议 |
用同一组字节跑三种帧
把三种解码器接到同一个喂数据循环上,每次只喂 3 个或 2 个字节,观察帧在哪一次出现、还剩多少字节。下面是实验的核心部分(完整的 FrameDecoderDemo 还包含构造输入和格式化输出):
static void feed(FrameDecoder decoder, byte[] stream, int chunk) {
ByteBuffer in = ByteBuffer.allocate(32);
int round = 0;
for (int offset = 0; offset < stream.length; offset += chunk) {
int n = Math.min(chunk, stream.length - offset);
in.put(stream, offset, n);
in.flip();
List<byte[]> frames = decoder.decode(in);
in.compact();
round++;
System.out.println("feed " + round + ", +" + n + "B, frames="
+ render(frames) + ", pending=" + in.position());
}
}
本机输出:
== length field, chunk=3 ==
feed 1, +3B, frames=[], pending=3
feed 2, +3B, frames=[], pending=6
feed 3, +3B, frames=["hello"(5)], pending=0
feed 4, +3B, frames=[], pending=3
feed 5, +3B, frames=[], pending=6
feed 6, +3B, frames=["abc"(3)], pending=2
feed 7, +2B, frames=[""(0)], pending=0
== delimiter, chunk=2 ==
feed 1, +2B, frames=[], pending=2
feed 2, +2B, frames=[], pending=4
feed 3, +2B, frames=["hello"(5)], pending=0
feed 4, +2B, frames=[], pending=2
feed 5, +2B, frames=["abc"(3)], pending=0
feed 6, +1B, frames=[""(0)], pending=0
== fixed 4 bytes, chunk=3 ==
feed 1, +3B, frames=[], pending=3
feed 2, +3B, frames=["abcd"(4)], pending=2
feed 3, +3B, frames=["efgh"(4)], pending=1
feed 4, +1B, frames=[], pending=2
== declared length over limit ==
frame length 1048576 exceeds limit 65536
== declared length negative ==
frame length 2147483648 exceeds limit 65536
逐段读这份输出:
- 长度域的输入是
4+5、4+3、4+0三帧,共 20 字节。第 2 次喂完有 6 字节,头部够了但 payload 还差 3 字节,所以frames=[]、pending=6;第 3 次凑满 9 字节才交出"hello"。第 6 次交出"abc"时pending=2,正是下一帧头部的头两个字节;第 7 次补齐后交出空帧。 - 分隔符的输入是
hello\nabc\n\n,12 字节。第 3 次喂完 6 字节,\n到达,交出"hello";第 6 次只剩 1 字节\n,交出一个空帧。空帧没有被解码器丢掉,是否处理由上层决定。 - 定长每 4 字节交一帧。第 2 次有 6 字节,交
"abcd"后剩 2 字节;第 4 次累计 10 字节,交完两帧后剩 2 字节,永远凑不满第三帧。这 2 字节会一直留在carry里,直到连接关闭或超时。 - 超限的两个输出都来自
LengthFieldFrameDecoder:1 MiB 超过 64 KiB 上限;0x80000000按无符号解释是 2147483648,同样被拒绝。注意异常消息里打印的是无符号值,不是-2147483648,这样日志不会误导人。
pending 的数字是 compact() 之后 position 的值,也就是尚未成帧的字节数。它同时是缓冲区增长的信号:如果某个连接的这个数字长期不归零,要么对端发了半条消息就停了,要么长度字段读错了。5.1 会把 position/limit/capacity 的关系画清楚。
在 linkd 里落地
linkd-core 把「用哪种帧、上限多少」收进一个配置对象,避免每个调用点各写一套常量:
package com.plume.linkd.core;
public record FrameConfig(int maxFrameLength) {
public static final FrameConfig DEFAULT = new FrameConfig(16 * 1024);
public FrameDecoder decoder() {
return new LengthFieldFrameDecoder(Integer.BYTES, maxFrameLength);
}
}
FrameConfig.DEFAULT 把单帧上限设为 16 KiB。上限不是拍脑袋:linkd 的业务消息是书号、分页参数和短 JSON,16 KiB 足够;上限越小,攻击者能迫使你分配的内存越少。LinkdFrameHandler 的 carry 从 1 KiB 开始按需翻倍,大多数连接只发几条短请求,按需增长比每个连接预分配 64 KiB 更省。
LinkdMain 装配读路径时只依赖这两个值:
FrameDecoder decoder = FrameConfig.DEFAULT.decoder();
Handler handler = new LinkdFrameHandler(decoder, new LengthFieldEncoder(), new MessageCodec());
loop.register(channel, handler);
LinkdFrameHandler 的完整实现在 4.3。这里先确认分工:FrameConfig 决定「切多大」,FrameDecoder 决定「怎么切」,Handler 决定「切出来之后做什么」。三者不互相依赖,换一种帧格式只需要换 decoder() 的返回值。
分隔符解码器在 linkd 里用于文本调试通道:运维通过 nc 127.0.0.1 9091 连上去,一行一条命令,空行忽略。它和主协议共用 FrameDecoder 接口,但注册在不同的 Handler 上。定长解码器只出现在测试代码里,用来构造固定大小的探针,验证读循环在不同分片下都能收敛。
常见错误与边界
错误一:在 decode 里调用 flip() 或 compact()。 这两个动作属于调用方的读循环。解码器擅自 flip 会把可读区变成可写区,调用方接着 read 会读到 0 或者覆盖数据。解码器只应该移动 position。
错误二:返回 in.slice() 或 in.array() 的视图。 调用方下一轮 compact() 会把剩余字节搬到开头,已经交付的帧内容被覆盖。日志里表现为「第一帧正常,第二帧变成半截」。decode 必须 new byte[] 并 get 出来,拷贝的成本换的是所有权清晰。
错误三:长度字段不做上限检查就分配。 反例:byte[] body = new byte[in.getInt()];。对端发 0x7FFFFFFF,这一行直接申请 2 GB;发 0xFFFFFFFF,抛 NegativeArraySizeException,而真正的问题是协议攻击。检查要在分配之前,并且用无符号语义。
错误四:把半包当异常。 每次 read 只拿到半个帧就抛 IOException,连接会被误关,日志里全是「协议错误」。半包是 TCP 的常态,返回空列表才是正确行为;只有「长度超限」「magic 不对」这类不可恢复的情况才抛异常。
错误五:分隔符协议不设上限。 对端持续发送不含 \n 的字节,pending 一路增长,最后 OOM。DelimiterFrameDecoder 在找不到分隔符且 remaining > maxFrameLength 时抛 FrameTooLargeException,这个检查不能省。
边界:一次 decode 可能返回多帧。 调用方必须遍历整个 List,不能只处理 frames.get(0) 就把剩下的丢掉。粘包时一次 read 带回三条消息很常见,只取第一条会让后两条永远留在缓冲区里,表现为「客户端说发了,服务端只处理了一条」。
边界:帧边界不等于业务消息边界。 长度域帧保证「这 N 个字节一起到达」,不保证这 N 个字节是合法 JSON、合法命令或合法 UTF-8。帧内的校验由 4.3 的 MessageCodec 负责;两层都做,才能把「传输错误」和「业务错误」分开。
小结
FrameDecoder.decode(ByteBuffer)只消费完整帧;不完整时返回空列表、不移动position;一次可以返回多帧;返回的byte[]必须是拷贝。- 定长帧用
while (remaining >= frameLength)循环,实现最简单,但需要填充,字段一变就要改协议。 - 分隔符帧用绝对读扫描、确认后再消费;单字节分隔符不会跨 read,双字节分隔符需要额外状态;payload 含分隔符时必须转义或改用长度域。
- 长度域帧先
mark()再读 4 字节大端长度,用Integer.toUnsignedLong处理负值,在校验上限之后再分配,半包时reset()回帧首。 - 本机实验里,长度域帧在第 3 次喂数据时交出
"hello",第 6 次交出"abc"并留下 2 字节头部;分隔符帧在\n到达时立即交付;定长帧的尾字节永远凑不满一帧。 - 没有上限的帧格式一定会在某个输入下耗尽内存;上限要在分配之前检查,并用无符号语义比较。
帧切出来了,但它还只是 byte[]。下一节回答三个问题:帧内的字节怎么校验、坏了之后是回错误还是关连接、编码器和解码器怎么分工。
阅读导航:上一节:4.1 字节序、字符集与边界 · 下一节:4.3 编解码器设计与错误处理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。