1

Netty中粘包/拆包处理

 2 years ago
source link: https://ytao.top/2019/12/09/10-netty/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

TCP 是基于流传输的协议,请求数据在其传输的过程中是没有界限区分,所以我们在读取请求的时候,不一定能获取到一个完整的数据包。如果一个包较大时,可能会切分成多个包进行多次传输。同时,如果存在多个小包时,可能会将其整合成一个大包进行传输。这就是 TCP 协议的粘包/拆包概念。

本文基于 Netty5 进行分析

粘包/拆包描述

假设当前有123abc两个数据包,那么他们传输情况示意图如下:

  • I 为正常情况,两次传输两个独立完整的包。
  • II 为粘包情况,123abc封装成了一个包。
  • III 为拆包情况,图中的描述是将123拆分成了123,并且1abc一起传输。123abc也可能是abc进行拆包。甚至123abc进行多次拆分也有可能。

Netty 粘包/拆包问题

为突出 Netty 的粘包/拆包问题,这里通过例子进行重现问题,以下为突出问题的主要代码:

/**
* 服务端网络事件的读写操作类
*
* Created by YangTao.
*/
public class ServerHandler extends ChannelHandlerAdapter {
// 接收消息计数器
private int i = 0;

// client端消息
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
i++;

System.out.print(msg);

// 对每条读取到的消息进行打数标记
System.out.println("================== ["+ i +"]");
// 发送应答消息给客户端
ByteBuf rmsg = Unpooled.copiedBuffer(String.valueOf(i).getBytes());
ctx.write(rmsg);
}

// 其他操作 .......
}


/**
* 客户端发送数据
*
* Created by YangTao.
*/
public class NettyClient {

public void send() {
Bootstrap bootstrap = new Bootstrap();
NioEventLoopGroup group = new NioEventLoopGroup();

try {
bootstrap.group(group)
.channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
.handler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new StringDecoder());
pipeline.addLast("logger", new LoggingHandler(LogLevel.INFO));
pipeline.addLast(new ClientHandler());
}
});
Channel channel = bootstrap.connect(HOST, PORT).channel();
int i = 1;
while (i <= 300){
channel.writeAndFlush(String.format("【时间 %s: \t%s】", new Date(), i));
// 打印发送请求的次数
System.out.println(i);
i++;
}
}catch (Exception e){
e.printStackTrace();
}finally {
if (group != null)
group.shutdownGracefully();
}
}
}

以上代码中,我们第一反应理解的是,如果非异常情况下客户端所有数据发送成功,并且服务端全部接收到。那么从打印信息中可以看到客户端的发送次数i和服务端的接收消息计数i应该是相同的数。那么下面通过运行程序,查看打印结果。

如上图所示,【】中的最后一个数字与[]中数字对上的是已独立完整的包接收到(粘包/拆包示意图中的情况 I)。但是【】中为3738的出现了粘包情况(粘包/拆包示意图中的情况 II),两条数据粘合在一起。

上图中可以看到【】167的数据被拆分为了两部分(图中画绿线数据),该情况为拆包(粘包/拆包示意图中的情况 III)

上面程序没有考虑到 TCP 的粘包/拆包问题,所以如果是我们实际应用的程序的话,不能保证数据的正常情况,就会导致程序异常。

Netty 解决粘包/拆包问题

LineBasedFrameDecoder 换行符处理

Netty 的强大,方便,简单使用的优势,在粘包/拆包问题上也提供了多种编解码解决方案,并且很容易理解和掌握。
这里使用 LineBasedFrameDecoder 和 StringDecoder(将接收到的对象转换成字符串) 来解决粘包/拆包问题。
只需在服务端和客户端分别添加 LineBasedFrameDecoder 和 StringDecoder解码器,因为是双向会话,所以两端都要添加,由于我一开始就添加 StringDecoder 编码器,所以只需添加 LineBasedFrameDecoder 就够了。
服务端:

服务端网络事件操作:

/**
* 服务端网络事件的读写操作类
*
* Created by YangTao.
*/
public class ServerHandler extends ChannelHandlerAdapter {
// 接收消息计数器
private int i = 0;

// client端消息
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
i++;

System.out.print(msg);

// 对每条读取到的消息进行打数标记
System.out.println("================== ["+ i +"]");
// 发送应答消息给客户端
ByteBuf rmsg = Unpooled.copiedBuffer(String.valueOf(i + System.getProperty("line.separator")).getBytes());
ctx.write(rmsg);
}

// 其他操作 .......
}

客户端发送数据:

/**
* 客户端发送数据
*
* Created by YangTao.
*/
public class NettyClient {

public void send() {
// 连接操作 .......

try {
// 获取 channel
Channel channel = channel();
int i = 1;
ByteBuf buf = null;
while (i <= 300){
String str = String.format("【时间 %s: \t%s】", new Date(), i) + System.getProperty("line.separator");
byte[] bytes = str.getBytes();
// 写入缓冲区
buf = Unpooled.buffer(bytes.length);
buf.writeBytes(bytes);
channel.writeAndFlush(buf);
// 打印发送请求的次数
System.out.println(i);
i++;
}
}catch (Exception e){
e.printStackTrace();
}

// 退出操作 .......
}
}

细心观察代码的变化,应该会发现现在的代码每次在发送消息的时候,在消息末尾后加了换行分隔符。注意,使用 LineBasedFrameDecoder 时,换行分隔符必须加,否则接收消息端收不到消息,如果手写换行分割,要记得区分不同系统的适配

经过多次测试 3W 条请求,没有再出现过粘包/拆包情况,看最后一条数据数字是否相同便知。

DelimiterBasedFrameDecoder 自定义分隔符

自定义分隔符和换行分隔符差不多,只需将发送的数据后换行符换成你自己设定的分割符即可。

服务端和客户端均在 pipeline 添加 DelimiterBasedFrameDecoder:

// 指定的分隔符
public static final String DELIMITER = "$@$";

// 如果当前数据2048个字节中没有分隔符,就会抛出异常,避免内存溢出。也可以自定义预检查当前读取的数据,自定义这里超过的规则
pipeline.addLast(new DelimiterBasedFrameDecoder(
2048,
Unpooled.wrappedBuffer(DELIMITER.getBytes())) // 分割符缓冲对象
);

FixedLengthFrameDecoder 根据固定长度

设定固定长度,进行数据传输,如果不达固定长度,使用空格补全。

服务端和客户端均在 pipeline 添加 FixedLengthFrameDecoder:

// 100为指定的固定长度
ch.pipeline().addLast(new FixedLengthFrameDecoder(100));

每次读取数据时都会按照 FixedLengthFrameDecoder 中设置的固定长度进行解码,如果出现粘包,那么会进行多次解码,如果出现拆包的情况,那么 FixedLengthFrameDecoder 会先缓存当前部分包的信息,当接收下一个包时,会与缓存的部分包进行拼接,知道满足规定的长度。

动态指定长度

动态指定长度就是说,每条消息的长度都是随着消息头进行指定,这里使用的编码器为 LengthFieldBasedFrameDecoder。

pipeline().addLast(
new LengthFieldBasedFrameDecoder(
2048, // 帧的最大长度,即每个数据包最大限度
0, // 长度字段偏移量
4, // 长度字段所占的字节数
0, // 消息头的长度,可以为负数
4) // 需要忽略的字节数,从消息头开始,这里是指整个包
);

发送消息时,创建自己的消息对象编码器

// 创建 byteBuf
ByteBuf buf = getBuf();

// .....

// 设置该条消息内容长度
buf.writeInt(msg.length());
// 设置消息内容
buf.writeBytes(msg.getBytes("UTF-8"));

服务端读取的时候就直接读取即可,没其他特殊操作。

除了以上 Netty 提供的现成方案,还可以通过重写 MessageToByteEncoder 编码实现自定义协议。

Netty 极大的为使用者提供了多种解决粘包/拆包方案,并且可以很愉快的对多种消息进行自动解码,在使用过程中也极容易掌握和理解,很大程度上提升开发效率和稳定性。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK