对于我们编写的程序来说,它需要通过网络传输的数据是什么形式的呢?是结构化的数据,比如,一条命令、一段文本或者是一条消息。对应到我们写的代码中,这些结构化的数据是什么?这些都可以用一个类(Class)或者一个结构体(Struct)来表示。那显然,要想使用网络框架的 API 来传输结构化的数据,必须得先实现结构化的数据与字节流之间的双向转换。这种将结构化数据转换成字节流的过程,我们称为序列化,反过来转换,就是反序列化。编解码技术这是实现网络通信的基础,让我们可以定义任何满足业务需求的应用层协议。在网络编程中,我们经常会使用各种网络传输协议,其中 TCP 是最常用的协议。我们首先需要了解的是 TCP 最基本的拆包/粘包问题以及常用的解决方案,才能更好地理解 Netty 的编解码框架。

为什么会有拆包/粘包问题?

TCP 传输协议是面向流的,没有数据包界限,它传输数据的基本形式就是二进制流,也就是一段一段的 1 和 0。在网络通信的过程中,每次可以发送的数据包大小是受多种因素限制的,如 MTU 传输单元大小、MSS 最大分段大小、滑动窗口等。如果一次传输的网络包数据大小超过传输单元大小,那么我们的数据可能会拆分为多个数据包发送出去。另外,如果每次请求的网络包数据都很小,一共请求了 10000 次,TCP 并不会分别发送 10000 次,因为 TCP 采用的 Nagle 算法对此作出了优化,从而发生了粘包。

MTU 最大传输单元和MSS 最大分段大小
MTU(Maxitum Transmission Unit) 是数据链路层一次最大传输数据的大小。MTU 一般来说大小为 1500 byte。MSS(Maximum Segement Size) 是指 TCP 最大报文段长度,它是传输层一次发送最大数据的大小。如下图所示,MTU 和 MSS 一般的计算关系为:MSS = MTU - IP 首部 - TCP首部,如果 MSS + TCP 首部 + IP 首部 > MTU,那么数据包将会被拆分为多个发送,这就是拆包现象。

Nagle 算法
Nagle 算法于 1984 年被福特航空和通信公司定义为 TCP/IP 拥塞控制方法。它主要用于解决频繁发送小数据包而带来的网络拥塞问题。试想如果每次需要发送的数据只有 1 字节,加上 20 个字节 IP Header 和 20 个字节 TCP Header,每次发送的数据包大小为 41 字节,但是只有 1 字节是有效信息,这就造成了非常大的浪费。Nagle 算法可以理解为批量发送,也是我们平时编程中经常用到的优化思路,它是在数据未得到确认之前先写入缓冲区,等待数据确认或者缓冲区积攒到一定大小再把数据包发送出去,这就是粘包现象。

Linux 在默认情况下是开启 Nagle 算法的,在大量小数据包的场景下可以有效地降低网络开销。但如果你的业务场景每次发送的数据都需要获得及时响应,那么 Nagle 算法就不能满足你的需求了,因为 Nagle 算法会有一定的数据延迟。你可以通过 Linux 提供的 TCP_NODELAY 参数禁用 Nagle 算法。Netty 中为了使数据传输延迟最小化,就默认禁用了 Nagle 算法,这一点与 Linux 操作系统的默认行为是相反的。

在客户端和服务端通信的过程中,服务端一次读到的数据大小是不确定的。如上图所示,拆包/粘包可能会出现以下五种情况:
微信截图_20211115230958.png

  1. 服务端恰巧读到了两个完整的数据包 A 和 B,没有出现拆包/粘包问题;
  2. 服务端接收到 A 和 B 粘在一起的数据包,服务端需要解析出 A 和 B;
  3. 服务端收到完整的 A 和 B 的一部分数据包 B-1,服务端需要解析出完整的 A,并等待读取完整的 B 数据包;
  4. 服务端接收到 A 的一部分数据包 A-1,此时需要等待接收到完整的 A 数据包;
  5. 数据包 A 较大,服务端需要多次才可以接收完数据包 A

拆包粘包解决方法

由于拆包/粘包问题的存在,数据接收方很难界定数据包的边界在哪里,很难识别出一个完整的数据包。所以需要提供一种机制来识别数据包的界限,这也是解决拆包/粘包的唯一方法:定义应用层的通信协议。下面我们一起看下主流协议的解决方案。

固定消息长度

每个数据报文都需要一个固定的长度。当接收方累计读取到固定长度的报文后,就认为已经获得一个完整的消息。当发送方的数据小于固定长度时,则需要空位补齐。

在 Netty 中为我们提供了固定长度解码器 FixedLengthFrameDecoder

  1. public class FixedLengthFrameDecoder extends ByteToMessageDecoder {
  2. //消息长度
  3. private final int frameLength;
  4. public FixedLengthFrameDecoder(int frameLength) {
  5. ObjectUtil.checkPositive(frameLength, "frameLength");
  6. this.frameLength = frameLength;
  7. }
  8. protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
  9. return in.readableBytes() < this.frameLength ? null : in.readRetainedSlice(this.frameLength);
  10. }
  11. }

固定长度解码器 FixedLengthFrameDecoder 非常简单,直接通过构造函数设置固定长度的大小 frameLength,无论接收方一次获取多大的数据,都会严格按照 frameLength 进行解码。如果累积读取到长度大小为 frameLength 的消息,那么解码器认为已经获取到了一个完整的消息。如果消息长度小于 frameLength,FixedLengthFrameDecoder 解码器会一直等后续数据包的到达,直至获得完整的消息。

特殊分隔符

既然接收方无法区分消息的边界,那么我们可以在每次发送报文的尾部加上特定分隔符,接收方就可以根据特殊分隔符进行消息拆分。

