龙空技术网

Netty: 何为拆包/粘包及Netty中对应的解决方案

云端行笔 186

前言:

而今我们对“软件拆包是什么意思”大致比较看重,兄弟们都需要知道一些“软件拆包是什么意思”的相关知识。那么小编在网摘上搜集了一些关于“软件拆包是什么意思””的相关内容,希望兄弟们能喜欢,姐妹们快快来了解一下吧!

拆包/粘包是在TCP协议中不得不提及的一个概念,也是必须解决的一个问题。本文由浅入深,带你揭开拆包/粘包的面纱,总结了常规的解决方案,并利用Netty的编解码器,在开发过程中,如何编写代码解决该问题,并在最后列举了Netty中开箱即用的解码器。希望通过本文,你不仅是弄清理论,更重要的是可以在项目中,根据需要定义协议并完成编解码。

为什么有拆包/粘包

TCP是面向流,没有边界,而操作系统在发送TCP数据时,会通过缓冲区来进行优化,例如缓冲区为1024个字节大小。

如果一次请求发送的数据量比较小,没达到缓冲区大小,TCP则会将多个请求合并为同一个请求进行发送,这就形成了粘包问题。如果一次请求发送的数据量比较大,超过了缓冲区大小,TCP就会将其拆分为多次发送,这就是拆包。

为什么UDP没有粘包?

粘包拆包问题在数据链路层、网络层以及传输层都有可能发生。日常的网络应用开发大都在传输层进行,由于UDP有消息保护边界,不会发生粘包拆包问题,因此粘包拆包问题只发生在TCP协议中。

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

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

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

所谓协议,就是通信双方事先商量好的接口暗语,在 TCP 网络编程中,发送方和接收方的数据包格式都是二进制,发送方将对象转化成二进制流发送给接收方,接收方获得二进制数据后需要知道如何解析成对象,所以协议是双方能够正常通信的基础。

这里先看一下主流协议的解决方案:

(1)消息长度固定

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

+----+------+------+---+----+| AB | CDEF | GHIJ | K | LM |+----+------+------+---+----+

假设我们的固定长度为 4 字节,那么如上所示的 5 条数据一共需要发送 4 个报文:

+------+------+------+------+| ABCD | EFGH | IJKL | M000 |+------+------+------+------+

消息定长法使用非常简单,但是缺点也非常明显,无法很好设定固定长度的值,如果长度太大会造成字节浪费,长度太小又会影响消息传输,所以在一般情况下消息定长法不会被采用。

(2)特定分隔符

既然接收方无法区分消息的边界,那么我们可以在每次发送报文的尾部加上特定分隔符,接收方就可以根据特殊分隔符进行消息拆分。以下报文根据特定分隔符 \n 按行解析,即可得到 AB、CDEF、GHIJ、K、LM 五条原始报文。

+-------------------------+| AB\nCDEF\nGHIJ\nK\nLM\n |+-------------------------+

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

(3)消息长度 + 消息内容

消息头     消息体+--------+----------+| Length |  Content |+--------+----------+

消息长度 + 消息内容是项目开发中最常用的一种协议,如上展示了该协议的基本格式。消息头中存放消息的总长度,例如使用 4 字节的 int 值记录消息的长度,消息体实际的二进制的字节数据。接收方在解析数据时,首先读取消息头的长度字段 Len,然后紧接着读取长度为 Len 的字节数据,该数据即判定为一个完整的数据报文。依然以上述提到的原始字节数据为例,使用该协议进行编码后的结果如下所示:

+-----+-------+-------+----+-----+| 2AB | 4CDEF | 4GHIJ | 1K | 2LM |+-----+-------+-------+----+-----+

消息长度 + 消息内容的使用方式非常灵活,且不会存在消息定长法和特定分隔符法的明显缺陷。当然在消息头中不仅只限于存放消息的长度,而且可以自定义其他必要的扩展字段,例如消息版本、算法类型等。

自定义协议通信

