高性能IO框架Netty四 - 解決粘包/半包問題

目錄

前言:demo演示

執行結果

一、什麼是TCP粘包半包?

二、TCP粘包/半包發生的原因

三、解決粘包半包問題

1、在包尾增加分割符

2、消息定長

3、將消息分爲消息頭和消息體


前言:demo演示

首先,我們來看個demo

1、EchoServer

/**
 * 作者:DarkKing
 * 類說明:
 */
public class EchoServer {

    private final int port;

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

    public static void main(String[] args) throws InterruptedException {
        EchoServer echoServer = new EchoServer(9999);
        System.out.println("服務器即將啓動");
        echoServer.start();
        System.out.println("服務器關閉");
    }

    public void start() throws InterruptedException {
        final EchoServerHandler serverHandler = new EchoServerHandler();
        EventLoopGroup group = new NioEventLoopGroup();/*線程組*/
        try {
            ServerBootstrap b = new ServerBootstrap();/*服務端啓動必須*/
            b.group(group)/*將線程組傳入*/
                    .channel(NioServerSocketChannel.class)/*指定使用NIO進行網絡傳輸*/
                    .localAddress(new InetSocketAddress(port))/*指定服務器監聽端口*/
                    /*服務端每接收到一個連接請求,就會新啓一個socket通信,也就是channel,
                    所以下面這段代碼的作用就是爲這個子channel增加handle*/
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        protected void initChannel(SocketChannel ch) throws Exception {
                            ch.pipeline().addLast(serverHandler);/*添加到該子channel的pipeline的尾部*/
                        }
                    });
            ChannelFuture f = b.bind().sync();/*異步綁定到服務器,sync()會阻塞直到完成*/
            System.out.println("服務器啓動完成,等待客戶端的連接和數據.....");
            f.channel().closeFuture().sync();/*阻塞直到服務器的channel關閉*/
        } finally {
            group.shutdownGracefully().sync();/*優雅關閉線程組*/
        }
    }


}

2、EchoServerHandler

/**
 * 作者:DarkKing
 * 類說明:自己的業務處理
 */
@ChannelHandler.Sharable
public class EchoServerHandler extends ChannelInboundHandlerAdapter {

    private AtomicInteger counter = new AtomicInteger(0);

    /*** 服務端讀取到網絡數據後的處理*/
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        ByteBuf in = (ByteBuf) msg;
        String request = in.toString(CharsetUtil.UTF_8);
        System.out.println("Server Accept[" + request
                + "] and the counter is:" + counter.incrementAndGet());
        String resp = "Hello," + request + ". Welcome to Netty World!"
                + System.getProperty("line.separator");
        ctx.writeAndFlush(Unpooled.copiedBuffer(resp.getBytes()));

    }


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

使用netty實現了個服務端,當接收到客戶端的消息是,打印出來請求的內容,並統計接收請求的次數。

3、EchoClient

/**
 * 作者:DarkKing
 * 類說明:
 */
public class EchoClient {

    private final int port;
    private final String host;

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

    public void start() throws InterruptedException {
        EventLoopGroup group = new NioEventLoopGroup();/*線程組*/
        try {
            final Bootstrap b = new Bootstrap();
            /*客戶端啓動必須*/
            b.group(group)/*將線程組傳入*/
                    .channel(NioSocketChannel.class)/*指定使用NIO進行網絡傳輸*/
                    .remoteAddress(new InetSocketAddress(host, port))/*配置要連接服務器的ip地址和端口*/
                    .handler(new ChannelInitializer<SocketChannel>() {
                        protected void initChannel(SocketChannel ch) throws Exception {
                            ch.pipeline().addLast(new EchoClientHandler());
                        }
                    });
            ChannelFuture f = b.connect().sync();
            f.channel().closeFuture().sync();
        } finally {
            group.shutdownGracefully().sync();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new EchoClient(9999, "127.0.0.1").start();
    }
}

4、EchoClientHandler

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.util.CharsetUtil;

import java.util.concurrent.atomic.AtomicInteger;

/**
 * 作者:DarkKing
 * 類說明:
 */
public class EchoClientHandler extends SimpleChannelInboundHandler<ByteBuf> {

    private AtomicInteger counter = new AtomicInteger(0);

    /*** 客戶端讀取到網絡數據後的處理*/
    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception {
        System.out.println("client Accept[" + msg.toString(CharsetUtil.UTF_8)
                + "] and the counter is:" + counter.incrementAndGet());
    }

    /*** 客戶端被通知channel活躍後,做事*/
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        ByteBuf msg = null;
        String request = "test1,test2,test3,test4"
                + System.getProperty("line.separator");
        for (int i = 0; i < 100; i++) {
            msg = Unpooled.buffer(request.length());
            msg.writeBytes(request.getBytes());
            ctx.writeAndFlush(msg);
        }
    }

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

使用netty實現了個客戶端,鏈接建立完成之後向服務端發送消息。循環100次。並且打印服務端返回的消息。並統計返回次數。

執行結果

服務端輸出

客戶端打印

結果發現,我們客戶單發送了100次數據,但實際上只接收了30次。而且每次消息發送的是test1,test2,test3,test4,test5,但實際接受的卻有很多相鏈接起來的。這是爲什麼呢?爲什麼不是100次test1,test2,test3,test4,test5呢?這就是TCP傳輸的粘包/半包問題。

一、什麼是TCP粘包半包?

假設客戶端分別發送了兩個數據包D1和D2給服務端,由於服務端一次讀取到的字節數是不確定的,故可能存在以下4種情況。

  1. 服務端分兩次讀取到了兩個獨立的數據包,分別是D1和D2,沒有粘包和拆包;
  2. 服務端一次接收到了兩個數據包,D1和D2粘合在一起,被稱爲TCP粘包;
  3. 服務端分兩次讀取到了兩個數據包,第一次讀取到了完整的D1包和D2包的部分內容,第二次讀取到了D2包的剩餘內容,這被稱爲TCP拆包;
  4. 服務端分兩次讀取到了兩個數據包,第一次讀取到了D1包的部分內容D1_1,第二次讀取到了D1包的剩餘內容D1_2和D2包的整包。

如果此時服務端TCP接收滑窗非常小,而數據包D1和D2比較大,很有可能會發生第五種可能,即服務端分多次才能將D1和D2包接收完全,期間發生多次拆包。

二、TCP粘包/半包發生的原因

由於TCP協議本身的機制(面向連接的可靠地協議-三次握手機制)客戶端與服務器會維持一個連接(Channel),數據在連接不斷開的情況下,可以持續不斷地將多個數據包發往服務器,但是如果發送的網絡數據包太小,那麼他本身會啓用Nagle算法(可配置是否啓用)對較小的數據包進行合併(基於此,TCP的網絡延遲要UDP的高些)然後再發送(超時或者包大小足夠)。那麼這樣的話,服務器在接收到消息(數據流)的時候就無法區分哪些數據包是客戶端自己分開發送的,這樣產生了粘包;服務器在接收到數據庫後,放到緩衝區中,如果消息沒有被及時從緩存區取走,下次在取數據的時候可能就會出現一次取出多個數據包的情況,造成粘包現象

UDP:本身作爲無連接的不可靠的傳輸協議(適合頻繁發送較小的數據包),他不會對數據包進行合併發送(也就沒有Nagle算法之說了),他直接是一端發送什麼數據,直接就發出去了,既然他不會對數據合併,每一個數據包都是完整的(數據+UDP頭+IP頭等等發一次數據封裝一次)也就沒有粘包一說了。

分包產生的原因就簡單的多:可能是IP分片傳輸導致的,也可能是傳輸過程中丟失部分包導致出現的半包,還有可能就是一個包可能被分成了兩次傳輸,在取數據的時候,先取到了一部分(還可能與接收的緩衝區大小有關係),總之就是一個數據包被分成了多次接收。

更具體的原因有三個,分別如下。

