深入理解 Netty 的编解码器

举报
大明哥 发表于 2024/11/19 22:55:12 2024/11/19
【摘要】 上篇文章大明哥已经详细分析了粘包/粘包的问题,并深入阐述了粘包/粘包的深层原因,既然知道了产生它的原因,那么下一步我们就是解决它了。 拆包/粘包解决方案TCP 是面向字节流的协议,它是无法区分数据包界限的。既然底层的 TCP 协议无法区分,那我们就只能在应用层下功夫了 。目前在应用层主流的解决方案有三种:固定长度双方约定一个固定的长度,比如 100 个字节,那么发送端在发送消息时,每个报文都...

上篇文章大明哥已经详细分析了粘包/粘包的问题,并深入阐述了粘包/粘包的深层原因,既然知道了产生它的原因,那么下一步我们就是解决它了。

拆包/粘包解决方案

TCP 是面向字节流的协议,它是无法区分数据包界限的。既然底层的 TCP 协议无法区分,那我们就只能在应用层下功夫了 。目前在应用层主流的解决方案有三种:

  • 固定长度

双方约定一个固定的长度,比如 100 个字节,那么发送端在发送消息时,每个报文都是 100 个字节,不足的补空格或者 0 等其他特殊字符。接收端则每次读取 100 个字节当做一个完整的报文。

  • 特殊字符

在报文尾部增加一个特殊字符(比如换行符)来作为分割符,接收端接受到消息后可以根据这个分隔符来判断这个消息是否 完整 。

  • 消息头携带信息

将消息分为消息头和消息体,消息头中包内含消息的长度,接收端获取消息后,从消息头解析出消息的长度,然后向后读取该长度的内容。

Netty 常用的解码器

Netty 提供了几种开箱即用的解码器,这些解码器基本覆盖了 TCP 拆包/粘包的通用解决方案。

下面我们就来了解这些解码器吧!

FixedLengthFrameDecoder

FixedLengthFrameDecoder 为定长解码器,使用起来非常方便,只需要通过构造函数设定一个长度 frameLength 即可。无论发送方怎么发送数据,它都会严格按照设定的长度 frameLength 来解码。下面就来演示下。

public class FixedLengthFrameServer {
    public static void main(String[] args) {
        new ServerBootstrap()
                .group(new NioEventLoopGroup(),new NioEventLoopGroup())
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ch.pipeline().addLast(new FixedLengthFrameDecoder(8));
                        ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
                        ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
                            @Override
                            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                System.out.println("接收内容:" + ((ByteBuf)msg).toString(Charset.defaultCharset()));
                            }
                        });
                    }
                })
                .bind(8081);
    }
}

new FixedLengthFrameDecoder(8),固定 8 byte 的解码器。我们通过 telnet 命令向服务端发送数据,发送内容为:

服务端响应结果:

绿色部分是每次读取缓冲区的大小,这里可以看出都是 8 byte。第一次发送 1234567890,服务端只读取了 12345678,其中 90\n还保留这,继续发送 12345678 ,这个时候够了 8 byte,服务端又读取 90\n12345678 保留。

FixedLengthFrameDecoder 的优势就在于使用起来非常简单,也很容易理解,但是缺点就在于,客户端每次发送报文的时候都要补齐 N 位,不够用特殊字符来补齐,是非常浪费空间的,比如定义固定长度为 1024,但是我们发送的数据为 1,客户端在发送这个报文时也需要补齐 1024 位,但是这个时候的有效位只有 1 位,1023 位都是无效数据,太浪费了。

LineBasedFrameDecoder

LineBasedFrameDecoder 是基于回车换行符解码器,它能够按照我们输入的回车换行符( \nor \r\n)对接收到的消息进行解码。

LineBasedFrameDecoder 的构造器接受一个 int 类型的参数 maxLength,用来限制一次最大的解码长度。如果超过 maxLength 还没有检测到回车换行符,就会抛出 TooLongFrameException,可以说 maxLength 是对程序的一种 保护措施。