目前市面上已经有不少通用的协议,例如 HTTP、HTTPS、JSON-RPC、FTP、IMAP、Protobuf 等。通用协议兼容性好,易于维护,各种异构系统之间可以实现无缝对接。如果在满足业务场景以及性能需求的前提下,推荐采用通用协议的方案。相比通用协议,自定义协议主要有以下优点。

极致性能:通用的通信协议考虑了很多兼容性的因素,必然在性能方面有所损失。扩展性:自定义的协议相比通用协议更好扩展,可以更好地满足自己的业务需求。安全性:通用协议是公开的,很多漏洞已经很多被黑客攻破。自定义协议更加安全,因为黑客需要先破解你的协议内容。

那么如何设计自定义的通信协议呢?结合实战经验我们一起看下一个完备的网络协议需要具备哪些基本要素。

(1)魔数

魔数是通信双方协商的一个暗号,通常采用固定的几个字节表示。魔数的作用是防止任何人随便向服务器的端口上发送数据。服务端在接收到数据时会解析出前几个固定字节的魔数,然后做正确性比对。如果和约定的魔数不匹配,则认为是非法数据,可以直接关闭连接或者采取其他措施以增强系统的安全防护。魔数的思想在压缩算法、Java Class 文件等场景中都有所体现,例如 Class 文件开头就存储了魔数 0xCAFEBABE,在加载 Class 文件时首先会验证魔数的正确性。

(2)协议版本号

随着业务需求的变化,协议可能需要对结构或字段进行改动,不同版本的协议对应的解析方法也是不同的。所以在生产级项目中强烈建议预留协议版本号这个字段。

(3)序列化算法

序列化算法字段表示数据发送方应该采用何种方法将请求的对象转化为二进制,以及如何再将二进制转化为对象,如 JSON、Hessian、Java 自带序列化等。

(4)报文类型

在不同的业务场景中,报文可能存在不同的类型。例如在 RPC 框架中有请求、响应、心跳等类型的报文,在 IM 即时通信的场景中有登陆、创建群聊、发送消息、接收消息、退出群聊等类型的报文。

(5)长度域字段

长度域字段代表请求数据的长度,接收方根据长度域字段获取一个完整的报文。

(6)请求数据

请求数据通常为序列化之后得到的二进制流,每种请求数据的内容是不一样的。

(7)状态

状态字段用于标识请求是否正常。一般由被调用方设置。例如一次 RPC 调用失败,状态字段可被服务提供方设置为异常状态。

(8)保留字段

保留字段是可选项,为了应对协议升级的可能性,可以预留若干字节的保留字段,以备不时之需。

基于以上协议基本要素习,可以得到一个较为通用的协议示例:

+---------------------------------------------------------------+| 魔数 2byte | 协议版本号 1byte | 序列化算法 1byte | 报文类型 1byte  |+---------------------------------------------------------------+| 状态 1byte |        保留字段 4byte     |      数据长度 4byte     | +---------------------------------------------------------------+|                   数据内容 (长度不定)                          |+---------------------------------------------------------------+
Netty 如何实现自定义通信协议

在 Netty 中如何实现自定义的通信协议呢?其实 Netty 作为一个非常优秀的网络通信框架,已经为我们提供了非常丰富的编解码抽象基类,帮助我们更方便地基于这些抽象基类扩展实现自定义协议。

首先来看下 Netty 中编解码器是如何分类的。

Netty 常用编码器类型:

MessageToByteEncoder 对象编码成字节流;MessageToMessageEncoder 一种消息类型编码成另外一种消息类型。

Netty 常用解码器类型:

ByteToMessageDecoder/ReplayingDecoder 将字节流解码为消息对象;MessageToMessageDecoder 将一种消息类型解码为另外一种消息类型。

编解码器可以分为一次解码器和二次解码器,一次解码器用于解决 TCP 拆包/粘包问题,按协议解析后得到的字节数据。如果你需要对解析后的字节数据做对象模型的转换,这时候便需要用到二次解码器,同理编码器的过程是反过来的。

一次编解码器:MessageToByteEncoder/ByteToMessageDecoder。二次编解码器:MessageToMessageEncoder/MessageToMessageDecoder。

