【Netty4核心原理⑮】【Netty 编解码的艺术】
文章目录
- 一、前言
- 二、什么是拆包、粘包
- 1. TCP 拆包、粘包
- 2. 粘包问题的解决策略
- 3. Socket、NIO、Netty 的关系
- 三、Netty 中常用的解码器
- 1. ByteToMessageDecoder 抽象解码器
- 2. LineBasedFrameDecoder 行解码器
- 3. DelimiterBasedFrameDecoder 分隔符解码器
- 4. FixedLengthFrameDecoder 固定长度解码器
- 5. LengthFieldBasedFrameDecoder 通用解码器
- 5.1 LengthFieldBasedFrameDecoder 的变量
- 5.2 半包”读取策略
- 四、Netty 编码器
- 五、自定义编解码
- 1. MessageToMessageDecoder 抽象解码器
- 2. MessageToMessageEncode 抽象编码器
- 3. ObjectEncoder 序列化编码器
- 4. LengthFieldPrepender 通用编码器
- 六、参考内容
一、前言
本系列虽说本意是作为 《Netty4 核心原理》一书的读书笔记,但在实际阅读记录过程中加入了大量个人阅读的理解和内容,因此对书中内容存在大量删改。
本篇涉及内容 :第十二章 Netty 编解码的艺术
本系列内容基于 Netty 4.1.73.Final 版本,如下:
<dependency><groupId>io.netty</groupId><artifactId>netty-all</artifactId><version>4.1.73.Final</version></dependency>
系列文章目录:
【Netty4核心原理】【全系列文章目录】
二、什么是拆包、粘包
1. TCP 拆包、粘包
TCP 是一个 “流” 协议。所谓流,就是没有界限的一长串二进制数据。而 TCP 作为传输层协议,并不了解上层业务数据的具体含义,他会根据 TCP 缓冲区的实际情况进行数据包的划分,所以在业务上认为是一个完整包的,可能会被 TCP 拆分成多个包进行发送,也有可能将多个小的包封装成一个大的数据包在发送,这就是所谓的 TCP拆包和粘包的问题。
在 Netty 的编码器中,也会对半包和粘包问题做相应的处理。什么是半包,顾名思义,就是不完整的包,因为 Netty 在轮询读事件的时候,每次从 Channel 中读取的数据,不一定是一个完成的数据包,这种情况就叫做半包。粘包同样也不难理解,Client 向 Server 发送数据包时,如果发送很频繁很有可能会将多个数据包的数据都发送到通道中,Server 在读取的时候可能会读取到一个完整数据包的长度,这种情况叫做粘包。如下图所示:

假设 TCP 缓冲区大小为 10KB(只是假设),如果我们一个消息的大小是 8KB ,由于没有填满 TCP 缓冲区,所以此时消息不会发送出去,而是等待下一个消息(假设 5KB)的到来,此时 TCP 的一个缓冲区会被填满便会发送,而接收方会接受到一个 10KB 的数据,其中 8KB 的完整消息和 2KB 的不完整消息 “粘”在了一起,即为 TCP 粘包,而我们需要根据一定的规则拆接触完整的消息,即为 拆包。
2. 粘包问题的解决策略
由于底层的 TCP 无法理解上层的业务数据,所以在底层时无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决。目前业界主流协议的解决方案可以归纳如下:
- 消息定长:报文长度固定,例如每个报文长度固定都是 200 字节,如果不够空位补空格。
- 报尾添加特殊分隔符:例如每条报文结束都添加回车换行符(如 FTP) 或者指定特殊字符作为报文分隔符,接收方通过特殊分隔符区分报文。
- 将消息分为消息头和消息体,消息头包含表示消息的总长度(或者消息体长度)的属性。
- 更复杂的自定义应用层协议。
Netty 对半包或者粘包的处理其实很简单。通过之前的学习知道,每个 Handler 都是和 Channel 唯一绑定的,一个 Handler 对应一个 Channel。所以 Channel中数据读取的时候经过解析,如果不是一个完整的数据包,则解析失败,将这个数据包进行报错,等下一次解析的时候再合这个数据包组装解析,直到解析到完成的数据包,才会将数据包向下传递。
3. Socket、NIO、Netty 的关系
Socket 是网络通信的基础,NIO 在 Socket 基础上进行改进,提供了更高效的非阻塞 I/O 方式。而 Netty 则是对 NIO 的进一步封装和优化,它利用 NIO 的特性,简化了网络编程的复杂度,提升了开发效率,使得开发者能更轻松地构建高性能、高并发的网络应用程序。在实际开发中,开发者通常借助 Netty 来使用 NIO 的能力,而 NIO 底层又依赖 Socket 进行网络通信 。
三、Netty 中常用的解码器
Netty 默认提供了多个解码器,可以进行分包操作,具体如下。
1. ByteToMessageDecoder 抽象解码器
使用 NIO 进行网络编程时,往往需要将读取到的字节数组或者字节缓冲区解码为业务可以使用的 POJO 对象。因此 Netty 提供了 ByteToMessageDecoder 抽象解码器类工具。
ByteToMessageDecoder 继承关系如下 :

