Netty解決粘包和拆包問題的四種方案

爛豬皮發表於2019-03-31

在RPC框架中,粘包和拆包問題是必須解決一個問題,因為RPC框架中,各個微服務相互之間都是維繫了一個TCP長連線,比如dubbo就是一個全雙工的長連線。由於微服務往對方傳送資訊的時候,所有的請求都是使用的同一個連線,這樣就會產生粘包和拆包的問題。本文首先會對粘包和拆包問題進行描述,然後介紹其常用的解決方案,最後會對Netty提供的幾種解決方案進行講解。這裡說明一下,由於oschina將“jie ma qi”認定為敏感文字,因而本文統一使用“解碼一器”表示該含義

1. 粘包和拆包

產生粘包和拆包問題的主要原因是,作業系統在傳送TCP資料的時候,底層會有一個緩衝區,例如1024個位元組大小,如果一次請求傳送的資料量比較小,沒達到緩衝區大小,TCP則會將多個請求合併為同一個請求進行傳送,這就形成了粘包問題;如果一次請求傳送的資料量比較大,超過了緩衝區大小,TCP就會將其拆分為多次傳送,這就是拆包,也就是將一個大的包拆分為多個小包進行傳送。如下圖展示了粘包和拆包的一個示意圖:

Netty解決粘包和拆包問題的四種方案
image.png

上圖中演示了粘包和拆包的三種情況:

  • A和B兩個包都剛好滿足TCP緩衝區的大小,或者說其等待時間已經達到TCP等待時長,從而還是使用兩個獨立的包進行傳送;
  • A和B兩次請求間隔時間內較短,並且資料包較小,因而合併為同一個包傳送給服務端;
  • B包比較大,因而將其拆分為兩個包B_1和B_2進行傳送,而這裡由於拆分後的B_2比較小,其又與A包合併在一起傳送。

2. 常見解決方案

對於粘包和拆包問題,常見的解決方案有四種:

  • 客戶端在傳送資料包的時候,每個包都固定長度,比如1024個位元組大小,如果客戶端傳送的資料長度不足1024個位元組,則通過補充空格的方式補全到指定長度;
  • 客戶端在每個包的末尾使用固定的分隔符,例如\r\n,如果一個包被拆分了,則等待下一個包傳送過來之後找到其中的\r\n,然後對其拆分後的頭部部分與前一個包的剩餘部分進行合併,這樣就得到了一個完整的包;
  • 將訊息分為頭部和訊息體,在頭部中儲存有當前整個訊息的長度,只有在讀取到足夠長度的訊息之後才算是讀到了一個完整的訊息;
  • 通過自定義協議進行粘包和拆包的處理。

3. Netty提供的粘包拆包解決方案

3.1 FixedLengthFrameDecoder

對於使用固定長度的粘包和拆包場景,可以使用FixedLengthFrameDecoder,該解碼一器會每次讀取固定長度的訊息,如果當前讀取到的訊息不足指定長度,那麼就會等待下一個訊息到達後進行補足。其使用也比較簡單,只需要在建構函式中指定每個訊息的長度即可。這裡需要注意的是,FixedLengthFrameDecoder只是一個解碼一器,Netty也只提供了一個解碼一器,這是因為對於解碼是需要等待下一個包的進行補全的,程式碼相對複雜,而對於編碼器,使用者可以自行編寫,因為編碼時只需要將不足指定長度的部分進行補全即可。下面的示例中展示瞭如何使用FixedLengthFrameDecoder來進行粘包和拆包處理:

public class EchoServer {

  public void bind(int port) throws InterruptedException {
    EventLoopGroup bossGroup = new NioEventLoopGroup();
    EventLoopGroup workerGroup = new NioEventLoopGroup();
    try {
      ServerBootstrap bootstrap = new ServerBootstrap();
      bootstrap.group(bossGroup, workerGroup)
        .channel(NioServerSocketChannel.class)
        .option(ChannelOption.SO_BACKLOG, 1024)
        .handler(new LoggingHandler(LogLevel.INFO))
        .childHandler(new ChannelInitializer<SocketChannel>() {
          @Override
          protected void initChannel(SocketChannel ch) throws Exception {
            // 這裡將FixedLengthFrameDecoder新增到pipeline中,指定長度為20
            ch.pipeline().addLast(new FixedLengthFrameDecoder(20));
            // 將前一步解碼得到的資料轉碼為字串
            ch.pipeline().addLast(new StringDecoder());
            // 這裡FixedLengthFrameEncoder是我們自定義的,用於將長度不足20的訊息進行補全空格
            ch.pipeline().addLast(new FixedLengthFrameEncoder(20));
            // 最終的資料處理
            ch.pipeline().addLast(new EchoServerHandler());
          }
        });

      ChannelFuture future = bootstrap.bind(port).sync();
      future.channel().closeFuture().sync();
    } finally {
      bossGroup.shutdownGracefully();
      workerGroup.shutdownGracefully();
    }
  }

