从零开始实现简单 RPC 框架 7:网络通信之自定义协议(粘包拆包、编解码)

当 RPC 框架使用 Netty 通信时,实际上是将数据转化成 ByteBuf 的方式进行传输。
那如何转化呢?可不可以把 请求参数 或者 响应结果 直接无脑序列化成 byte 数组发出去?
答:直接序列化传输是不行的,会出现粘包拆包的问题。

粘包拆包

什么是粘包拆包

RPC 通信使用 TPC (别问我为什么不用 UDP),TCP 是一个“流”协议。所谓流,就是没有界限的一长串二进制数据。TCP 作为传输层协议,并不了解上层业务数据的具体含义,它会根据 TCP 缓冲区的实际情况进行数据包的划分,所以在业务上认为是一个完整包的,可能会被 TCP 拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的 TCP 拆包和粘包问题。

直接序列化发出去是可以,但是接收方收到了一坨数据包,它不知道一个完整的报文哪里开始、哪里结束,也就没有办法解析了。

粘包拆包的解决方案

由于底层的 TCP 无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决。目前业界主流协议的解决方案如下:

  1. 消息定长:报文长度固定,例如每个报文的长度固定为 200 字节,如果不够空位补空格,接受方每次拿 200 字节。
  2. 使用特殊分隔符分割:例如每条报文结束都添加回车换行符作为报文分隔符,接收方读到回车换行符则分割出报文。
  3. 将消息分为消息头和消息体,消息头包含消息的长度。接收方从消息头拿到消息长度,就知道剩下的报文是多少字节了。
  4. 更复杂的自定义应用层协议。

编解码

在网络通信中,将数据转成报文的过程称为 编码,将报文转成数据的过程称为 解码
在 Netty 中,编解码的处理放在 PipeLine 中。在前文的介绍中,我们知道每个 PipeLine 都是和 Channel 唯一绑定的,一个 PipeLine 只对应一个 Channel,所以 Channel 中的数据读取的时候经过解析,如果不是一个完整的数据包,则解析失败,将这个数据包进行保存,等下次解析时再和这个数据包进行组装解析,直到解析到完整的数据包,才会将数据包向下传递。

解码器

Netty提供了多个解码器,分别是:

  1. LineBasedFrameDecoder按行分包。
  2. DelimiterBasedFrameDecoder特殊分隔符分包。
  3. FixedLengthFrameDecoder:使用定长的报文来分包。
  4. LengthFieldBasedFrameDecoder: 将消息分为消息头和消息体,消息头包含消息的长度的方式分包。

在 RPC 这个场景中,我们来分析一下我们应该选哪种解码器:

  1. LineBasedFrameDecoder:按行分包显然不行,因为我们的请求响应数据中,极有可能包含换行符。
  2. DelimiterBasedFrameDecoder:按照特殊分隔符也不行,因为 RPC 框架是一个通用的场景,请求响应数据中什么都有可能包含,特殊分隔符无论是什么都有可能存在于请求响应数据中。这样会导致分包错误。
  3. FixedLengthFrameDecoder:使用定长报文显然就更加不合适了,在 RPC 框架这样一个通用场景中,定的长度太短,可能不够,定得太长又会造成极大的资源浪费。
  4. LengthFieldBasedFrameDecoder:将消息分成消息头消息体的方式比较使用于大部分的网络通信场景。ccx-rpc 采用了此解码器,并定义出自己的一套私有协议(下面讲)。

编码器

Netty 提供了个常用的抽象编码器:MessageToByteEncoder<I>,编码器不像解码器需要考虑粘包拆包,只需要将数据转换成协议规定的二进制格式发送即可。

ccx-rpc 的自定义协议

前面提到 ccx-rpc 使用了消息头+消息体 的方式制定私有协议。其格式如下:

 0   1   2       3   4   5   6   7           8        9        10   11  12  13  14  15  16  17  18
 +---+---+-------+---+---+---+---+-----------+---------+--------+---+---+---+---+---+---+---+---+
 | magic |version|  full length  |messageType|serialize|compress|           RequestId           |
 +---+---+-------+---+---+---+---+-----------+---------+--------+---+---+---+---+---+---+---+---+
 |                                                                                              |
 |                                         body                                                 |
 |                                                                                              |
 |                                        ... ...                                               |
 +----------------------------------------------------------------------------------------------+
2B magic(魔数)
1B version(版本)
4B full length(消息长度)
1B messageType(消息类型)
1B serialize(序列化类型)
1B compress(压缩类型)
8B requestId(请求的Id)
body(object类型数据)

字段解释

1. magic(魔数)

是通信双方协商的一个暗号,2 个字节,定义在 MessageFormatConst.MAGIC
魔数的作用是用于服务端在接收数据时先解析出魔数做正确性对比。如果和协议中的魔数不匹配,则认为是非法数据,可以直接关闭连接或采取其他措施增强系统安全性。
注意:这只是一个简单的校验,如果有安全性方面的需求,需要使用其他手段,例如 SSL/TLS。
魔数的思想在很多场景中都有体现,如 Java Class 文件开头就存储了魔数 OxCAFEBABE,在 JVM 加载 Class 文件时首先就会验证魔数对的正确性。

2. version(版本)

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

3. full length(消息长度)

记录了整个消息的长度,这个字段是报文分包的关键。

4. messageType(消息类型)

消息类型包括,普通请求、普通响应、心跳。解码器可以根据消息类型来确定解析的类型。

消息类型的定义如下:

public enum MessageType {
   
    /**
     * 普通请求
     */
    REQUEST((byte) 1),

    /**
     * 普通响应
     */
    RESPONSE((byte) 2),

    /**
     * 心跳
     */
    HEARTBEAT((byte) 3),
    ;
    private final byte value;
}

6. serialize(序列化类型)

通过这个类型来确定使用哪种序列化方式,将字节流序列化成对应的对象。

序列化类型定义如下:

public enum SerializeType {
   
    PROTOSTUFF((byte) 1, "protostuff");
}

7. compress(压缩类型)

序列化的字节流,还可以进行压缩,使得体积更小,在网络传输更快,但是同时会消耗 CPU 资源。
如果使用压缩效果好的序列化器,可以考虑不适用压缩。

压缩类型的定义如下:

public enum CompressType {
   
    /**
     * 伪压缩器,啥事不干。有一些序列化工具压缩已经做得很好了,无需再压缩
     */
    DUMMY((byte) 0, "dummy"),

    GZIP((byte) 1, "gzip");

    private final byte value;
    private final String name;
}

8. requestId(请求的Id)

每个请求分配好请求Id,这样响应数据的时候,才能对的上。使用 8 字节的 long 类型,可以支持更多的请求。

9. body

body 里面放具体的数据,通常来说是请求的参数、响应的结果,再经过序列化、压缩后的字节数组。

ccx-rpc 的编码器 RpcMessageEncoder

RpcMessage 是通用的消息结构体,请求参数和响应结果都会封装成这个结构。

编码器相对比较简单,按照协议定义的长度和值进行设置,例如请求Id是8字节的Long,那就 out.writeLong(rpcMessage.getRequestId())

有个细节:消息长度事先不知道 body 的长度,可以先跳过。当然也可以先把 body 解析出来算长度。

代码如下:

@Override
protected voi
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值