我们来演示下。

public class LineBasedFrameDecoderServer {
    public static void main(String[] args) {
        new ServerBootstrap()
                .group(new NioEventLoopGroup(), new NioEventLoopGroup())
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ch.pipeline().addLast(new LineBasedFrameDecoder(12));
                        ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
                        ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                            @Override
                            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                System.out.println("接收内容:" + ((ByteBuf) msg).toString(Charset.defaultCharset()));
                            }
                        });
                    }
                })
                .bind(8081);
    }
}

定义 LineBasedFrameDecoder(12),即解码最大的长度为 12 byte,超过 12 byte 的数据都会丢弃。客户端发送内容为 123\n45678\r90\r\nabcdf\n\rghijkmnopqrst\n,服务端解析结果如下:

2022-08-09 08:57:29.933 [nioEventLoopGroup-3-1] DEBUG io.netty.handler.logging.LoggingHandler - [id: 0x13804585, L:/127.0.0.1:8081 - R:/127.0.0.1:57368] READ: 3B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 31 32 33                                        |123             |
+--------+-------------------------------------------------+----------------+
接收内容:123
2022-08-09 08:57:29.934 [nioEventLoopGroup-3-1] DEBUG io.netty.handler.logging.LoggingHandler - [id: 0x13804585, L:/127.0.0.1:8081 - R:/127.0.0.1:57368] READ: 8B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 34 35 36 37 38 0d 39 30                         |45678.90        |
+--------+-------------------------------------------------+----------------+
90
2022-08-09 08:57:29.934 [nioEventLoopGroup-3-1] DEBUG io.netty.handler.logging.LoggingHandler - [id: 0x13804585, L:/127.0.0.1:8081 - R:/127.0.0.1:57368] READ: 5B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 61 62 63 64 66                                  |abcdf           |
+--------+-------------------------------------------------+----------------+
接收内容:abcdf
2022-08-09 08:57:29.935 [nioEventLoopGroup-3-1] DEBUG io.netty.handler.logging.LoggingHandler - [id: 0x13804585, L:/127.0.0.1:8081 - R:/127.0.0.1:57368] EXCEPTION: io.netty.handler.codec.TooLongFrameException: frame length (14) exceeds the allowed maximum (12)
io.netty.handler.codec.TooLongFrameException: frame length (14) exceeds the allowed maximum (12)
.....

从结果可以看到:

  • 第一次:读取 3 个字节,内容为 123,所以 \n 可以解析。
  • 第二次:读取 8 个字节,内容为 45678.90,所以 \r 无法解析,\r\n 可以解析。
  • 第三次:读取 5 个字节,内容为 abcdf
  • 第四次:抛出 TooLongFrameException 异常,因为我们输入的内容为 14 个字节超过了 12 个字节。细心的小伙伴可能会发现 ghijkmnopqrst 不是只有 12 个字节么,怎么会是 14 呢?因为前面还有一个 \r

DelimiterBasedFrameDecoder

DelimiterBasedFrameDecoder 是特殊分隔符解码器,它和 LineBasedFrameDecoder 相似,只不是 LineBasedFrameDecoder 是固定回车换行符为分割符而已。相比 LineBasedFrameDecoder,DelimiterBasedFrameDecoder 更加通用,允许我们指定任何特殊字符作为分割符,而且提供了更加精细的控制。

DelimiterBasedFrameDecoder 提供了多个构造方法以供我们来使用它,但最终都是调用以下构造方法:

    public DelimiterBasedFrameDecoder(
            int maxFrameLength, boolean stripDelimiter, boolean failFast, ByteBuf... delimiters) {
    }

下面我们就来了解这个构造方法每个属性的含义。

  • delimiters

delimiters 指定分割符。我们可以指定一个或者多个分割符,如果指定多个,那么 DelimiterBasedFrameDecoder 在解码的时候会选择长度最短的分割符进行消息拆分。

比如

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