  public static void main(String[] args) throws InterruptedException {
    new EchoServer().bind(8080);
  }
}
複製程式碼

上面的pipeline中,對於入棧資料,這裡主要新增了FixedLengthFrameDecoder和StringDecoder,前面一個用於處理固定長度的訊息的粘包和拆包問題,第二個則是將處理之後的訊息轉換為字串。最後由EchoServerHandler處理最終得到的資料,處理完成後,將處理得到的資料交由FixedLengthFrameEncoder處理,該編碼器是我們自定義的實現,主要作用是將長度不足20的訊息進行空格補全。下面是FixedLengthFrameEncoder的實現程式碼:

public class FixedLengthFrameEncoder extends MessageToByteEncoder<String> {
  private int length;

  public FixedLengthFrameEncoder(int length) {
    this.length = length;
  }

  @Override
  protected void encode(ChannelHandlerContext ctx, String msg, ByteBuf out)
      throws Exception {
    // 對於超過指定長度的訊息,這裡直接丟擲異常
    if (msg.length() > length) {
      throw new UnsupportedOperationException(
          "message length is too large, it's limited " + length);
    }

    // 如果長度不足,則進行補全
    if (msg.length() < length) {
      msg = addSpace(msg);
    }

    ctx.writeAndFlush(Unpooled.wrappedBuffer(msg.getBytes()));
  }

  // 進行空格補全
  private String addSpace(String msg) {
    StringBuilder builder = new StringBuilder(msg);
    for (int i = 0; i < length - msg.length(); i++) {
      builder.append(" ");
    }

    return builder.toString();
  }
}
複製程式碼

這裡FixedLengthFrameEncoder實現了decode()方法,在該方法中,主要是將訊息長度不足20的訊息進行空格補全。EchoServerHandler的作用主要是列印接收到的訊息,然後傳送響應給客戶端:

public class EchoServerHandler extends SimpleChannelInboundHandler<String> {

  @Override
  protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
    System.out.println("server receives message: " + msg.trim());
    ctx.writeAndFlush("hello client!");
  }
}
複製程式碼

對於客戶端,其實現方式基本與服務端的使用方式類似,只是在最後進行訊息傳送的時候與服務端的處理方式不同。如下是客戶端EchoClient的程式碼:

public class EchoClient {

  public void connect(String host, int port) throws InterruptedException {
    EventLoopGroup group = new NioEventLoopGroup();
    try {
      Bootstrap bootstrap = new Bootstrap();
      bootstrap.group(group)
        .channel(NioSocketChannel.class)
        .option(ChannelOption.TCP_NODELAY, true)
        .handler(new ChannelInitializer<SocketChannel>() {
          @Override
          protected void initChannel(SocketChannel ch) throws Exception {
            // 對服務端傳送的訊息進行粘包和拆包處理,由於服務端傳送的訊息已經進行了空格補全,
            // 並且長度為20,因而這裡指定的長度也為20
            ch.pipeline().addLast(new FixedLengthFrameDecoder(20));
            // 將粘包和拆包處理得到的訊息轉換為字串
            ch.pipeline().addLast(new StringDecoder());
            // 對客戶端傳送的訊息進行空格補全,保證其長度為20
            ch.pipeline().addLast(new FixedLengthFrameEncoder(20));
            // 客戶端傳送訊息給服務端,並且處理服務端響應的訊息
            ch.pipeline().addLast(new EchoClientHandler());
          }
        });

      ChannelFuture future = bootstrap.connect(host, port).sync();
      future.channel().closeFuture().sync();
    } finally {
      group.shutdownGracefully();
    }
  }

  public static void main(String[] args) throws InterruptedException {
    new EchoClient().connect("127.0.0.1", 8080);
  }
}
複製程式碼

對於客戶端而言,其訊息的處理流程其實與服務端是相似的,對於入站訊息,需要對其進行粘包和拆包處理,然後將其轉碼為字串,對於出站訊息,則需要將長度不足20的訊息進行空格補全。客戶端與服務端處理的主要區別在於最後的訊息處理handler不一樣,也即這裡的EchoClientHandler,如下是該handler的原始碼:

public class EchoClientHandler extends SimpleChannelInboundHandler<String> {

  @Override
  protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
    System.out.println("client receives message: " + msg.trim());
  }

  @Override
  public void channelActive(ChannelHandlerContext ctx) throws Exception {
    ctx.writeAndFlush("hello server!");
  }
}
複製程式碼

這裡客戶端的處理主要是重寫了channelActive()和channelRead0()兩個方法,這兩個方法的主要作用在於,channelActive()會在客戶端連線上伺服器時執行,也就是說,其連上伺服器之後就會往伺服器傳送訊息。而channelRead0()主要是在伺服器傳送響應給客戶端時執行,這裡主要是列印伺服器的響應訊息。對於服務端而言,前面我們我們可以看到,EchoServerHandler只重寫了channelRead0()方法,這是因為伺服器只需要等待客戶端傳送訊息過來,然後在該方法中進行處理,處理完成後直接將響應傳送給客戶端。如下是分別啟動服務端和客戶端之後控制檯列印的資料:

// server
server receives message: hello server!
複製程式碼
// client
client receives message: hello client!
複製程式碼

3.2 LineBasedFrameDecoder與DelimiterBasedFrameDecoder

對於通過分隔符進行粘包和拆包問題的處理,Netty提供了兩個編解碼的類,LineBasedFrameDecoder和DelimiterBasedFrameDecoder。這裡LineBasedFrameDecoder的作用主要是通過換行符,即\n或者\r\n對資料進行處理;而DelimiterBasedFrameDecoder的作用則是通過使用者指定的分隔符對資料進行粘包和拆包處理。同樣的,這兩個類都是解碼一器類,而對於資料的編碼,也即在每個資料包最後新增換行符或者指定分割符的部分需要使用者自行進行處理。這裡以DelimiterBasedFrameDecoder為例進行講解,如下是EchoServer中使用該類的程式碼片段,其餘部分與前面的例子中的完全一致:

@Override
protected void initChannel(SocketChannel ch) throws Exception {
    String delimiter = "_$";
    // 將delimiter設定到DelimiterBasedFrameDecoder中,經過該解碼一器進行處理之後,源資料將會
    // 被按照_$進行分隔,這裡1024指的是分隔的最大長度,即當讀取到1024個位元組的資料之後,若還是未
    // 讀取到分隔符,則捨棄當前資料段,因為其很有可能是由於碼流紊亂造成的
    ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024,
        Unpooled.wrappedBuffer(delimiter.getBytes())));
    // 將分隔之後的位元組資料轉換為字串資料
    ch.pipeline().addLast(new StringDecoder());
    // 這是我們自定義的一個編碼器,主要作用是在返回的響應資料最後新增分隔符
    ch.pipeline().addLast(new DelimiterBasedFrameEncoder(delimiter));
    // 最終處理資料並且返回響應的handler
    ch.pipeline().addLast(new EchoServerHandler());
}
複製程式碼

上面pipeline的設定中,新增的解碼一器主要有DelimiterBasedFrameDecoder和StringDecoder,經過這兩個處理器處理之後,接收到的位元組流就會被分隔,並且轉換為字串資料,最終交由EchoServerHandler處理。這裡DelimiterBasedFrameEncoder是我們自定義的編碼器,其主要作用是在返回的響應資料之後新增分隔符。如下是該編碼器的原始碼:

public class DelimiterBasedFrameEncoder extends MessageToByteEncoder<String> {

  private String delimiter;

  public DelimiterBasedFrameEncoder(String delimiter) {
    this.delimiter = delimiter;
  }

  @Override
  protected void encode(ChannelHandlerContext ctx, String msg, ByteBuf out) 
      throws Exception {
    // 在響應的資料後面新增分隔符
    ctx.writeAndFlush(Unpooled.wrappedBuffer((msg + delimiter).getBytes()));
  }
}
複製程式碼

對於客戶端而言,這裡的處理方式與服務端類似,其pipeline的新增方式如下:

@Override
protected void initChannel(SocketChannel ch) throws Exception {
    String delimiter = "_$";
    // 對服務端返回的訊息通過_$進行分隔,並且每次查詢的最大大小為1024位元組
    ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024, 
        Unpooled.wrappedBuffer(delimiter.getBytes())));
    // 將分隔之後的位元組資料轉換為字串
    ch.pipeline().addLast(new StringDecoder());
    // 對客戶端傳送的資料進行編碼,這裡主要是在客戶端傳送的資料最後新增分隔符
    ch.pipeline().addLast(new DelimiterBasedFrameEncoder(delimiter));
    // 客戶端傳送資料給服務端,並且處理從服務端響應的資料
    ch.pipeline().addLast(new EchoClientHandler());
}
複製程式碼