  1. 應用程序寫入數據的字節大小大於套接字發送緩衝區的大小
  2. 進行MSS大小的TCP分段。MSS是最大報文段長度的縮寫。MSS是TCP報文段中的數據字段的最大長度。數據字段加上TCP首部纔等於整個的TCP報文段。所以MSS並不是TCP報文段的最大長度,而是:MSS=TCP報文段長度-TCP首部長
  3. 以太網的payload大於MTU進行IP分片。MTU指:一種通信協議的某一層上面所能通過的最大數據包大小。如果IP層有一個數據包要傳,而且數據的長度比鏈路層的MTU大,那麼IP層就會進行分片,把數據包分成託乾片,讓每一片都不超過MTU。注意,IP分片可以發生在原始發送端主機上,也可以發生在中間路由器上。

三、解決粘包半包問題

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

1、在包尾增加分割符

在包尾增加分割符,比如回車換行符進行分割,例如FTP協議;

demo如下:

LineBaseEchoServer 


/**
 * 作者:DarkKing
 * 類說明:
 */
public class LineBaseEchoServer {

    public static final int PORT = 9998;

    public static void main(String[] args) throws InterruptedException {
        LineBaseEchoServer lineBaseEchoServer = new LineBaseEchoServer();
        System.out.println("服務器即將啓動");
        lineBaseEchoServer.start();
    }

    public void start() throws InterruptedException {
        final LineBaseServerHandler serverHandler = new LineBaseServerHandler();
        EventLoopGroup group = new NioEventLoopGroup();/*線程組*/
        try {
            ServerBootstrap b = new ServerBootstrap();/*服務端啓動必須*/
            b.group(group)/*將線程組傳入*/
                    .channel(NioServerSocketChannel.class)/*指定使用NIO進行網絡傳輸*/
                    .localAddress(new InetSocketAddress(PORT))/*指定服務器監聽端口*/
                    /*服務端每接收到一個連接請求,就會新啓一個socket通信,也就是channel,
                    所以下面這段代碼的作用就是爲這個子channel增加handle*/
                    .childHandler(new ChannelInitializerImp());
            ChannelFuture f = b.bind().sync();/*異步綁定到服務器,sync()會阻塞直到完成*/
            System.out.println("服務器啓動完成,等待客戶端的連接和數據.....");
            f.channel().closeFuture().sync();/*阻塞直到服務器的channel關閉*/
        } finally {
            group.shutdownGracefully().sync();/*優雅關閉線程組*/
        }
    }

    private static class ChannelInitializerImp extends ChannelInitializer<Channel> {

        @Override
        protected void initChannel(Channel ch) throws Exception {
            //添加換行解碼器
            ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
            ch.pipeline().addLast(new LineBaseServerHandler());
        }
    }

}

 LineBaseEchoServer

/**
 * 作者:DarkKing
 * 類說明:
 */
public class LineBaseEchoServer {

    public static final int PORT = 9998;

    public static void main(String[] args) throws InterruptedException {
        LineBaseEchoServer lineBaseEchoServer = new LineBaseEchoServer();
        System.out.println("服務器即將啓動");
        lineBaseEchoServer.start();
    }

    public void start() throws InterruptedException {
        final LineBaseServerHandler serverHandler = new LineBaseServerHandler();
        EventLoopGroup group = new NioEventLoopGroup();/*線程組*/
        try {
            ServerBootstrap b = new ServerBootstrap();/*服務端啓動必須*/
            b.group(group)/*將線程組傳入*/
                    .channel(NioServerSocketChannel.class)/*指定使用NIO進行網絡傳輸*/
                    .localAddress(new InetSocketAddress(PORT))/*指定服務器監聽端口*/
                    /*服務端每接收到一個連接請求,就會新啓一個socket通信,也就是channel,
                    所以下面這段代碼的作用就是爲這個子channel增加handle*/
                    .childHandler(new ChannelInitializerImp());
            ChannelFuture f = b.bind().sync();/*異步綁定到服務器,sync()會阻塞直到完成*/
            System.out.println("服務器啓動完成,等待客戶端的連接和數據.....");
            f.channel().closeFuture().sync();/*阻塞直到服務器的channel關閉*/
        } finally {
            group.shutdownGracefully().sync();/*優雅關閉線程組*/
        }
    }