如果我们指定分隔符为 \n 和 \r\n,那么将会解码出 2 个消息:

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

如果我们指定分割符为 \r\n,那只会解码出来 1 个消息:

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

maxLength 为最大报文长度限制,与 LineBasedFrameDecoder 的 maxLength 属性意义一样:如果超过 maxLength 还没有检测到分割符,就会抛出 TooLongFrameException。

  • failFast

failFast 为是否快速失败开关。它与 maxLength 需要搭配使用,通过设置 failFast 可以控制抛出 TooLongFrameException 的时机。如果 failFast = true,那么就会立刻抛出 TooLongFrameException,不再继续解码。如果 failFast = false,那么会 等到解码出这个完整的消息后才会抛出 TooLongFrameException。

  • stripDelimiter

用于判断解码后是否需要去掉分割符。如果为 false,那么上面的解码结果为:

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

下面我们来小试牛刀。

public class DelimiterBasedFrameDecoderServer {
    public static void main(String[] args) {
        new ServerBootstrap()
                .group(new NioEventLoopGroup(), new NioEventLoopGroup())
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ByteBuf delimiter = Unpooled.copiedBuffer("|".getBytes());
                        ch.pipeline().addLast(new DelimiterBasedFrameDecoder(10, false, false, delimiter));

                        ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
                        ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                            @Override
                            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                System.out.println("接收内容:" + ((ByteBuf) msg).toString(Charset.defaultCharset()));
                            }
                        });
                    }
                })
                .bind(8081);
    }
}

咱们的解码器为: new DelimiterBasedFrameDecoder(10, true, true, delimiter)

  • 分隔符 delimiter:|
  • 最大报文长度 maxLength :10
  • 是否快速失败 failFast:false
  • 是否去掉分隔符stripDelimiter:false

我们发送如下内容:

hello,|this is|sikejava.com|wula|

运行结果:

2022-08-10 21:53:50.706 [nioEventLoopGroup-3-1] DEBUG io.netty.handler.logging.LoggingHandler - [id: 0x96ae16a6, L:/127.0.0.1:8081 - R:/127.0.0.1:63971] READ: 7B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 68 65 6c 6c 6f 2c 7c                            |hello,|         |
+--------+-------------------------------------------------+----------------+
接收内容:hello,|
2022-08-10 21:53:50.706 [nioEventLoopGroup-3-1] DEBUG io.netty.handler.logging.LoggingHandler - [id: 0x96ae16a6, L:/127.0.0.1:8081 - R:/127.0.0.1:63971] READ: 8B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 74 68 69 73 20 69 73 7c                         |this is|        |
+--------+-------------------------------------------------+----------------+
接收内容:this is|
2022-08-10 21:53:50.707 [nioEventLoopGroup-3-1] DEBUG io.netty.handler.logging.LoggingHandler - [id: 0x96ae16a6, L:/127.0.0.1:8081 - R:/127.0.0.1:63971] EXCEPTION: io.netty.handler.codec.TooLongFrameException: frame length exceeds 10: 12 - discarded
io.netty.handler.codec.TooLongFrameException: frame length exceeds 10: 12 - discarded

LengthFieldBasedFrameDecoder

LengthFieldBasedFrameDecoder 是长度域解码器,它相比前面三个解码器来说复杂多了,当然功能也更加强大了,它是 Netty 中最常用解决拆包/粘包的解码器了。

要掌握 LengthFieldBasedFrameDecoder 必须要理解它的 4 个属性:

  • lengthFieldOffset:长度字段的偏移量,也就是存放长度数据的起始位置,即接收的字节数组中下标为 lengthFieldOffset 的地方就是长度域的开始地方。
  • lengthFieldLength:长度字段所占用的字节数。即接收的字节数组中 bytes[lengthFieldOffset,(lengthFieldOffset + lengthFieldLength)] 就是长度字段。
  • lengthAdjustment:消息修正值。在一些复杂的场景中,长度域不仅仅只是单存地包括消息,还包括了版本号、属性类型、数据状态等等,这个时候我们就需要利用 lengthAdjustment 来进行修正。
  • initialBytesToStrip:解码后需要跳过的字节数,也就是我们消息内容的起始位置。

