《Netty 高性能网络编程》6.2 长度域、分隔符与定长解码

LengthFieldBasedFrameDecoder 的五个参数最容易配错。本节用三组真实字节序列算清 lengthFieldOffset、lengthFieldLength、lengthAdjustment 与 initialBytesToStrip,给出 linkd-v2 十六字节头 strip 16 的配置,并对比 LengthFieldPrepender、分隔符与定长解码器。

本节目标:把长度域解码器的五个参数从「背构造器」变成「能从十六进制帧反推配置」,并选对分隔符、定长与长度前缀三类解码器。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-codec-base 模块)。

承接的问题

6.1 的 LinkdFrameDecoder 手写了一遍「读头、验长度、等负载、消费帧」。这段逻辑每个长度域协议都要写一次,而且很容易在 readerIndex 上出错。Netty 已经把这类逻辑封装成 LengthFieldBasedFrameDecoder:只要告诉它长度字段在哪、多长、要不要加减、要剥掉多少字节,它就能把完整帧切出来。

难点不在 API,而在参数。linkd-v2 的帧头是 16 字节,长度字段在 offset 12、占 4 字节、值是负载长度;maxFrameLength 到底该填 1 MiB 还是 1 MiB 加 16;lengthAdjustment 什么时候是 0、什么时候是 -16;initialBytesToStrip 剥掉 16 字节之后下游还能不能拿到 requestId。这三个问题配错一个,表现都不是报错,而是「一直等」「多等 16 字节」或者「把下一帧的头当负载」。

本节先用三组字节序列把公式跑通,再给 linkd-v2 的最终配置。分隔符和定长解码器只解决另外两类边界,放在同一节里对比,避免选错工具。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面标注「本机输出」的内容都来自这台机器,单次运行,未做统计。

核心机制一:五个参数与一条公式

LengthFieldBasedFrameDecoder 的核心公式只有一条:

frameLength = lengthFieldOffset + lengthFieldLength
            + lengthAdjustment
            + lengthFieldValue

frameLength 是「一整帧的总字节数」,从当前 readerIndex 开始算。lengthFieldOffset + lengthFieldLength 又叫 lengthFieldEndOffset,表示长度字段结束的位置。五个参数的分工如下:

参数含义配错的典型表现
maxFrameLength整帧上限,含头、含 adjustment 的结果上限偏小:合法最大帧被判超长;偏大:内存 DoS
lengthFieldOffset长度字段从帧首开始的偏移读错字段,帧长变成随机值
lengthFieldLength长度字段字节数,支持 1/2/3/4/8字段跨界,值恒定错位
lengthAdjustment长度字段之外还有多少字节没被计入正负号反了,少等或多等
initialBytesToStrip切出整帧后从帧首剥掉多少字节剥多:负载缺头;剥少:下游多出 16 字节

maxFrameLength 检查的是 frameLength,发生在 initialBytesToStrip 之前。也就是说,剥不剥头不影响上限的算法。linkd-v2 的负载上限是 1 MiB,整帧上限必须是 1 MiB + 16,即 1,048,592;如果只填 1,048,576,一个刚好 1 MiB 的负载会被判超长。

lengthAdjustment 的符号由「长度字段的值算到了哪里」决定。长度字段只算负载,帧里还有 16 字节头没算进去,调整量是 16 - 16 = 0,因为公式里的 lengthFieldEndOffset 已经把 16 字节头加上了。长度字段把 16 字节头也算进去了,调整量是 -16,把重复计算的头减掉。帧尾还有 4 字节 CRC 没算进长度,调整量是 +4。

initialBytesToStrip 决定下游看到什么。填 0,下游拿到「头 + 负载」的完整帧,自己解析头;填 16,下游只拿到负载,头字段(包括 requestId)全部丢失。这个选择不是审美问题,而是下游还能不能做请求关联的问题。

核心机制二:三组字节序列

第一组:2 字节长度前缀

线上字节是两帧连在一起:

00 05 68 65 6c 6c 6f 00 03 61 62 63

