当 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(消息类型)

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

消息类型的定义如下:

public enum MessageType {
/**
* 普通请求
*/
REQUEST((byte) 1), /**
* 普通响应
*/
RESPONSE((byte) 2), /**
* 心跳 ping 请求
*/
HEARTBEAT_PING((byte) 3), /**
* 心跳 pong 响应
*/
HEARTBEAT_PONG((byte) 4),
;
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 void encode(ChannelHandlerContext ctx, RpcMessage rpcMessage, ByteBuf out) {
// 2B magic code(魔数)
out.writeBytes(MessageFormatConst.MAGIC);
// 1B version(版本)
out.writeByte(MessageFormatConst.VERSION);
// 4B full length(消息长度). 总长度先空着,后面填。
out.writerIndex(out.writerIndex() + MessageFormatConst.FULL_LENGTH_LENGTH);
// 1B messageType(消息类型)
out.writeByte(rpcMessage.getMessageType());
// 1B codec(序列化类型)
out.writeByte(rpcMessage.getSerializeType());
// 1B compress(压缩类型)
out.writeByte(rpcMessage.getCompressTye());
// 8B requestId(请求的Id)
out.writeLong(rpcMessage.getRequestId());
// 写 body,返回 body 长度
int bodyLength = writeBody(rpcMessage, out); // 当前写指针
int writerIndex = out.writerIndex();
out.writerIndex(MessageFormatConst.MAGIC_LENGTH + MessageFormatConst.VERSION_LENGTH);
// 4B full length(消息长度)
out.writeInt(MessageFormatConst.HEADER_LENGTH + bodyLength);
// 写指针复原
out.writerIndex(writerIndex);
}

写 body 的方法抽了出来,因为涉及到了消息类型、序列化、压缩等步骤,比较长。代码如下:

private int writeBody(RpcMessage rpcMessage, ByteBuf out) {
byte messageType = rpcMessage.getMessageType();
// 如果是 ping、pong 心跳类型的,没有 body,直接返回头部长度
if (messageType == MessageType.HEARTBEAT_PING.getValue()
|| messageType == MessageType.HEARTBEAT_PONG.getValue()) {
return 0;
} // 序列化类型
SerializeType serializeType = SerializeType.fromValue(rpcMessage.getSerializeType());
if (serializeType == null) {
throw new IllegalArgumentException("codec type not found");
}
// 根据序列化类型获得序列化器
Serializer serializer = ExtensionLoader.getLoader(Serializer.class).getExtension(serializeType.getName()); // 压缩类型
CompressType compressType = CompressType.fromValue(rpcMessage.getCompressTye());
// 根据压缩类型获得压缩器
Compressor compressor = ExtensionLoader.getLoader(Compressor.class).getExtension(compressType.getName()); // 使用序列化器对数据进行序列化
byte[] notCompressBytes = serializer.serialize(rpcMessage.getData());
// 序列化完之后进行压缩
byte[] compressedBytes = compressor.compress(notCompressBytes); // 写 body
out.writeBytes(compressedBytes);
return compressedBytes.length;
}

从上面的代码和注释可以看出,写 body 的流程如下:

  1. 判断消息类型,如果是心跳的,则不用写 body
  2. 根据序列化类型获得序列化器
  3. 根据压缩类型获得压缩器
  4. 使用序列化器对数据进行序列化
  5. 序列化完的数据再进行压缩。如果获取不到压缩器,则不压缩,这里抽象成一个伪序列化器DummyCompressor ,少点特殊化代码。
public class DummyCompressor implements Compressor {
@Override
public byte[] compress(byte[] bytes) {
return bytes;
} @Override
public byte[] decompress(byte[] bytes) {
return bytes;
}
}
  1. 压缩完的数据,就可以通过 out.writeBytes(compressedBytes) 写到输出流啦

ccx-rpc 的解码器 RpcMessageDecoder

LengthFieldBasedFrameDecoder

ccx-rpc 的解码器 RpcMessageDecoder 继承 Netty 自带的 LengthFieldBasedFrameDecoder,其完整的构造函数定义如下:

public LengthFieldBasedFrameDecoder(
ByteOrder byteOrder, int maxFrameLength, int lengthFieldOffset, int lengthFieldLength,
int lengthAdjustment, int initialBytesToStrip, boolean failFast) {
// 忽略 ...
}

构造函数的参数非常多,我们来一一解释一下:

  1. byteOrder:在各种计算机体系结构中,对于字节、字等的存储机制有所不同。如果不达成一致的规则,通信双方将无法进行正确的编/译码从而导致通信失败。默认值是:ByteOrder.BIG_ENDIAN
  2. maxFrameLength:指定包的最大长度,如果超过,直接丢弃
  3. lengthFieldOffset:描述长度的字段(我们叫length)在哪个位置(前面有几个字节)
  4. lengthFieldLengthlength 字段本身的长度(几个字节)
  5. lengthAdjustment:包的总长度调整。

    这个参数比较难理解,我们先假设 lengthFieldOffset = 3,lengthFieldLength=4,我们存的长度是 10。

    那么lengthFieldOffsetlengthFieldLength可以拿到长度结束的偏移量(lengthFieldEndOffset)是 7。

    这个长度10,Netty 认为是 length 字段后的长度,所以 Netty 在计算消息总长度frameLength的时候,会再加上lengthFieldEndOffsetframeLength += lengthFieldEndOffset

    如果我们本来存的长度就是 length 字段后的长度,那这个结果就是对的了。但是我们长度存的就是总长度,这么一加,就相当于多加了一个 lengthFieldEndOffset 了!!!

    由于协议的定义没有谁对谁错,也不能强制要人家就那么设置,所以 Netty 还提供了一个长度调整参数 lengthAdjustment 给我们, frameLength += lengthAdjustment

    因为多加了 lengthFieldEndOffset,那我们把这个它减回去,所以大部分的时候,这个参数就是个负数。
  6. initialBytesToStrip:之前的几个参数,已经足够识别出整个数据包了。但是很多时候,调用者只关心包的内容,包的头部完全可以丢弃掉,initialBytesToStrip 就是用来告诉 Netty,识别出整个数据包之后,截掉 initialBytesToStrip 之前的数据。
  7. failFast:参数一般设置为 true。当这个参数为 true 时,Netty 一旦读到 length 字段,并判断 length 超过 maxFrameLength,就立即抛出异常。false 表示只有当真正读取完所有的字节之后,才会抛出异常。一般不用修改,否则可能会内存溢出。

RpcMessageDecoder 构造函数

下面来看看,ccx-rpc 是如何使用这几个参数的吧,上代码:

public class RpcMessageDecoder extends LengthFieldBasedFrameDecoder {
public RpcMessageDecoder() {
super(
// 最大的长度,如果超过,会直接丢弃
MAX_FRAME_LENGTH,
// 描述长度的字段[4B full length(消息长度)]在哪个位置:在 [2B magic(魔数)]、[1B version(版本)] 后面
MAGIC_LENGTH + VERSION_LENGTH,
// 描述长度的字段[4B full length(消息长度)]本身的长度,也就是 4B 啦
FULL_LENGTH_LENGTH,
// LengthFieldBasedFrameDecoder 拿到消息长度之后,还会加上 [4B full length(消息长度)] 字段前面的长度
// 因为我们的消息长度包含了这部分了,所以需要减回去
-(MAGIC_LENGTH + VERSION_LENGTH + FULL_LENGTH_LENGTH),
// initialBytesToStrip: 去除哪个位置前面的数据。因为我们还需要检测 魔数 和 版本号,所以不能去除
0);
}
}

解码的方法先使用父类 LengthFieldBasedFrameDecoderdecode 方法得到完整的报文数据:

@Override
protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
Object decoded = super.decode(ctx, in);
if (decoded instanceof ByteBuf) {
ByteBuf frame = (ByteBuf) decoded;
if (frame.readableBytes() >= HEADER_LENGTH) {
try {
return decodeFrame(frame);
} catch (Exception ex) {
log.error("Decode frame error.", ex);
} finally {
frame.release();
}
}
}
return decoded;
}

注意:如果解码报错,需要调用 frame.release() 来释放。

自定义协议解码

1. 初步读出字段并做基础检查

接下来就是自定义协议的解码方法 decodeFrame,返回值就是业务的消息结构体 RpcMessage

/**
* 业务解码
*/
private RpcMessage decodeFrame(ByteBuf in) {
readAndCheckMagic(in);
readAndCheckVersion(in);
int fullLength = in.readInt();
byte messageType = in.readByte();
byte codec = in.readByte();
byte compress = in.readByte();
long requestId = in.readLong(); RpcMessage rpcMessage = RpcMessage.builder()
.serializeType(codec)
.compressTye(compress)
.requestId(requestId)
.messageType(messageType)
.build(); //...
}

第一步:检查魔数,比较简单,就是把前两位字节读出来,跟我们的魔数进行对比,不一样就抛出异常。