这 4 个属性说实在话,这样硬看还是很难理解的,Netty LengthFieldBasedFrameDecoder 注释中一共给了 7 种场景,描述的非常详细了,我们把这 7 中场景明白了,就真正理解了这 4 个属性的含义以及如何使用 LengthFieldBasedFrameDecoder 了。

场景 一:最基本消息长度 + 消息内容

BEFORE DECODE (14 bytes)         AFTER DECODE (14 bytes)

+--------+----------------+      +--------+----------------+
| Length | Actual Content |----->| Length | Actual Content |
| 0x000C | "HELLO, WORLD" |      | 0x000C | "HELLO, WORLD" |
+--------+----------------+      +--------+----------------+

这个场景是最简单,最基本的,因为解码前后,报文内容没有任何变化,报文只包含消息长度 Length 和消息内容 Actual Content。Length 为 16 进制表示,占 2 Byte,Length 内容为 0x000C = 12,表示 Actual Content 内容占用 12 Byte。所以对应 LengthFieldBasedFrameDecoder 参数为:

  • lengthFieldOffset = 0,因为 Length 字段在报文开始的位置。
  • lengthFieldLength = 2,0x000C 内容占用 2 B。
  • lengthAdjustment = 0,Length 字段只包含了 Actual Content 长度,不需要做任何修正。
  • initialBytesToStrip = 0,解码后的内容依然为 Length + Actual Content,不需要跳过任何字节,为 0。

场景二:解码后内容只保留 Conteng

BEFORE DECODE (14 bytes)         AFTER DECODE (12 bytes)

+--------+----------------+      +----------------+
| Length | Actual Content |----->| Actual Content |
| 0x000C | "HELLO, WORLD" |      | "HELLO, WORLD" |
+--------+----------------+      +----------------+

相比场景一,场景二解码后的内容只保留了 Actual Content,其他部分保持不变,所以对应参数如下:

  • lengthFieldOffset = 0,因为 Length 字段在报文开始的位置。
  • lengthFieldLength = 2,0x000C 内容占用 2 B。
  • lengthAdjustment = 0,Length 字段只包含了 Actual Content 长度,不需要做任何修正。
  • initialBytesToStrip = 2,解码后的内容只有 Actual Content,需要跳过 Length,所以为 2

场景三:长度字段包含 Length + Content 的长度

BEFORE DECODE (14 bytes)         AFTER DECODE (14 bytes)

+--------+----------------+      +--------+----------------+
| Length | Actual Content |----->| Length | Actual Content |
| 0x000E | "HELLO, WORLD" |      | 0x000E | "HELLO, WORLD" |
+--------+----------------+      +--------+----------------+

场景三与场景一的区别在于 Length 为 0x000E(14),它包含了长度字段 Length 所占用的字节和 Actual Content 所占用的字节。所以如果我们要得到 Actual Content 的长度就必须要减去 Length 所占用的字节,参数如下:

  • lengthFieldOffset = 0,因为 Length 字段在报文开始的位置。
  • lengthFieldLength = 2,0x000E 内容占用 2 B。
  • lengthAdjustment = -2,长度字段 0x000E 为 14 ,需要减去长度字段 Length 所占用的字节才能得到 Content 的长度。
  • initialBytesToStrip = 0,解码后的内容依然为 Length + Actual Content,不需要跳过任何字节,为 0。

场景四:基于长度字段偏移的解码

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" |
+----------+----------+----------------+      +----------+----------+----------------+