第一帧 00 05 表示后面 5 字节,第二帧 00 03 表示后面 3 字节。长度字段在帧首,offset 0、长度 2;长度只算负载,adjustment 0;切完剥掉 2 字节长度域,strip 2:

new LengthFieldBasedFrameDecoder(1024, 0, 2, 0, 2);

算第一帧:frameLength = 0 + 2 + 0 + 5 = 7,从 00 到第一个 6f 正好 7 字节;strip 2 后输出 68 65 6c 6c 6f。第二帧同理,frameLength = 2 + 3 = 5,输出 61 62 63。本机输出:

input hex=00 05 68 65 6c 6c 6f 00 03 61 62 63
A -> len=5 ascii='hello' hex=68 65 6c 6c 6f
A -> len=3 ascii='abc' hex=61 62 63

这一组是最常见的「长度前缀 + 负载」形态。lengthFieldOffset=0 时,lengthAdjustment 只有在长度值包含长度域自身时才需要调整:如果 00 07 表示「包括这 2 字节共 7 字节」,配置应改成 (1024, 0, 2, -2, 2)。

第二组:linkd-v2 的 16 字节头,长度只算负载

linkd-v2 的 HELLO 帧,负载是 hello:

4c 4b 44 46 01 05 00 00 00 00 00 07 00 00 00 05 68 65 6c 6c 6f

逐段拆开:4c4b4446 是 magic LKDF,01 是 version,05 是 FrameType.REQUEST,0000 是 flags,00000007 是 requestId,00000005 是负载长度,最后 5 字节是 hello。长度字段在 offset 12、占 4 字节,值是 5,表示负载长度;整帧总长 21。配置是:

new LengthFieldBasedFrameDecoder(
        ProtocolConstants.MAX_PAYLOAD + ProtocolConstants.HEADER_LENGTH,
        12, 4, 0, 16);

算一遍:lengthFieldEndOffset = 12 + 4 = 16,frameLength = 16 + 0 + 5 = 21,strip 16 后输出 5 字节 hello。本机输出:

frame len=21 hex=4c 4b 44 46 01 05 00 00 00 00 00 07 00 00 00 05 68 65 6c 6c 6f
B -> len=5 ascii='hello' hex=68 65 6c 6c 6f

maxFrameLength 用 MAX_PAYLOAD + HEADER_LENGTH,是因为上限检查发生在 strip 之前,算的是 21 这样的整帧长度,而不是 5 这样的负载长度。长度字段是 4 字节,Netty 按无符号整数读取;对端写 ff ff ff ff 会变成 4,294,967,295,直接被 maxFrameLength 拦下,不会走到分配内存。

第三组:长度包含头,adjustment 取负

另一种常见约定是长度字段表示「整帧长度」,包括 16 字节头。同一个 hello 帧,长度字段会写成 00 00 00 15(21):

4c 4b 44 46 01 05 00 00 00 00 00 07 00 00 00 15 68 65 6c 6c 6f

此时 lengthFieldEndOffset 已经是 16,长度值又包含这 16 字节,必须减掉,否则会算成 37 字节,把下一帧的头也吞进来。配置改成:

new LengthFieldBasedFrameDecoder(1024, 12, 4, -16, 16);

算一遍:frameLength = 16 + (-16) + 21 = 21,strip 16 后仍然是 hello。本机输出:

frame len=21 hex=4c 4b 44 46 01 05 00 00 00 00 00 07 00 00 00 15 68 65 6c 6c 6f
C -> len=5 ascii='hello' hex=68 65 6c 6c 6f

补充:帧尾有 CRC,adjustment 取正

如果长度字段只算负载,但帧尾固定跟 2 字节 CRC,总长是 16 + 5 + 2 = 23。长度值仍是 5,帧尾的 2 字节需要正调整:

new LengthFieldBasedFrameDecoder(1024, 12, 4, 2, 16);

frameLength = 16 + 2 + 5 = 23,strip 16 后输出 7 字节,也就是 hello 加 CRC。本机输出:

frame len=23 hex=... 00 00 00 05 68 65 6c 6c 6f 43 52
D -> len=7 ascii='helloCR' hex=68 65 6c 6c 6f 43 52

四组配置放在一起,差别只在两个数字:

场景offsetlengthadjustmentstrip输出
2 字节长度前缀0202负载
长度只算负载,16 字节头124016负载
长度包含头124-1616负载
长度只算负载,尾部 2 字节 CRC124216负载 + CRC

核心机制三:LengthFieldPrepender

LengthFieldPrepender 是出站方向的配对工具:它不解析帧,只在消息前面写一个长度字段。构造器有三种常用形态:

new LengthFieldPrepender(4);                 // 4 字节长度,只算负载
new LengthFieldPrepender(4, true);           // 4 字节长度,长度包含这 4 字节
new LengthFieldPrepender(4, 2, false);       // 4 字节长度,负载外再补 2 字节

第一个参数是长度字段字节数,只支持 1/2/3/4/8;第二个布尔值是 lengthIncludesLengthFieldLength;中间那个整数是 lengthAdjustment,用于「长度字段之外还有固定字节」的场景。它适合「消息本身就是负载」的简单协议,不适合已经有自定义头的 linkd-v2:LinkdFrameEncoder 已经在 16 字节头里写了 4 字节长度,再加一个 LengthFieldPrepender 会变成两个长度字段,对端从错误的位置开始读。

本机用 new LengthFieldPrepender(2) 编码 hello,输出是两次 write:

prepended len=2 hex=00 05
prepended len=5 hex=68 65 6c 6c 6f

4.2.19 的实现把长度头和原消息分别放进 outbound 列表,长度头是新分配的 2 字节,原消息被 retain 后原样传递。不要把「一次编码等于一个 ByteBuf」写进假设;需要合并时,让下游的编码器或传输层去 gather,而不是在 handler 里手工拼。

核心机制四:分隔符与定长解码器

DelimiterBasedFrameDecoder 用分隔符找边界,适合文本行协议、Redis 风格的 \r\n 协议。默认会剥掉分隔符,超过 maxFrameLength 仍找不到分隔符时抛 TooLongFrameException:

new DelimiterBasedFrameDecoder(
        64 * 1024,
        Unpooled.wrappedBuffer(new byte[]{'\r', '\n'}));

喂入 alpha\r\nbeta\r\n,本机输出两个消息:

E -> len=5 ascii='alpha' hex=61 6c 70 68 61
E -> len=4 ascii='beta' hex=62 65 74 61

它的边界是「负载里不能出现分隔符」。二进制负载只要有一个字节序列等于 \r\n,帧就会被提前切断;即使用多字节分隔符,也只是降低概率,不是消除。需要承载任意二进制时,用长度域,或者在应用层做转义/COBS,再配分隔符。

FixedLengthFrameDecoder 更简单:每个帧固定 N 字节,不够就等,够了就切:

new FixedLengthFrameDecoder(4);

喂入 abcdefgh,输出 abcd 和 efgh;喂入 abc 则一个消息也没有,残余留到关闭。它适合定长采样记录、定长信令,不适合长度可变的协议。发送方必须把每条消息补齐到 N 字节,接收方也要知道补位规则,否则「定长」只是把半包问题换成了截断问题。

三类解码器的适用边界可以放在一张表里:

解码器边界来源适合不适合
LengthFieldBasedFrameDecoder固定位置的长度字段二进制、可变长、需要精确定界长度字段位置不固定、加密后长度不可见
DelimiterBasedFrameDecoder一个或多个分隔符文本行、分隔符不会出现在负载任意二进制、负载可含分隔符
FixedLengthFrameDecoder固定字节数定长记录、定长信令可变长消息、需要补位语义

在 linkd-v2 里落地:16 字节头的最终配置

6.1 手写的 LinkdFrameDecoder 在 6.2 换成 LengthFieldBasedFrameDecoder 的子类:边界判断交给父类,子类只负责校验头字段和把负载转成 LinkdFrame。类名和输出类型不变,pipeline 不用改。