    private static class ChannelInitializerImp extends ChannelInitializer<Channel> {

        @Override
        protected void initChannel(Channel ch) throws Exception {
            //添加換行解碼器
            ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
            ch.pipeline().addLast(new LineBaseServerHandler());
        }
    }

}

 LineBaseEchoClient

/**
 * 作者:DarkKing
 */
public class LineBaseEchoClient {

    private final String host;

    public LineBaseEchoClient(String host) {
        this.host = host;
    }

    public void start() throws InterruptedException {
        EventLoopGroup group = new NioEventLoopGroup();/*線程組*/
        try {
            final Bootstrap b = new Bootstrap();
            b.group(group)/*將線程組傳入*/
                    .channel(NioSocketChannel.class)/*指定使用NIO進行網絡傳輸*/
                    .remoteAddress(new InetSocketAddress(host, LineBaseEchoServer.PORT))/*配置要連接服務器的ip地址和端口*/
                    .handler(new ChannelInitializerImp());
            ChannelFuture f = b.connect().sync();
            System.out.println("已連接到服務器.....");
            f.channel().closeFuture().sync();
        } finally {
            group.shutdownGracefully().sync();
        }
    }

    private static class ChannelInitializerImp extends ChannelInitializer<Channel> {

        @Override
        protected void initChannel(Channel ch) throws Exception {
            //回車符做了分割
            ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
            ch.pipeline().addLast(new LineBaseClientHandler());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new LineBaseEchoClient("127.0.0.1").start();
    }
}

LineBaseClientHandler



/**
 * 作者:DarkKing
 * 類說明:
 */
public class LineBaseClientHandler extends SimpleChannelInboundHandler<ByteBuf> {

    private AtomicInteger counter = new AtomicInteger(0);

    /*** 客戶端讀取到網絡數據後的處理*/
    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception {
        System.out.println("client Accept[" + msg.toString(CharsetUtil.UTF_8)
                + "] and the counter is:" + counter.incrementAndGet());
        ctx.close();
    }

    /*** 客戶端被通知channel活躍後,做事*/
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        ByteBuf msg = null;
        String request = "test1,test2,test3,test4,test5"
                + System.getProperty("line.separator");
        for (int i = 0; i < 10; i++) {
            Thread.sleep(500);
            System.out.println(System.currentTimeMillis() + ":即將發送數據:"
                    + request);
            msg = Unpooled.buffer(request.length());
            msg.writeBytes(request.getBytes());
            ctx.writeAndFlush(msg);
        }
    }

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

執行效果

2、消息定長

例如每個報文的大小爲固定長度200字節,如果不夠,空位補空格;

服務端只需將服務端的ChannelInitializerImp 解碼器new LineBasedFrameDecoder(1024)替換爲new FixedLengthFrameDecoder( FixedLengthEchoClient.REQUEST.length())即可。

 private static class ChannelInitializerImp extends ChannelInitializer<Channel> {

        @Override
        protected void initChannel(Channel ch) throws Exception {
            //添加定長報文長度解碼器,長度問請求的長度
            ch.pipeline().addLast(
                    new FixedLengthFrameDecoder(
                            FixedLengthEchoClient.REQUEST.length()));
            ch.pipeline().addLast(new FixedLengthServerHandler());
        }
    }

 

3、將消息分爲消息頭和消息體

消息頭中包含表示消息總長度(或者消息體長度)的字段,通常設計思路爲消息頭的第一個字段使用int32來表示消息的總長度。類似與第二條,只是我們按照頭部的content-length長度進行定長解碼。

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