场景四相比前面三个场景它多了一个 Header 1部分,该部分内容 0xCAFE 占用 2 个字节,Length 内容为 0x00000C 占用三个字节,值为 12 ,所以参数如下 :

  • lengthFieldOffset = 2,Length 的 offset 不再是起始位置了,它需要跳过 Header 1 所占用的 2 字节,所以为 2。
  • lengthFieldLength = 3,0x00000C 占用 3 字节。
  • lengthAdjustment = 0,Length 字段只包含有 Content 的长度,不需要做调整,为 0。
  • initialBytesToStrip = 0,解码后的内容为完整报文,不需要跳过任何字节,为 0。

场景五:长度字段与内容字段不再相邻

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" |
+----------+----------+----------------+      +----------+----------+----------------+

场景五与场景四类似,只不过它的 Length 和 Actual Content 不再相邻,这个时候如果我们要取 Actual Content 的内容就要略过 Header 1 字段,参数如下:

  • lengthFieldOffset = 0,Length 在报文开始位置。
  • lengthFieldLength = 3,0x00000C 占用 3 字节。
  • lengthAdjustment = 2,Header 1 + Actual Content 为 14 字节,但是 Length 内容为 12,所以需要加上 lengthAdjustment(2)才能得到 Header 1 + Actual Content 的内容。
  • initialBytesToStrip = 0,解码后的内容为完整报文,不需要跳过任何字节,为 0。

场景六:基于长度偏移和长度修正的解码

BEFORE DECODE (16 bytes)                       AFTER DECODE (13 bytes)

+------+--------+------+----------------+      +------+----------------+
| HDR1 | Length | HDR2 | Actual Content |----->| HDR2 | Actual Content |
| 0xCA | 0x000C | 0xFE | "HELLO, WORLD" |      | 0xFE | "HELLO, WORLD" |
+------+--------+------+----------------+      +------+----------------+

场景六相比场景五而言,多了一个 HDR1 部分,占用 1 字节,且解码后的内容丢弃了 HDR1 和 Length,所以参数如下:

  • lengthFieldOffset = 1,需要跳过 HDR1部分,占用 1 字节,所以为 1。
  • lengthFieldLength = 2,0x000C 占用 2 字节。
  • lengthAdjustment = 1,HDR2 + Actual Content 为 13 字节,所以 Length 字段值(12)需要加上 lengthAdjustment(1)才能得到 HDR2 + Actual Content 的内容。
  • initialBytesToStrip = 3,解码后的内容跳过了 HDR1 + Length,占用 3 字节,所以为 3。

场景七:长度字段包含除 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" |
+------+--------+------+----------------+      +------+----------------+

场景七与场景六的区别在于 Length 字段记录了整个消息报文的长度,所以如果要得到 HDR2 + Actual Content 的内容,我们需要通过 lengthAdjustment 来调整,所以参数如下:

  • lengthFieldOffset = 1,需要跳过 HDR1部分,占用 1 字节,所以为 1。
  • lengthFieldLength = 2,0x000C 占用 2 字节。
  • lengthAdjustment = -3,HDR2 + Actual Content 为 13 字节,但是 Length 字段值为 16 ,需要减去 HDR1 + Length 的字节数(3),才能得到 HDR2 + Actual Content (13)。
  • initialBytesToStrip = 3,解码后的内容跳过了 HDR1 + Length,占用 3 字节,所以为 3。

对于 LengthFieldBasedFrameDecoder 而言,上面 7 中场景已经涵盖了大部分的使用场景了,所以如果对那四个属性的含义还不是很理解的话,多看几遍吧。

总结

这篇文章介绍了 Netty 常用的四种解码器,这四种解码器基本上可以解决 TCP 拆包/粘包的问题。而且这四种解码器使用起来也是非常方便的,我们只需要将他们当做 ChannelHandler 添加到 Pipe 中就可以了,而且只需要调整相对应的参数就可以实现各种功能,这里我们不得不感慨 Netty 设计上的优雅。

但是,在实际开发过程中,我们需要构建出满足我们自己业务场景的通信协议,Netty 内置的解码器可能还不够,所以我们需要能够自定义通信协议,下篇文章大明哥来教你如何利用 Netty 实现自己的通信协议。

源码:http://suo.nz/1IXdN5

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。