TCP/IP粘包拆包现象及netty解决方案

1、问题背景

       传输层除了有TCP协议外还有UDP协议。

       首先,UDP不会发生粘包或拆包现象。因为UDP是基于报文发送的,从UDP的帧结构可以看出,在UDP首部采用了16bit来指示UDP数据报文的长度,因此在应用层能很好的将不同的数据报文区分开,从而避免粘包和拆包的问题。

       但TCP是基于字节流的,在基于流的传输里(如TCP/IP),接收到的数据会先被存储到一个socket接收缓冲里。不幸的是,基于流的传输并不是一个数据包队列,而是一个字节队列。TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行化包的划分,所以在业务上认为,一个完整的包可能会被TCP拆成多个包进行发送,也有多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题。粘包拆包现象发生如图所示:

图1 粘包及拆包现象

2、发生粘包或拆包的直接原因

    原因与解决方式,网上一查有很多,服务端   和 客户端  都会造成粘包、半包问题,以下列出常见原因。

  • 服务端
  1. 要发送的数据大于TCP发送缓冲区剩余空间大小,将会发生拆包。
  2. 待发送数据大于MSS(最大报文长度),TCP在传输前将进行拆包。
  3. 要发送的数据小于TCP发送缓冲区的大小,TCP将多次写入缓冲区的数据一次发送出去,将会发生粘包。
  • 接收端
  1. 接收数据端的应用层没有及时读取接收缓冲区中的数据,将发生粘包。

3、粘包问题的解决策略

       TCP以流的方式进行数据传输,由于底层TCP无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,上层的应用协议为了对消息进行区分,业界主流的解决方案归纳如下:

  1. 固定消息长度,累计读取到长度总和为定长LEN的报文后,就认为读取到了一个完整的消息,如果不够,空位补空格;将计数器置位,重新开始读取下一个数据。
  2. 将回车换行符作为消息结束符,例如FTP协议,这种方式在文本协议中应该比较广泛。
  3. 将特殊的分隔符作为消息结束标志,回车换行符就是一种特殊的结束分隔符。
  4. 通过在消息头中定义长度字段来标识消息的总长度。通常设计思路为消息头的第一个字段使用int32来表示消息的总长度。
  5. 更复杂的应用层协议。

4、netty提供的处理方式

       Netty对上述前四种应用做了统一抽象,提供了Decoder来解决对应的问题,使用起来非常方便。只要能够熟练的掌握这些类库的使用,TCP粘包问题就可以变得非常容易,这个就是其他NIO框架和JDK原生的NIO API无法匹敌的。有了这些Decoder,用户不需要对自己读取的报文进行人工解码,也不需要考虑TCP的粘包和拆包。

  4.1、Decoder API    

  • LineBasedFrameDecoder(消息以回车换行符结尾的处理)
  • StringDecoder
  • DelimiterBasedFrameDecoder(消息以自定义分隔符结尾的处理)
  • FixedLengthFrameDecoder

  4.2、上述API使用方式及原理

   4.2.1、LineBasedFrameDecoder 和 StringDecoder示例

  •  服务端:
@Override
public void run() {
    EventLoopGroup bossGroup = new NioEventLoopGroup();
    EventLoopGroup workerGroup = new NioEventLoopGroup();
    try {
        ServerBootstrap b = new ServerBootstrap();
        b.group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                .option(ChannelOption.SO_BACKLOG, 100)
                .handler(new LoggingHandler(LogLevel.INFO))
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel socketChannel) throws Exception {
                        //add decoder
                        socketChannel.pipeline().addLast(new LineBasedFrameDecoder(MAX_LEN));
                        socketChannel.pipeline().addLast(new StringDecoder());
                    }
                });
        //绑定端口
        ChannelFuture future = b.bind(port).sync();
        //等待服务端监听端口关闭
        logger.debug("server start!");
        future.channel().closeFuture().sync();
        logger.debug("server end");
    } catch (InterruptedException e) {
        e.printStackTrace();
    }finally {
        bossGroup.shutdownGracefully();
        workerGroup.shutdownGracefully();
    }
}
  •  客户端:
@Override
public void run() {
    EventLoopGroup group = new NioEventLoopGroup();
    Bootstrap b = new Bootstrap();
    b.group(group)
            .channel(NioSocketChannel.class)
            .remoteAddress(new InetSocketAddress(host, port))
            .handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel socketChannel) throws Exception {
					//add decoder
                    socketChannel.pipeline().addLast(new LineBasedFrameDecoder(MAX_LEN));
                    socketChannel.pipeline().addLast(new StringDecoder());
                }
            });
    try {
        ChannelFuture f = b.connect().sync();
        f.channel().closeFuture().sync();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }finally {
        try {
            group.shutdownGracefully().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  •  原理分析:

       LineBasedFrameDecoder + StringDecoder组合就是按行切换的文本解码器,它被设计用来支持TCP的粘包和拆包。其工作原理是它依次遍历ByteBuf中的可读字节,判断是否有”\n” 或者”\r\n”, 如果有,就以此位置为结束位置,从可读索引到结束位置区间的字节就组成了一行。它是以换行符为结束标志的解码器, 支持携带结束符或者不携带结束符两种解码方式,同时支持配置单行的最大长度。回车换行解码器实际上是一种特殊的 DelimiterBasedFrameDecoder 解码器
       如果连续读取到最大长度之后,仍然没有发现换行符就会抛出异常,同时忽略掉之前读到的异常码流。StringDecoder的功能非常简单,就是将接收到的对象换成字符串,然后继续调用后面的Handller。

 

 4.2.2、DelimiterBasedFrameDecoder+StringDecoder示例

    选定特殊字符作为收发双方发送数据包的分隔符,注:此方式需确定数据包中不含该分隔符,否则数据包会被拆包。若服务器与客户端之间相互发送的数据均含特定分隔符"_#",则C、S两端initChannel方法中都需添加该解码器。

  • 服务端:
@Override
protected void initChannel(SocketChannel ch) throws Exception {
    // 告诉DelimiterBasedFrameDecoder以_#作为分隔符
    ByteBuf delimiter = Unpooled.copiedBuffer("_#".getBytes());
    ChannelPipeline pipeline = ch.pipeline();
    //1024表示单条消息的最大长度,当达到该长度还没有找到分隔符,则抛出TooLongFrameException
    pipeline.addLast(new DelimiterBasedFrameDecoder(1024,delimiter));
    pipeline.addLast(new StringDecoder());
}
  • 客户端:
@Override
protected void initChannel(SocketChannel ch) throws Exception {
    ByteBuf delimiter = Unpooled.copiedBuffer("_#".getBytes());
    ChannelPipeline pipeline = ch.pipeline();
    pipeline.addLast(new DelimiterBasedFrameDecoder(1024,delimiter));
    pipeline.addLast(new StringDecoder());
}

      注:消息尾需加上"_#" ,即ctx.writeAndFlush (yourMessage+"_#");     

      DelimiterBasedFrameDecoder 原理分析:解码时,判断当前已经读取的 ByteBuf 中是否包含分隔符 ByteBuf,如果包含,则截取对应的 ByteBuf 返回。

4.2.3、定义消息长度字段编解码示例

  • 服务端
public class SocketServer {
	public static void main(String[] args) throws InterruptedException {
		EventLoopGroup bossGroup = new NioEventLoopGroup();
		EventLoopGroup workerGroup = new NioEventLoopGroup();

		try {
			ServerBootstrap serverBootstrap = new ServerBootstrap();
			serverBootstrap.group(bossGroup, workerGroup)
					.channel(NioServerSocketChannel.class)
					.handler(new LoggingHandler(LogLevel.INFO))
					.childHandler(new ChannelInitializer<SocketChannel>(){
						@Override
						protected void initChannel(SocketChannel ch) throws Exception {
							ChannelPipeline pipeline = ch.pipeline();
							//添加自定义解码处理器
							pipeline.addLast(new SelfDefineDecodeHandler());
							pipeline.addLast(new ServerBusinessHandler());
						}
					});

			ChannelFuture channelFuture = serverBootstrap.bind(8090).sync();
			channelFuture.channel().closeFuture().sync();
		}
		finally {
			bossGroup.shutdownGracefully();
			workerGroup.shutdownGracefully();
		}
	}
}

          自定义解码处理器:

/**
 * 定长消息数据格式
 *
 * | length |  msg  | 头部length用4字节存储,存储的长度为消息体msg的总长度
 *
 *
 * */
public class SelfDefineDecodeHandler extends ByteToMessageDecoder {
	@Override
	protected void decode(ChannelHandlerContext ctx, ByteBuf bufferIn, List<Object> out) throws Exception {
		if (bufferIn.readableBytes() < 4) {
			return;
		}

		//返回当前buff中readerIndex索引
		int beginIndex = bufferIn.readerIndex();
		//在当前readerIndex基础上读取4字节并返回,同时增加readIndex
		int length = bufferIn.readInt();

		/**
		 * 1.当可读数据小于length,说明包还没有接收完全
		 * 2.开始可读为beginindex,此时读完readInt后需要重置readerindex
		 * 3.重置readerindex后继续等待下一个读事件到来
		 * */
		if (bufferIn.readableBytes() < length) {
			//重置当前的readerindex为beginindex
			bufferIn.readerIndex(beginIndex);
			return;
		}

		//4字节存放length,这里整个消息长度为4+length,跳过当前消息,增大bufferIn的readindex,bufferIn中数组可复用
		bufferIn.readerIndex(beginIndex + 4 + length);

		//Returns a slice of this buffer's sub-region.
		//取出当前的整条消息并存入otherByteBufRef中
		ByteBuf otherByteBufRef = bufferIn.slice(beginIndex, 4 + length);

		/**
		 * 1.每一个bytebuf都有一个计数器,每次调用计数器减1,当计数器为0时则不可用。
		 * 2.当前bytebuf中数据包含多条消息,本条信息会通过out返回被继续封装成一个新的bytebuf返回下一个hander处理
		 * 3.retain方法是将当前的bytebuf计数器加1
		 * */
		otherByteBufRef.retain();
		out.add(otherByteBufRef);
	}
}

          服务端业务处理器:

public class ServerBusinessHandler extends ChannelInboundHandlerAdapter {
	
	@Override
	public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
		/**
		 * 1.读数据,这里收到的是一个完整的消息数据,从上述decoder方法中的out传递到当前逻辑
		 * 2.对消息进一步的解码
		 * */
		ByteBuf buf = (ByteBuf)msg;
		int length = buf.readInt();
		assert length == (8);

		byte[] head = new byte[4];
		buf.readBytes(head);
		String headString = new String(head);
		assert  "head".equals(headString);

		byte[] body = new byte[4];
		buf.readBytes(body);
		String bodyString = new String(body);
		assert  "body".equals(bodyString);
	}
}
  • 客户端
public class SocketClient {
	public static void main(String[] args) throws InterruptedException {
		EventLoopGroup eventLoopGroup = new NioEventLoopGroup();

		try {
			Bootstrap bootstrap = new Bootstrap();
			bootstrap.group(eventLoopGroup)
					.channel(NioSocketChannel.class)
					.handler(new LoggingHandler(LogLevel.INFO))
					.handler(new ChannelInitializer<SocketChannel>(){
						@Override
						protected void initChannel(SocketChannel ch) throws Exception {
							ChannelPipeline pipeline = ch.pipeline();
							pipeline.addLast(new SocketClientHandler());
						}});

			ChannelFuture channelFuture = bootstrap.connect("localhost", 8090).sync();
			channelFuture.channel().closeFuture().sync();
		}
		finally {
			eventLoopGroup.shutdownGracefully();
		}
	}
}

         客户端消息处理器

public class SocketClientHandler extends ChannelInboundHandlerAdapter {
	
	@Override
	public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
		//暂不处理
	}

	/**
	 * 1.写数据的逻辑,先写入消息的总长度
	 * 2.分别写入消息体的内容
	 * 3.以bytebuf的方式发送数据
	 * */
	@Override
	public void channelActive(ChannelHandlerContext ctx) throws Exception {
		UnpooledByteBufAllocator allocator = new UnpooledByteBufAllocator(false);
		ByteBuf buffer = allocator.buffer(20);
		buffer.writeInt(8);
		buffer.writeBytes("head".getBytes());
		buffer.writeBytes("body".getBytes());
		ctx.writeAndFlush(buffer);
	}
}

 

 


此外,本人觉得https://www.jianshu.com/p/adc2de3691c7基于netty实现了后台简易聊天程序, 可加深初学者理解,供参考。

另,netty相关分析可参考资料:

零拷贝:https://www.cnblogs.com/xys1228/p/6088805.html

channelHandler:https://www.cnblogs.com/krcys/p/9297092.html

                             https://www.jianshu.com/p/a9bcd89553f5

心跳保活处理:https://blog.csdn.net/u013967175/article/details/78591810

编解码处理:https://www.cnblogs.com/itdragon/p/8384014.html

本文参考以下大神博客:

http://www.importnew.com/26577.html

https://blog.csdn.net/linsongbin1/article/details/77854957

https://blog.csdn.net/a925907195/article/details/74942472

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章