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

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