一、问题描述:
1. 什么是粘包、拆包
粘包和拆包是TCP网络编程中不可避免的,无论是服务端还是客户端,当我们读取或者发送消息 的时候,都需要考虑TCP底层的粘包/拆包机制。
TCP传输会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆分 成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包 问题。
如下示例,假设客户端分别发送了两个数据包package1和package2给服务端,由于服务端一次读取到的字节数 是不确定的,故可能存在以下4种情况:
- 服务端分两次读取到了两个独立的数据包,分别是P1和P2,没有粘包和拆包;
- 服务端一次接收到了两个数据包,P1和P2粘合在一起,被称为TCP粘包;
- 如果P2的数据包比较大, 服务端分两次读取到了两个数据包,第一次读取到了完整的P1包和P2包 的部分内容,第二次读取到了P2包的剩余内容,这被称为TCP拆包
- 如果P1, P2的数据包都很大, 服务端分多次才能将P1和P2包接收完全,期间发生多次拆包
TCP粘包和拆包产生的原因:
数据从发送方到接收方需要经过操作系统的缓冲区,而造成粘包和拆包的主要原因就在这个缓冲区 上。粘包可以理解为缓冲区数据堆积,导致多个请求数据粘在一起,而拆包可以理解为发送的数据大于 缓冲区,进行拆分处理。
2. 粘包拆包演示
首先应该了解一次socket连接 对应 一个Channel,而一个Channel 对应一个 Handler;而不同的socket连接对应不同的handler,所以拆包和粘包只针对同一个socket连接发送的数据。
-
拆包演示
-
NettyServer
public class NettyServer { public static void main(String[] args) throws InterruptedException { //1. 创建bossGroup线程组: 处理网络事件--连接事件 EventLoopGroup bossGroup = new NioEventLoopGroup(1); //2. 创建workerGroup线程组: 处理网络事件--读写事件 2*处理器线程数 EventLoopGroup workerGroup = new NioEventLoopGroup(); //3. 创建服务端启动助手 ServerBootstrap serverBootstrap = new ServerBootstrap(); //4. 设置bossGroup线程组和workerGroup线程组 serverBootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) //5. 设置服务端通道实现为NIO .option(ChannelOption.SO_BACKLOG, 128)//6. 参数设置 .childOption(ChannelOption.SO_KEEPALIVE, Boolean.TRUE)//6. 参数设置 .childHandler(new ChannelInitializer<SocketChannel>() { //7. 创建一个通道初始化对象 @Override protected void initChannel(SocketChannel ch) throws Exception { //8. 向pipeline中添加自定义业务处理handler ch.pipeline().addLast(new NettyServerHandler()); } }); //9. 启动服务端并绑定端口,同时将异步改为同步 ChannelFuture future = serverBootstrap.bind(9999); future.addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture channelFuture) throws Exception { if (channelFuture.isSuccess()){ System.out.println("服务端启动成功."); }else { System.out.println("服务端启动失败."); } } }); //10. 关闭通道(并不是真正意义上关闭,而是监听通道关闭的状态)和关闭连接池 future.channel().closeFuture().sync(); bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } }
-
NettyServerHandler,修改ChannelRead函数,获取当前读取的到的消息长度 和 当前读取次数
public class NettyServerHandler implements ChannelInboundHandler { public int count = 0; /** * 通道读取事件 * * @param ctx * @param msg * @throws Exception */ @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf byteBuf = (ByteBuf) msg; System.out.println("长度是:" + byteBuf.readableBytes()); System.out.println("读取次数 = " + (++count)); } /** * 通道读取完毕事件 * * @param ctx * @throws Exception */ @Override public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { } /** * 通道异常事件 * * @param ctx * @param cause * @throws Exception */ @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { cause.printStackTrace(); ctx.close(); } @Override public void channelRegistered(ChannelHandlerContext ctx) throws Exception { } @Override public void channelUnregistered(ChannelHandlerContext ctx) throws Exception { } @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { } @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { } @Override public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception { } @Override public void handlerAdded(ChannelHandlerContext ctx) throws Exception { } @Override public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { } }
-
NettyClient
public class NettyClient { public static void main(String[] args) throws InterruptedException { //1. 创建线程组 EventLoopGroup group = new NioEventLoopGroup(); //2. 创建客户端启动助手 Bootstrap bootstrap = new Bootstrap(); //3. 设置线程组 bootstrap.group(group) .channel(NioSocketChannel.class)//4. 设置客户端通道实现为NIO .handler(new ChannelInitializer<SocketChannel>() { //5. 创建一个通道初始化对象 @Override protected void initChannel(SocketChannel ch) throws Exception { //6. 向pipeline中添加自定义业务处理handler ch.pipeline().addLast(new NettyClientHandler()); } }); //7. 启动客户端,等待连接服务端,同时将异步改为同步 ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 9999).sync(); //8. 关闭通道和关闭连接池 channelFuture.channel().closeFuture().sync(); group.shutdownGracefully(); } }
-
NettyClientHandler,在通道就绪事件channelActive发送长包数据
public class NettyClientHandler implements ChannelInboundHandler { /** * 通道就绪事件 * * @param ctx * @throws Exception */ @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { //一次发送102400 char[] chars = new char[102400]; Arrays.fill(chars, 0, 102398, '1'); chars[102399] = '\n'; ctx.writeAndFlush(Unpooled.copiedBuffer(chars, CharsetUtil.UTF_8)); } /** * 通道读就绪事件 * * @param ctx * @param msg * @throws Exception */ @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf byteBuf = (ByteBuf) msg; System.out.println("服务端发送的消息:" + byteBuf.toString(CharsetUtil.UTF_8)); } @Override public void channelRegistered(ChannelHandlerContext ctx) throws Exception { } @Override public void channelUnregistered(ChannelHandlerContext ctx) throws Exception { } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { } @Override public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { } @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { } @Override public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception { } @Override public void handlerAdded(ChannelHandlerContext ctx) throws Exception { } @Override public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { } }
-
测试结果:
一次发送大数据量的包出现了拆分包的情况。
-
-
粘包演示
-
NettyServer 和 NettyClient不做改变
-
NettyServerHandler 修改通道读取事件channelRead,显示每次接收的信息
@Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf byteBuf = (ByteBuf) msg; System.out.println("长度是:" + byteBuf.readableBytes()); System.out.println("客户端发送过来的消息:" + byteBuf.toString(CharsetUtil.UTF_8)); System.out.println("读取次数:"+(++count)); }
-
NettyClientHandler修改通道就绪事件channelActive,每次发送一个短包数据
@Override public void channelActive(ChannelHandlerContext ctx) throws Exception { for (int i = 0; i < 2; i++) { ctx.writeAndFlush(Unpooled.copiedBuffer("我是Netty客户端"+i, CharsetUtil.UTF_8)); } }
-
测试结果:
多次发送短包数据出现了粘包现象。
-
二、解决思路:
由于底层的TCP无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个 问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案,可以归纳如下:
- 消息长度固定,累计读取到长度和为定长LEN的报文后,就认为读取到了一个完整的信息
- 将换行符作为消息结束符
- 将特殊的分隔符作为消息的结束标志,回车换行符就是一种特殊的结束分隔符
- 通过在消息头中定义长度字段来标识消息的总长度
三、解决方案:
-
Netty提供了4种解码器来解决,分别如下:
-
固定长度的拆包器 FixedLengthFrameDecoder,每个应用层数据包的都拆分成都是固定长度 的大小
-
行拆包器 LineBasedFrameDecoder,每个应用层数据包,都以换行符作为分隔符,进行分割 拆分
-
分隔符拆包器 DelimiterBasedFrameDecoder,每个应用层数据包,都通过自定义的分隔 符,进行分割拆分
-
基于数据包长度的拆包器 LengthFieldBasedFrameDecoder,将应用层数据包的长度,作为 接收端应用层数据包的拆分依据。按照应用层数据包的大小,拆包。这个拆包器,有一个要求,就是应用层协议中包含数据包的长度
-
-
自定义分隔符,修改代码:
-
在NettyClientHandler的通道就绪事件channelActive中发送的数据以"$"分隔
-
在NettyServer的pipeline中添加分隔符拆包器 DelimiterBasedFrameDecoder:
ByteBuf byteBuf = Unpooled.copiedBuffer("$".getBytes(StandardCharsets.UTF_8)); ch.pipeline().addLast(new DelimiterBasedFrameDecoder(102400, byteBuf));
-
-
测试结果:
-
长包数据tcp会拆包,加了分隔符拆包器 DelimiterBasedFrameDecoder后:
-
短包数据tcp会粘包,加了分隔符拆包器 DelimiterBasedFrameDecoder后:
-