這裡客戶端的處理方式與服務端基本一致,關於這裡沒展示的程式碼,其與示例一中的程式碼完全一致,這裡則不予展示。

3.3 LengthFieldBasedFrameDecoder與LengthFieldPrepender

這裡LengthFieldBasedFrameDecoder與LengthFieldPrepender需要配合起來使用,其實本質上來講,這兩者一個是解碼,一個是編碼的關係。它們處理粘拆包的主要思想是在生成的資料包中新增一個長度欄位,用於記錄當前資料包的長度。LengthFieldBasedFrameDecoder會按照引數指定的包長度偏移量資料對接收到的資料進行解碼,從而得到目標訊息體資料;而LengthFieldPrepender則會在響應的資料前面新增指定的位元組資料,這個位元組資料中儲存了當前訊息體的整體位元組資料長度。LengthFieldBasedFrameDecoder的解碼過程如下圖所示:

Netty解決粘包和拆包問題的四種方案
image.png

關於LengthFieldBasedFrameDecoder,這裡需要對其建構函式引數進行介紹:

  • maxFrameLength:指定了每個包所能傳遞的最大資料包大小;
  • lengthFieldOffset:指定了長度欄位在位元組碼中的偏移量;
  • lengthFieldLength:指定了長度欄位所佔用的位元組長度;
  • lengthAdjustment:對一些不僅包含有訊息頭和訊息體的資料進行訊息頭的長度的調整,這樣就可以只得到訊息體的資料,這裡的lengthAdjustment指定的就是訊息頭的長度;
  • initialBytesToStrip:對於長度欄位在訊息頭中間的情況,可以通過initialBytesToStrip忽略掉訊息頭以及長度欄位佔用的位元組。

這裡我們以json序列化為例對LengthFieldBasedFrameDecoder和LengthFieldPrepender的使用方式進行講解。如下是EchoServer的原始碼:

public class EchoServer {

  public void bind(int port) throws InterruptedException {
    EventLoopGroup bossGroup = new NioEventLoopGroup();
    EventLoopGroup workerGroup = new NioEventLoopGroup();
    try {
      ServerBootstrap bootstrap = new ServerBootstrap();
      bootstrap.group(bossGroup, workerGroup)
        .channel(NioServerSocketChannel.class)
        .option(ChannelOption.SO_BACKLOG, 1024)
        .handler(new LoggingHandler(LogLevel.INFO))
        .childHandler(new ChannelInitializer<SocketChannel>() {
          @Override
          protected void initChannel(SocketChannel ch) throws Exception {
            // 這裡將LengthFieldBasedFrameDecoder新增到pipeline的首位,因為其需要對接收到的資料
            // 進行長度欄位解碼,這裡也會對資料進行粘包和拆包處理
            ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 2, 0, 2));
            // LengthFieldPrepender是一個編碼器,主要是在響應位元組資料前面新增位元組長度欄位
            ch.pipeline().addLast(new LengthFieldPrepender(2));
            // 對經過粘包和拆包處理之後的資料進行json反序列化,從而得到User物件
            ch.pipeline().addLast(new JsonDecoder());
            // 對響應資料進行編碼,主要是將User物件序列化為json
            ch.pipeline().addLast(new JsonEncoder());
            // 處理客戶端的請求的資料,並且進行響應
            ch.pipeline().addLast(new EchoServerHandler());
          }
        });

      ChannelFuture future = bootstrap.bind(port).sync();
      future.channel().closeFuture().sync();
    } finally {
      bossGroup.shutdownGracefully();
      workerGroup.shutdownGracefully();
    }
  }

  public static void main(String[] args) throws InterruptedException {
    new EchoServer().bind(8080);
  }
}
複製程式碼

這裡EchoServer主要是在pipeline中新增了兩個編碼器和兩個解碼一器,編碼器主要是負責將響應的User物件序列化為json物件,然後在其位元組陣列前面新增一個長度欄位的位元組陣列;解碼一器主要是對接收到的資料進行長度欄位的解碼,然後將其反序列化為一個User物件。下面是JsonDecoder的原始碼:

public class JsonDecoder extends MessageToMessageDecoder<ByteBuf> {