package com.plume.linkd.v2.protocol;

import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.CorruptedFrameException;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;

import java.util.List;

public final class LinkdFrameDecoder extends LengthFieldBasedFrameDecoder {

    public LinkdFrameDecoder() {
        super(ProtocolConstants.MAX_PAYLOAD + ProtocolConstants.HEADER_LENGTH,
                12, 4, 0, 16);
    }

    @Override
    protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
        if (in.readableBytes() < ProtocolConstants.HEADER_LENGTH) {
            return null;
        }
        int start = in.readerIndex();
        int magic = in.getInt(start);
        if (magic != ProtocolConstants.MAGIC) {
            throw new CorruptedFrameException("bad magic: 0x" + Integer.toHexString(magic));
        }
        byte version = in.getByte(start + 4);
        if (version != ProtocolConstants.VERSION) {
            throw new CorruptedFrameException("unsupported version: " + version);
        }
        int type = in.getByte(start + 5) & 0xFF;
        int flags = in.getUnsignedShort(start + 6);
        int requestId = in.getInt(start + 8);
        ByteBuf payload = (ByteBuf) super.decode(ctx, in);
        if (payload == null) {
            return null;
        }
        try {
            byte[] data = new byte[payload.readableBytes()];
            payload.readBytes(data);
            return new LinkdFrame(type, flags, requestId, data);
        } finally {
            payload.release();
        }
    }

    @Override
    protected void decodeLast(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
        if (in.isReadable()) {
            throw new CorruptedFrameException(
                    "truncated frame: " + in.readableBytes() + " trailing bytes");
        }
    }
}

这段代码的关键是「在父类剥头之前把头字段读出来」。super.decode(ctx, in) 会按 (12, 4, 0, 16) 切出整帧、剥掉 16 字节头、返回负载;剥之前,子类已经从 readerIndex 处用 getInt/getByte 读到了 magic、version、type、flags 和 requestId。负载返回后复制进 byte[] 并 release,因为 super.decode 返回的 ByteBuf 所有权在这一层。decodeLast 负责关闭时的残余:还有字节就说明对端在帧中间断开,抛 CorruptedFrameException,由 6.3 的 exceptionCaught 记录。

如果头还没收全,子类直接返回 null;如果头收全但负载没到齐,super.decode 返回 null,子类也返回 null。两种情况都没有消费 readerIndex,父类的 cumulation 会保留全部字节,下一次 read 重新走一遍。把头字段的读取放在 super.decode 之前是安全的:同一线程、同一 readerIndex,中间没有别的 handler 插入。

把一个 21 字节帧从第 19 字节切开,分两次喂入,本机输出:

after part1, frames=0
LinkdFrame[type=5,flags=0,requestId=42,payload=hello]
LinkdFrame[type=6,flags=1,requestId=43,payload=world!]

这里有一个必须讲清楚的取舍:strip=16 让下游只看到负载,requestId 等头字段是靠子类在剥头之前读出来的。如果直接把裸的 LengthFieldBasedFrameDecoder(..., 12, 4, 0, 16) 放进 pipeline,后面再接一个普通的 ChannelInboundHandlerAdapter,那个 handler 只能拿到负载,拿不到 requestId,RpcClient 就无法把响应和请求对上。要么像上面这样用子类在边界层保留头字段,要么把 strip 改成 0、让下游自己解析头;linkd-v2 选择前者,因为 LinkdFrame 是协议层统一输出,业务 handler 不应该再碰字节。

长度上限也在这一层兜住:MAX_PAYLOAD + HEADER_LENGTH = 1,048,592。对端声明 1 MiB 负载时,整帧正好 1,048,592,可以通过;声明 1 MiB + 1 时,整帧 1,048,593,父类抛 TooLongFrameException。这个检查发生在读取负载之前,不会先分配再拒绝。

常见错误与边界

