TCP 是基于流传输的协议,请求数据在其传输的过程中是没有界限区分,所以我们在读取请求的时候,不一定能获取到一个完整的数据包。如果一个包较大时,可能会切分成多个包进行多次传输。同时,如果存在多个小包时,可能会将其整合成一个大包进行传输。这就是 TCP 协议的粘包/拆包概念。
假设当前有 123
和 abc
两个数据包,那么他们传输情况示意图如下:
123
和 abc
封装成了一个包。 123
拆分成了 1
和 23
,并且 1
和 abc
一起传输。 123
和 abc
也可能是 abc
进行拆包。甚至 123
和 abc
进行多次拆分也有可能。 为突出 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) 。但是 【】
中为 37
和 38
的出现了粘包情况 (粘包/拆包示意图中的情况 II) ,两条数据粘合在一起。
上图中可以看到 【】
中 167
的数据被拆分为了两部分(图中画绿线数据),该情况为拆包 (粘包/拆包示意图中的情况 III) 。
上面程序没有考虑到 TCP 的粘包/拆包问题,所以如果是我们实际应用的程序的话,不能保证数据的正常情况,就会导致程序异常。
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 条请求,没有再出现过粘包/拆包情况,看最后一条数据数字是否相同便知。
自定义分隔符和换行分隔符差不多,只需将发送的数据后换行符换成你自己设定的分割符即可。
服务端和客户端均在 pipeline 添加 DelimiterBasedFrameDecoder:
// 指定的分隔符 public static final String DELIMITER = "$@$"; // 如果当前数据2048个字节中没有分隔符,就会抛出异常,避免内存溢出。也可以自定义预检查当前读取的数据,自定义这里超过的规则 pipeline.addLast(new DelimiterBasedFrameDecoder( 2048, Unpooled.wrappedBuffer(DELIMITER.getBytes())) // 分割符缓冲对象 );
设定固定长度,进行数据传输,如果不达固定长度,使用空格补全。
服务端和客户端均在 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 极大的为使用者提供了多种解决粘包/拆包方案,并且可以很愉快的对多种消息进行自动解码,在使用过程中也极容易掌握和理解,很大程度上提升开发效率和稳定性。
个人博客: https://ytao.top
我的公众号 ytao