用户自定义解码器时可以直接继承 ByteToMessageDecoder ,只需要实现 io.netty.handler.codec.ByteToMessageDecoder#decode 抽象方法即可完成 ByteBuf 到 POJO 对象的解码。
因为 ByteToMessageDecoder 提供的功能稍显 “简陋” ,因此大多数场景下我们并不会直接继承 ByteToMessageDecoder ,而是继承另外一些更高级的解码器。
上面我们通过 ByteToMessageDecoder 的类图可以发现 ByteToMessageDecoder 也继承了 ChannelInboundHandlerAdapter,因此 ByteToMessageDecoder 是 Inbound 类型的 Handler,也就是处理流向自身事件的 Handler。
因此,我们这里来简单看下 ByteToMessageDecoder#channelRead 方法,其实现如下:
@Overridepublic void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {// 只处理ByteBuf类型的消息(网络传输的字节流),其他类型直接透传。if (msg instanceof ByteBuf) {selfFiredChannelRead = true;CodecOutputList out = CodecOutputList.newInstance();try {// 1. 数据累积(解决拆包问题)first = cumulation == null;cumulation = cumulator.cumulate(ctx.alloc(),first ? Unpooled.EMPTY_BUFFER : cumulation, (ByteBuf) msg); // 2. 解码逻辑调用callDecode(ctx, cumulation, out);} catch (DecoderException e) {throw e;} catch (Exception e) {throw new DecoderException(e);} finally {try {if (cumulation != null && !cumulation.isReadable()) {// 累积缓冲区已无可读数据,释放资源numReads = 0;cumulation.release();cumulation = null;} else if (++numReads >= discardAfterReads) {// We did enough reads already try to discard some bytes so we not risk to see a OOME.// See https://github.com/netty/netty/issues/4275// 达到读取次数阈值,丢弃已读字节(优化内存)numReads = 0;discardSomeReadBytes();}// 将out列表中解码出的消息(可能是多个,解决粘包问题)通过fireChannelRead传递给下一个ChannelHandler(如业务处理器)。// 支持一次性传递多个解码结果,应对 “粘包” 场景(单次接收的数据包含多个完整消息)。int size = out.size();firedChannelRead |= out.insertSinceRecycled();fireChannelRead(ctx, out, size);} finally {// 回收CodecOutputList,复用资源out.recycle();}}} else {ctx.fireChannelRead(msg);}}
我们按照上面的注释简单说明一下 :
-
数据累积(解决拆包问题),具体代码如下:
first = cumulation == null;cumulation = cumulator.cumulate(ctx.alloc(),first ? Unpooled.EMPTY_BUFFER : cumulation, (ByteBuf)cumulation是 ByteToMessageDecoder 内部维护的一个累计缓冲区,每次收到新的 ByteBuf 数据时,都会将其合并到这个缓冲区中,也就是说即使单次接收的数据不完整(拆包),也能通过累积多次数据,凑齐完整的消息后再解码。 -
解码逻辑调用 :
callDecode(ctx, cumulation, out);
该方法内部会循环调用 子类实现的decode方法,直到累积缓冲区中没有足够的数据解码出新消息为止。也就是说 即使单次接收的数据包含多个完整消息(粘包),也能通过循环解码,一次性解析出所有消息。
综上:ByteToMessageDecoder 本身提供了数据累积和循环解码的核心机制,是处理粘包和拆包的基础。但具体如何解析消息边界(即 “如何判断一个完整消息的开始和结束”),需要结合具体的协议规则(通过重写 decode 方法或使用 Netty 内置的解码器)来实现。
2. LineBasedFrameDecoder 行解码器
LineBasedFrameDecoder 是回车换行解码器,如果用户发送的消息以回车换行符(以 \n 或 \r\n 结尾)作为消息结束的标识,则可以直接使用的 Netty 的 LineBasedFrameDecoder 对消息进行解码,只需要在初始化 Netty 服务端或客户端时将 LineBasedFrameDecoder 正确地添加到 ChannelPipeline 中即可。
LineBasedFrameDecoder 的工作原理是它依次遍历 ByteBuf 的可读字节,判断是否有 \n 或 \r\n。如果有,就以此位置为结束位置,从可读索引到结束位置区间的字节就组成了一行。
他是以换行符为结束标志的解码器,支持携带结束符或不携带结束符两种解码方式,同时支持配置单行的最大长度。如果连续读取到最大长度后仍然没有发现换行符,就会抛出异常,同时忽略之前读到的异常码流,防止由于数据报没有携带换行符导致接收到的 ByteBuf 无限制积压,引起系统内存溢出。
通常情况下 LineBasedFrameDecoder 会和 StringDecoder 配合使用,组合成按行切换的文本解码器,对于文本类协议的解析,文本换行解码器非常实用,例如对于 Http 消息头的解析、FTP 消息的解析等。
LineBasedFrameDecoder 是 ByteToMessageDecoder 的子类,因此在 ByteToMessageDecoder 的基础上,实现了 ByteToMessageDecoder#decode 抽象方法的内容,如下:
// 定义最大长度,用于限制读取数据的长度
private final int maxLength;
// 定义是否在数据长度超过maxLength时立即抛出异常
private final boolean failFast;
// 定义是否去除分隔符
private final boolean stripDelimiter;// 标记是否正在丢弃输入数据,当数据长度超过maxLength时会设置为true
private boolean discarding;
// 记录已经丢弃的字节数
private int discardedBytes;// 记录上次扫描的位置
private int offset;
...// 重写decode方法,该方法是框架调用的入口,用于将接收到的数据进行解码并添加到输出列表中
@Override
protected final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {// 调用自定义的decode方法进行数据解码Object decoded = decode(ctx, in);// 如果解码后的数据不为空,则将其添加到输出列表中if (decoded != null) {out.add(decoded);}
}// 自定义的解码方法,用于处理具体的解码逻辑
protected Object decode(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception {// 查找换行符(\n 或 \r\n)的位置// 如果是以 \n 结尾,则返回索引值是 \n 的索引值,如果是 \r\n 结尾的,返回的索引值是 \r 的索引值final int eol = findEndOfLine(buffer);// 如果当前没有处于丢弃数据的状态if (!discarding) {// 如果找到了换行符if (eol >= 0) {ByteBuf frame;// 计算当前行数据的长度(不包含分隔符)final int length = eol - buffer.readerIndex();// 计算分隔符的长度,\r\n为2,\n为1final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;// 如果数据长度超过了最大长度if (length > maxLength) {// 移动读指针到换行符后的位置,跳过当前行数据buffer.readerIndex(eol + delimLength);// 处理数据长度超过限制的情况,可能会抛出异常fail(ctx, length);return null;}// 如果需要去除分隔符if (stripDelimiter) {// 从当前读指针位置开始,读取长度为length的切片数据frame = buffer.readRetainedSlice(length);// 跳过分隔符buffer.skipBytes(delimLength);} else {// 从当前读指针位置开始,读取长度为length + delimLength的切片数据,包含分隔符frame = buffer.readRetainedSlice(length + delimLength);}return frame;} else {// 如果没有找到换行符,计算当前可读数据的长度final int length = buffer.readableBytes();// 如果可读数据长度超过了最大长度if (length > maxLength) {// 记录已经丢弃的字节数discardedBytes = length;// 移动读指针到写指针位置,丢弃所有可读数据buffer.readerIndex(buffer.writerIndex());// 标记为正在丢弃数据的状态discarding = true;// 重置扫描位置offset = 0;// 如果设置为failFast,立即处理数据长度超过限制的情况,可能会抛出异常if (failFast) {fail(ctx, "over " + discardedBytes);}}return null;}} else {// 如果当前处于丢弃数据的状态if (eol >= 0) {// 计算丢弃的字节数加上当前行数据的长度(不包含分隔符)final int length = discardedBytes + eol - buffer.readerIndex();// 计算分隔符的长度,\r\n为2,\n为1final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;// 移动读指针到换行符后的位置,跳过当前行数据buffer.readerIndex(eol + delimLength);// 重置已经丢弃的字节数discardedBytes = 0;// 标记不再丢弃数据discarding = false;// 如果没有设置为failFast,处理数据长度超过限制的情况,可能会抛出异常if (!failFast) {fail(ctx, length);}} else {// 如果没有找到换行符,增加已经丢弃的字节数discardedBytes += buffer.readableBytes();// 移动读指针到写指针位置,丢弃所有可读数据buffer.readerIndex(buffer.writerIndex());// 重置扫描位置offset = 0;}return null;}
}
LineBasedFrameDecoder#decode 代码在上面已经有了非常详细的注释,以下是对代码逻辑的详细总结:
-
成员变量
- maxLength:设定允许读取的最大数据长度。
- failFast:布尔值,若为 true,当数据长度超过 maxLength 时会立即抛出异常。
- stripDelimiter:布尔值,若为 true,解码结果将去除分隔符。
- discarding:布尔值,标记当前是否处于丢弃数据的状态。
- discardedBytes:记录已丢弃的字节数。
- offset:记录上次扫描的位置。
-
核心方法在
decode(ChannelHandlerContext ctx, ByteBuf buffer)中,该方法是具体的解码逻辑实现,主要步骤如下:- 查找分隔符:调用
findEndOfLine(buffer)查找换行符(\n 或 \r\n)的位置。 - 非丢弃状态处理:
- 找到分隔符:
- 计算当前行数据的长度和分隔符长度。
- 若数据长度超过
maxLength,移动读指针跳过当前行,并调用 fail 方法处理错误。 - 若未超过
maxLength,根据stripDelimiter的值决定是否去除分隔符,然后读取相应数据并返回。
- 未找到分隔符:
- 计算当前可读数据的长度。
- 若长度超过
maxLength,标记为丢弃状态,记录丢弃字节数,移动读指针到写指针位置,若failFast为 true,调用 fail 方法处理错误。
- 找到分隔符:
- 丢弃状态处理:
- 找到分隔符:
- 计算丢弃字节数与当前行数据长度之和。
- 移动读指针跳过当前行,重置丢弃字节数和丢弃状态。
- 若
failFast为 false,调用 fail 方法处理错误。
- 未找到分隔符:
- 累加丢弃字节数,移动读指针到写指针位置,重置扫描位置。
- 找到分隔符:
- 查找分隔符:调用
3. DelimiterBasedFrameDecoder 分隔符解码器
DelimiterBasedFrameDecoder 分隔符解码器是按照指定的分隔符进行解码的解码器,通过分隔符可以将二进制流拆分成完整的数据包。LineBasedFrameDecoder 实际上是一种特殊的 DelimiterBasedFrameDecoder 解码器。
与 LineBasedFrameDecoder 一样,DelimiterBasedFrameDecoder 也是 ByteToMessageDecoder 的子类。
在 DelimiterBasedFrameDecoder 构造函数中会判断用户指定的分隔符是否是 \n 或 \r\n,如果是,则直接在内部创建一个 LineBasedFrameDecoder 实例,后面解码的操作直接交由 LineBasedFrameDecoder 来完成,如下:
public DelimiterBasedFrameDecoder(int maxFrameLength, boolean stripDelimiter, boolean failFast, ByteBuf... delimiters) {...// 如果 分隔符是 换行符,则创建 LineBasedFrameDecoder 对象if (isLineBased(delimiters) && !isSubclass()) {lineBasedDecoder = new LineBasedFrameDecoder(maxFrameLength, stripDelimiter, failFast);this.delimiters = null;} else {...}....}
在 DelimiterBasedFrameDecoder#decode 中会判断 DelimiterBasedFrameDecoder#lineBasedDecoder 是否为空,不为空则直接调用 lineBasedDecoder.decode(ctx, buffer) 来处理。
DelimiterBasedFrameDecoder#decode 的方法实现思想与 LineBasedFrameDecoder 类似,因此这里不在展开说明。
4. FixedLengthFrameDecoder 固定长度解码器
FixedLengthFrameDecoder 固定长度解码器能够按照指定的长度对消息进行自动解码,开发者不需要考虑粘包合拆包等问题。对于定长消息,如果消息实际长度小于定长,则往往会进行部分操作,他在一定程度上导致了空间和资源的浪费。但是他的优点在于其编解码比较简单,所以在实际项目中仍有一定的应用场景。
FixedLengthFrameDecoder#decode 方法实现比较简单,直接判断当前 ByteBuf 中可读数据是否到达指定长度,如果到达则读出。如下:
protected Object decode(@SuppressWarnings("UnusedParameters") ChannelHandlerContext ctx, ByteBuf in) throws Exception {if (in.readableBytes() < frameLength) {return null;} else {return in.readRetainedSlice(frameLength);}}
5. LengthFieldBasedFrameDecoder 通用解码器
当我们接收消息时,并不能认为一次读取到的报文就是整包消息,特别是对于采用非阻塞IO 和长连接通信的程序。
如何区分一个整包消息,通常有四种做法。
- 固定长度。
- 通过回车换行符区分消息,例如 HTTP。这类区分消息的方式多用于文本协议。
- 通过特定的分隔符区分整包消息。
- 通过在协议头、消息头重设置长度属性来标识整包消息。
前三种解码器上面已经分析过了,这里我们来介绍通用解码器 LengthFieldBasedFrameDecoder 。
大多数协议(私有或公有)的协议头中都会携带长度协议,用于标识消息体或整包消息的长度,例如 SMPP(短消息对等协议)、HTTP 等。由于基于长度解码需求的通用性,以及为了降低用户协议开发难度,Netty 提供了 LengthFieldBasedFrameDecoder ,自动屏蔽 TCP 底层的拆包和黏包问题,只需要传入正确的参数,即可以轻松解决 “读半包” 问题。
5.1 LengthFieldBasedFrameDecoder 的变量
要灵活的实现各种解码场景,势必会存在很多变量参数来控制各种场景,所以 LengthFieldBasedFrameDecoder 中存在很多变量,我们挑下面几个来说明。
-
maxFrameLength- 作用:指定允许的最大帧长度。当接收到的帧长度超过这个值时,会触发相应的错误处理机制。
- 使用场景:用于防止恶意攻击或错误数据导致的内存溢出问题。例如,攻击者可能发送一个超长的帧来耗尽服务器的内存资源,通过设置 maxFrameLength 可以有效避免这种情况。
-
lengthFieldOffset- 作用:指定长度字段在消息中的偏移量,即从消息的起始位置到长度字段的起始位置的字节数。
- 使用场景:在一些协议中,长度字段可能不是位于消息的起始位置,而是在消息的某个偏移处。通过设置 lengthFieldOffset 可以准确找到长度字段的位置。
-
lengthFieldLength- 作用:指定长度字段本身的长度,即长度字段占用的字节数。常见的长度字段长度为 1 字节、2 字节、4 字节等。
- 使用场景:不同的协议可能使用不同长度的长度字段来表示消息的长度。例如,一个简单的协议可能使用 1 字节的长度字段,而一个复杂的协议可能使用 4 字节的长度字段。
-
lengthFieldEndOffset- 作用:长度字段的结束偏移量,计算公式为 lengthFieldOffset + lengthFieldLength。它表示长度字段在消息中的结束位置。
- 使用场景:在解析消息时,需要知道长度字段的结束位置,以便继续解析后续的数据。
-
lengthAdjustment- 作用:长度字段的值可能并不直接表示整个消息的长度,可能需要进行一些调整。lengthAdjustment 就是用于对长度字段的值进行调整的偏移量。
- 使用场景:例如,长度字段的值可能只表示消息内容的长度,而不包括长度字段本身的长度和一些头部信息的长度,此时就需要通过 lengthAdjustment 来进行调整。
-
initialBytesToStrip- 作用:指定在解析出完整的帧后,需要跳过的初始字节数。这些字节通常是长度字段和一些不需要的头部信息。
- 使用场景:当解析出的帧包含一些不需要的头部信息时,可以通过设置 initialBytesToStrip 来跳过这些信息,只保留有用的消息内容。
-
failFast- 作用:表示是否快速失败。当接收到的帧长度超过 maxFrameLength 时,如果 failFast 为 true,则会立即抛出异常;如果为 false,则会在丢弃所有超出长度的字节后才抛出异常。
- 使用场景:根据具体的业务需求来决定是否需要快速失败。如果希望尽快发现并处理超长帧的问题,可以将 failFast 设置为 true。
-
discardingTooLongFrame- 作用:标记当前是否正在丢弃超长帧。当接收到的帧长度超过 maxFrameLength 时,会将该标记设置为 true,表示正在丢弃超长帧。
- 使用场景:用于在后续的解码过程中判断是否需要继续丢弃超长帧的数据。
-
tooLongFrameLength- 作用:记录超长帧的长度。当接收到的帧长度超过 maxFrameLength 时,会将该帧的长度记录在 tooLongFrameLength 中。
- 使用场景:用于在丢弃超长帧的过程中,知道还需要丢弃多少字节的数据。
-
bytesToDiscard- 作用:记录还需要丢弃的字节数。当接收到的帧长度超过 maxFrameLength 时,会计算出需要丢弃的字节数,并记录在 bytesToDiscard 中。
- 使用场景:在丢弃超长帧的过程中,每次丢弃一部分字节后,会更新 bytesToDiscard 的值,直到所有需要丢弃的字节都被丢弃。
5.2 半包”读取策略
在使用 LengthFieldBasedFrameDecoder 时,我们在构造 LengthFieldBasedFrameDecoder 对象时会通过构造函数传入一些参数,下面看看如何通过不同的参数组合来实现不同的“半包”读取策略。
-
偏移量为 0 的 2 字节长度字段,不剥离头部 :在此示例中,长度字段的值为 12 (0x000C),它表示 “HELLO, WORLD” 的长度。默认情况下,解码器假定长度字段表示跟随在长度字段之后的字节数。因此,可以使用简单的参数组合来对其进行解码。

上面的消息长度字段值为0x000C,转换为十进制是 12,解码的参入含义如下:
- lengthFieldOffset = 0 :说明长度字段位于消息的起始位置
- lengthFieldLength = 2 : 说明长度字段长度占用 2 字节
- lengthAdjustment = 0 : 说明长度字段表示的是整个消息的长度,无需调整。
- initialBytesToStrip = 0 : 说明消息解析后没有无用信息需要跳过。
因此解码后的结果是 长度字段 + “HELLO, WORLD”
-
偏移量为 0 的 2 字节长度字段,剥离头部 :因为我们可以通过调用 {@link ByteBuf#readableBytes()} 来获取内容的长度,所以您可能希望通过指定 initialBytesToStrip 来剥离长度字段。在此示例中,我们指定 2,这与长度字段的长度相同,以剥离前两个字节。

上面的消息长度字段值为0x000C,转换为十进制是 12,解码的参入含义如下:
- lengthFieldOffset = 0 :说明长度字段位于消息的起始位置
- lengthFieldLength = 2 : 说明长度字段长度占用 2 字节
- lengthAdjustment = 0 : 说明长度字段表示的是整个消息的长度,无需调整。
- initialBytesToStrip = 2 : 说明消息解析后需要跳过 2 字节。
因此解码后的结果是 “HELLO, WORLD”
-
偏移量为 0 的 2 字节长度字段,不剥离头部,长度字段表示整个消息的长度 :在大多数情况下,如前面的示例所示,长度字段仅表示消息体的长度。然而,在某些协议中,长度字段表示整个消息的长度,包括消息头部。在这种情况下,我们指定一个非零的 lengthAdjustment。因为在此示例消息中,长度值总是比消息体长度大 2,所以我们指定 −2 作为 lengthAdjustment 进行补偿。

上面的消息长度字段值为0x000E,转换为十进制是 14,解码的参入含义如下:- lengthFieldOffset = 0 :说明长度字段位于消息的起始位置
- lengthFieldLength = 2 : 说明长度字段长度占用 2 字节
- lengthAdjustment = -2 : 说明长度字段表示的是整个消息的长度 + 2 字节(长度字段本身 + 实际内容长度),因此实际消息内容长度应该是 14 - 2 = 12 字节。
- initialBytesToStrip = 0 : 说明消息解析后没有无用信息需要跳过。。
因此解码后的结果是 长度字段 + “HELLO, WORLD”
-
5 字节头部末尾的 3 字节长度字段,不剥离头部 :由于协议种类繁多,并不是所有的协议都将长度属性放在消息头的首位,当表示消息长度的属性位位于消息头的中间或尾部时,需要通过 lengthFieldOffset 属性进行标识。

上面的消息长度字段值为 0x00000C ,转换为十进制是 12,解码的参入含义如下:- lengthFieldOffset = 2 :说明长度字段的偏移量为2 (因为头部1 的长度为2)
- lengthFieldLength = 3 : 说明长度字段长度占用 3 字节
- lengthAdjustment = 0 : 说明长度字段表示的是整个消息的长度。
- initialBytesToStrip = 0 : 说明消息解析后没有无用信息需要跳过。
-
5 字节头部开头的 3 字节长度字段,不剥离头部 :还有一种场景是在长度属性和实际内容中间存在其他消息内容,此时需要通过

上面的消息长度字段值为 0x00000C ,转换为十进制是 12,解码的参入含义如下:- lengthFieldOffset = 0 :说明长度字段位于消息首位
- lengthFieldLength = 3 : 说明长度字段长度占用 3 字节
- lengthAdjustment = 2 : 对消息长度做调整,加上 头部 1 的长度才能保证解码后的消息完整。
- initialBytesToStrip = 0 : 说明消息解析后没有无用信息需要跳过。
-
4 字节头部中间偏移量为 1 的 2 字节长度字段,剥离第一个头部字段和长度字段 :还有一种场景是在长度属性和实际内容中间存在其他消息内容,此时需要通过 initialBytesToStrip

上面的消息长度字段值为 0x00000C ,转换为十进制是 12,解码的参入含义如下:
- lengthFieldOffset = 1 :说明长度字段偏移量为 1 字节
- lengthFieldLength = 2 : 说明长度字段长度占用 2 字节
- lengthAdjustment = 1 : 解码后如果携带消息头中的属性,则需通过 lengthAdjustment 进行调整,此时他的值为 1,代表的是 头部 2 的长度。
- initialBytesToStrip = 3 : 解码后缓冲区需要忽略长度属性和 HDR 1 部分,所以 initialBytesToStrip 为3 。
四、Netty 编码器
编码器与解码器比较类似,编码器也是一个 Handler ,并且属于 OutboundHandler,就是将准备发出去的数据进行拦截,拦截之后进行相应的处理后再次进行发送。
同解码器一样,编码器中也有一个抽象类叫 MessageToByteEncoder ,定义了解码器的基础方法,具体编码逻辑交给子类实现。
MessageToByteEncoder 是 ChannelOutboundHandlerAdapter 的子类,因此作为一个出站处理器,自然可以处理出站方法。
关于 入站 出站的概念,详参 【Netty4核心原理⑦】【揭开Bootstrap的神秘面纱 - 客户端Bootstrap ❷】 的 【入站和出站】部分。
MessageToByteEncoder 重写了 write 方法,并在其中调用了提供给子类的 encode 抽象方法,如下:
/*** 处理出站消息的编码与写入逻辑* 负责将业务对象编码为ByteBuf,并传递给下一个ChannelHandler** @param ctx ChannelHandler上下文,用于操作通道和传递消息* @param msg 待处理的出站消息对象* @param promise 用于通知操作结果的Promise* @throws Exception 处理过程中发生的异常*/
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {// 声明ByteBuf变量,用于存储编码后的字节数据ByteBuf buf = null;try {// 检查当前消息是否是该编码器可处理的类型if (acceptOutboundMessage(msg)) {// 类型转换:将消息转换为编码器支持的泛型类型I@SuppressWarnings("unchecked")I cast = (I) msg;// 分配缓冲区:根据配置选择直接内存或堆内存缓冲区buf = allocateBuffer(ctx, cast, preferDirect);try {// 核心编码逻辑:将业务对象转换为字节数据写入ByteBufencode(ctx, cast, buf);} finally {// 释放原始消息对象的引用计数// 无论编码成功与否,都需要释放原始对象ReferenceCountUtil.release(cast);}// 检查缓冲区中是否有可读数据if (buf.isReadable()) {// 若有数据,将缓冲区写入通道,并关联promisectx.write(buf, promise);} else {// 若无可读数据,释放缓冲区buf.release();// 写入空缓冲区,确保promise能被正常通知ctx.write(Unpooled.EMPTY_BUFFER, promise);}// 置空buf引用,避免finally中重复释放buf = null;} else {// 若消息不是当前编码器可处理的类型,直接传递给下一个处理器ctx.write(msg, promise);}} catch (EncoderException e) {// 直接抛出编码器异常throw e;} catch (Throwable e) {// 将其他异常包装为编码器异常抛出throw new EncoderException(e);} finally {// 最终确保缓冲区被释放,防止内存泄漏// 只有当buf不为null时才需要释放(已处理过的情况buf会被置空)if (buf != null) {buf.release();}}
}
该方法的核心逻辑总结如下:
- 类型检查:通过
acceptOutboundMessage(msg)判断当前消息是否为本编码器可处理的类型,实现消息的过滤与分流。 - 缓冲区管理:
- 调用
allocateBuffer()分配合适的缓冲区(直接内存或堆内存) - 编码完成后根据是否有数据决定写入内容或空缓冲区
- 最终通过 finally 块确保缓冲区资源被正确释放,防止内存泄漏
- 调用
- 引用计数处理:
- 使用
ReferenceCountUtil.release(cast)释放原始消息对象 - 通过
buf.release()管理缓冲区的引用计数
- 使用
- 异常处理:将所有异常统一包装为 EncoderException,符合 Netty 的异常处理规范
- 消息传递:对于不可处理的消息,直接通过
ctx.write()传递给下一个处理器,保证管道的正常流转
综上我们可以通过继承MessageToByteEncoder类并实现其 encode 方法来实现不同的解码规则, 示例如下:
public class IntegerEncoder extends MessageToByteEncoder<Integer> {@Overrideprotected void encode(ChannelHandlerContext ctx, Integer msg, ByteBuf out) throws Exception {out.writeInt(msg);}
}
五、自定义编解码
尽管Netty预置了丰富的编解码类库功能,但是在实际的业务开发过程中,总是需要对编解码功能做一些定制。使用Netty的编解码框架,可以非常方便地进行协议定制,如下:
1. MessageToMessageDecoder 抽象解码器
MessageToMessageDecoder 实际上是 Netty 的二次解码器,他的职责是将一个对象二次解码为其他对象。
从 SocketChannel 读取的 TCP 数据报是 ByteBuf,即字节数组。首先需要将 ByteBuf 缓冲区中的数据报读取出来,并将其解码为 Java 对象;然后根据某些规则对 Java 对象做二次解码,将其解码为另一个 POJO 对象。因为 MessageToMessageDecoder 在 ByteToMessageDecoder 之后,所以称为二次解码器。
MessageToMessageDecoder 是一个抽象类,如果用户需要实现自定义解码器,可以直接实现 MessageToMessageDecoder#decode 方法即可。
2. MessageToMessageEncode 抽象编码器
存在 MessageToMessageDecoder ,自然就会存在 MessageToMessageEncode,他与之前提到的 MessageToByteEncoder 类似,会将发送数据进行自定义编码,与 MessageToByteEncoder 的区别在于它编码后输出的是中间对象,而非最终可传输的 ByteBuf。
如果用户需要实现自定义编码器,可以直接实现 MessageToMessageEncode#ecode 方法即可。
3. ObjectEncoder 序列化编码器
ObjectEncoder 是 Java序列化编码器,他负责将实现 Serializable 接口的对象序列化为 byte[],然后写入 ByteBuf 用于消息的跨网络传输。作为 MessageToByteEncoder 的子类,其关键方法 ObjectEncoder#encode 实现如下:
/*** 对Serializable对象进行编码,将其序列化为字节流并写入ByteBuf*/
protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out) throws Exception {// 记录当前写入指针位置,用于后续计算实际数据长度int startIdx = out.writerIndex();// 创建基于ByteBuf的输出流,用于将数据写入ByteBufByteBufOutputStream bout = new ByteBufOutputStream(out);ObjectOutputStream oout = null;try {// 先写入4字节的占位符,后续会替换为实际数据长度// 之所以先占位是因为此时还不知道序列化后的数据总长度bout.write(LENGTH_PLACEHOLDER);// 创建紧凑的对象输出流,用于序列化对象// CompactObjectOutputStream是Netty对ObjectOutputStream的优化实现oout = new CompactObjectOutputStream(bout);// 将对象序列化并写入输出流(最终会写入到ByteBuf)oout.writeObject(msg);// 刷新输出流,确保所有数据都被写入到底层ByteBufoout.flush();} finally {// 确保资源正确关闭if (oout != null) {// 关闭对象输出流oout.close();} else {// 如果对象输出流未创建成功,关闭字节输出流bout.close();}}// 记录序列化完成后的写入指针位置int endIdx = out.writerIndex();// 计算实际数据长度(总长度 - 4字节的长度占位符)// 并将其写入到一开始预留的4字节占位符位置out.setInt(startIdx, endIdx - startIdx - 4);
}
4. LengthFieldPrepender 通用编码器
如果协议中的第一个属性为长度属性,Netty 提供了 LengthFieldPrepender 编码器,他可以计算当前待发送消息的二进制字节长度,将该长度添加到 ByteBuf 的缓冲区投中,如下所示:
编码器(12字节) 编码后(14字节)+----------------+ * +--------+----------------+| "HELLO, WORLD" | ----> * + 0x000C | "HELLO, WORLD" |+----------------+ * +--------+----------------+
通过 LengthFieldPrepender 可以将待发送消息的长度写入 ByteBuf 的前 2 字节,编码后的消息组成为长度属性 + 原消息的方式。
编码后的消息总长度为 14 字节,其中前两个字节为0x000C,转换为十进制为 12 , 代表这个消息真实长度为 12字节。
通过设置 LengthFieldPrepender 为 true,消息长度将包含长度本身占用的字节数,打开 LengthFieldPrepender 后,上面示例中的编码结果如下图所示:
编码器(12字节) 编码后(14字节)+----------------+ * +--------+----------------+| "HELLO, WORLD" | ----> * + 0x000E | "HELLO, WORLD" |+----------------+ * +--------+----------------+
编码后的消息总长度为 14 字节,其中前两个字节为0x000E,转换为十进制为 14 , 代表这个消息长度为 14字节(长度占两个字节 + 真实消息占用的长度)。
LengthFieldPrepender工作原理分析如下:首先对长度属性进行设置,如果需要包含消息长度自身,则在原来长度的基础上再加上lengthFieldLength的长度。
如果调整后的消息长度小于0,则抛出参数非法异常。对消息长度自身所占的字节数进行判断,以便采用正确的方法将长度属性写入ByteBuf,共有6种可能。
- 长度属性所占字节为1:如果使用1字节代表消息长度,则最大长度需要小于256字节。对长度进行校验,如果校验失败,则抛出参数非法异常;若校验通过,则创建新的ByteBuf并通过writeByte将长度值写入ByteBuf。
- 长度属性所占字节为2:如果使用2字节代表消息长度,则最大长度需要小于65536字节,对长度进行校验,如果校验失败,则抛出参数非法异常;若校验通过,则创建新的ByteBuf并通过writeShort将长度值写入ByteBuf。
- 长度属性所占字节为3:如果使用3字节代表消息长度,则最大长度需要小于16777216字节,对长度进行校验,如果校验失败,则抛 出 参 数 非 法 异 常 ; 若 校 验 通 过 , 则 创 建 新 的 ByteBuf 并 通 过writeMedium将长度值写入ByteBuf。
- 长度属性所占字节为4:创建新的ByteBuf,并通过writeInt将长度值写入ByteBuf。
- 长 度 属 性 所 占 字 节 为 8 : 创 建 新 的 ByteBuf , 并 通 过writeLong将长度值写入ByteBuf。
- 其他长度值:直接抛出Error。
六、参考内容
- 《Netty4核心原理》
- 豆包