接下来,针对前面的自定义通信协议,通过代码展示如何利用 Netty 的编解码框架实现该协议的解码器。

在实现协议编码器之前,首先需要清楚一个问题:如何判断 ByteBuf 是否存在完整的报文?最常用的做法就是通过读取消息长度 dataLength 进行判断。如果 ByteBuf 的可读数据长度小于 dataLength,说明 ByteBuf 还不够获取一个完整的报文。在该协议前面的消息头部分包含了魔数、协议版本号、数据长度等固定字段,共 14 个字节。固定字段长度和数据长度可以作为我们判断消息完整性的依据,具体编码器实现逻辑示例如下:

/*+---------------------------------------------------------------+| 魔数 2byte | 协议版本号 1byte | 序列化算法 1byte | 报文类型 1byte  |+---------------------------------------------------------------+| 状态 1byte |        保留字段 4byte     |      数据长度 4byte     | +---------------------------------------------------------------+|                   数据内容 (长度不定)                          |+---------------------------------------------------------------+ */@Overridepublic final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {    // 判断 ByteBuf 可读取字节    if (in.readableBytes() < 14) {         return;    }    in.markReaderIndex(); // 标记 ByteBuf 读指针位置    in.skipBytes(2); // 跳过魔数    in.skipBytes(1); // 跳过协议版本号    byte serializeType = in.readByte();    in.skipBytes(1); // 跳过报文类型    in.skipBytes(1); // 跳过状态字段    in.skipBytes(4); // 跳过保留字段    int dataLength = in.readInt();    // 数据内容为达到定义的数据长度(即包不完整)- 解决拆包    if (in.readableBytes() < dataLength) {        in.resetReaderIndex(); // 重置 ByteBuf 读指针位置        return;    }  	// 按照定义的长度读取长度 - 解决粘包    byte[] data = new byte[dataLength];    in.readBytes(data);    SerializeService serializeService = getSerializeServiceByType(serializeType);    Object obj = serializeService.deserialize(data);    if (obj != null) {        out.add(obj);    }}
Netty 支持哪些常用的解码器?

在 Netty 的框架中,也提供了很多开箱即用的解码器,这些解码器基本覆盖了 TCP 拆包/粘包的通用解决方案。接下来,对照 TCP 拆包/粘包的主流解决方案,梳理一下 Netty 对应的编码器类。

(1)固定长度解码器 FixedLengthFrameDecoder

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

通过一个例子感受一下使用 Netty 实现固定长度解码是多么简单。

public class EchoServer {        public void startEchoServer(int port) throws Exception {            EventLoopGroup bossGroup = new NioEventLoopGroup();        EventLoopGroup workerGroup = new NioEventLoopGroup();        try {                ServerBootstrap b = new ServerBootstrap();            b.group(bossGroup, workerGroup)                    .channel(NioServerSocketChannel.class)                    .childHandler(new ChannelInitializer<SocketChannel>() {                            @Override                        public void initChannel(SocketChannel ch) {                                ch.pipeline().addLast(new FixedLengthFrameDecoder(10));                            ch.pipeline().addLast(new EchoServerHandler());                        }                    });            ChannelFuture f = b.bind(port).sync();            f.channel().closeFuture().sync();        } finally {                bossGroup.shutdownGracefully();            workerGroup.shutdownGracefully();        }    }    public static void main(String[] args) throws Exception {            new EchoServer().startEchoServer(8088);    }}@Sharablepublic class EchoServerHandler extends ChannelInboundHandlerAdapter {        @Override    public void channelRead(ChannelHandlerContext ctx, Object msg) {            System.out.println("Receive client : [" + ((ByteBuf) msg).toString(CharsetUtil.UTF_8) + "]");    }}

在上述服务端的代码中使用了固定 10 字节的解码器,并在解码之后通过 EchoServerHandler 打印结果。启动服务端之后,通过 telnet 命令像服务端发送数据,观察代码输出的结果。

客户端输入:

telnet localhost 8088Trying ::1...Connected to localhost.Escape character is '^]'.1234567890123456789012

服务端输出:

Receive client : [1234567890]Receive client : [12345678]

(2)特殊分隔符解码器 DelimiterBasedFrameDecoder

使用特殊分隔符解码器 DelimiterBasedFrameDecoder 之前我们需要了解以下几个属性的作用。

delimiters

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

例如接收方收到的数据为:

+--------------+| ABC\nDEF\r\n |+--------------+

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

+-----+-----+| ABC | DEF |+-----+-----+

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

+----------+| ABC\nDEF |+----------+
maxLength

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

failFast

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

stripDelimiter

stripDelimiter 的作用是判断解码后得到的消息是否去除分隔符。如果 stripDelimiter=false,特定分隔符为 \n,那么上述数据包解码出的结果为:

+-------+---------+| ABC\n | DEF\r\n |+-------+---------+

下面还是结合代码示例学习 DelimiterBasedFrameDecoder 的用法,依然以固定编码器小节中使用的代码为基础稍做改动,引入特殊分隔符解码器 DelimiterBasedFrameDecoder:

b.group(bossGroup, workerGroup)    .channel(NioServerSocketChannel.class)    .childHandler(new ChannelInitializer<SocketChannel>() {        @Override        public void initChannel(SocketChannel ch) {            ByteBuf delimiter = Unpooled.copiedBuffer("&".getBytes());            ch.pipeline().addLast(new DelimiterBasedFrameDecoder(10, true, true, delimiter));            ch.pipeline().addLast(new EchoServerHandler());        }    });

依然通过 telnet 模拟客户端发送数据,观察代码输出的结果,可以发现由于 maxLength 设置的只有 10,所以在解析到第三个消息时抛出异常。

客户端输入:

telnet localhost 8088Trying ::1...Connected to localhost.Escape character is '^]'.hello&world&1234567890ab

服务端输出:

Receive client : [hello]Receive client : [world]九月 25, 2020 8:46:01 下午 io.netty.channel.DefaultChannelPipeline onUnhandledInboundException警告: An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle thexception.io.netty.handler.codec.TooLongFrameException: frame length exceeds 10: 13 - discarded	at io.netty.handler.codec.DelimiterBasedFrameDecoder.fail(DelimiterBasedFrameDecoder.java:302)	at io.netty.handler.codec.DelimiterBasedFrameDecoder.decode(DelimiterBasedFrameDecoder.java:268)	at io.netty.handler.codec.DelimiterBasedFrameDecoder.decode(DelimiterBasedFrameDecoder.java:218)

(3)长度域解码器 LengthFieldBasedFrameDecoder

长度域解码器 LengthFieldBasedFrameDecoder 是解决 TCP 拆包/粘包问题最常用的解码器。它基本上可以覆盖大部分基于长度拆包场景,开源消息中间件 RocketMQ 就是使用 LengthFieldBasedFrameDecoder 进行解码的。LengthFieldBasedFrameDecoder 相比 FixedLengthFrameDecoder 和 DelimiterBasedFrameDecoder 要复杂一些,接下来我们就一起学习下这个强大的解码器。

首先,同样先了解 LengthFieldBasedFrameDecoder 中的几个重要属性,这里我主要把它们分为两个部分:长度域解码器特有属性以及与其他解码器(如特定分隔符解码器)的相似的属性。

长度域解码器特有属性。

// 长度字段的偏移量,也就是存放长度数据的起始位置private final int lengthFieldOffset; // 长度字段所占用的字节数private final int lengthFieldLength; /* * 消息长度的修正值 * * 在很多较为复杂一些的协议设计中,长度域不仅仅包含消息的长度,而且包含其他的数据,如版本号、数据类型、数据状态等,那么这时候我们需要使用 lengthAdjustment 进行修正 *  * lengthAdjustment = 包体的长度值 - 长度域的值 * */private final int lengthAdjustment; // 解码后需要跳过的初始字节数,也就是消息内容字段的起始位置private final int initialBytesToStrip;// 长度字段结束的偏移量,lengthFieldEndOffset = lengthFieldOffset + lengthFieldLengthprivate final int lengthFieldEndOffset;
与固定长度解码器和特定分隔符解码器相似的属性。
private final int maxFrameLength; // 报文最大限制长度private final boolean failFast; // 是否立即抛出 TooLongFrameException,与 maxFrameLength 搭配使用private boolean discardingTooLongFrame; // 是否处于丢弃模式private long tooLongFrameLength; // 需要丢弃的字节数private long bytesToDiscard; // 累计丢弃的字节数

下面我们结合具体的示例来解释下每种参数的组合,其实在 Netty LengthFieldBasedFrameDecoder 源码的注释中已经描述得非常详细,一共给出了 7 个场景示例,理解了这些示例基本上可以真正掌握 LengthFieldBasedFrameDecoder 的参数用法。

示例 1:典型的基于消息长度 + 消息内容的解码。

BEFORE DECODE (14 bytes)         AFTER DECODE (14 bytes)+--------+----------------+      +--------+----------------+| Length | Actual Content |----->| Length | Actual Content || 0x000C | "HELLO, WORLD" |      | 0x000C | "HELLO, WORLD" |+--------+----------------+      +--------+----------------+

上述协议是最基本的格式,报文只包含消息长度 Length 和消息内容 Content 字段,其中 Length 为 16 进制表示,共占用 2 字节,Length 的值 0x000C 代表 Content 占用 12 字节。该协议对应的解码器参数组合如下:

lengthFieldOffset = 0,因为 Length 字段就在报文的开始位置。lengthFieldLength = 2,协议设计的固定长度。lengthAdjustment = 0,Length 字段只包含消息长度,不需要做任何修正。initialBytesToStrip = 0,解码后内容依然是 Length + Content,不需要跳过任何初始字节。

示例 2:解码结果需要截断。

BEFORE DECODE (14 bytes)         AFTER DECODE (12 bytes)+--------+----------------+      +----------------+| Length | Actual Content |----->| Actual Content || 0x000C | "HELLO, WORLD" |      | "HELLO, WORLD" |+--------+----------------+      +----------------+

示例 2 和示例 1 的区别在于解码后的结果只包含消息内容,其他的部分是不变的。该协议对应的解码器参数组合如下:

lengthFieldOffset = 0,因为 Length 字段就在报文的开始位置。lengthFieldLength = 2,协议设计的固定长度。lengthAdjustment = 0,Length 字段只包含消息长度,不需要做任何修正。initialBytesToStrip = 2,跳过 Length 字段的字节长度,解码后 ByteBuf 中只包含 Content字段。

示例 3:长度字段包含消息长度和消息内容所占的字节。

BEFORE DECODE (14 bytes)         AFTER DECODE (14 bytes)+--------+----------------+      +--------+----------------+| Length | Actual Content |----->| Length | Actual Content || 0x000E | "HELLO, WORLD" |      | 0x000E | "HELLO, WORLD" |+--------+----------------+      +--------+----------------+

与前两个示例不同的是,示例 3 的 Length 字段包含 Length 字段自身的固定长度以及 Content 字段所占用的字节数,Length 的值为 0x000E(2 + 12 = 14 字节),在 Length 字段值(14 字节)的基础上做 lengthAdjustment(-2)的修正,才能得到真实的 Content 字段长度,所以对应的解码器参数组合如下:

lengthFieldOffset = 0,因为 Length 字段就在报文的开始位置。lengthFieldLength = 2,协议设计的固定长度。lengthAdjustment = -2,长度字段为 14 字节,需要减 2 才是拆包所需要的长度。initialBytesToStrip = 0,解码后内容依然是 Length + Content,不需要跳过任何初始字节。

示例 4:基于长度字段偏移的解码。

BEFORE DECODE (17 bytes)                      AFTER DECODE (17 bytes)+----------+----------+----------------+      +----------+----------+----------------+| Header 1 |  Length  | Actual Content |----->| Header 1 |  Length  | Actual Content ||  0xCAFE  | 0x00000C | "HELLO, WORLD" |      |  0xCAFE  | 0x00000C | "HELLO, WORLD" |+----------+----------+----------------+      +----------+----------+----------------+

示例 4 中 Length 字段不再是报文的起始位置,Length 字段的值为 0x00000C,表示 Content 字段占用 12 字节,该协议对应的解码器参数组合如下:

lengthFieldOffset = 2,需要跳过 Header 1 所占用的 2 字节,才是 Length 的起始位置。lengthFieldLength = 3,协议设计的固定长度。lengthAdjustment = 0,Length 字段只包含消息长度,不需要做任何修正。initialBytesToStrip = 0,解码后内容依然是完整的报文,不需要跳过任何初始字节。

示例 5:长度字段与内容字段不再相邻。

BEFORE DECODE (17 bytes)                      AFTER DECODE (17 bytes)+----------+----------+----------------+      +----------+----------+----------------+|  Length  | Header 1 | Actual Content |----->|  Length  | Header 1 | Actual Content || 0x00000C |  0xCAFE  | "HELLO, WORLD" |      | 0x00000C |  0xCAFE  | "HELLO, WORLD" |+----------+----------+----------------+      +----------+----------+----------------+

示例 5 中的 Length 字段之后是 Header 1,Length 与 Content 字段不再相邻。Length 字段所表示的内容略过了 Header 1 字段,所以也需要通过 lengthAdjustment 修正才能得到 Header + Content 的内容。示例 5 所对应的解码器参数组合如下:

lengthFieldOffset = 0,因为 Length 字段就在报文的开始位置。lengthFieldLength = 3,协议设计的固定长度。lengthAdjustment = 2,由于 Header + Content 一共占用 2 + 12 = 14 字节,所以 Length 字段值(12 字节)加上 lengthAdjustment(2 字节)才能得到 Header + Content 的内容(14 字节)。initialBytesToStrip = 0,解码后内容依然是完整的报文,不需要跳过任何初始字节。

示例 6:基于长度偏移和长度修正的解码。

BEFORE DECODE (16 bytes)                       AFTER DECODE (13 bytes)+------+--------+------+----------------+      +------+----------------+| HDR1 | Length | HDR2 | Actual Content |----->| HDR2 | Actual Content || 0xCA | 0x000C | 0xFE | "HELLO, WORLD" |      | 0xFE | "HELLO, WORLD" |+------+--------+------+----------------+      +------+----------------+

示例 6 中 Length 字段前后分为别 HDR1 和 HDR2 字段,各占用 1 字节,所以既需要做长度字段的偏移,也需要做 lengthAdjustment 修正,具体修正的过程与 示例 5 类似。对应的解码器参数组合如下:

lengthFieldOffset = 1,需要跳过 HDR1 所占用的 1 字节,才是 Length 的起始位置。lengthFieldLength = 2,协议设计的固定长度。lengthAdjustment = 1,由于 HDR2 + Content 一共占用 1 + 12 = 13 字节,所以 Length 字段值(12 字节)加上 lengthAdjustment(1)才能得到 HDR2 + Content 的内容(13 字节)。initialBytesToStrip = 3,解码后跳过 HDR1 和 Length 字段,共占用 3 字节。

示例 7:长度字段包含除 Content 外的多个其他字段。

BEFORE DECODE (16 bytes)                       AFTER DECODE (13 bytes)+------+--------+------+----------------+      +------+----------------+| HDR1 | Length | HDR2 | Actual Content |----->| HDR2 | Actual Content || 0xCA | 0x0010 | 0xFE | "HELLO, WORLD" |      | 0xFE | "HELLO, WORLD" |+------+--------+------+----------------+      +------+----------------+

示例 7 与 示例 6 的区别在于 Length 字段记录了整个报文的长度,包含 Length 自身所占字节、HDR1 、HDR2 以及 Content 字段的长度,解码器需要知道如何进行 lengthAdjustment 调整,才能得到 HDR2 和 Content 的内容。所以我们可以采用如下的解码器参数组合:

lengthFieldOffset = 1,需要跳过 HDR1 所占用的 1 字节,才是 Length 的起始位置。lengthFieldLength = 2,协议设计的固定长度。lengthAdjustment = -3,Length 字段值(16 字节)需要减去 HDR1(1 字节) 和 Length 自身所占字节长度(2 字节)才能得到 HDR2 和 Content 的内容(1 + 12 = 13 字节)。initialBytesToStrip = 3,解码后跳过 HDR1 和 Length 字段,共占用 3 字节。

以上 7 种示例涵盖了 LengthFieldBasedFrameDecoder 大部分的使用场景,你是否学会了呢?最后,大家可以尝试利用LengthFieldBasedFrameDecoder,完成我们前文自定义的协议的解码。(在看后续代码之前,最好先自我尝试一下[加油])

通信协议:

/** * +---------------------------------------------------------------+ * | 魔数 2byte | 协议版本号 1byte | 序列化算法 1byte | 报文类型 1byte  | * +---------------------------------------------------------------+ * | 状态 1byte |        保留字段 4byte     |      数据长度 4byte     | * +---------------------------------------------------------------+ * |                   数据内容 (长度不定)                          | * +---------------------------------------------------------------+ */public class RequestParam {    /**     * 魔数 2byte     */    private short magic;    /**     * 协议版本号     */    private byte version;    /**     * 序列化算法     */    private byte algorithm;    /**     * 报文类型     */    private byte type;    /**     * 状态     */    private byte status;    /**     * 保留字段     */    private int remark;    /**     * 请求消息内容长度     */    private int length;    /**     * 请求消息体     */    private String body;}

解码器:

public class RequestDecoder extends ByteToMessageDecoder {    @Override    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {        //注意在读的过程中,readIndex的指针也在移动        short magic = in.readShort();        byte version = in.readByte();        byte algorithm = in.readByte();        byte type = in.readByte();        byte status = in.readByte();        int remark = in.readInt();        int length = in.readInt();        String body = null;        if (length > 0){            ByteBuf buf = in.readBytes(length);            byte[] req = new byte[buf.readableBytes()];            buf.readBytes(req);            body = new String(req, "UTF-8");        }        RequestParam requestParam = new RequestParam(magic,version,algorithm,type,status,remark,length,body);        out.add(requestParam);    }}

消息处理:

public class RequestHandler extends ChannelInboundHandlerAdapter {    @Override    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {        if (msg instanceof RequestParam) {            RequestParam requestParam = (RequestParam) msg;            switch (requestParam.getType()){                case 1:                    System.out.println("this is beat message received msg := " + requestParam.toString());                    break;                case 2:                    System.out.println("this is business message received msg := " + requestParam.toString());                    break;                default:                    System.out.println("error message " + requestParam.toString());                    break;            }        } else {            System.out.println("error");        }    }}

启动服务:

public static void main(String[] args) throws InterruptedException {        EventLoopGroup bossGroup = new NioEventLoopGroup(1);        EventLoopGroup workerGroup = new NioEventLoopGroup();        try {            ServerBootstrap b = new ServerBootstrap();            b.group(bossGroup, workerGroup)                    .channel(NioServerSocketChannel.class)                    .childOption(ChannelOption.TCP_NODELAY, true)                    .childHandler(new ChannelInitializer<SocketChannel>() {                        @Override                        protected void initChannel(SocketChannel ch) throws Exception {                            ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(65534,                                    10, 4));                            ch.pipeline().addLast(new RequestDecoder());                            ch.pipeline().addLast(new RequestHandler());                        }                    });            ChannelFuture channelFuture = b.bind(8088).sync();            channelFuture.channel().closeFuture().sync();        }finally {            bossGroup.shutdownGracefully();            workerGroup.shutdownGracefully();        }    }
总结

本文对拆包/粘包的产生原因作了分析,并给出通用解决方案及Netty中的实现。希望朋友们在项目开发中能够学以致用。

标签: #软件拆包是什么意思 #程序拆包是什么意思 #应用程序拆包 #应用程序拆包教程