  @Override
  protected void decode(ChannelHandlerContext ctx, ByteBuf buf, List<Object> out) 
      throws Exception {
    byte[] bytes = new byte[buf.readableBytes()];
    buf.readBytes(bytes);
    User user = JSON.parseObject(new String(bytes, CharsetUtil.UTF_8), User.class);
    out.add(user);
  }
}
複製程式碼

JsonDecoder首先從接收到的資料流中讀取位元組陣列,然後將其反序列化為一個User物件。下面我們看看JsonEncoder的原始碼:

public class JsonEncoder extends MessageToByteEncoder<User> {

  @Override
  protected void encode(ChannelHandlerContext ctx, User user, ByteBuf buf)
      throws Exception {
    String json = JSON.toJSONString(user);
    ctx.writeAndFlush(Unpooled.wrappedBuffer(json.getBytes()));
  }
}
複製程式碼

JsonEncoder將響應得到的User物件轉換為一個json物件,然後寫入響應中。對於EchoServerHandler,其主要作用就是接收客戶端資料,並且進行響應,如下是其原始碼:

public class EchoServerHandler extends SimpleChannelInboundHandler<User> {

  @Override
  protected void channelRead0(ChannelHandlerContext ctx, User user) throws Exception {
    System.out.println("receive from client: " + user);
    ctx.write(user);
  }
}
複製程式碼

對於客戶端,其主要邏輯與服務端的基本類似,這裡主要展示其pipeline的新增方式,以及最後傳送請求,並且對伺服器響應進行處理的過程:

@Override
protected void initChannel(SocketChannel ch) throws Exception {
    ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 2, 0, 2));
    ch.pipeline().addLast(new LengthFieldPrepender(2));
    ch.pipeline().addLast(new JsonDecoder());
    ch.pipeline().addLast(new JsonEncoder());
    ch.pipeline().addLast(new EchoClientHandler());
}
複製程式碼
public class EchoClientHandler extends SimpleChannelInboundHandler<User> {

  @Override
  public void channelActive(ChannelHandlerContext ctx) throws Exception {
    ctx.write(getUser());
  }

  private User getUser() {
    User user = new User();
    user.setAge(27);
    user.setName("zhangxufeng");
    return user;
  }

  @Override
  protected void channelRead0(ChannelHandlerContext ctx, User user) throws Exception {
    System.out.println("receive message from server: " + user);
  }
}
複製程式碼

這裡客戶端首先會在連線上伺服器時,往伺服器傳送一個User物件資料,然後在接收到伺服器響應之後,會列印伺服器響應的資料。

3.4 自定義粘包與拆包器

對於粘包與拆包問題,其實前面三種基本上已經能夠滿足大多數情形了,但是對於一些更加複雜的協議,可能有一些定製化的需求。對於這些場景,其實本質上,我們也不需要手動從頭開始寫一份粘包與拆包處理器,而是通過繼承LengthFieldBasedFrameDecoder和LengthFieldPrepender來實現粘包和拆包的處理。

如果使用者確實需要不通過繼承的方式實現自己的粘包和拆包處理器,這裡可以通過實現MessageToByteEncoder和ByteToMessageDecoder來實現。這裡MessageToByteEncoder的作用是將響應資料編碼為一個ByteBuf物件,而ByteToMessageDecoder則是將接收到的ByteBuf資料轉換為某個物件資料。通過實現這兩個抽象類,使用者就可以達到實現自定義粘包和拆包處理的目的。如下是這兩個類及其抽象方法的宣告:

public abstract class ByteToMessageDecoder extends ChannelInboundHandlerAdapter {
    protected abstract void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) 
        throws Exception;
}
複製程式碼
public abstract class MessageToByteEncoder<I> extends ChannelOutboundHandlerAdapter {
    protected abstract void encode(ChannelHandlerContext ctx, I msg, ByteBuf out) 
        throws Exception;
}
複製程式碼

4. 小結

本文首先對粘包和拆包的問題原理進行描述,幫助讀者理解粘包和拆包問題所在。然後對處理粘包和拆包的幾種常用解決方案進行講解。接著通過輔以示例的方式對Netty提供的幾種解決粘包和拆包問題的解決方案進行了詳細講解。

大家可以加我的程式設計師交流群:833145934,群內每晚都會有阿里技術大牛講解的最新Java架構技術。並會錄製錄播視訊分享在群公告中,作為給廣大朋友的加群的福利——分散式(Dubbo、Redis、RabbitMQ、Netty、RPC、Zookeeper、高併發、高可用架構)/微服務(Spring Boot、Spring Cloud)/原始碼(Spring、Mybatis)/效能優化(JVM、TomCat、MySQL)【加群備註好訊息領取最新面試資料】


相關文章