在 Netty 中为我们提供了特殊分隔符解码器 DelimiterBasedFrameDecoder

  1. public class DelimiterBasedFrameDecoder extends ByteToMessageDecoder {
  2. //存放指定的分隔符,是一个数组,可以指定多个特殊分隔符
  3. private final ByteBuf[] delimiters;
  4. //报文最大长度限制
  5. private final int maxFrameLength;
  6. private final boolean stripDelimiter;
  7. private final boolean failFast;
  8. private boolean discardingTooLongFrame;
  9. private int tooLongFrameLength;
  10. }

该解码器有几个重要的属性:

  • delimiters:指定特殊分隔符,通过写入 ByteBuf 作为参数传入。delimiters 的类型是 ByteBuf 数组,所以我们可以同时指定多个分隔符,但是最终会选择长度最短的分隔符进行消息拆分。

    例如接收方收到的数据为: +———————+ | ABC\nDEF\r\n | +———————+

    如果指定的多个分隔符为 \n 和 \r\n,DelimiterBasedFrameDecoder 会退化成使用 LineBasedFrameDecoder 进行解析,那么会解码出两个消息。 +——-+——-+ | ABC | DEF | +——-+——-+

    如果指定的特定分隔符只有 \r\n,那么只会解码出一个消息: +—————+ | ABC\nDEF | +—————+

  • maxFrameLength:报文最大长度的限制,如果超过 maxFrameLength 还没有检测到指定分隔符,将会抛出 TooLongFrameException。可以说 maxFrameLength 是对程序在极端情况下的一种保护措施


  • failFast:failFast 与 maxFrameLength 需要搭配使用,通过设置 failFast 可以控制抛出 TooLongFrameException 的时机,可以说 Netty 在细节上考虑得面面俱到。如果 failFast=true,那么在超出 maxFrameLength 会立即抛出 TooLongFrameException,不再继续进行解码。如果 failFast=false,那么会等到解码出一个完整的消息后才会抛出 TooLongFrameException

  • StripDelimiter:stripDelimiter 的作用是判断解码后得到的消息是否去除分隔符

由于在发送报文时尾部需要添加特定分隔符,所以对于分隔符的选择一定要避免和消息体中字符相同,以免冲突。否则可能出现错误的消息拆分。比较推荐的做法是将消息进行编码,例如 base64 编码,然后可以选择 64 个编码字符之外的字符作为特定分隔符。特定分隔符法在消息协议足够简单的场景下比较高效,例如 Redis 在通信过程中采用的就是换行分隔符。

消息头部+消息体

“消息头部 + 消息内容” 是项目开发中最常用的一种协议,消息头中存放消息的总长度,例如使用 4 字节的 int 值记录消息的长度,消息体实际的二进制的字节数据。接收方在解析数据时,首先读取消息头的长度字段 Len,然后紧接着读取长度为 Len 的字节数据,该数据即判定为一个完整的数据报文。

在 Netty 中为我们提供了长度域解码器 LengthFieldBasedFrameDecoder,也是解决 TCP 拆包/粘包问题最常用的解码器,该解码器要比上面两个解码器要复杂一些

  1. public class LengthFieldBasedFrameDecoder extends ByteToMessageDecoder {
  2. //报文最大限制长度
  3. private final int maxFrameLength;
  4. //长度字段字节偏移量
  5. private final int lengthFieldOffset;
  6. //长度字段所占字节大小
  7. private final int lengthFieldLength;
  8. //长度字段结束的偏移量
  9. private final int lengthFieldEndOffset;
  10. //消息长度修正值
  11. private final int lengthAdjustment;
  12. //解码后需要跳过的初始字节数,也就是消息内容字段的起始位置
  13. private final int initialBytesToStrip;
  14. //是否立即抛出TooLongFrameException,与maxFrameLength搭配使用
  15. private final boolean failFast;
  16. //是否处于丢弃模式
  17. private boolean discardingTooLongFrame;
  18. //需要丢弃的字节数
  19. private long tooLongFrameLength;
  20. //累计丢弃的字节数
  21. private long bytesToDiscard;
  22. }

首先我们同样先了解 LengthFieldBasedFrameDecoder 中的几个重要属性:

  • lengthFieldLength:长度字段锁占用字节数

  • lengthFieldOffset:长度字段偏移量,即存放长度字段的起始位置

  • lengthFieldEndOffset:长度字段结束的偏移量

    lengthFieldEndOffset = lengthFieldOffset + lengthFieldLength

  • initialBytesToStrip:解码后需要跳过的初始字节数,也就是消息内容字段的起始位置

  • lengthAdjustment:消息长度修正值,在很多较为复杂的一些协议设计中,长度域不仅包含消息的长度,而且包含其他数据,如版本号、报文类型、数据状态等。这时我们需要使用 lengthAdjustment 进行修正,即 length 字段所在位置 + length 字段长度 + lengthAdjustment 就得到数据内容的起始位置。

  • maxFrameLength:报文最大长度

  • failFast:是否立即抛出 TooLongFrameException,与 maxFrameLength 搭配使用

  • discardingTooLongFrame:是否属于丢弃模式

  • tooLongFrameLength:需要丢弃的字节数

  • bytesToDiscard:累计对齐字节数

以上介绍了三种常用的解码器,从中我们可以体会到 Netty 在设计上的优雅,只需要调整参数就可以轻松实现各种功能。在健壮性上,Netty 也考虑得非常全面,很多边界情况 Netty 都贴心地增加了保护性措施。实现一个健壮的解码器并不容易,很可能因为一次解析错误就会导致解码器一直处理错乱的状态。如果你使用了基于长度编码的二进制协议,那么推荐你使用 LengthFieldBasedFrameDecoder,它已经可以满足实际项目中的大部分场景,基本不需要再自定义实现了。