Netty拾遺(七)——粘包與拆包問題

謎一樣的Coder發表於2020-09-25

前言

Netty中粘包與拆包問題是一個比較麻煩的問題,《Netty權威指南》一書中,對其解釋的比較少,只是從字面意思做了一個總結。這一篇部落格簡單總結一下Netty中的粘包與拆包。

問題介紹

在這裡插入圖片描述

其實也不需要過多的理解,無法就是客戶端在往伺服器傳送資料的時候,資料包的大小並沒有在應用層面作出控制,而是直接交給網路通訊協議層控制,通訊協議層,根據每次傳送資料包的大小會進行相關的組裝與拆分,導致客戶端與伺服器傳送的資料包大小並不規整,解析資訊就會出現問題。

產生的原因

產生粘包和拆包的原因,個人總結,主要有以下幾點

  • 1、要傳送的資料大於套接字傳送緩衝區大小,這個時候會發生拆包。
  • 2、要傳送的資料大於MSS(最大報文長度),這個時候也會發生拆包。
  • 3、要傳送的資料小於MSS,這個時候就會發生粘包。
  • 4、要傳送的資料小於套接字傳送緩衝區大小,這個時候會發生粘包。

還有很多其他原因(奈何不是網路專業,只歸納這麼多)。

解決方法

其實整體可以看出,粘包拆包的發生,主要就是因為應用層沒有規定資料包的長度,因此需要在應用層採取相關措施。

常見的解決方案有四種

1、固定報文長度,客戶端在傳送報文的時候通過固定報文長度的方式傳送資料,如果不足指定的長度,則通過補空格的方式使資料包達到指定長度。

2、在報文尾部增加固定字元進行分割,客戶端在傳送報文的時候,在每個報文的結尾追加固定的字元,表示報文的結尾,伺服器端在讀取到自動的分割符時,表示之前收到的都是一個完整的報文。

3、將訊息分為頭部和訊息體,客戶端在傳送訊息的時候,在訊息頭部儲存整個訊息的長度,伺服器在讀取到足夠長度的訊息之後才算是讀取到了一個完整的資料包。

4、自定義協議解決粘包拆包,《Netty權威指南》一書中提到了這個方式。

例項

粘包/拆包的例項

這裡在之前查詢服務端時間的基礎上做一點簡單的改造

客戶端

客戶端server啟動類

/**
 * autor:liman
 * createtime:2020/8/18
 * comment:粘包拆包客戶端啟動類
 */
@Slf4j
public class NettyHalfAndStickPackTimeClient {

    private final int port;
    private final String host;

    public NettyHalfAndStickPackTimeClient(int port, String host) {
        this.port = port;
        this.host = host;
    }

