Netty2:粘包/拆包問題與使用LineBasedFrameDecoder的解決方案

五月的倉頡發表於2018-04-07

什麼是粘包、拆包

粘包、拆包是Socket程式設計中最常遇見的一個問題,本文來研究一下Netty是如何解決粘包、拆包的,首先我們從什麼是粘包、拆包開始說起:

TCP是個"流"協議,所謂流,就是沒有界限的一串資料,TCP底層並不瞭解上層業務的具體含義,它會根據TCP緩衝區的實際情況進行包的劃分,所以在業務上:
  • 一個完整的包可能會被TCP拆分為多個包進行傳送(拆包)
  • 多個小的包也有可能被封裝成一個大的包進行傳送(粘包)

這就是所謂的TCP粘包與拆包

下圖演示了粘包、拆包的場景:

 基本上有四種情況:

  • Data1、Data2都分開傳送到了Server端,沒有產生粘包與拆包的情況
  • Data1、Data2資料粘在了一起,打成了一個大的包傳送到了Server端,這種情況就是粘包
  • Data1被分成Data1_1與Data1_2,Data1_1先到服務端,Data1_2與Data2再到服務端,這種情況就是拆包
  • Data2被分成Data2_1與Data2_2,Data1與Data2_1先到服務端,Data2_2再到服務端,同上,這也是一種拆包的場景

 

粘包、拆包產生的原因

上面我們詳細瞭解了TCP粘包與拆包,那麼粘包與拆包為什麼會發生呢,大致上有三種原因:

  • 應用程式寫入的位元組大小大於Socket傳送緩衝區大小
  • 進行MSS大小的TCP,MSS是最大報文段長度的縮寫,是TCP報文段中的資料欄位最大長度,MSS=TCP報文段長度-TCP首部長度
  • 乙太網的Payload大於MTU,進行IP分片,MTU是最大傳輸單元的縮寫,乙太網的MTU為1500位元組

 

粘包、拆包解決策略

由於底層的TCP無法理解上層的業務資料,所以在底層是無法保證資料包不被拆分和重組的,這個問題只能通過上層的應用協議棧設計來解決,根據業界的主流協議的解決方案,可以歸納如下:

  • 訊息定長,例如每個報文的大小固定為200位元組,如果不夠空位補空格
  • 包尾增加回車換行符進行分割,例如FTP協議
  • 將訊息分為訊息頭和訊息體,訊息頭中包含表示長度的欄位,通常涉及思路為訊息頭的第一個欄位使用int32來表示訊息的總長度
  • 更復雜的應用層協議

 

未考慮TCP粘包導致功能異常演示

基於Netty的第一篇文章《Netty1:初識Netty》,TimeServer與TimeClient不變,簡單修改一下TimeServerHandler與TimeClientHandler即可以模擬出TCP粘包的情況,首先修改TimeClientHandler:

 1 public class TimeClientHandler extends ChannelHandlerAdapter {
 2 
 3     private static final Logger LOGGER = LoggerFactory.getLogger(TimeClientHandler.class);
 4     
 5     private int counter;
 6     
 7     private byte[] req;
 8     
 9     public TimeClientHandler() {
10         req = ("QUERY TIME ORDER" + System.getProperty("line.separator")).getBytes();
11     }
12     
13     @Override
14     public void channelActive(ChannelHandlerContext ctx) throws Exception {
15         ByteBuf message = null;
16         for (int i = 0; i < 100; i++) {
17             message = Unpooled.buffer(req.length);
18             message.writeBytes(req);
19             ctx.writeAndFlush(message);
20         }
21     }
22     
23     @Override
24     public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
25         ByteBuf buf = (ByteBuf)msg;
26         byte[] req = new byte[buf.readableBytes()];
27         buf.readBytes(req);
28         
29         String body = new String(req, "UTF-8");
30         System.out.println("Now is:" + body + "; the counter is:" + ++counter);
31     }
32     
33     @Override
34     public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
35         LOGGER.warn("Unexcepted exception from downstream:" + cause.getMessage());
36         ctx.close();
37     }
38     
39 }

TimeClientHandler的變化是,之前是傳送一次"QUERY TIME ORDER"到服務端,現在變為傳送100次"QUERY TIME ORDER"+標準換行符到服務端,並在客戶端增加一個計數器,記錄從服務端收到的響應次數。

