对于我们编写的程序来说,它需要通过网络传输的数据是什么形式的呢?是结构化的数据,比如,一条命令、一段文本或者是一条消息。对应到我们写的代码中,这些结构化的数据是什么?这些都可以用一个类(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 操作系统的默认行为是相反的。
在客户端和服务端通信的过程中,服务端一次读到的数据大小是不确定的。如上图所示,拆包/粘包可能会出现以下五种情况:
- 服务端恰巧读到了两个完整的数据包 A 和 B,没有出现拆包/粘包问题;
- 服务端接收到 A 和 B 粘在一起的数据包,服务端需要解析出 A 和 B;
- 服务端收到完整的 A 和 B 的一部分数据包 B-1,服务端需要解析出完整的 A,并等待读取完整的 B 数据包;
- 服务端接收到 A 的一部分数据包 A-1,此时需要等待接收到完整的 A 数据包;
- 数据包 A 较大,服务端需要多次才可以接收完数据包 A
拆包粘包解决方法
由于拆包/粘包问题的存在,数据接收方很难界定数据包的边界在哪里,很难识别出一个完整的数据包。所以需要提供一种机制来识别数据包的界限,这也是解决拆包/粘包的唯一方法:定义应用层的通信协议。下面我们一起看下主流协议的解决方案。
固定消息长度
每个数据报文都需要一个固定的长度。当接收方累计读取到固定长度的报文后,就认为已经获得一个完整的消息。当发送方的数据小于固定长度时,则需要空位补齐。
在 Netty 中为我们提供了固定长度解码器 FixedLengthFrameDecoder
public class FixedLengthFrameDecoder extends ByteToMessageDecoder {
//消息长度
private final int frameLength;
public FixedLengthFrameDecoder(int frameLength) {
ObjectUtil.checkPositive(frameLength, "frameLength");
this.frameLength = frameLength;
}
protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
return in.readableBytes() < this.frameLength ? null : in.readRetainedSlice(this.frameLength);
}
}
固定长度解码器 FixedLengthFrameDecoder 非常简单,直接通过构造函数设置固定长度的大小 frameLength,无论接收方一次获取多大的数据,都会严格按照 frameLength 进行解码。如果累积读取到长度大小为 frameLength 的消息,那么解码器认为已经获取到了一个完整的消息。如果消息长度小于 frameLength,FixedLengthFrameDecoder 解码器会一直等后续数据包的到达,直至获得完整的消息。
特殊分隔符
既然接收方无法区分消息的边界,那么我们可以在每次发送报文的尾部加上特定分隔符,接收方就可以根据特殊分隔符进行消息拆分。
在 Netty 中为我们提供了特殊分隔符解码器 DelimiterBasedFrameDecoder
public class DelimiterBasedFrameDecoder extends ByteToMessageDecoder {
//存放指定的分隔符,是一个数组,可以指定多个特殊分隔符
private final ByteBuf[] delimiters;
//报文最大长度限制
private final int maxFrameLength;
private final boolean stripDelimiter;
private final boolean failFast;
private boolean discardingTooLongFrame;
private int tooLongFrameLength;
}
该解码器有几个重要的属性:
- 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 拆包/粘包问题最常用的解码器,该解码器要比上面两个解码器要复杂一些
public class LengthFieldBasedFrameDecoder extends ByteToMessageDecoder {
//报文最大限制长度
private final int maxFrameLength;
//长度字段字节偏移量
private final int lengthFieldOffset;
//长度字段所占字节大小
private final int lengthFieldLength;
//长度字段结束的偏移量
private final int lengthFieldEndOffset;
//消息长度修正值
private final int lengthAdjustment;
//解码后需要跳过的初始字节数,也就是消息内容字段的起始位置
private final int initialBytesToStrip;
//是否立即抛出TooLongFrameException,与maxFrameLength搭配使用
private final boolean failFast;
//是否处于丢弃模式
private boolean discardingTooLongFrame;
//需要丢弃的字节数
private long tooLongFrameLength;
//累计丢弃的字节数
private long bytesToDiscard;
}
首先我们同样先了解 LengthFieldBasedFrameDecoder 中的几个重要属性:
lengthFieldLength:长度字段锁占用字节数
lengthFieldOffset:长度字段偏移量,即存放长度字段的起始位置
lengthFieldEndOffset:长度字段结束的偏移量
lengthFieldEndOffset = lengthFieldOffset + lengthFieldLength
initialBytesToStrip:解码后需要跳过的初始字节数,也就是消息内容字段的起始位置
lengthAdjustment:消息长度修正值,在很多较为复杂的一些协议设计中,长度域不仅包含消息的长度,而且包含其他数据,如版本号、报文类型、数据状态等。这时我们需要使用 lengthAdjustment 进行修正,即 length 字段所在位置 + length 字段长度 + lengthAdjustment 就得到数据内容的起始位置。
maxFrameLength:报文最大长度
failFast:是否立即抛出 TooLongFrameException,与 maxFrameLength 搭配使用
discardingTooLongFrame:是否属于丢弃模式
tooLongFrameLength:需要丢弃的字节数
bytesToDiscard:累计对齐字节数
以上介绍了三种常用的解码器,从中我们可以体会到 Netty 在设计上的优雅,只需要调整参数就可以轻松实现各种功能。在健壮性上,Netty 也考虑得非常全面,很多边界情况 Netty 都贴心地增加了保护性措施。实现一个健壮的解码器并不容易,很可能因为一次解析错误就会导致解码器一直处理错乱的状态。如果你使用了基于长度编码的二进制协议,那么推荐你使用 LengthFieldBasedFrameDecoder,它已经可以满足实际项目中的大部分场景,基本不需要再自定义实现了。