    public void start() throws InterruptedException {
        EventLoopGroup group = new NioEventLoopGroup();
        Bootstrap bootstrap = new Bootstrap();
        try {
            bootstrap.group(group);
            bootstrap.channel(NioSocketChannel.class);
            bootstrap.option(ChannelOption.TCP_NODELAY,true);
            bootstrap.remoteAddress(new InetSocketAddress(host, port));
            bootstrap.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    //普通的客戶端處理類,沒有進行粘包和拆包的處理
                    ch.pipeline().addLast(new NettyHalfAndStickPackTimeClientHandler());
                }
            });

            ChannelFuture clientFuture = bootstrap.connect().sync();
            clientFuture.channel().closeFuture().sync();
        } catch (Exception e) {

        } finally {
            group.shutdownGracefully();
        }
    }

    public static void main(String[] args) {
        try {
            new NettyHalfAndStickPackTimeClient(9999, "127.0.0.1").start();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

客戶端處理類

/**
 * autor:liman
 * createtime:2020/8/18
 * comment:
 */
@Slf4j
public class NettyHalfAndStickPackTimeClientHandler extends ChannelInboundHandlerAdapter {

    private static int count;
    /**
     * 客戶端讀取到資料之後幹什麼
     * @param ctx
     * @param msg
     * @throws Exception
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    	//讀取服務端傳送回來的資料
        ByteBuf buf = (ByteBuf) msg;
        byte[] req = new byte[buf.readableBytes()];
        buf.readBytes(req);
        String body = new String(req,"UTF-8");
        log.info("receive message from server,now is {},the counter is {}",body,++count);
    }

    /**
     * 當客戶端被通知channel活躍以後可以做的事情
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
    	
        log.info("連線建立,開始傳送資料");
        ByteBuf message = null;
        //這裡在每一條命令末位都增加系統換行符
        byte[] request = ("QUERY TIME"+System.getProperty("line.separator")).getBytes();
        //這裡直接將命令迴圈100次,然後傳送100次“QUERY TIME"命令。
        for(int i=0;i<100;i++){
            message = Unpooled.buffer(request.length);
            message.writeBytes(request);
            ctx.writeAndFlush(message);
        }
    }

    /**
     * 異常處理的邏輯
     * @param ctx
     * @param cause
     * @throws Exception
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        log.info("異常,異常資訊為:{}",cause.fillInStackTrace());
        cause.printStackTrace();
        ctx.close();
    }
}

服務端

服務端啟動類

/**
 * autor:liman
 * createtime:2020/8/17
 * comment:Netty的粘包和半包問題
 */
@Slf4j
public class NettyHalfAndStickPackTimeServer {

    private final int port;

    public NettyHalfAndStickPackTimeServer(int port) {
        this.port = port;
    }

    public void start() throws InterruptedException {
        EventLoopGroup group = new NioEventLoopGroup();//這個相當於執行緒組
        try{
            ServerBootstrap serverBootstrap = new ServerBootstrap();//這個相當於服務端的channel,等同於ServerScoketChannel
            serverBootstrap.group(group);
            serverBootstrap.channel(NioServerSocketChannel.class);
            serverBootstrap.localAddress(new InetSocketAddress(port));

            //服務端接收到連線請求,需要新開啟一個channel通訊(socket),每一個channel需要有自己的事件處理的handler
            serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel socketChannel) throws Exception {
					//沒有做粘包和拆包的處理,直接讀取客戶端的請求
                    socketChannel.pipeline().addLast(new NettyHalfAndStickPackTimeServerHandler());
                }
            });
            //繫結到埠,阻塞等待直到連線完成
            ChannelFuture serverFuture =serverBootstrap.bind().sync();
            //阻塞,直到channel關閉
            serverFuture.channel().closeFuture().sync();

        }catch (Exception e){
            log.info("連線出現異常,異常資訊為:{}",e);
        }finally {
            group.shutdownGracefully().sync();
        }
    }

    public static void main(String[] args) {
        int port = 9999;
        try {
            log.info("服務端啟動,監聽埠為:{}",port);
            new NettyHalfAndStickPackTimeServer(port).start();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

服務端處理類

/**
 * autor:liman
 * createtime:2020/8/17
 * comment:粘包拆包的服務端處理類
 */
@Slf4j
public class NettyHalfAndStickPackTimeServerHandler extends ChannelInboundHandlerAdapter {

	//統計建立連線的計數器
    private static int totalConnectCount;

    /**
     * 服務端讀取到網路資料之後的處理邏輯
     *
     * @param ctx
     * @param msg
     * @throws Exception 如果客戶端資料很多,在一個方法中讀不完,就會出現拆包的操作
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {

        ByteBuf resp = sendMessage2Client(msg);
        ctx.write(resp);
        super.channelRead(ctx,msg);
    }

    /**
     * 服務端讀取完成網路資料之後的處理邏輯
     *
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {

        //在伺服器寫完資料之後,addListener中會關閉連線
        ctx.writeAndFlush(Unpooled.EMPTY_BUFFER)
                .addListener(ChannelFutureListener.CLOSE);
    }

    /**
     * 發生異常之後的處理邏輯
     *
     * @param ctx
     * @param cause
     * @throws Exception
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        ctx.close();
    }

	//傳送訊息到客戶端
    public ByteBuf sendMessage2Client(Object msg) {
        ByteBuf resp = Unpooled.buffer(1024);
        try {
            ByteBuf buf = (ByteBuf) msg;
            byte[] req = new byte[buf.readableBytes()];
            buf.readBytes(req);
            String body = new String(req, "UTF-8");
            log.info("the time server receive the order from client : {},the counter is {}", body, ++totalConnectCount);
            String currentTime = "";
            if ("QUERY TIME".equalsIgnoreCase(body)) {
                currentTime = LocalDateTime.now().toString();
            } else {
                currentTime = "BAD REQUIRE";
            }
            //加入拆包的LineBasedFrameDecoder之後,服務端返回給客戶端的報文中也要有分隔符,否則客戶端readComplete會呼叫多次,而不會去呼叫channelRead
            currentTime += System.getProperty("line.separator");
            resp = Unpooled.copiedBuffer(currentTime.getBytes());
        } catch (Exception e) {
            log.error("傳送訊息異常,異常資訊為:{}", e);
        }
        return resp;
    }
}

測試結果

在這裡插入圖片描述

客戶端自然就無法正常查詢伺服器的時間。

解決粘包/拆包的例項

報文分隔符

LineBasedFrameDecoder解碼器

這個是Netty提供的開箱即用的解碼器,預設以系統中的換行符為報文分割符。我們只需要在客戶端傳送的報文,以及服務端返回的報文中都加入系統換行符,同時在服務端和客戶端都加入該解碼器,則可以正確解決粘包和拆包問題

客戶端啟動類修改,同時客戶端handler中傳送報文的時候,也需要指定

public void start() throws InterruptedException {
    EventLoopGroup group = new NioEventLoopGroup();
    Bootstrap bootstrap = new Bootstrap();
    try {
        bootstrap.group(group);
        bootstrap.channel(NioSocketChannel.class);
        bootstrap.option(ChannelOption.TCP_NODELAY,true);
        bootstrap.remoteAddress(new InetSocketAddress(host, port));
        bootstrap.handler(new ChannelInitializer<SocketChannel>() {
            @Override
            protected void initChannel(SocketChannel ch) throws Exception {
                //加入LineBaseFrameDecoder解碼器,指定最長報文長度,如果超過這個報文長度,依舊沒有看到指定分隔符,則會丟擲異常。
                ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
                ch.pipeline().addLast(new NettyHalfAndStickPackTimeClientHandler());
            }
        });

        ChannelFuture clientFuture = bootstrap.connect().sync();
        clientFuture.channel().closeFuture().sync();
    } catch (Exception e) {

    } finally {
        group.shutdownGracefully();
    }

}

上述程式碼只加入了13行一行而已,就解決了客戶端的粘包和拆包的問題。

服務端啟動類修改

public void start() throws InterruptedException {
    EventLoopGroup group = new NioEventLoopGroup();//這個相當於執行緒組
    try{
        ServerBootstrap serverBootstrap = new ServerBootstrap();//這個相當於服務端的channel,等同於ServerScoketChannel
        serverBootstrap.group(group);
        serverBootstrap.channel(NioServerSocketChannel.class);
        serverBootstrap.localAddress(new InetSocketAddress(port));

        //服務端接收到連線請求,需要新開啟一個channel通訊(socket),每一個channel需要有自己的事件處理的handler
        serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
            @Override
            protected void initChannel(SocketChannel socketChannel) throws Exception {
                //和服務端一樣,加入了指定的解碼器
                socketChannel.pipeline().addLast(new LineBasedFrameDecoder(1024));
                socketChannel.pipeline().addLast(new NettyHalfAndStickPackTimeServerHandler());
            }
        });
        //繫結到埠,阻塞等待直到連線完成
        ChannelFuture serverFuture =serverBootstrap.bind().sync();
        //阻塞,直到channel關閉
        serverFuture.channel().closeFuture().sync();

    }catch (Exception e){
        log.info("連線出現異常,異常資訊為:{}",e);
    }finally {
        group.shutdownGracefully().sync();
    }
}

上述程式碼加了14行一行而已,就可以解決粘包和拆包問題

服務端處理類,需要在傳送訊息的方法中,返回報文中增加指定的分隔符。

public ByteBuf sendMessage2Client(Object msg) {
    ByteBuf resp = Unpooled.buffer(1024);
    try {
        ByteBuf buf = (ByteBuf) msg;
        byte[] req = new byte[buf.readableBytes()];
        buf.readBytes(req);
        String body = new String(req, "UTF-8");
        log.info("the time server receive the order from client : {},the counter is {}", body, ++totalConnectCount);
        String currentTime = "";
        if ("QUERY TIME".equalsIgnoreCase(body)) {
            currentTime = LocalDateTime.now().toString();
        } else {
            currentTime = "BAD REQUIRE";
        }
        //加入拆包的LineBasedFrameDecoder之後,服務端返回給客戶端的報文中也要有分隔符,否則客戶端readComplete會呼叫多次,而不會去呼叫channelRead
        currentTime += System.getProperty("line.separator");
        resp = Unpooled.copiedBuffer(currentTime.getBytes());
    } catch (Exception e) {
        log.error("傳送訊息異常,異常資訊為:{}", e);
    }
    return resp;
}

上述程式碼加了16行,報文結尾增加系統分隔符

DelimiterBasedFrameDecoder解碼器

這個解碼器與LineBasedFrameDecoder解碼器的區別在於,DelimiterBasedFrameDecoder可以自定義分隔符,LineBasedFrameDecoder解碼器取的是系統預設的換行符。

private static final String delimiter_symbol = "@~";

serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        //只需要將DelimiterBasedFrameDecoder加入到channel中即可。並指定長度和我們自定義的分隔符
        socketChannel.pipeline().addLast(new DelimiterBasedFrameDecoder(1024,Unpooled.copiedBuffer(delimiter_symbol.getBytes("UTF-8"))));
        //這裡不再貼出 DelimiterCoderTimeServerHandler的程式碼,和之前的Handler一樣
        socketChannel.pipeline().addLast(new DelimiterCoderTimeServerHandler());
    }
});

上述程式碼第七行,直接將DelimiterBasedFrameDecoder加入到pipeline中即可(客戶端和服務端的程式碼都需要加入上述程式碼),同時指定分割符。服務端的處理類需要在返回報文中加入該指定的分隔符,這裡不再詳細貼出具體程式碼。

固定的報文長度

FixedLengthFrameDecoder就是固定長度的解碼器,也由Netty提供給我們,開箱即用,這裡就不囉嗦了,直接上程式碼

//這裡是服務端程式碼,客戶端指定命令的報文長度
private final int requestLength = ("QUERY TIME").getBytes().length;

serverBootstrap.handler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
        ch.pipeline().addLast(new FixedLengthFrameDecoder(requestLength));
        ch.pipeline().addLast(new FixLengthTimeClientHandler());
    }
});

這個時候為了測試,我們需要對例項作出一些改造,客戶端不能使用FixedLengthFrameDecoder解碼器,畢竟服務端給客戶端的報文長度並不固定,這裡還是約定系統固定的回車換行符即可,因此客戶端的程式碼只需要與LineBasedFrameDecoder例項中的程式碼一致即可。

總結

簡單總結了一下Netty中粘包和拆包的解決方案,歸納了一下Netty中開箱即用的,解決粘包拆包的解碼器。關於Netty的編解碼器其實又是另外一塊內容了,粘包拆包的問題,其實落到實處,也是利用編解碼器解決而已。關於通用的編解碼,如何自定義編解碼,如何利用編解碼器傳送序列化的物件,這個後續會繼續總結。

參考文件

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

TCP粘包拆包的產生原因分析及解決思路

《Netty權威指南》

相關文章