错误一:maxFrameLength 只填负载上限。 linkd-v2 如果写成 new LengthFieldBasedFrameDecoder(1 << 20, 12, 4, 0, 16),一个 1 MiB 的负载加上 16 字节头就是 1,048,592,超过 1,048,576,合法帧被拒。上限的口径是「整帧总长」,不是「负载长度」;所有含头的协议都要把这部分加回去。

错误二:lengthAdjustment 符号反了。 长度值包含头时用了 0,frameLength 会多算 16 字节,解码器会一直等下一帧的头来补足,直到 cumulation 里攒够 37 字节;这 16 字节本来是下一帧的 magic 和 version,被当成当前帧负载吃掉,之后每一帧都错位。长度值只算负载却用了 -16,frameLength 只有 5,在 strip=16 的配置下小于 initialBytesToStrip,父类直接抛 CorruptedFrameException;如果 strip 是 0,则只切出 5 字节的半帧,后续同样错位。判断符号只看一件事:长度值有没有把 lengthFieldEndOffset 这段算进去。

错误三:initialBytesToStrip 大于帧长。

new LengthFieldBasedFrameDecoder(1024, 12, 4, 0, 200);

一个 21 字节的帧,strip 200,本机报:

io.netty.handler.codec.CorruptedFrameException: Adjusted frame length (21) is less than initialBytesToStrip: 200

strip 的合法范围是 0..frameLength,不是「想剥多少剥多少」。剥 16 字节头是常见值,剥到负载中间则说明配置和协议对不上。

错误四:给 linkd-v2 再加一个 LengthFieldPrepender。 LinkdFrameEncoder 已经在 16 字节头里写了长度字段,再 prepend 一个 4 字节长度,线上就变成「4 字节长度 + 16 字节头 + 负载」,对端按 offset 12 读到的会是头里的 requestId 或 length 的错位值。长度域协议只在一个地方写长度:要么编码器写,要么 prepender 写,不能两边都写。

错误五:用分隔符解码器处理二进制负载。 DelimiterBasedFrameDecoder 不关心负载语义,只找字节序列。负载里出现 \r\n 就提前切帧,后面的字节被当成新帧;这不是解码器 bug,是协议选型错误。

边界:长度字段的字节序。 LengthFieldBasedFrameDecoder 默认大端;协议如果是小端,必须用带 ByteOrder 的构造器,不能靠调 offset 绕过。linkd-v2 使用网络字节序,保持默认。

边界:failFast 的取舍。 默认 failFast=true,一旦算出超长就立即抛 TooLongFrameException;设为 false 会先把超长帧的字节丢弃,等整帧「读」完再抛。前者响应快,后者给对端一个把话说完的机会,但都会让当前连接不可继续解析,后面仍然要关闭。

小结

  • frameLength = lengthFieldOffset + lengthFieldLength + lengthAdjustment + lengthFieldValue,maxFrameLength 检查的是这个总长,且发生在 strip 之前。
  • lengthAdjustment 的符号由长度值是否包含头决定:只算负载用 0,包含 16 字节头用 -16,尾部有未计入的 CRC 用正数。
  • initialBytesToStrip=16 输出纯负载,但会丢掉 requestId;linkd-v2 用子类在剥头前读头字段,再输出 LinkdFrame。
  • LengthFieldPrepender 只负责出站长度前缀,不能替代自定义头;和 LinkdFrameEncoder 同时用会写出两个长度字段。
  • 分隔符解码器适合文本、分隔符不会出现在负载的协议;定长解码器适合定长记录,两者都不能替代长度域。
  • linkd-v2 的整帧上限是 1,048,592(1 MiB 负载 + 16 字节头),超限在读取负载前就会被拒绝。

边界切出来了,下一个问题是「切出来的帧如果本身是坏的怎么办」。bad magic、超长声明、截断帧都会让解码器抛异常,异常沿着 pipeline 往哪里走、连接是关还是留、HTTP 的 400 怎么回,是 6.3 的内容。

阅读导航:上一节:6.1 ByteToMessageDecoder · 下一节:6.3 编解码错误与异常传播 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发