服務單TimeServerHandler也簡單改造一下,增加一個計數器記錄一下從客戶端收到的請求次數:

 1 public class TimeServerHandler extends ChannelHandlerAdapter {
 2 
 3     private int counter;
 4     
 5     @Override
 6     public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
 7         ByteBuf buf = (ByteBuf)msg;
 8         byte[] req = new byte[buf.readableBytes()];
 9         buf.readBytes(req);
10         
11         String body = new String(req, "UTF-8").substring(0, req.length - System.getProperty("line.separator").length());
12         System.out.println("The time server receive order:" + body + "; the counter is:" + ++counter);
13         
14         String currentTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ? new Date(System.currentTimeMillis()).toString() : "BAD ORDER";
15         currentTime = currentTime + System.getProperty("line.separator");
16         
17         ByteBuf resp = Unpooled.copiedBuffer(currentTime.getBytes());
18         ctx.writeAndFlush(resp);
19     }
20     
21     @Override
22     public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
23         ctx.close();
24     }
25     
26 }

按照設計,服務端應該會列印出100次"Time time server...",客戶端應當會列印出100次"Now is ...",因為客戶端向服務端傳送了100次"QUERY TIME ORDER"的請求,實際執行起來呢?先看一下服務端的列印:

The time server receive order:QUERY TIME ORDER
QUERY TIME ORDER
...省略,這裡有55個
QUERY TIME ORD; the counter is:1
The time server receive order:
...省略,這裡有42個
QUERY TIME ORDER; the counter is:2

counter最終等於2,表明服務端實際上只收到了2條請求,很顯然這裡發生了粘包,即多個客戶端的包合成了一個傳送到了服務端,服務端每收到一個包的大小為1024位元組。

接著看一下客戶端的列印:

Now is:BAD ORDER
BAD ORDER
; the counter is:1

因為服務端只收到了2條訊息,因此客戶端也只會收到2條訊息,因為服務端兩次收到的內容都不滿足"QUERY TIME ORDER",因此返回"BAD ORDER"到客戶端,但是為什麼客戶端的counter=1呢?回過頭來仔細想想,因此服務端傳送給客戶端的訊息也發生了粘包。因此這裡簡單得出一個結論:粘包/拆包不僅僅發生在客戶端給服務端傳送資料,服務端回資料給客戶端同樣有可能發生粘包/拆包

上面的例子演示了粘包,拆包其實一樣的,既然可以知道服務端每收到一個包的大小為1024位元組,那客戶端每次傳送一個大於1024位元組的資料給服務端就可以了,有興趣的朋友可以自己嘗試一下。

 

利用LineBasedFrameDecoder解決粘包問題

為了解決TCP粘包/拆包導致的半包讀寫問題,Netty預設提供了多種編解碼器用於處理半包,針對上面傳送"QUERY TIME ORDER"+標準換行符的這種場景,簡單使用LineBasedFrameDecoder就可以解決上面發生的粘包問題。

首先對TimeServer進行改造,加入LineBasedFrameDecoder與StringDecoder:

 1 public class TimeServer {
 2 
 3     public void bind(int port) throws Exception {
 4         // NIO執行緒組
 5         EventLoopGroup bossGroup = new NioEventLoopGroup();
 6         EventLoopGroup workerGroup = new NioEventLoopGroup();
 7         
 8         try {
 9             ServerBootstrap b = new ServerBootstrap();
10             b.group(bossGroup, workerGroup)
11                 .channel(NioServerSocketChannel.class)
12                 .option(ChannelOption.SO_BACKLOG, 1024)
13                 .childHandler(new ChildChannelHandler());
14             
15             // 繫結埠,同步等待成功
16             ChannelFuture f = b.bind(port).sync();
17             // 等待服務端監聽埠關閉
18             f.channel().closeFuture().sync();
19         } finally {
20             // 優雅退出,釋放執行緒池資源
21             bossGroup.shutdownGracefully();
22             workerGroup.shutdownGracefully();
23         }
24     }
25     
26     private class ChildChannelHandler extends ChannelInitializer<SocketChannel> {
27         @Override
28         protected void initChannel(SocketChannel arg0) throws Exception {
29             arg0.pipeline().addLast(new LineBasedFrameDecoder(1024));
30             arg0.pipeline().addLast(new StringDecoder());
31             arg0.pipeline().addLast(new TimeServerHandler());
32         }
33     }
34     
35 }

改造點就在29行、30行兩行,加入了LineBasedFrameDecoder與StringDecoder,同時TimeServerHandler也需要相應改造:

 1 public class TimeServerHandler extends ChannelHandlerAdapter {
 2 
 3     private int counter;
 4     
 5     @Override
 6     public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
 7         String body = (String)msg;
 8         System.out.println("The time server receive order:" + body + "; the counter is:" + ++counter);
 9         
10         String currentTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ? new Date(System.currentTimeMillis()).toString() : "BAD ORDER";
11         currentTime = currentTime + System.getProperty("line.separator");
12         
13         ByteBuf resp = Unpooled.copiedBuffer(currentTime.getBytes());
14         ctx.writeAndFlush(resp);
15     }
16     
17     @Override
18     public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
19         ctx.close();
20     }
21     
22 }

改造點在第7行,由於使用了StringDecoder,因此channelRead的第二個引數msg不再是ByteBuf型別而是String型別,因此這裡只需要做一次String強轉即可。

TimeClient改造類似:

 1 public class TimeClient {
 2 
 3     public void connect(int port, String host) throws Exception {
 4         EventLoopGroup group = new NioEventLoopGroup();
 5         try {
 6             Bootstrap b = new Bootstrap();
 7             
 8             b.group(group)
 9                 .channel(NioSocketChannel.class)
10                 .option(ChannelOption.TCP_NODELAY, true)
11                 .handler(new ChannelInitializer<SocketChannel>() {
12                     protected void initChannel(SocketChannel ch) throws Exception {
13                         ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
14                         ch.pipeline().addLast(new StringDecoder());
15                         ch.pipeline().addLast(new TimeClientHandler());
16                     };
17                 });
18             
19             // 發起非同步連線操作
20             ChannelFuture f = b.connect(host, port).sync();
21             // 等待客戶端連線關閉
22             f.channel().closeFuture().sync();
23         } finally {
24             // 優雅退出,釋放NIO執行緒組
25             group.shutdownGracefully();
26         }
27     }
28     
29 }

第13行、第14行這兩行加入了LineBasedFrameDecoder與StringDecoder,TimeClientHandler相應改造:

 1 public class TimeClientHandler extends ChannelHandlerAdapter {
 2 
 3     private static final Logger LOGGER = LoggerFactory.getLogger(TimeClientHandler.class);
 4     
 5     private int counter;
 6     
 7     private byte[] req;
 8     
 9     public TimeClientHandler() {
10         req = ("QUERY TIME ORDER" + System.getProperty("line.separator")).getBytes();
11     }
12     
13     @Override
14     public void channelActive(ChannelHandlerContext ctx) throws Exception {
15         ByteBuf message = null;
16         for (int i = 0; i < 100; i++) {
17             message = Unpooled.buffer(req.length);
18             message.writeBytes(req);
19             ctx.writeAndFlush(message);
20         }
21     }
22     
23     @Override
24     public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
25         String body = (String)msg;
26         System.out.println("Now is:" + body + "; the counter is:" + ++counter);
27     }
28     
29     @Override
30     public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
31         LOGGER.warn("Unexcepted exception from downstream:" + cause.getMessage());
32         ctx.close();
33     }
34     
35 }

第25行這裡使用String進行強轉即可。接下來看一下服務端的列印:

The time server receive order:QUERY TIME ORDER; the counter is:1
The time server receive order:QUERY TIME ORDER; the counter is:2
The time server receive order:QUERY TIME ORDER; the counter is:3
The time server receive order:QUERY TIME ORDER; the counter is:4
The time server receive order:QUERY TIME ORDER; the counter is:5
...
The time server receive order:QUERY TIME ORDER; the counter is:98
The time server receive order:QUERY TIME ORDER; the counter is:99
The time server receive order:QUERY TIME ORDER; the counter is:100

看到服務端正常counter從1列印到了100,即收到了100個完整的客戶端請求,客戶端的列印如下:

Now is:Sat Apr 07 16:00:51 CST 2018; the counter is:1
Now is:Sat Apr 07 16:00:51 CST 2018; the counter is:2
Now is:Sat Apr 07 16:00:51 CST 2018; the counter is:3
Now is:Sat Apr 07 16:00:51 CST 2018; the counter is:4
Now is:Sat Apr 07 16:00:51 CST 2018; the counter is:5
...
Now is:Sat Apr 07 16:00:51 CST 2018; the counter is:98
Now is:Sat Apr 07 16:00:51 CST 2018; the counter is:99
Now is:Sat Apr 07 16:00:51 CST 2018; the counter is:100

看到同樣的客戶端也正常counter從1列印到了100,即收到了100個完整的服務端響應,至此,使用LineBasedFrameDecoder與StringDecoder解決了上述粘包問題。

整個LineBasedFrameDecoder的原理也比較簡單:

LineBasedFrameDecoder依次遍歷ByteBuf中的可讀位元組,判斷是否有"\n"或者"\r\n",如果有就以此位置為結束位置,從可讀索引到結束位置區間的位元組就組成了一行,它是以換行符為結束標誌的解碼器,支援攜帶結束符或者不攜帶結束符兩種解碼方式,同時支援配置單行的最大長度,如果連續讀到最大長度後仍然沒有發現換行符,就會丟擲異常,同時忽略掉之前讀到的異常碼流。

StringDecoder的功能非常簡單,就是將接收到的物件轉換為字串,然後繼續呼叫後面的Handler

LineBasedFrameDecoder+StringDecoder就是按行切換的文字解碼器,被設計用於支援TCP的粘包和拆包

相關文章