本节目标:把「切帧」和「解释帧」分成两层,能判断哪类错误必须关连接、哪类可以回错误帧后继续。
适用版本:JDK 25(Temurin 25.0.5+8);FrameDecoder、FrameEncoder、MessageCodec、Handler。
承接的问题
4.2 的 FrameDecoder 只保证「这 N 个字节是一帧」,它不保证这 N 个字节是 linkd 的消息。帧里可能有错的 magic、不支持的版本、未知的 type,或者短到连信封头都放不下。这些错误发生在边界之内,解码器没有资格判断,必须由上一层处理。
更麻烦的是「处理完之后怎么办」。4.2 的 LengthFieldFrameDecoder 在长度超限时抛 FrameTooLargeException,此时缓冲区已经失步:你不知道下一帧从哪里开始。继续读只会把后面的字节当成新帧头,产生一连串假帧。相反,如果只是某个 type 不认识,帧边界仍然完好,完全可以回一个错误帧然后继续服务。
还有一个具体的旧账。2.1 的 FramedEchoHandler 每次 onReadable 都从池里借一个新 buffer,decode 之后在 finally 里 release。那段代码只处理完整帧,一旦出现半包,未消费的字节会跟着 buffer 一起被释放,下一帧永远拼不回来。根因不是 FrameDecoder,而是「解码状态没有归属」:状态应该属于连接,不属于某一次 read。
本节先把状态归属和错误分类讲清楚,再给出 linkd 的 MessageCodec、FrameEncoder 和 LinkdFrameHandler,把 4.1、4.2 的字节与帧接进连接层。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
核心机制一:解码器可以无状态,处理链必须有状态
FixedLengthFrameDecoder、DelimiterFrameDecoder、LengthFieldFrameDecoder 都只有 final 配置字段,decode 不写任何成员变量。它们是无状态的,可以被多个连接共享——但 linkd 仍然每个连接创建一个实例,原因是以后可能加入「最大帧计数」「协议版本」这类每连接状态,提前把归属定下来比事后拆共享对象便宜。
真正有状态的是「还没成帧的字节」和「解码到一半的字符」。这两份状态都必须满足三个条件:
- 每连接一份。 不能放在
static字段,也不能让Handler单例持有。两个客户端各发半个帧,共享carry会把它们的字节拼成一条假消息。 - 只在一个线程里访问。
linkd的连接在EventLoop上注册,同一个连接的onReadable不会并发执行;carry和Utf8StreamDecoder因此不需要加锁。跨线程传递只能通过EventLoop.execute投递任务,不能直接摸carry。 - 生命周期跟连接一致。 连接关闭时丢弃,不要放回池里给下一个连接复用;池化的是
ByteBuffer的内存,不是里面的字节。
各组件在这三条上的表现:
| 组件 | 是否有状态 | 归属 | 能否共享 |
|---|---|---|---|
FrameDecoder 的三种实现 | 无(只有配置) | 连接或全局 | 可以 |
FrameEncoder | 无 | 全局 | 可以 |
MessageCodec | 无 | 全局 | 可以 |
carry 缓冲区 | 有(未成帧字节) | 连接 | 不可以 |
Utf8StreamDecoder | 有(半个字符) | 连接 | 不可以 |
LinkdFrameHandler | 有(持有上面两者) | 连接 | 不可以 |
carry 的读写循环固定为「读 → flip → decode → compact」:
初始 read 追加 flip decode 交出 1 帧 compact
[pos=0 lim=cap] [data ][ ] [data ][ ] [frame][left][ ] [left ][ ]
pos=n lim=cap pos=0 lim=n pos=k lim=n pos=n-k lim=cap
compact 是状态能跨 read 保持的关键:它把 left 搬到数组开头,position 设为 left 的长度,下一次 read 直接往后追加。如果忘了 compact,下一次 read 会写在不该写的位置,或者因为 position == limit 一直读到 0 字节。
核心机制二:错误分三层,关闭策略跟着分层
编解码链路上的错误可以按「边界是否完好」分成三层:
| 层 | 例子 | 边界 | 能否继续读 | 处理 |
|---|---|---|---|---|
| 成帧 | 长度超限、分隔符超长 | 已丢失 | 不能 | 记录后关闭,不回错误帧 |
| 信封 | magic 错、版本不支持 | 完好 | 能 | 关闭;对端说的不是本协议 |
| 信封 | type 未知、信封短于 4 字节 | 完好 | 能 | 回错误帧,继续 |
| 业务 | 书号不存在、参数非法 | 完好 | 能 | 回错误帧,继续 |
判断标准不是「错误严重不严重」,而是「下一帧的起始位置还确不确定」。长度字段读到 2 GB,你既没有分配内存,也无法知道对端到底发了多少字节;此时缓冲区里的下一个字节可能是长度字段的一部分,也可能是 payload,继续解析只会产生假帧。相反,magic 错了但帧边界是 4.2 算出来的,边界可信,只是内容不是本协议——这种情况可以关闭,但没必要回错误帧,因为对端可能根本解析不了 linkd 的错误格式。
ProtocolException 把信封层的错误码固定下来:
package com.plume.linkd.core;
public final class ProtocolException extends RuntimeException {
public enum Kind {
BAD_MAGIC, BAD_VERSION, BAD_TYPE, SHORT_PAYLOAD
}
private final Kind kind;
public ProtocolException(Kind kind, String detail) {
super(kind + ": " + detail);
this.kind = kind;
}
public Kind kind() {
return kind;
}
}
用枚举而不是字符串前缀,是为了让 catch 分支可以按类别判断,不依赖异常消息的文案。BAD_MAGIC 和 BAD_VERSION 属于「对端不是 linkd」,直接关闭;BAD_TYPE 和 SHORT_PAYLOAD 属于「帧是 linkd 的,但内容有问题」,回错误帧后继续。
错误帧沿用同一套信封:type = 0xFF,payload 是 UTF-8 文本。客户端收到后按正常帧解析,看到 0xFF 就知道是错误。生产环境通常把 payload 换成「数字错误码 + 请求 id + 简短描述」,而不是直接放异常消息;异常消息可能包含内部类名、路径或对端传入的原文,回显给对方既泄露信息,也容易被用来放大日志。
错误帧的内容要有限制:只回错误码和一句固定描述,不回显请求 payload,不附堆栈。否则一个 16 KiB 的坏请求可以换回一个 16 KiB 的错误响应,放大倍数虽然不大,但足以把日志和带宽拖垮。
关闭策略要和 3.3 的语义对齐。Connection.close() 的约定是「停止接收新数据、等待写队列排空、关闭通道」,它是优雅关闭,不是 SO_LINGER(0)。因此:
- 致命错误(成帧失败、magic/version 错)不
enqueue任何东西,直接close();写队列本来就是空的,关闭很快。 - 可恢复错误先
enqueue错误帧,再继续读;不要立刻close(),否则错误帧可能还没发出去。 - 业务错误同上,但业务层要保证错误响应本身不会再抛异常。
- 对端已关闭(
read返回-1)直接close(),不要再写。
核心机制三:编码与解码分离
解码面对的是不可信输入,错误要分类、要决定连接命运;编码面对的是本地数据,出错基本是程序 bug,直接抛运行时异常即可。两者的失败模式不同,接口也应该分开:
package com.plume.linkd.core;
import java.nio.ByteBuffer;
public interface FrameEncoder {
ByteBuffer encode(byte[] payload);
}
package com.plume.linkd.core;
import java.nio.ByteBuffer;
public final class LengthFieldEncoder implements FrameEncoder {
@Override
public ByteBuffer encode(byte[] payload) {
ByteBuffer out = ByteBuffer.allocate(Integer.BYTES + payload.length);
out.putInt(payload.length);
out.put(payload);
out.flip();
return out;
}
}
三个约定必须写进接口文档,否则调用方一定踩:
- 返回的 buffer 处于读模式。
encode内部put完必须flip(),否则position == limit,enqueue写出 0 字节,对端只看到连接空闲。这个 bug 在压测时表现为「请求有日志、响应永远超时」。 - 所有权转移给调用方。 返回的
ByteBuffer不再被编码器引用,调用方可以安全地交给写队列。编码器不能把它放进成员变量复用,否则下一个请求会覆盖还在队列里的上一个响应。 - 长度字段和
FrameDecoder必须对称。 编码器写 4 字节大端,解码器读 4 字节大端,两边都用Integer.BYTES,不要一边 4 字节一边 2 字节。协议升级时两个类要一起改。
信封在代码里就是一个不可变的记录:
package com.plume.linkd.core;
public record Message(int type, byte[] payload) {
}
type 是 0–255 的无符号值,payload 是帧体。record 只提供构造器和访问器,不做防御性拷贝;payload 的所有权约定在调用方,谁解码谁负责不要再改它。
MessageCodec 负责信封的 encode/decode,不碰 socket、不碰缓冲区状态:
package com.plume.linkd.core;
import java.nio.ByteBuffer;
public final class MessageCodec {
public static final short MAGIC = 0x4C4B;
public static final byte VERSION = 1;
public static final int HEADER = 4;
public Message decode(byte[] frame) {
if (frame.length < HEADER) {
throw new ProtocolException(ProtocolException.Kind.SHORT_PAYLOAD,
"frame " + frame.length + " < " + HEADER);
}
ByteBuffer buf = ByteBuffer.wrap(frame);
short magic = buf.getShort();
if (magic != MAGIC) {
throw new ProtocolException(ProtocolException.Kind.BAD_MAGIC,
"0x" + Integer.toHexString(magic & 0xffff));
}
byte version = buf.get();
if (version != VERSION) {
throw new ProtocolException(ProtocolException.Kind.BAD_VERSION,
"v" + version);
}
int type = buf.get() & 0xff;
byte[] payload = new byte[buf.remaining()];
buf.get(payload);
return new Message(type, payload);
}
public byte[] encode(Message message) {
byte[] payload = message.payload();
ByteBuffer out = ByteBuffer.allocate(HEADER + payload.length);
out.putShort(MAGIC).put(VERSION).put((byte) message.type()).put(payload);
return out.array();
}
}
信封是 4 字节:magic 2 字节(4C 4B,ASCII 的 LK)、version 1 字节、type 1 字节,后面直接跟 payload。帧的总长由 4.2 的 4 字节长度域给出,信封里不再放长度字段——同一件事只在一个地方表达,少一个可能对不上的数字。ByteBuffer.wrap(frame) 默认大端,所以 getShort 直接按 4.1 的结论读,不需要再设 order()。
MessageCodec 不检查帧长,因为帧边界已经由 LengthFieldFrameDecoder 保证。它只校验信封的最小长度、magic 和 version;payload 的长度就是 frame.length - 4。这也是分层的价值:长度域负责「到哪里结束」,信封负责「这帧是不是 linkd 的、type 是什么」,两层各管一件事。如果将来把 MessageCodec 复用到没有长度域的传输上,才需要在它内部加回长度校验。
跑一次编码再解码(CodecDemo):
本机输出:
frame=17B hex=4c4b0101474554202f626f6f6b732f3432
type=1 payload=GET /books/42
SHORT_PAYLOAD: frame 2 < 4
BAD_MAGIC: 0x4b
逐段拆第一行:4c4b 是 magic,01 是版本,01 是 type,后面 13 字节是 GET /books/42。总长 4 + 13 = 17 字节,和输出一致。第三行是一个只有 2 字节的帧,连信封头都不够,被 SHORT_PAYLOAD 拒绝;第四行把 magic 的首字节改成 0x00,剩下 00 4b,被 BAD_MAGIC 拒绝。
在 linkd 里落地:每连接一份 carry 的 Handler
把上面几块接起来,就是 LinkdFrameHandler:
package com.plume.linkd.core;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.List;
public final class LinkdFrameHandler implements Handler {
private static final int INITIAL = 1024;
private static final int MAX_BUFFER = 64 * 1024;
private static final int TYPE_ECHO = 1;
private final FrameDecoder decoder;
private final FrameEncoder encoder;
private final MessageCodec codec;
private ByteBuffer carry = ByteBuffer.allocate(INITIAL);
public LinkdFrameHandler(FrameDecoder decoder, FrameEncoder encoder, MessageCodec codec) {
this.decoder = decoder;
this.encoder = encoder;
this.codec = codec;
}
@Override
public void onReadable(Connection c) {
try {
if (!carry.hasRemaining()) {
if (!grow()) {
c.close();
return;
}
}
int n = c.channel().read(carry);
if (n == -1) {
c.close();
return;
}
carry.flip();
List<byte[]> frames = decoder.decode(carry);
for (byte[] frame : frames) {
dispatch(c, frame);
}
carry.compact();
} catch (FrameTooLargeException e) {
System.getLogger("linkd").log(System.Logger.Level.WARNING,
"closing connection: " + e.getMessage());
c.close();
} catch (IOException e) {
c.close();
}
}
private void dispatch(Connection c, byte[] frame) {
try {
Message request = codec.decode(frame);
if (request.type() != TYPE_ECHO) {
throw new ProtocolException(ProtocolException.Kind.BAD_TYPE,
"type " + request.type());
}
c.enqueue(encoder.encode(codec.encode(
new Message(request.type(), request.payload()))));
} catch (ProtocolException e) {
if (e.kind() == ProtocolException.Kind.BAD_MAGIC
|| e.kind() == ProtocolException.Kind.BAD_VERSION) {
c.close();
return;
}
c.enqueue(encoder.encode(codec.encode(
new Message(0xFF, e.getMessage().getBytes(StandardCharsets.UTF_8)))));
}
}
private boolean grow() {
if (carry.capacity() >= MAX_BUFFER) {
return false;
}
int capacity = Math.min(carry.capacity() * 2, MAX_BUFFER);
ByteBuffer bigger = ByteBuffer.allocate(capacity);
carry.flip();
bigger.put(carry);
carry = bigger;
return true;
}
@Override
public void onWritable(Connection c) {
}
@Override
public void onClosed(Connection c) {
}
}
这段代码逐块对应前面的契约:
carry是实例字段,LinkdFrameHandler每个连接创建一个,所以半包字节不会串连接。它和 2.1 的FramedEchoHandler最大的区别就在这里:后者每次onReadable借新 buffer,finally里释放,半包字节跟着内存一起没了。carry.hasRemaining()为 false 表示缓冲区写满了还没有成帧。grow()翻倍扩容,上限MAX_BUFFER;到上限仍满就关闭连接。这里有一个必须成立的不变量:MAX_BUFFER要不小于maxFrameLength + 4,否则一个合法的最大帧永远装不下,grow会提前放弃。linkd取 64 KiB 对 16 KiB 的单帧上限,留了 4 倍余量。read返回-1表示对端发了 FIN。此时carry里可能还有半个帧,但连接已经结束,没有「下一次」了,直接关闭。半关闭的语义在 3.3 已经讲过,这里不重复。decode可能返回多帧,for循环逐帧dispatch,不能只处理第一帧。dispatch把「解信封」和「检查 type」放在同一个try里。BAD_MAGIC/BAD_VERSION直接close(),不回错误帧;SHORT_PAYLOAD/BAD_TYPE回type=0xFF的错误帧后继续。type 检查也抛ProtocolException,是为了让两类错误走同一条catch,不用在 handler 里写两份错误响应。FrameTooLargeException在onReadable外层捕获,只记日志并关闭,不enqueue。原因是边界已经失步,回错误帧也没有意义,而且对端可能正处在「发一个超大帧」的状态,继续读只会浪费内存。catch (IOException)覆盖read的失败。Connection.close()本身不抛受检异常,关闭失败只影响资源回收,不影响控制流。
dispatch 末尾的「业务」在本节是回显:把请求的 type 和 payload 原样编码回去,方便用 nc 或测试客户端验证整条链路。真实的 linkd 会按 type 分派到健康检查或 /books/{id} 的处理器,payload 的解析在 8.3 的路由层展开。回显版本的价值在于:它把「编解码是否正确」和「业务是否正确」分开,客户端收到原样字节,就说明帧、信封、错误处理都对齐了。
LinkdMain 的装配在 4.2 已经出现,这里补上生命周期:Handler 在 accept 之后、注册读事件之前创建,一个 SocketChannel 对应一个实例;连接关闭时 EventLoop 调用 onClosed,之后不再调用 onReadable。onClosed 目前是空实现,如果以后在 handler 里加了定时器或会话状态,必须在这里取消和清理,否则连接对象会被回调引用住,GC 收不掉。
onWritable 也是空实现,因为 linkd 的写队列由 Connection 自己管理,Handler 不需要在可写事件里做额外动作。14.1 讲背压时,这里会加上「队列高于高水位就停止读」的逻辑;本节只保证读路径正确。
常见错误与边界
错误一:把 LinkdFrameHandler 做成单例。 单例意味着所有连接共享一个 carry。客户端 A 的半个帧和客户端 B 的半个帧会被拼成一条消息,MessageCodec 可能解析成功,但内容是错的,日志里完全看不出来。症状是「偶发串包,压测时更明显」。Handler 必须每连接一个实例。
错误二:解码失败后继续读。 长度超限时缓冲区已经失步,继续调用 decode 会把 payload 当成长度字段,产生一连串假帧,最后可能是 OOM 或大量 BAD_MAGIC。致命错误必须关闭连接,不能「跳过这一帧再试」。
错误三:编码器返回写模式的 buffer。 encode 里 put 完忘记 flip(),返回的 buffer position == limit,enqueue 写出 0 字节。对端看到连接一直空闲,服务端日志里却有完整的请求记录。所有 FrameEncoder 实现都要在返回前 flip()。
错误四:业务异常冒泡出 onReadable。 dispatch 只捕获了 ProtocolException,业务代码如果抛 RuntimeException,会一路冒到 EventLoop,carry.compact() 不会执行。下一次 onReadable 时 carry 还停留在读模式,read 写入位置错误。业务异常要在 dispatch 内单独 catch,回错误帧,不要让它在缓冲区状态未收尾时逃出去。
错误五:错误响应回显请求原文或堆栈。 对端发一个 16 KiB 的坏请求,你回一个 16 KiB 的异常消息,里面还带类名和路径。这既是信息泄露,也是放大攻击。错误帧只放错误码和固定描述,原文只进本地日志。
边界:Message.payload() 是可变数组。 record Message(int type, byte[] payload) 不会防御性拷贝,两个 Message 可以共享同一个数组。如果解码后把 payload 直接存进会话,又在下一帧复用了同一块缓冲区,前一帧的内容会被改写。需要长期保存时自己 clone()。
边界:close() 是优雅关闭,不是立即复位。 对端已经失步时,close() 仍会尝试把写队列里已有的数据发完。致命错误路径不 enqueue,所以队列是空的;如果之前已经排了响应,close() 会先把它们发完,这段时间对端可能继续发垃圾数据,但 linkd 不再读,内核缓冲区满了以后对端的写会阻塞。这是可接受的收尾方式,不要为了「立刻断」去调 SO_LINGER。
边界:协议版本升级要留兼容窗口。 VERSION = 1 直接拒绝其他版本,升级时所有旧客户端会同时断线。生产协议通常保留「支持 N 和 N-1」的逻辑,把版本检查从 BAD_VERSION 改成「按版本选择解码器」。本节为了保证错误分类清晰,只做单版本。
小结
FrameDecoder和FrameEncoder可以是无状态的共享对象;carry和Utf8StreamDecoder有状态,必须每连接一份,并且只在EventLoop线程里访问。- 错误的分类标准是「帧边界是否完好」:成帧失败必须关闭,magic/version 错关闭,type 未知/信封太短回错误帧后继续,业务错误回错误帧后继续。
ProtocolException.Kind用枚举固定错误码,catch按类别分支,不依赖异常消息文案。- 编码器返回的
ByteBuffer必须是读模式、所有权转移给调用方;长度字段要和解码器对称,统一用 4 字节大端。 MessageCodec的信封是 4 字节:magic4C4B、version、type,后面跟 payload;长度只由 4.2 的长度域表达,本机 17 字节的帧可以逐段对上。LinkdFrameHandler每连接持有carry,decode后compact;MAX_BUFFER必须大于等于单帧上限加头部,否则合法帧永远装不下。
到这一章结束,字节、字符、帧、信封都有了,但所有讨论都默认「缓冲区就在手边」。下一节拆开 ByteBuffer 的 position、limit、capacity,解释 flip() 到底做了什么,以及为什么 compact() 是读循环能继续的前提。
阅读导航:上一节:4.2 定长、分隔符与长度域帧 · 下一节:5.1 Buffer 的 position、limit 与 flip 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。