/**
* 读取并检查魔数
*/
private void readAndCheckMagic(ByteBuf in) {
byte[] bytes = new byte[MAGIC_LENGTH];
in.readBytes(bytes);
for (int i = 0; i < bytes.length; i++) {
if (bytes[i] != MAGIC[i]) {
throw new IllegalArgumentException("Unknown magic: " + Arrays.toString(bytes));
}
}
}

第二步:检查版本,目前来说版本的逻辑还很简单。后续如果版本不一样,可能解码的方式还不一样。

/**
* 读取并检查版本
*/
private void readAndCheckVersion(ByteBuf in) {
byte version = in.readByte();
if (version != VERSION) {
throw new IllegalArgumentException("Unknown version: " + version);
}
}

第三步:读出其他字段,并初步构造出 RpcMessage

2. 不需要解析 body 的情况

正常来说我们接下来需要解析 body 了,但是有几种情况是不需要解析的。那就是心跳类型的请求、body 长度 0 的情况。

if (messageType == MessageType.HEARTBEAT_PING.getValue()) {
rpcMessage.setData(PING_DATA);
return rpcMessage;
}
if (messageType == MessageType.HEARTBEAT_PONG.getValue()) {
rpcMessage.setData(PONG_DATA);
return rpcMessage;
} int bodyLength = fullLength - HEADER_LENGTH;
if (bodyLength == 0) {
return rpcMessage;
}

3. 解析 body

拿到 body 之后,应该先要解压再反序列化,跟编码时的先序列化再压缩相反。代码如下:

byte[] bodyBytes = new byte[bodyLength];
in.readBytes(bodyBytes);
CompressType compressType = CompressType.fromValue(compress);
// 根据压缩类型找出压缩器
Compressor compressor = ExtensionLoader.getLoader(Compressor.class).getExtension(compressType.getName());
// 进行解压
byte[] decompressedBytes = compressor.decompress(bodyBytes); SerializeType serializeType = SerializeType.fromValue(codec);
if (serializeType == null) {
throw new IllegalArgumentException("unknown codec type:" + codec);
}
// 根据序列化类型找出序列化器
Serializer serializer = ExtensionLoader.getLoader(Serializer.class).getExtension(serializeType.getName());
// 根据消息类型获取消息体结构
Class<?> clazz = messageType == MessageType.REQUEST.getValue() ? RpcRequest.class : RpcResponse.class;
// 反序列化
Object object = serializer.deserialize(decompressedBytes, clazz);
rpcMessage.setData(object);
return rpcMessage;

总结

上文介绍了 TCP 中的粘包拆包问题,并且介绍了 Netty 提供的解决方案。着重介绍了 ccx-rpc 选择的 LengthFieldBasedFrameDecoder,他的构造参数比较多,一次看不懂没关系,多看几遍,尝试 debug 一下代码,也许就豁然开朗了。

最后介绍了 ccx-rpc 的自定义协议和编解码器,大家在自定义协议的时候,可以不用跟我的一样,不过大体上的思想是一样的,希望同学们能活学活用。

ccx-rpc 代码已经开源

Github:https://github.com/chenchuxin/ccx-rpc

Gitee:https://gitee.com/imccx/ccx-rpc

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

  1. 从零开始实现简单 RPC 框架 5:网络通信之序列化

    我们在接下来会开始讲网络通信相关的内容了.既然是网络通信,那必然会涉及到序列化的相关技术. 下面是 ccx-rpc 序列化器的接口定义. /** * 序列化器 */ public interface ...

  2. 从零开始实现简单 RPC 框架 6:网络通信之 Netty

    网络通信的开发,就涉及到一些开发框架:Java NIO.Netty.Mina 等等. 理论上来说,类似于序列化器,可以为其定义一套统一的接口,让不同类型的框架实现,事实上,Dubbo 就是这么干的. ...

  3. 从零开始实现简单 RPC 框架 8:网络通信之 Request-Response 模型

    Netty 在服务端与客户端的网络通信中,使用的是异步双向通信(双工)的方式,即客户端和服务端可以相互主动发请求给对方,发消息后不会同步等响应.这样就会有一下问题: 如何识别消息是请求还是响应? 请求 ...

  4. 从零开始实现简单 RPC 框架 2:扩展利器 SPI

    RPC 框架有很多可扩展的地方,如:序列化类型.压缩类型.负载均衡类型.注册中心类型等等. 假设框架提供的注册中心只有zookeeper,但是使用者想用Eureka,修改框架以支持使用者的需求显然不是 ...

  5. 从零开始实现简单 RPC 框架 9:网络通信之心跳与重连机制

    一.心跳 什么是心跳 在 TPC 中,客户端和服务端建立连接之后,需要定期发送数据包,来通知对方自己还在线,以确保 TPC 连接的有效性.如果一个连接长时间没有心跳,需要及时断开,否则服务端会维护很多 ...

  6. 从零开始实现简单 RPC 框架 4:注册中心

    RPC 中服务消费端(Consumer) 需要请求服务提供方(Provider)的接口,必须要知道 Provider 的地址才能请求到. 那么,Consumer 要从哪里获取 Provider 的地址 ...

  7. 从零开始实现简单 RPC 框架 3:配置总线 URL

    URL 的定义 URL 对于大部分程序猿来说都是很熟悉的,其全称是 Uniform Resource Locator (统一资源定位器).它是互联网的统一资源定位标志,也就是指网络地址. 一个标准的 ...

  8. 网络编程 -- RPC实现原理 -- Netty -- 迭代版本V4 -- 粘包拆包

    网络编程 -- RPC实现原理 -- 目录 啦啦啦 V2——Netty -- new LengthFieldPrepender(2) : 设置数据包 2 字节的特征码 new LengthFieldB ...

  9. Java实现简单RPC框架(转)

    一.RPC简介 RPC,全称Remote Procedure Call, 即远程过程调用,它是一个计算机通信协议.它允许像本地服务一样调用远程服务.它可以有不同的实现方式.如RMI(远程方法调用).H ...

随机推荐

  1. C#曲线分析平台的制作(五,Sqldependency+Signalr+windows 服务 学习资料总结)

    在前篇博客中,利用interval()函数,进行ajax轮询初步的实现的对数据的实时显示.但是在工业级别实时显示中,这并非是一种最好的解决方案.随着Html5 websocket的发展,这种全双工的通 ...

  2. Adobe ColdFusion 反序列化漏洞(CVE-2017-3066)

    影响版本 以下版本受到影响:Adobe ColdFusion (2016 release) Update 3及之前的版本,ColdFusion 11 Update 11及之前的版本,ColdFusio ...

  3. DL基础补全计划(五)---数值稳定性及参数初始化(梯度消失、梯度爆炸)

    PS:要转载请注明出处,本人版权所有. PS: 这个只是基于<我自己>的理解, 如果和你的原则及想法相冲突,请谅解,勿喷. 前置说明   本文作为本人csdn blog的主站的备份.(Bl ...

  4. 给每个li延时添加样式动画效果(setInterval,clearInterval)

    btnsAnime($('ul li')) function btnsAnime(pagesl) { var that = this $(pagesl).hide() let i = 0; funct ...

  5. C++员工管理系统(封装+多态+继承+分类化+函数调用+读写文件+指针+升序降序算法等一系列知识结合)

    1 C++职工管理系统 2 该项目实现 八个 功能 3 1-增加功能 2-显示功能 3-删除功能 4-修改功能 4 5-查找功能 6-排序功能 7-清空功能 8-退出功能 5 实现多个功能使用了多个C ...

  6. ceph介绍和安装

    目录 1.Ceph简介 2.Ceph的特点 3.Ceph的缺点 4.架构与组件 4.1.组件介绍 4.2.存储过程 5.部署 5.1 设置主机名.配置时间同步 5.2 配置添加清华源 5.3 初始化c ...

  7. Access Java API in Groovy Script

    $ cat Hello.java package test; public class Hello { public int myadd(int x, int y) { return 10 * x + ...

  8. SpringBoot开发十九-添加评论

    需求介绍 熟悉事务管理,并且应用到添加评论的功能. 数据层:增加评论数据,修改帖子的评论数量 业务层:处理添加评论的业务,先增加评论再更新帖子的评论数量(因为用到了两个DML操作所以要用到事务管理) ...

  9. Pikachu-XSS模块与3个案例演示

    一.概述 XSS是一种发生在前端浏览器端的漏洞,所以其危害的对象也是前端用户. 形成XSS漏洞的主要原因是程序对输入和输出没有做合适的处理,导致"精心构造"的字符输出在前端时被浏览 ...

  10. Mysql的分区表

    概论: 分区表一般用作Mysql库表的水平切割(也就是常说的mysql性能优化的几种通用手法"读写分离.分库分表"中的一种),适用于单表的数据量可能很大的场景.因为分区表可以将一个 ...