(十)Netty進(jìn)階篇:漫談網(wǎng)絡(luò)粘包、半包問(wèn)題、解碼器與長(zhǎng)連接、心跳機(jī)制實(shí)戰(zhàn)

引言

在前面關(guān)于《Netty入門篇》的文章中,咱們已經(jīng)初步對(duì)Netty這個(gè)著名的網(wǎng)絡(luò)框架有了認(rèn)知,本章的目的則是承接上文,再對(duì)Netty中的一些進(jìn)階知識(shí)進(jìn)行闡述,畢竟前面的內(nèi)容中,僅闡述了一些Netty的核心組件,想要真正掌握Netty框架,對(duì)于它我們應(yīng)該具備更為全面的認(rèn)知。

一、Netty中的粘包半包問(wèn)題

實(shí)際上粘包、半包問(wèn)題,并不僅僅只在Netty中存在,但凡基于TCP協(xié)議構(gòu)建的網(wǎng)絡(luò)組件,基本都需要面臨這兩個(gè)問(wèn)題,對(duì)于粘包問(wèn)題,在之前關(guān)于《計(jì)算機(jī)網(wǎng)絡(luò)與協(xié)議簇-TCP沾包》中也曾講到過(guò):
[圖片上傳失敗...(image-da6ecd-1671678429584)]

但當(dāng)時(shí)我寫成了沾包,但實(shí)際上專業(yè)的術(shù)語(yǔ)解釋為:粘包,這里我糾正一下,接著再簡(jiǎn)單說(shuō)清楚粘包和半包的問(wèn)題:

粘包:這種現(xiàn)象就如同其名,指通信雙方中的一端發(fā)送了多個(gè)數(shù)據(jù)包,但在另一端則被讀取成了一個(gè)數(shù)據(jù)包,比如客戶端發(fā)送123、ABC兩個(gè)數(shù)據(jù)包,但服務(wù)端卻收成的卻是123ABC這一個(gè)數(shù)據(jù)包。造成這個(gè)問(wèn)題的本質(zhì)原因,在前面TCP的章節(jié)中講過(guò),這主要是因?yàn)?code>TPC為了優(yōu)化傳輸效率,將多個(gè)小包合并成一個(gè)大包發(fā)送,同時(shí)多個(gè)小包之間沒(méi)有界限分割造成的。

半包:指通信雙方中的一端發(fā)送一個(gè)大的數(shù)據(jù)包,但在另一端被讀取成了多個(gè)數(shù)據(jù)包,例如客戶端向服務(wù)端發(fā)送了一個(gè)數(shù)據(jù)包:ABCDEFGXYZ,而服務(wù)端則讀取成了ABCEFG、XYZ兩個(gè)包,這兩個(gè)包實(shí)際上都是一個(gè)數(shù)據(jù)包中的一部分,這個(gè)現(xiàn)象則被稱之為半包問(wèn)題(產(chǎn)生這種現(xiàn)象的原因在于:接收方的數(shù)據(jù)接收緩沖區(qū)過(guò)小導(dǎo)致的)。

上述提到的這兩種網(wǎng)絡(luò)通信的問(wèn)題具體該如何解決,這點(diǎn)咱們放到后面再細(xì)說(shuō),先來(lái)看看Netty中的沾包和半包問(wèn)題。

1.1、Netty的粘包、半包問(wèn)題演示

這里也就不多說(shuō)廢話了,結(jié)合《Netty入門篇》的知識(shí),快速搭建出一個(gè)服務(wù)端、客戶端的通信案例,如下:

// 演示數(shù)據(jù)粘包問(wèn)題的服務(wù)端
public class AdhesivePackageServer {

    public static void main(String[] args) {
        EventLoopGroup group = new NioEventLoopGroup();
        ServerBootstrap server = new ServerBootstrap();

        server.group(group);
        server.channel(NioServerSocketChannel.class);
        server.childHandler(new ServerInitializer());

        server.bind("127.0.0.1",8888);
    }
}

// 演示粘包、半包問(wèn)題的通用初始化器
public class ServerInitializer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        socketChannel.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
        socketChannel.pipeline().addLast(new ChannelInboundHandlerAdapter() {
            // 數(shù)據(jù)就緒事件:當(dāng)收到客戶端數(shù)據(jù)時(shí)會(huì)讀取通道內(nèi)的數(shù)據(jù)
            @Override
            public void channelReadComplete(ChannelHandlerContext ctx)
                    throws Exception {
                // 在這里直接輸出通道內(nèi)的數(shù)據(jù)信息
                System.out.println(ctx.channel());
                super.channelReadComplete(ctx);
            }
        });
    }
}

// 演示數(shù)據(jù)粘包問(wèn)題的客戶端
public class AdhesivePackageClient {

    public static void main(String[] args) {
        EventLoopGroup worker = new NioEventLoopGroup();
        Bootstrap client = new Bootstrap();
        try {
            client.group(worker);
            client.channel(NioSocketChannel.class);
            client.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                        // 在通道準(zhǔn)備就緒后會(huì)觸發(fā)的事件
                        @Override
                        public void channelActive(ChannelHandlerContext ctx) 
                                                            throws Exception {
                            // 向服務(wù)端發(fā)送十次數(shù)據(jù),每次發(fā)送一個(gè)字節(jié)!
                            for (int i = 0; i < 10; i++) {
                                System.out.println("正在向服務(wù)端發(fā)送第"+ 
                                                        i +"次數(shù)據(jù)......");
                                ByteBuf buffer = ctx.alloc().buffer(1);
                                buffer.writeBytes(new byte[]{(byte) i});
                                ctx.writeAndFlush(buffer);
                            }
                        }
                    });
                }
            });
            client.connect("127.0.0.1", 8888).sync();
        } catch (Exception e){
            e.printStackTrace();
        } finally {
            worker.shutdownGracefully();
        }
    }
}
復(fù)制代碼

這個(gè)案例中的代碼也并不難理解,客戶端的代碼中,會(huì)向服務(wù)端發(fā)送十次數(shù)據(jù),而服務(wù)端僅僅只做了數(shù)據(jù)讀取的動(dòng)作而已,接著來(lái)看看運(yùn)行結(jié)果:
[圖片上傳失敗...(image-c1d075-1671678429582)]

從運(yùn)行結(jié)果中可明顯觀測(cè)到,客戶端發(fā)送的十個(gè)1Bytes的數(shù)據(jù)包,在服務(wù)端直接被合并成了一個(gè)10Bytes的數(shù)據(jù)包,這顯然就是粘包的現(xiàn)象,接著再來(lái)看看半包的問(wèn)題,代碼如下:

// 演示半包問(wèn)題的服務(wù)端
public class HalfPackageServer {

    public static void main(String[] args) {
        EventLoopGroup group = new NioEventLoopGroup();
        ServerBootstrap server = new ServerBootstrap();

        server.group(group);
        server.channel(NioServerSocketChannel.class);
        // 調(diào)整服務(wù)端的接收窗口大小為四字節(jié)
        server.option(ChannelOption.SO_RCVBUF,4);
        server.childHandler(new ServerInitializer());
        server.bind("127.0.0.1",8888);
    }
}

// 演示半包問(wèn)題的客戶端
public class HalfPackageClient {
    public static void main(String[] args) {
        EventLoopGroup worker = new NioEventLoopGroup();
        Bootstrap client = new Bootstrap();
        try {
            client.group(worker);
            client.channel(NioSocketChannel.class);
            client.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                        // 在通道準(zhǔn)備就緒后會(huì)觸發(fā)的事件
                        @Override
                        public void channelActive(ChannelHandlerContext ctx)
                                throws Exception {
                            // 向服務(wù)端發(fā)送十次數(shù)據(jù),每次發(fā)送十個(gè)字節(jié)!
                            for (int i = 0; i < 10; i++) {
                                ByteBuf buffer = ctx.alloc().buffer();
                                buffer.writeBytes(new byte[]
                                        {'a','b','c','d','e','f','g','x','y','z'});
                                ctx.writeAndFlush(buffer);
                            }
                        }
                    });
                }
            });
            client.connect("127.0.0.1", 8888).sync();
        } catch (Exception e){
            e.printStackTrace();
        } finally {
            worker.shutdownGracefully();
        }
    }
}-
復(fù)制代碼

上面的代碼中,客戶端向服務(wù)端發(fā)送了十次數(shù)據(jù),每次數(shù)據(jù)會(huì)發(fā)送10個(gè)字節(jié),而在服務(wù)端多加了下述這行代碼:

server.option(ChannelOption.SO_RCVBUF,4);
復(fù)制代碼

這行代碼的作用是調(diào)整服務(wù)端的接收窗口大小為四字節(jié),因?yàn)槟J(rèn)的接收窗口較大,客戶端需要一次性發(fā)送大量數(shù)據(jù)才能演示出半包現(xiàn)象,這里為了便于演示,因此將接收窗口調(diào)小,運(yùn)行結(jié)果如下:
[圖片上傳失敗...(image-dc21e5-1671678429582)]

從上述運(yùn)行結(jié)果中,也能夠明顯觀察到半包現(xiàn)象,客戶端發(fā)送的十個(gè)數(shù)據(jù)包,每個(gè)包中的數(shù)據(jù)都為10字節(jié),但服務(wù)端中,接收到的數(shù)據(jù)顯然并不符合預(yù)期,尤其是第三個(gè)數(shù)據(jù)包,是一個(gè)不折不扣的半包現(xiàn)象。

1.2、粘包、半包問(wèn)題的產(chǎn)生原因

前面簡(jiǎn)單聊了一下粘包、半包問(wèn)題,但這些問(wèn)題究竟是什么原因?qū)е碌哪兀繉?duì)于這點(diǎn)前面并未深入探討,這里來(lái)做統(tǒng)一講解,想要弄明白粘包、半包問(wèn)題的產(chǎn)生原因,這還得說(shuō)回TCP協(xié)議,大家還記得之前說(shuō)過(guò)的TCP-滑動(dòng)窗口嘛?
[圖片上傳失敗...(image-91c0e5-1671678429582)]

1.2.1、TCP協(xié)議的滑動(dòng)窗口

由于TCP是一種可靠性傳輸協(xié)議,所以在網(wǎng)絡(luò)通信過(guò)程中,會(huì)采用一問(wèn)一答的形式,也就是一端發(fā)送數(shù)據(jù)后,必須得到另一端返回ACK響應(yīng)后,才會(huì)繼續(xù)發(fā)送后續(xù)的數(shù)據(jù)。但這種一問(wèn)一答的同步方式,顯然會(huì)十分影響數(shù)據(jù)的傳輸效率。

TCP協(xié)議為了解決傳輸效率的問(wèn)題,引入了一種名為滑動(dòng)窗口的技術(shù),也就是在發(fā)送方和接收方上各有一個(gè)緩沖區(qū),這個(gè)緩沖區(qū)被稱為“窗口”,假設(shè)發(fā)送方的窗口大小為100KB,那么發(fā)送端的前100KB數(shù)據(jù),無(wú)需等待接收端返回ACK,可以一直發(fā)送,直到發(fā)滿100KB數(shù)據(jù)為止。

如果發(fā)送端在發(fā)送前100KB數(shù)據(jù)時(shí),接收端返回了某個(gè)數(shù)據(jù)包的ACK,那此時(shí)發(fā)送端的窗口會(huì)一直向下滑動(dòng),比如最初窗口范圍是0~100KB,收到ACK后會(huì)滑動(dòng)到20~120KB、120~220KB....(實(shí)際上窗口的大小、范圍,TCP會(huì)根據(jù)網(wǎng)絡(luò)擁塞程度、ACK響應(yīng)時(shí)間等情況來(lái)自動(dòng)調(diào)整)。

同時(shí),除開(kāi)發(fā)送方有窗口外,接收方也會(huì)有一個(gè)窗口,接收方只會(huì)讀取窗口范圍之內(nèi)的數(shù)據(jù),如果超出窗口范圍的數(shù)據(jù)并不會(huì)讀取,這也就意味著不會(huì)對(duì)窗口之外的數(shù)據(jù)包返回ACK,所以發(fā)送方在未收到ACK時(shí),對(duì)應(yīng)的窗口會(huì)停止向后滑動(dòng),并在一定時(shí)間后對(duì)未返回ACK的數(shù)據(jù)進(jìn)行重發(fā)。

對(duì)于TCP的滑動(dòng)窗口,發(fā)送方的窗口起到優(yōu)化傳輸效率的作用,而接收端的窗口起到流量控制的作用。

1.2.2、傳輸層的MSS與鏈路層的MTU

理解了滑動(dòng)窗口的概念后,接著來(lái)說(shuō)說(shuō)MSS、MTU這兩個(gè)概念,MSS是傳輸層的最大報(bào)文長(zhǎng)度限制,而MTU則是鏈路層的最大數(shù)據(jù)包大小限制,一般MTU會(huì)限制MSS,比如MTU=1500,那么MSS最大只能為1500減去報(bào)文頭長(zhǎng)度,以TCP協(xié)議為例,MSS最大為1500-40=1460

為什么需要這個(gè)限制呢?這是由于網(wǎng)絡(luò)設(shè)備硬件導(dǎo)致的,比如任意類型的網(wǎng)卡,不可能讓一個(gè)數(shù)據(jù)包無(wú)限增長(zhǎng),因?yàn)榫W(wǎng)卡會(huì)有帶寬限制,比如一次性傳輸一個(gè)1GB的數(shù)據(jù)包,如果不限制大小直接發(fā)送,這會(huì)導(dǎo)致網(wǎng)絡(luò)出現(xiàn)堵塞,并且超出網(wǎng)絡(luò)硬件設(shè)備單次傳輸?shù)淖畲笙拗啤?/p>

所以當(dāng)一個(gè)數(shù)據(jù)包,超出MSS大小時(shí),TCP協(xié)議會(huì)自動(dòng)切割這個(gè)數(shù)據(jù)包,將該數(shù)據(jù)包拆分成一個(gè)個(gè)的小包,然后分批次進(jìn)行傳輸,從而實(shí)現(xiàn)大文件的傳輸。

1.2.3、TCP協(xié)議的Nagle算法

基于MSS最大報(bào)文限制,可以實(shí)現(xiàn)大文件的切割并分批發(fā)送,但在網(wǎng)絡(luò)通信中,還有另一種特殊情況,即是極小的數(shù)據(jù)包傳輸,因?yàn)?code>TCP的報(bào)文頭默認(rèn)會(huì)有40個(gè)字節(jié),如果數(shù)據(jù)只有1字節(jié),那加上報(bào)文頭依舊會(huì)產(chǎn)生一個(gè)41字節(jié)的數(shù)據(jù)包。

如果這種體積較小的數(shù)據(jù)包在傳輸中經(jīng)常出現(xiàn),這定然會(huì)導(dǎo)致網(wǎng)絡(luò)資源的浪費(fèi),畢竟數(shù)據(jù)包中只有1字節(jié)是數(shù)據(jù),另外40個(gè)字節(jié)是報(bào)文頭,如果出現(xiàn)1W個(gè)這樣的數(shù)據(jù)包,也就意味著會(huì)產(chǎn)生400MB的報(bào)文頭,但實(shí)際數(shù)據(jù)只占10MB,這顯然是不妥當(dāng)?shù)摹?/p>

正是由于上述原因,因此TCP協(xié)議中引入了一種名為Nagle的算法,如若連續(xù)幾次發(fā)送的數(shù)據(jù)都很小,TCP會(huì)根據(jù)算法把多個(gè)數(shù)據(jù)合并成一個(gè)包發(fā)出,從而優(yōu)化網(wǎng)絡(luò)傳輸?shù)男剩⑶覝p少對(duì)資源的占用。

1.2.4、應(yīng)用層的接收緩沖區(qū)和發(fā)送緩沖區(qū)

對(duì)于操作系統(tǒng)的IO函數(shù)而言,網(wǎng)絡(luò)數(shù)據(jù)不管是發(fā)送也好,還是接收也罷,并不會(huì)采用“復(fù)制”的方式工作,比如現(xiàn)在想要傳輸一個(gè)10MB的數(shù)據(jù),不可能直接將這個(gè)數(shù)據(jù)一次性拷貝到緩沖區(qū)內(nèi),而是一個(gè)一個(gè)字節(jié)進(jìn)行傳輸,舉個(gè)例子:

假設(shè)現(xiàn)在要發(fā)送ABCDEFGXYZ....這組數(shù)據(jù),IO函數(shù)會(huì)挨個(gè)將每個(gè)字節(jié)放到發(fā)送緩沖區(qū)中,會(huì)呈現(xiàn)A、B、C、D、E、F....這個(gè)順序挨個(gè)寫入,而接收方依舊如此,讀取數(shù)據(jù)時(shí)也會(huì)一個(gè)個(gè)字節(jié)讀取,以A、B、C、D、E、F....這個(gè)順序讀取一個(gè)數(shù)據(jù)包中的數(shù)據(jù)(實(shí)際情況會(huì)復(fù)雜一些,可能會(huì)按一定單位操作數(shù)據(jù),而并不是以單個(gè)字節(jié)作為單位)。

而應(yīng)用程序?yàn)榱税l(fā)送/接收數(shù)據(jù),通常都需要具備兩個(gè)緩沖區(qū),即所說(shuō)的接收緩沖區(qū)和發(fā)送緩沖區(qū),一個(gè)用來(lái)暫存要發(fā)送的數(shù)據(jù),另一個(gè)則用來(lái)暫存接收到的數(shù)據(jù),同時(shí)這兩個(gè)緩沖區(qū)的大小,可自行調(diào)整其大小(Netty默認(rèn)的接收/發(fā)送緩沖區(qū)大小為1024KB)。

1.2.5、粘包、半包問(wèn)題的產(chǎn)生原因

理解了上述幾個(gè)概念后,接著再來(lái)看看粘包和半包就容易很多了,粘包和半包問(wèn)題,可能會(huì)由多方面因素導(dǎo)致,如下:

  • 粘包:發(fā)送12345、ABCDE兩個(gè)數(shù)據(jù)包,被接收成12345ABCDE一個(gè)數(shù)據(jù)包,多個(gè)包粘在一起。
    • 應(yīng)用層:接收方的接收緩沖區(qū)太大,導(dǎo)致讀取多個(gè)數(shù)據(jù)包一起輸出。
    • TCP滑動(dòng)窗口:接收方窗口較大,導(dǎo)致發(fā)送方發(fā)出多個(gè)數(shù)據(jù)包,處理不及時(shí)造成粘包。
    • Nagle算法:由于發(fā)送方的數(shù)據(jù)包體積過(guò)小,導(dǎo)致多個(gè)數(shù)據(jù)包合并成一個(gè)包發(fā)送。
  • 半包:發(fā)送12345ABCDE一個(gè)數(shù)據(jù)包,被接收成12345、ABCDE兩個(gè)數(shù)據(jù)包,一個(gè)包拆成多個(gè)。
    • 應(yīng)用層:接收方緩沖區(qū)太小,無(wú)法存方發(fā)送方的單個(gè)數(shù)據(jù)包,因此拆開(kāi)讀取。
    • 滑動(dòng)窗口:接收方的窗口太小,無(wú)法一次性放下完整數(shù)據(jù)包,只能讀取其中一部分。
    • MSS限制:發(fā)送方的數(shù)據(jù)包超過(guò)MSS限制,被拆分為多個(gè)數(shù)據(jù)包發(fā)送。

上述即是出現(xiàn)粘包、半包問(wèn)題的根本原因,更多的是由于TCP協(xié)議造成的,所以想要解決這兩個(gè)問(wèn)題,就得自己重寫底層的TCP協(xié)議,這對(duì)于咱們而言并不現(xiàn)實(shí),畢竟TCP/IP協(xié)議棧,基本涵蓋各式各樣的網(wǎng)絡(luò)設(shè)備,想要從根源上解決粘包、半包問(wèn)題,重寫協(xié)議后還得替換掉所有網(wǎng)絡(luò)設(shè)備內(nèi)部的TCP實(shí)現(xiàn),目前世界上沒(méi)有任何一個(gè)組織、企業(yè)、個(gè)人具備這樣的影響力。

1.3、粘包、半包問(wèn)題的解決方案

既然無(wú)法在底層從根源上解決問(wèn)題,那此時(shí)可以換個(gè)思路,也就是從應(yīng)用層出發(fā),粘包、半包問(wèn)題都是由于數(shù)據(jù)包與包之間,沒(méi)有邊界分割導(dǎo)致的,那想要解決這樣的問(wèn)題,發(fā)送方可以在每個(gè)數(shù)據(jù)包的尾部,自己拼接一個(gè)特殊分隔符,接收方讀取到數(shù)據(jù)時(shí),再根據(jù)對(duì)應(yīng)的分隔符讀取數(shù)據(jù)即可。

對(duì)于其他的一些網(wǎng)絡(luò)編程的技術(shù)棧,咱們不做過(guò)多延伸,重點(diǎn)來(lái)聊一聊Netty中的粘包、半包問(wèn)題該如何解決呢?其實(shí)這也并不需要自己動(dòng)手解決,因?yàn)?code>Netty內(nèi)部早已內(nèi)置了相關(guān)實(shí)現(xiàn),畢竟我們能想到的問(wèn)題,框架的設(shè)計(jì)者也早已料到,接著一起來(lái)看看Netty的解決方案吧。

1.3.1、使用短連接解決粘包問(wèn)題

對(duì)于短連接大家應(yīng)該都不陌生,HTTP/1.0版本中,默認(rèn)使用的就是TCP短連接,這是指客戶端在發(fā)送一次數(shù)據(jù)后,就會(huì)立馬斷開(kāi)與服務(wù)端的網(wǎng)絡(luò)連接,在客戶端斷開(kāi)連接后,服務(wù)端會(huì)收到一個(gè)-1的狀態(tài)碼,而咱們可以用這個(gè)作為消息(數(shù)據(jù))的邊界,以此區(qū)分不同的數(shù)據(jù)包,如下:

// 演示通過(guò)短連接解決粘包問(wèn)題的服務(wù)端
public class AdhesivePackageServer {

    public static void main(String[] args) {
        EventLoopGroup group = new NioEventLoopGroup();
        ServerBootstrap server = new ServerBootstrap();

        server.group(group);
        server.channel(NioServerSocketChannel.class);
        server.childHandler(new ServerInitializer());

        server.bind("127.0.0.1",8888);
    }
}

// 演示通過(guò)短連接解決粘包問(wèn)題的客戶端
public class Client {

    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            sendData();
        }
    }

    private static void sendData(){
        EventLoopGroup worker = new NioEventLoopGroup();
        Bootstrap client = new Bootstrap();
        try {
            client.group(worker);
            client.channel(NioSocketChannel.class);
            client.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                        // 在通道準(zhǔn)備就緒后會(huì)觸發(fā)的事件
                        @Override
                        public void channelActive(ChannelHandlerContext ctx)
                                throws Exception {
                            // 向服務(wù)端發(fā)送一個(gè)20字節(jié)的數(shù)據(jù)包,然后斷開(kāi)連接
                            ByteBuf buffer = ctx.alloc().buffer(1);
                            buffer.writeBytes(new byte[]
                                        {'0','1','2','3','4',
                                        '5','6','7','8','9',
                                        'A','B','C','D','E',
                                        'M','N','X','Y','Z'});
                            ctx.writeAndFlush(buffer);
                            ctx.channel().close();
                        }
                    });
                }
            });
            client.connect("127.0.0.1", 8888).sync();
        } catch (Exception e){
            e.printStackTrace();
        } finally {
            worker.shutdownGracefully();
        }
    }
}
復(fù)制代碼

服務(wù)端的代碼,依舊用之前演示粘包問(wèn)題的AdhesivePackageServer,上述只對(duì)客戶端的代碼進(jìn)行了改造,主要是將創(chuàng)建客戶端連接、發(fā)送數(shù)據(jù)的代碼抽象成了一個(gè)方法,然后在循環(huán)內(nèi)部調(diào)用該方法,運(yùn)行結(jié)果如下:
[圖片上傳失敗...(image-a39c54-1671678429582)]

從運(yùn)行結(jié)果中可以看出,發(fā)送的3個(gè)數(shù)據(jù)包,都未出現(xiàn)粘包問(wèn)題,每個(gè)數(shù)據(jù)包之間都是獨(dú)立分割的。但這種方式解決粘包問(wèn)題,實(shí)際上屬于一種“投機(jī)取巧”的方案,畢竟每個(gè)數(shù)據(jù)包都采用新的連接發(fā)送,在操作系統(tǒng)級(jí)別來(lái)看,每個(gè)數(shù)據(jù)包都源自于不同的網(wǎng)絡(luò)套接字,自然會(huì)分開(kāi)讀取。

但這種方式無(wú)法解決半包問(wèn)題,例如這里咱們將服務(wù)端的接收緩沖區(qū)調(diào)小:

// 演示半包問(wèn)題的服務(wù)端
public class HalfPackageServer {

    public static void main(String[] args) {
        EventLoopGroup group = new NioEventLoopGroup();
        ServerBootstrap server = new ServerBootstrap();

        server.group(group);
        server.channel(NioServerSocketChannel.class);
        // 調(diào)整服務(wù)端的接收緩沖區(qū)大小為16字節(jié)(最小為16,無(wú)法設(shè)置更小)
        server.childOption(ChannelOption.RCVBUF_ALLOCATOR,
                new AdaptiveRecvByteBufAllocator(16,16,16));
        server.childHandler(new ServerInitializer());
        server.bind("127.0.0.1",8888);
    }
}
復(fù)制代碼

然后再啟動(dòng)這個(gè)服務(wù)端,接著再啟動(dòng)前面的客戶端,效果如下:
[圖片上傳失敗...(image-b7ca1d-1671678429582)]

從結(jié)果中依舊會(huì)發(fā)現(xiàn),多個(gè)數(shù)據(jù)包之間還是發(fā)生了半包問(wèn)題,因?yàn)榉?wù)端的接收緩沖區(qū)一次性最大只能存下16Bytes數(shù)據(jù),所以客戶端每次發(fā)送20Bytes數(shù)據(jù),無(wú)法全部存入緩沖區(qū),最終就出現(xiàn)了一個(gè)數(shù)據(jù)包被拆成多個(gè)包讀取。

正由于短連接這種方式,無(wú)法很好的解決半包問(wèn)題,所以一般線上除開(kāi)特殊場(chǎng)景外,否則不會(huì)使用短連接這種形式來(lái)單獨(dú)解決粘包問(wèn)題,接著看看Netty中提供的一些解決方案。

1.3.2、定長(zhǎng)幀解碼器

前面聊到的短連接方式,解決粘包問(wèn)題的思路屬于投機(jī)取巧行為,同時(shí)也需要頻繁的建立/斷開(kāi)連接,這無(wú)論是從資源利用率、還是程序執(zhí)行的效率上來(lái)說(shuō),都并不妥當(dāng),而Netty中提供了一系列解決粘包、半包問(wèn)題的實(shí)現(xiàn)類,即Netty的幀解碼器,先來(lái)看看定長(zhǎng)幀解碼器,案例如下:

// 通過(guò)定長(zhǎng)幀解碼器解決粘包、半包問(wèn)題的演示類
public class FixedLengthFrameDecoderDemo {

    public static void main(String[] args) {
        // 通過(guò)Netty提供的測(cè)試通道來(lái)代替服務(wù)端、客戶端
        EmbeddedChannel channel = new EmbeddedChannel(
                // 添加一個(gè)定長(zhǎng)幀解碼器(每條數(shù)據(jù)以8字節(jié)為單位拆包)
                new FixedLengthFrameDecoder(8),
                new LoggingHandler(LogLevel.DEBUG)
        );

        // 調(diào)用三次發(fā)送數(shù)據(jù)的方法(等價(jià)于向服務(wù)端發(fā)送三次數(shù)據(jù))
        sendData(channel,"ABCDEGF",8);
        sendData(channel,"XYZ",8);
        sendData(channel,"12345678",8);
    }

    private static void sendData(EmbeddedChannel channel, String data, int len){
        //  獲取發(fā)送數(shù)據(jù)的字節(jié)長(zhǎng)度
        byte[] bytes = data.getBytes();
        int dataLength = bytes.length;

        // 根據(jù)固定長(zhǎng)度補(bǔ)齊要發(fā)送的數(shù)據(jù)
        String alignString = "";
        if (dataLength < len){
            int alignLength = len - bytes.length;
            for (int i = 1; i <= alignLength; i++) {
                alignString = alignString + "*";
            }
        }

        // 拼接上補(bǔ)齊字符,得到最終要發(fā)送的消息數(shù)據(jù)
        String msg = data + alignString;
        byte[] msgBytes = msg.getBytes();

        // 構(gòu)建緩沖區(qū),通過(guò)channel發(fā)送數(shù)據(jù)
        ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8);
        buffer.writeBytes(msgBytes);
        channel.writeInbound(buffer);
    }
}
復(fù)制代碼

注意看上述這個(gè)案例,在其中就并未搭建服務(wù)端、客戶端了,而是采用EmbeddedChannel對(duì)象來(lái)測(cè)試,這個(gè)通道是Netty提供的測(cè)試通道,可以基于它來(lái)快速搭建測(cè)試用例,上述中的:

new EmbeddedChannel(
    new FixedLengthFrameDecoder(8),
    new LoggingHandler(LogLevel.DEBUG)
);
復(fù)制代碼

這段代碼,就類似于之前在服務(wù)端的pipeline添加處理器的過(guò)程,等價(jià)于下述這段代碼:

socketChannel.pipeline().addLast(new FixedLengthFrameDecoder(8));
socketChannel.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
復(fù)制代碼

理解了EmbeddedChannel后,接著先來(lái)看看運(yùn)行結(jié)果,如下:
[圖片上傳失敗...(image-6ad4ef-1671678429581)]

注意看上述結(jié)果,在該案例中,服務(wù)端會(huì)以8Bytes為單位,然后對(duì)數(shù)據(jù)進(jìn)行分包處理,平均每讀取8Bytes數(shù)據(jù),就會(huì)將其當(dāng)作一個(gè)數(shù)據(jù)包。如果客戶端發(fā)送的一條數(shù)據(jù),長(zhǎng)度沒(méi)有8個(gè)字節(jié),在sendData()方法中則會(huì)以*號(hào)補(bǔ)齊。比如上圖中,發(fā)送了一條XYZ數(shù)據(jù),因?yàn)殚L(zhǎng)度只有3字節(jié),所以會(huì)再拼接五個(gè)*號(hào)補(bǔ)齊八字節(jié)的長(zhǎng)度。

這種采用固定長(zhǎng)度解析數(shù)據(jù)的方式,的確能夠有效避免粘包、半包問(wèn)題的出現(xiàn),因?yàn)槊總€(gè)數(shù)據(jù)包之間,會(huì)以八個(gè)字節(jié)的長(zhǎng)度作為界限,然后分割數(shù)據(jù)。但這種方式也存在三個(gè)致命缺陷:

  • ①只適用于傳輸固定長(zhǎng)度范圍內(nèi)的數(shù)據(jù)場(chǎng)景,而且客戶端在發(fā)送數(shù)據(jù)前,還需自己根據(jù)長(zhǎng)度補(bǔ)齊數(shù)據(jù)。
  • ②如果發(fā)送的數(shù)據(jù)超出固定長(zhǎng)度,服務(wù)端依舊會(huì)按固定長(zhǎng)度分包,所以仍然會(huì)存在半包問(wèn)題。
  • ③對(duì)于未達(dá)到固定長(zhǎng)度的數(shù)據(jù),還需要額外傳輸補(bǔ)齊的*號(hào)字符,會(huì)占用不必要的網(wǎng)絡(luò)資源。

1.3.3、行幀解碼器

上面說(shuō)到的定長(zhǎng)幀解碼器,由于使用時(shí)存在些許限制,使用它來(lái)解析數(shù)據(jù)就并不那么靈活,尤其是針對(duì)于一些數(shù)據(jù)長(zhǎng)度可變的場(chǎng)景,顯得就有些許乏力,因此Netty中還提供了行幀解碼器,案例如下:

// 通過(guò)行幀解碼器解決粘包、半包問(wèn)題的演示類
public class LineFrameDecoderDemo {
    public static void main(String[] args) {
        // 通過(guò)Netty提供的測(cè)試通道來(lái)代替服務(wù)端、客戶端
        EmbeddedChannel channel = new EmbeddedChannel(
            // 添加一個(gè)行幀解碼器(在超出1024后還未檢測(cè)到換行符,就會(huì)停止讀取)
            new LineBasedFrameDecoder(1024),
            new LoggingHandler(LogLevel.DEBUG)
        );

        // 調(diào)用三次發(fā)送數(shù)據(jù)的方法(等價(jià)于向服務(wù)端發(fā)送三次數(shù)據(jù))
        sendData(channel,"ABCDEGF");
        sendData(channel,"XYZ");
        sendData(channel,"12345678");
    }

    private static void sendData(EmbeddedChannel channel, String data){
        // 在要發(fā)送的數(shù)據(jù)結(jié)尾,拼接上一個(gè)\n換行符(\r\n也可以)
        String msg = data + "\n";
        //  獲取發(fā)送數(shù)據(jù)的字節(jié)長(zhǎng)度
        byte[] msgBytes = msg.getBytes();

        // 構(gòu)建緩沖區(qū),通過(guò)channel發(fā)送數(shù)據(jù)
        ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8);
        buffer.writeBytes(msgBytes);
        channel.writeInbound(buffer);
    }
}
復(fù)制代碼

在上述案例中,咱們給服務(wù)端添加了一個(gè)LineBasedFrameDecoder(1024)行解碼器,其中有個(gè)1024的數(shù)字,這是啥意思呢?這個(gè)是數(shù)據(jù)的最大長(zhǎng)度限制,畢竟在網(wǎng)絡(luò)接收過(guò)程中,如果一直沒(méi)有讀取到換行符,總不能一直接收下去,所以當(dāng)數(shù)據(jù)的長(zhǎng)度超出該值后,Netty會(huì)默認(rèn)將前面讀到的數(shù)據(jù)分成一個(gè)數(shù)據(jù)包。

同時(shí)在發(fā)送數(shù)據(jù)的sendData()方法中,這回就無(wú)需咱們自己補(bǔ)齊數(shù)據(jù)了,只需在每個(gè)要發(fā)送的數(shù)據(jù)末尾,手動(dòng)拼接上一個(gè)\n\r\n換行符即可,服務(wù)端在讀取數(shù)據(jù)時(shí),會(huì)按換行符來(lái)作為界限分割,運(yùn)行結(jié)果如下:
[圖片上傳失敗...(image-67deb2-1671678429581)]

從結(jié)果中能夠看出,每個(gè)數(shù)據(jù)包都是按客戶端發(fā)送的格式做了解析,并未出現(xiàn)粘包、半包現(xiàn)象。

1.3.4、分隔符幀解碼器

上面聊了以換行符作為分隔符的解碼器,但Netty中還提供了自定義分隔符的解碼器,使用這種解碼器,能讓諸位隨心所欲的定義自己的分隔符,案例如下:

public class DelimiterFrameDecoderDemo {
    public static void main(String[] args) {
        // 自定義一個(gè)分隔符(記得要用ByteBuf對(duì)象來(lái)包裝)
        ByteBuf delimiter = ByteBufAllocator.DEFAULT.buffer(1);
        delimiter.writeByte('*');

        // 通過(guò)Netty提供的測(cè)試通道來(lái)代替服務(wù)端、客戶端
        EmbeddedChannel channel = new EmbeddedChannel(
                // 添加一個(gè)分隔符幀解碼器(傳入自定義的分隔符)
                new DelimiterBasedFrameDecoder(1024,delimiter),
                new LoggingHandler(LogLevel.DEBUG)
        );

        // 調(diào)用三次發(fā)送數(shù)據(jù)的方法(等價(jià)于向服務(wù)端發(fā)送三次數(shù)據(jù))
        sendData(channel,"ABCDEGF");
        sendData(channel,"XYZ");
        sendData(channel,"12345678");
    }

    private static void sendData(EmbeddedChannel channel, String data){
        // 在要發(fā)送的數(shù)據(jù)結(jié)尾,拼接上一個(gè)*號(hào)(因?yàn)榍懊孀远x的分隔符為*號(hào))
        String msg = data + "*";
        //  獲取發(fā)送數(shù)據(jù)的字節(jié)長(zhǎng)度
        byte[] msgBytes = msg.getBytes();

        // 構(gòu)建緩沖區(qū),通過(guò)channel發(fā)送數(shù)據(jù)
        ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8);
        buffer.writeBytes(msgBytes);
        channel.writeInbound(buffer);
    }
}
復(fù)制代碼

這個(gè)案例的運(yùn)行結(jié)果與上一個(gè)完全相同,不同點(diǎn)則在于換了一個(gè)解碼器,換成了:

new DelimiterBasedFrameDecoder(1024,delimiter)
復(fù)制代碼

而后發(fā)送數(shù)據(jù)的時(shí)候,對(duì)每個(gè)數(shù)據(jù)的結(jié)尾,手動(dòng)拼接一個(gè)*號(hào)作為分隔符即可。

相較于原本的定長(zhǎng)解碼器,行解碼器、自定義分隔符解碼器顯然更加靈活,因?yàn)橹С挚勺冮L(zhǎng)度的數(shù)據(jù),但這兩種解碼器,依舊存在些許缺點(diǎn):

  • ①對(duì)于每一個(gè)讀取到的字節(jié)都需要判斷一下:是否為結(jié)尾的分隔符,這會(huì)影響整體性能。
  • ②依舊存在最大長(zhǎng)度限制,當(dāng)數(shù)據(jù)超出最大長(zhǎng)度后,會(huì)自動(dòng)將其分包,在數(shù)據(jù)傳輸量較大的情況下,依舊會(huì)導(dǎo)致半包現(xiàn)象出現(xiàn)。

1.3.5、LTC幀解碼器

前面聊過(guò)的多個(gè)解碼器中,無(wú)論是哪個(gè),都多多少少會(huì)存在些許不完美,因此Netty最終提供了一款LTC解碼器,這個(gè)解碼器也屬于實(shí)際Netty開(kāi)發(fā)中,應(yīng)用最為廣泛的一種,但理解起來(lái)略微有些復(fù)雜,先來(lái)看看它的構(gòu)造方法:

public class LengthFieldBasedFrameDecoder extends ByteToMessageDecoder {
    public LengthFieldBasedFrameDecoder(
            int maxFrameLength, 
            int lengthFieldOffset, 
            int lengthFieldLength, 
            int lengthAdjustment, 
            int initialBytesToStrip) {
        this(maxFrameLength, 
        lengthFieldOffset, 
        lengthFieldLength, 
        lengthAdjustment, 
        initialBytesToStrip, true);
    }

    // 暫時(shí)省略其他參數(shù)的構(gòu)造方法......
}
復(fù)制代碼

從上述構(gòu)造器中可明顯看出,LTC中存在五個(gè)參數(shù),看起來(lái)都比較長(zhǎng),接著簡(jiǎn)單解釋一下:

  • maxFrameLength:數(shù)據(jù)最大長(zhǎng)度,允許單個(gè)數(shù)據(jù)包的最大長(zhǎng)度,超出長(zhǎng)度后會(huì)自動(dòng)分包。
  • lengthFieldOffset:長(zhǎng)度字段偏移量,表示描述數(shù)據(jù)長(zhǎng)度的信息從第幾個(gè)字段開(kāi)始。
  • lengthFieldLength:長(zhǎng)度字段的占位大小,表示數(shù)據(jù)中的使用了幾個(gè)字節(jié)描述正文長(zhǎng)度。
  • lengthAdjustment:長(zhǎng)度調(diào)整數(shù),表示在長(zhǎng)度字段的N個(gè)字節(jié)后才是正文數(shù)據(jù)的開(kāi)始。
  • initialBytesToStrip:頭部剝離字節(jié)數(shù),表示先將數(shù)據(jù)去掉N個(gè)字節(jié)后,再開(kāi)始讀取數(shù)據(jù)。

上述這種方式描述五個(gè)參數(shù),大家估計(jì)理解起來(lái)有些困難,那么下面結(jié)合Netty源碼中的注釋,先把這幾個(gè)參數(shù)徹底搞明白再說(shuō),先來(lái)看個(gè)案例:
[圖片上傳失敗...(image-997b10-1671678429581)]

比如上述這組數(shù)據(jù),對(duì)應(yīng)的參數(shù)如下:

lengthFieldOffset = 0
lengthFieldLength = 4
lengthAdjustment = 0
initialBytesToStrip = 0
復(fù)制代碼

這組參數(shù)表示啥意思呢?表示目前這條數(shù)據(jù),長(zhǎng)度字段從第0個(gè)字節(jié)開(kāi)始,使用4個(gè)字節(jié)來(lái)描述數(shù)據(jù)長(zhǎng)度,這時(shí)服務(wù)端會(huì)讀取數(shù)據(jù)的前4個(gè)字節(jié),得到正文數(shù)據(jù)的長(zhǎng)度,從而得知:在第四個(gè)字節(jié)之后,再往后讀十個(gè)字節(jié),是一條完整的數(shù)據(jù),最終向后讀取10個(gè)字節(jié),最終就會(huì)讀到Hi, ZhuZi.這條數(shù)據(jù)。

但上述這種方式對(duì)數(shù)據(jù)解碼之后,讀取時(shí)依舊會(huì)顯示長(zhǎng)度字段,也就是前四個(gè)用來(lái)描述長(zhǎng)度的字節(jié)也會(huì)被讀到,因此最終會(huì)顯示出10Hi, ZhuZi.這樣的格式,那如果想要去掉前面的長(zhǎng)度字段怎么辦呢?這需要用到initialBytesToStrip參數(shù),如下:

lengthFieldOffset = 0
lengthFieldLength = 4
lengthAdjustment = 0
initialBytesToStrip = 4
復(fù)制代碼

[圖片上傳失敗...(image-f92bd5-1671678429581)]

這組參數(shù)又是啥意思呢?其實(shí)和前面那一組數(shù)據(jù)沒(méi)太大的變化,只是用initialBytesToStrip聲明要?jiǎng)冸x掉前4個(gè)字節(jié),所以數(shù)據(jù)經(jīng)過(guò)解碼后,最終會(huì)去掉前面描述長(zhǎng)度的四個(gè)字節(jié),僅顯示Hi, ZhuZi.這十個(gè)字節(jié)的數(shù)據(jù)。

上述這種形式,其實(shí)就是預(yù)設(shè)了一個(gè)長(zhǎng)度字段,服務(wù)端、客戶端之間約定使用N個(gè)字節(jié)來(lái)描述數(shù)據(jù)長(zhǎng)度,接著在讀取數(shù)據(jù)時(shí),讀取指定個(gè)字節(jié),得到本次數(shù)據(jù)的長(zhǎng)度,最終能夠正常解碼數(shù)據(jù)。但這種方式只能滿足最基本的數(shù)據(jù)傳輸,如果在數(shù)據(jù)中還需要添加一些正文信息,比如附加數(shù)據(jù)頭信息、版本號(hào)的情況,又該如何處理呢?如下:

lengthFieldOffset = 8
lengthFieldLength = 4
lengthAdjustment = 0
initialBytesToStrip = 0
復(fù)制代碼

[圖片上傳失敗...(image-1d27fb-1671678429581)]

上述這個(gè)示例中,假設(shè)附加信息占8Bytes,這里就需要用到lengthFieldOffset參數(shù),以此來(lái)表示長(zhǎng)度字段偏移量是8,這意味著讀取數(shù)據(jù)時(shí),要從第九個(gè)字節(jié)開(kāi)始,往后讀四個(gè)字節(jié)的數(shù)據(jù),才能夠得到描述數(shù)據(jù)長(zhǎng)度的字段,然后解析得到10,最終再往后讀取十個(gè)字節(jié)的數(shù)據(jù),讀到一條完整的數(shù)據(jù)。

當(dāng)然,如果只想要讀到正文數(shù)據(jù)怎么辦?如下:

lengthFieldOffset = 8
lengthFieldLength = 4
lengthAdjustment = 0
initialBytesToStrip = 12
復(fù)制代碼

[圖片上傳失敗...(image-4a5f46-1671678429581)]

依舊只需要通過(guò)initialBytesToStrip參數(shù),從頭部剝離掉前12個(gè)字節(jié)即可,這里的12個(gè)字節(jié),由八字節(jié)的附加信息、四字節(jié)的長(zhǎng)度描述組成,去掉這兩部分,自然就得到了正文數(shù)據(jù)。

OK,再來(lái)看另一種情況,假如長(zhǎng)度字段在最前面,附加信息在中間,但我只想要讀取正文數(shù)據(jù)怎么辦呢?

lengthFieldOffset = 0
lengthFieldLength = 4
lengthAdjustment = 8
initialBytesToStrip = 12
復(fù)制代碼

[圖片上傳失敗...(image-fd4f1f-1671678429581)]

在這里咱們又用到了lengthAdjustment這個(gè)參數(shù),這個(gè)參數(shù)是長(zhǎng)度調(diào)整數(shù)的意思,上面的示例中賦值為8,即表示從長(zhǎng)度字段后開(kāi)始,跳過(guò)8個(gè)字節(jié)后,才是正文數(shù)據(jù)的開(kāi)始。接收方在解碼數(shù)據(jù)時(shí),首先會(huì)從0開(kāi)始讀取四個(gè)字節(jié),得到正文數(shù)據(jù)的長(zhǎng)度為10,接著會(huì)根據(jù)lengthAdjustment參數(shù),跳過(guò)中間8個(gè)的字節(jié),最后再往后讀10個(gè)字節(jié)數(shù)據(jù),從而得到最終的正文數(shù)據(jù)。

OK~,經(jīng)過(guò)上述幾個(gè)示例的講解后,相信大家對(duì)給出的幾個(gè)參數(shù)都有所了解,如若覺(jué)得有些暈乎,可回頭再多仔細(xì)閱讀幾遍,這樣有助于加深對(duì)各個(gè)參數(shù)的印象。但本質(zhì)上來(lái)說(shuō),LTC解碼器,就是基于這些參數(shù),來(lái)確定一條數(shù)據(jù)的長(zhǎng)度、位置,從而讀取到精確的數(shù)據(jù),避免粘包、半包的現(xiàn)象產(chǎn)生,接下來(lái)上個(gè)Demo理解:

// 通過(guò)LTC幀解碼器解決粘包、半包問(wèn)題的演示類
public class LTCDecoderDemo {
public static void main(String[] args) {
    // 通過(guò)Netty提供的測(cè)試通道來(lái)代替服務(wù)端、客戶端
    EmbeddedChannel channel = new EmbeddedChannel(
            // 添加一個(gè)行幀解碼器(在超出1024后還未檢測(cè)到換行符,就會(huì)停止讀取)
            new LengthFieldBasedFrameDecoder(1024,0,4,0,0),
            new LoggingHandler(LogLevel.DEBUG)
    );

    // 調(diào)用三次發(fā)送數(shù)據(jù)的方法(等價(jià)于向服務(wù)端發(fā)送三次數(shù)據(jù))
    sendData(channel,"Hi, ZhuZi.");
}

    private static void sendData(EmbeddedChannel channel, String data){
        // 獲取要發(fā)送的數(shù)據(jù)字節(jié)以及長(zhǎng)度
        byte[] dataBytes = data.getBytes();
        int dataLength = dataBytes.length;

        // 先將數(shù)據(jù)長(zhǎng)度寫入到緩沖區(qū)、再將正文數(shù)據(jù)寫入到緩沖區(qū)
        ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer();
        buffer.writeInt(dataLength);
        buffer.writeBytes(dataBytes);

        // 發(fā)送最終組裝好的數(shù)據(jù)
        channel.writeInbound(buffer);
    }
}
復(fù)制代碼

上述案例中創(chuàng)建了一個(gè)LTC解碼器,對(duì)應(yīng)的參數(shù)值為1024,0,4,0,0,這分別對(duì)應(yīng)前面的五個(gè)參數(shù),如下:

maxFrameLength = 1024
lengthFieldOffset = 0
lengthFieldLength = 4
lengthAdjustment = 0
initialBytesToStrip = 0
復(fù)制代碼

這組值意思為:數(shù)據(jù)的第0~4個(gè)字節(jié)是長(zhǎng)度字段,用來(lái)描述正文數(shù)據(jù)的長(zhǎng)度,運(yùn)行結(jié)果如下:
[圖片上傳失敗...(image-da4342-1671678429581)]

效果十分明顯,既沒(méi)有產(chǎn)生粘包、半包問(wèn)題,而且無(wú)需逐個(gè)字節(jié)判斷是否為分割符,這對(duì)比之前的幾種解碼器而言,這種方式的效率顯然好上特別特別多。當(dāng)然,上述結(jié)果中,如果想要去掉前面的四個(gè).,就只需要將initialBytesToStrip = 4即可,從頭部剝離掉四個(gè)字節(jié)再讀取。

1.3.6、粘包、半包解決方案小結(jié)

前面介紹了短連接、定長(zhǎng)解碼器、行解碼器、分隔符解碼器以及LTC解碼器這五種方案,其中咱們需要牢記的是最后一種,因?yàn)槠渌姆桨付嗌俅嬖谝恍┬阅軉?wèn)題,而通過(guò)LTC解碼器這種方式處理粘包、半包問(wèn)題的效率最好,因?yàn)闊o(wú)需逐個(gè)字節(jié)判斷消息邊界。

但實(shí)際Netty開(kāi)發(fā)中,如果其他解碼器更符合業(yè)務(wù)需求,也不必死死追求使用LTC解碼器,畢竟技術(shù)為業(yè)務(wù)提供服務(wù),適合自己業(yè)務(wù)的,才是最好的!

二、Netty的長(zhǎng)連接與心跳機(jī)制

對(duì)于長(zhǎng)連接、短連接,這個(gè)概念在前面稍有提及,所謂的短連接就是每次讀寫數(shù)據(jù)完成后,立馬斷開(kāi)客戶端與服務(wù)端的網(wǎng)絡(luò)連接。而長(zhǎng)連接則是相反的意思,一次數(shù)據(jù)交互完成后,服務(wù)端和客戶端之間繼續(xù)保持連接,當(dāng)后續(xù)需再次收/發(fā)數(shù)據(jù)時(shí),可直接復(fù)用原有的網(wǎng)絡(luò)連接。

長(zhǎng)連接這種模式,在并發(fā)較高的情況下能夠帶來(lái)額外的性能收益,因?yàn)?code>Netty服務(wù)端、客戶端綁定IP端口,搭建Channel通道的過(guò)程,放到底層實(shí)際上就是TCP三次握手的過(guò)程,同理,客戶端、服務(wù)端斷開(kāi)連接的過(guò)程,即對(duì)應(yīng)著TCP的四次揮手。

大家都知道,TCP三次握手/四次揮手,這個(gè)過(guò)程無(wú)疑是比較“重量級(jí)”的,并發(fā)情況下,頻繁創(chuàng)建、銷毀網(wǎng)絡(luò)連接,其資源開(kāi)銷、性能開(kāi)銷會(huì)比較大,所以使用長(zhǎng)連接的方案,能夠有效減少創(chuàng)建和銷毀網(wǎng)絡(luò)連接的動(dòng)作。

那如何讓Netty開(kāi)啟長(zhǎng)連接支持呢?這需要涉及到之前用過(guò)的ChannelOption這個(gè)類,接著來(lái)詳細(xì)講講它。

2.1、Netty調(diào)整網(wǎng)絡(luò)參數(shù)(ChannelOption)

ChannelOptionNetty提供的參數(shù)調(diào)整類,該類中提供了很多常量,分別對(duì)應(yīng)著底層TCP、UDP、計(jì)算機(jī)網(wǎng)絡(luò)的一些參數(shù),在創(chuàng)建服務(wù)端、客戶端時(shí),我們可以通過(guò)ChannelOption類來(lái)調(diào)整網(wǎng)絡(luò)參數(shù),以此滿足不同的業(yè)務(wù)需求,該類中提供的常量列表如下:

  • ALLOCATORByteBuf緩沖區(qū)的分配器,默認(rèn)值為ByteBufAllocator.DEFAULT
  • RCVBUF_ALLOCATOR:通道接收數(shù)據(jù)的ByteBuf分配器,默認(rèn)為AdaptiveRecvByteBufAllocator.DEFAULT
  • MESSAGE_SIZE_ESTIMATOR:消息大小估算器,默認(rèn)為DefaultMessageSizeEstimator.DEFAULT
  • CONNECT_TIMEOUT_MILLIS:設(shè)置客戶端的連接超時(shí)時(shí)間,默認(rèn)為3000ms,超出會(huì)斷開(kāi)連接。
  • MAX_MESSAGES_PER_READ:一次Loop最大讀取的消息數(shù)。
    • ServerChannel/NioChannel默認(rèn)16,其他類型的Channel默認(rèn)為1
  • WRITE_SPIN_COUNT:一次Loop最大寫入的消息數(shù),默認(rèn)為16
    • 一個(gè)數(shù)據(jù)16次還未寫完,需要提交一個(gè)新的任務(wù)給EventLoop,防止數(shù)據(jù)量較大的場(chǎng)景阻塞系統(tǒng)。
  • WRITE_BUFFER_HIGH_WATER_MARK:寫高水位標(biāo)記,默認(rèn)為64K,超出時(shí)Channel.isWritable()返回Flase
  • WRITE_BUFFER_LOW_WATER_MARK:寫低水位標(biāo)記,默認(rèn)為32K,超出高水位又下降到低水位時(shí),isWritable()返回True
  • WRITE_BUFFER_WATER_MARK:寫水位標(biāo)記,如果寫的數(shù)據(jù)量也超出該值,依舊返回Flase
  • ALLOW_HALF_CLOSURE:一個(gè)遠(yuǎn)程連接關(guān)閉時(shí),是否半關(guān)本地連接,默認(rèn)為Flase
    • Flase表示自動(dòng)關(guān)閉本地連接,為True會(huì)觸發(fā)入站處理器的userEventTriggered()方法。
  • AUTO_READ:自動(dòng)讀取機(jī)制,默認(rèn)為True,通道上有數(shù)據(jù)時(shí),自動(dòng)調(diào)用channel.read()讀取數(shù)據(jù)。
  • AUTO_CLOSE:自動(dòng)關(guān)閉機(jī)制,默認(rèn)為Flase,發(fā)生錯(cuò)誤時(shí)不會(huì)斷開(kāi)與某個(gè)通道的連接。
  • SO_BROADCAST:設(shè)置廣播機(jī)制,默認(rèn)為Flase,為True時(shí)會(huì)開(kāi)啟Socket的廣播消息。
  • SO_KEEPALIVE:開(kāi)啟長(zhǎng)連接機(jī)制,一次數(shù)據(jù)交互完后不會(huì)立馬斷開(kāi)連接。
  • SO_SNDBUF:發(fā)送緩沖區(qū),用于保存要發(fā)送的數(shù)據(jù),未收到接收數(shù)據(jù)的ACK之前,數(shù)據(jù)會(huì)存在這里。
  • SO_RCVBUF:接受緩沖區(qū),用戶保存要接受的數(shù)據(jù)。
  • SO_REUSEADDR:是否復(fù)用IP地址與端口號(hào),開(kāi)啟后可重復(fù)綁定同一個(gè)地址。
  • SO_LINGER:設(shè)置延遲關(guān)閉,默認(rèn)為-1
    • -1:表示禁用該功能,當(dāng)調(diào)用close()方法后會(huì)立即返回,底層會(huì)先處理完數(shù)據(jù)。
    • 0:表示禁用該功能,調(diào)用后立即返回,底層會(huì)直接放棄正在處理的數(shù)據(jù)。
    • 大于0的正整數(shù):關(guān)閉時(shí)等待n秒,或數(shù)據(jù)處理完成才正式關(guān)閉。
  • SO_BACKLOG:指定服務(wù)端的連接隊(duì)列長(zhǎng)度,當(dāng)連接數(shù)達(dá)到該值時(shí),會(huì)拒絕新的連接請(qǐng)求。
  • SO_TIMEOUT:設(shè)置接受數(shù)據(jù)時(shí)等待的超時(shí)時(shí)間,默認(rèn)為0,表示無(wú)限等待。
  • IP_TOS
  • IP_MULTICAST_ADDR:設(shè)置IP頭的Type-of-Service字段,描述IP包的優(yōu)先級(jí)和QoS選項(xiàng)。
  • IP_MULTICAST_IF:對(duì)應(yīng)IP參數(shù)IP_MULTICAST_IF,設(shè)置對(duì)應(yīng)地址的網(wǎng)卡為多播模式。
  • IP_MULTICAST_TTL:對(duì)應(yīng)IP參數(shù)IP_MULTICAST_IF2,同上但支持IPv6
  • IP_MULTICAST_LOOP_DISABLED:對(duì)應(yīng)IP參數(shù)IP_MULTICAST_LOOP,設(shè)置本地回環(huán)地址的多播模式。
  • TCP_NODELAY:開(kāi)啟TCPNagle算法,會(huì)將多個(gè)小包合并成一個(gè)大包發(fā)送。
  • DATAGRAM_CHANNEL_ACTIVE_ON_REGISTRATIONDatagramChannel注冊(cè)的EventLoop即表示已激活。
  • SINGLE_EVENTEXECUTOR_PER_GROUPPipeline是否由單線程執(zhí)行,默認(rèn)為True,所有處理器由一條線程執(zhí)行,無(wú)需經(jīng)過(guò)線程上下文切換。

上面列出了ChannelOption類中提供的參數(shù),其中涵蓋了網(wǎng)絡(luò)通用的參數(shù)、TCP協(xié)議、UDP協(xié)議以及IP協(xié)議的參數(shù),其他的咱們無(wú)需過(guò)多關(guān)心,這里重點(diǎn)注意TCP協(xié)議的兩個(gè)參數(shù):

  • TCP_NODELAY:開(kāi)啟TCPNagle算法,會(huì)將多個(gè)小包合并成一個(gè)大包發(fā)送。
  • SO_KEEPALIVE:開(kāi)啟長(zhǎng)連接機(jī)制,一次數(shù)據(jù)交互完后不會(huì)立馬斷開(kāi)連接。

第一個(gè)參數(shù)就是之前聊到的Nagle算法,而關(guān)于現(xiàn)在要聊的長(zhǎng)連接,就是SO_KEEPALIVE這個(gè)參數(shù),想要讓這些參數(shù)生效,需要將其裝載到對(duì)應(yīng)的服務(wù)端/客戶端上,Netty中提供了兩個(gè)裝載參數(shù)的方法:

  • option():發(fā)生在連接初始化階段,也就是程序初始化時(shí),就會(huì)裝載該方法配置的參數(shù)。
  • childOption():發(fā)生在連接建立之后,這些參數(shù)只有等連接建立后才會(huì)被裝載。

其實(shí)也可以這樣理解,option()方法配置的參數(shù)是對(duì)全局生效的,而childOption()配置的參數(shù),是針對(duì)于連接生效的,而想要開(kāi)啟長(zhǎng)連接配置,只需稍微改造一下服務(wù)端/客戶端代碼即可:

// 服務(wù)端代碼
server.childOption(ChannelOption.SO_KEEPALIVE, true);

// 客戶端代碼
client.option(ChannelOption.SO_KEEPALIVE, true);
復(fù)制代碼

通過(guò)上述的方式開(kāi)啟長(zhǎng)連接之后,TCP默認(rèn)每?jī)尚r(shí)會(huì)發(fā)送一次心跳檢測(cè),查看對(duì)端是否還存活,如果對(duì)端由于網(wǎng)絡(luò)故障導(dǎo)致下線,TCP會(huì)自動(dòng)斷開(kāi)與對(duì)方的連接。

2.2、Netty的心跳機(jī)制

前面聊到了Netty的長(zhǎng)連接,其實(shí)本質(zhì)上并不是Netty提供的長(zhǎng)連接實(shí)現(xiàn),而是通過(guò)調(diào)整參數(shù),借助傳輸層TCP協(xié)議提供的長(zhǎng)連接機(jī)制,從而實(shí)現(xiàn)服務(wù)端與客戶端的長(zhǎng)連接支持。不過(guò)TCP雖然提供了長(zhǎng)連接支持,但其心跳機(jī)制并不夠完善,Why?其實(shí)答案很簡(jiǎn)單,因?yàn)樾奶鴻z測(cè)的間隔時(shí)間太長(zhǎng)了,每隔兩小時(shí)才檢測(cè)一次!

也許有人會(huì)說(shuō):兩小時(shí)就兩小時(shí),這有什么問(wèn)題嗎?其實(shí)問(wèn)題有些大,因?yàn)閮尚r(shí)太長(zhǎng)了,無(wú)法有效檢測(cè)到機(jī)房斷電、機(jī)器重啟、網(wǎng)線拔出、防火墻更新等情況,假設(shè)一次心跳結(jié)束后,對(duì)端就出現(xiàn)了這些故障,依靠TCP自身的心跳頻率,需要等到兩小時(shí)之后才能檢測(cè)到問(wèn)題。而這些已經(jīng)失效的連接應(yīng)當(dāng)及時(shí)剔除,否則會(huì)長(zhǎng)時(shí)間占用服務(wù)端資源,畢竟服務(wù)端的可用連接數(shù)是有限的。

所以,光依靠TCP的心跳機(jī)制,這無(wú)法保障咱們的應(yīng)用穩(wěn)健性,因此一般開(kāi)發(fā)中間件也好、通信程序也罷、亦或是RPC框架等,都會(huì)在應(yīng)用層再自實(shí)現(xiàn)一次心跳機(jī)制,而所謂的心跳機(jī)制,也并不是特別高大上的東西,實(shí)現(xiàn)的思路有兩種:

  • 服務(wù)端主動(dòng)探測(cè):每間隔一定時(shí)間后,向所有客戶端發(fā)送一個(gè)檢測(cè)信號(hào),過(guò)程如下:
    • 假設(shè)目前有三個(gè)節(jié)點(diǎn),A為服務(wù)端,B、C都為客戶端。
      • A:你們還活著嗎?
      • B:我還活著!
      • C:.....(假設(shè)掛掉了,無(wú)響應(yīng))
    • A收到了B的響應(yīng),但C卻未給出響應(yīng),很有可能掛了,A中斷與C的連接。
  • 客戶端主動(dòng)告知:每間隔一定時(shí)間后,客戶端向服務(wù)端發(fā)送一個(gè)心跳包,過(guò)程如下:
    • 依舊是上述那三個(gè)節(jié)點(diǎn)。
    • B:我還活著,不要開(kāi)除我!
    • C:....(假設(shè)掛掉了,不發(fā)送心跳包)
    • A:收到B的心跳包,但未收到C的心跳包,將C的網(wǎng)絡(luò)連接斷開(kāi)。

一般來(lái)說(shuō),一套健全的心跳機(jī)制,都會(huì)結(jié)合上述兩種方案一起實(shí)現(xiàn),也就是客戶端定時(shí)向服務(wù)端發(fā)送心跳包,當(dāng)服務(wù)端未收到某個(gè)客戶端心跳包的情況下,再主動(dòng)向客戶端發(fā)起探測(cè)包,這一步主要是做二次確認(rèn),防止由于網(wǎng)絡(luò)擁塞或其他問(wèn)題,導(dǎo)致原本客戶端發(fā)出的心跳包丟失。

2.2.1、心跳機(jī)制的實(shí)現(xiàn)思路分析

前面叨叨絮絮說(shuō)了很多,那么在Netty中該如何實(shí)現(xiàn)呢?其實(shí)在Netty中提供了一個(gè)名為IdleStateHandler的類,它可以對(duì)一個(gè)通道上的讀、寫、讀/寫操作設(shè)置定時(shí)器,其中主要提供了三種類型的心跳檢測(cè):

// 當(dāng)一個(gè)Channel(Socket)在指定時(shí)間后未觸發(fā)讀事件,會(huì)觸發(fā)這個(gè)事件
public static final IdleStateEvent READER_IDLE_STATE_EVENT;
// 當(dāng)一個(gè)Channel(Socket)在指定時(shí)間后未觸發(fā)寫事件,會(huì)觸發(fā)這個(gè)事件
public static final IdleStateEvent WRITER_IDLE_STATE_EVENT;
// 上述讀、寫等待事件的結(jié)合體
public static final IdleStateEvent ALL_IDLE_STATE_EVENT;
復(fù)制代碼

Netty中,當(dāng)一個(gè)已建立連接的通道,超出指定時(shí)間后還沒(méi)有出現(xiàn)數(shù)據(jù)交互,對(duì)應(yīng)的Channel就會(huì)進(jìn)入閑置Idle狀態(tài),根據(jù)不同的Socket/Channel事件,會(huì)進(jìn)入不同的閑置狀態(tài),而不同的閑置狀態(tài)又會(huì)觸發(fā)不同的閑置事件,也就是上述提到的三種閑置事件,在Netty中用IdleStateEvent事件類來(lái)表示。

OK,正是由于Netty提供了IdleStateEvent閑置事件類,所以咱們可以基于它來(lái)實(shí)現(xiàn)心跳機(jī)制,但這里還需要用到《Netty入門篇-入站處理器》中聊到的一個(gè)方法:userEventTriggered(),這個(gè)鉤子方法,會(huì)在通道觸發(fā)任意事件后被調(diào)用,這也就意味著:只要通道上觸發(fā)了事件,都會(huì)觸發(fā)該方法執(zhí)行,閑置事件也不例外

有了IdleState、userEventTriggered()這兩個(gè)基礎(chǔ)后,咱們就可基于這兩個(gè)玩意兒,去實(shí)現(xiàn)一個(gè)簡(jiǎn)單的心跳機(jī)制,最基本的功能實(shí)現(xiàn)如下:

  • 客戶端:在閑置一定時(shí)間后,能夠主動(dòng)給服務(wù)端發(fā)送心跳包。
  • 服務(wù)端:能夠主動(dòng)檢測(cè)到未發(fā)送數(shù)據(jù)包的閑置連接,并中斷連接。

2.2.2、帶有心跳機(jī)制的客戶端實(shí)現(xiàn)

上述這兩點(diǎn)功能實(shí)現(xiàn)起來(lái)并不難,咱們首先寫一下客戶端的實(shí)現(xiàn),如下:

// 心跳機(jī)制的客戶端處理器
public class HeartbeatClientHandler extends ChannelInboundHandlerAdapter {
    // 通用的心跳包數(shù)據(jù)
    private static final ByteBuf HEARTBEAT_DATA =
            Unpooled.unreleasableBuffer(Unpooled.copiedBuffer("I am Alive", CharsetUtil.UTF_8));

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object event) throws Exception {
        // 如果當(dāng)前觸發(fā)的事件是閑置事件
        if (event instanceof IdleStateEvent) {
            IdleStateEvent idleEvent = (IdleStateEvent) event;
            // 如果當(dāng)前通道觸發(fā)了寫閑置事件
            if (idleEvent.state() == IdleState.WRITER_IDLE){
                // 表示當(dāng)前客戶端有一段時(shí)間未向服務(wù)端發(fā)送數(shù)據(jù)了,
                // 為了防止服務(wù)端關(guān)閉當(dāng)前連接,手動(dòng)發(fā)送一個(gè)心跳包
                ctx.channel().writeAndFlush(HEARTBEAT_DATA.duplicate());
                System.out.println("成功向服務(wù)端發(fā)送心跳包....");
            } else {
                super.userEventTriggered(ctx, event);
            }
        }
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("正在與服務(wù)端建立連接....");
        // 建立連接成功之后,先向服務(wù)端發(fā)送一條數(shù)據(jù)
        ctx.channel().writeAndFlush("我是會(huì)發(fā)心跳包的客戶端-A!");
        super.channelActive(ctx);
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("服務(wù)端主動(dòng)關(guān)閉了連接....");
        super.channelInactive(ctx);
    }
}
復(fù)制代碼

因?yàn)橐柚?code>userEventTriggered()方法來(lái)實(shí)現(xiàn)事件監(jiān)聽(tīng),所以咱們需要定義一個(gè)類繼承入站處理器,接著在其中做了一個(gè)判斷,如果當(dāng)前觸發(fā)了IdleStateEvent閑置事件,這也就意味著目前沒(méi)有向服務(wù)端發(fā)送數(shù)據(jù)了,因此需要發(fā)送一個(gè)心跳包,告知服務(wù)端自己還活著,接著需要將這個(gè)處理器加在客戶端上面,如下:

// 演示心跳機(jī)制的客戶端(會(huì)發(fā)送心跳包)
public class ClientA {
    public static void main(String[] args) {
        EventLoopGroup worker = new NioEventLoopGroup();
        Bootstrap client = new Bootstrap();
        try {
            client.group(worker);
            client.channel(NioSocketChannel.class);
            // 打開(kāi)長(zhǎng)連接配置
            client.option(ChannelOption.SO_KEEPALIVE, true);
            // 指定一個(gè)自定義的初始化器
            client.handler(new ClientInitializer());
            client.connect("127.0.0.1", 8888).sync();
        } catch (Exception e){
            e.printStackTrace();
        }
    }
}

// 客戶端的初始化器
public class ClientInitializer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        ChannelPipeline pipeline = socketChannel.pipeline();
        // 配置如果3s內(nèi)未觸發(fā)寫事件,就會(huì)觸發(fā)寫閑置事件
        pipeline.addLast("IdleStateHandler", 
                new IdleStateHandler(0,3,0,TimeUnit.SECONDS));
        pipeline.addLast("Encoder",new StringEncoder(CharsetUtil.UTF_8));
        pipeline.addLast("Decoder",new StringDecoder(CharsetUtil.UTF_8));
        // 裝載自定義的客戶端心跳處理器
        pipeline.addLast("HeartbeatHandler",new HeartbeatClientHandler());
    }
}
復(fù)制代碼

客戶端的代碼基本上和之前的案例差異不大,重點(diǎn)看ClientInitializer這個(gè)初始化器,里面首先加入了一個(gè)IdleStateHandler,參數(shù)為0、3、0,單位是秒,這是啥意思呢?點(diǎn)進(jìn)源碼看看構(gòu)造函數(shù),如下:

public IdleStateHandler(long readerIdleTime, 
                        long writerIdleTime, 
                        long allIdleTime, 
                        TimeUnit unit) {
    this(false, readerIdleTime, writerIdleTime, allIdleTime, unit);
}
復(fù)制代碼

沒(méi)錯(cuò),其實(shí)賦值的三個(gè)參數(shù),也就分別對(duì)應(yīng)著讀操作的閑置事件、寫操作的閑置事件、讀寫操作的閑置事件,如果賦值為0,表示這些閑置事件不需要關(guān)心,在前面的賦值中,第二個(gè)參數(shù)writerIdleTime被咱們賦值成了3,這表示如果客戶端通道在三秒內(nèi),未觸發(fā)寫事件,就會(huì)觸發(fā)寫閑置事件,而后會(huì)調(diào)用HeartbeatClientHandler.userEventTriggered()方法,從而向服務(wù)端發(fā)送一個(gè)心跳包。

2.2.3、帶有心跳機(jī)制的服務(wù)端實(shí)現(xiàn)

接著再來(lái)看看服務(wù)端的代碼實(shí)現(xiàn),同樣需要有一個(gè)心跳處理器,如下:

// 心跳機(jī)制的服務(wù)端處理器
public class HeartbeatServerHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object event) throws Exception {
        // 如果當(dāng)前觸發(fā)的事件是閑置事件
        if (event instanceof IdleStateEvent) {
            IdleStateEvent idleEvent = (IdleStateEvent) event;
            // 如果對(duì)應(yīng)的Channel通道觸發(fā)了讀閑置事件
            if (idleEvent.state() == IdleState.READER_IDLE){
                // 表示對(duì)應(yīng)的客戶端沒(méi)有發(fā)送心跳包,則關(guān)閉對(duì)應(yīng)的網(wǎng)絡(luò)連接
                // (心跳包也是一種特殊的數(shù)據(jù),會(huì)觸發(fā)讀事件,有心跳就不會(huì)進(jìn)這步)
                ctx.channel().close();
                System.out.println("關(guān)閉了未發(fā)送心跳包的連接....");
            } else {
                super.userEventTriggered(ctx, event);
            }
        }
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        // 如果收到的是心跳包,則給客戶端做出一個(gè)回復(fù)
        if ("I am Alive".equals(msg)){
            ctx.channel().writeAndFlush("I know");
        }
        System.out.println("收到客戶端消息:" + msg);
        super.channelRead(ctx, msg);
    }
}
復(fù)制代碼

Server端的心跳處理器中,同樣監(jiān)聽(tīng)了閑置事件,但這里監(jiān)聽(tīng)的是讀閑置事件,因?yàn)橐粋€(gè)通道如果長(zhǎng)時(shí)間沒(méi)有觸發(fā)讀事件,這表示對(duì)應(yīng)的客戶端已經(jīng)很長(zhǎng)事件沒(méi)有發(fā)數(shù)據(jù)了,所以需要關(guān)閉對(duì)應(yīng)的客戶端連接。

有小伙伴或許會(huì)疑惑:為什么一個(gè)客戶端通道長(zhǎng)時(shí)間未發(fā)送數(shù)據(jù)就需要關(guān)閉連接呀?這不是違背了長(zhǎng)連接的初衷嗎?答案并非如此,因?yàn)榍懊嬖谠蹅兊目蛻舳酥校谕ǖ篱L(zhǎng)時(shí)間未觸發(fā)寫事件的情況下,會(huì)主動(dòng)向服務(wù)端發(fā)送心跳包,而心跳包也是一種特殊的數(shù)據(jù)包,依舊會(huì)觸發(fā)服務(wù)端上的讀事件,所以但凡正常發(fā)送心跳包的連接,都不會(huì)被服務(wù)端主動(dòng)關(guān)閉。

OK,接著來(lái)看看服務(wù)端的實(shí)現(xiàn),其實(shí)和前面的客戶端差不多:

// 演示心跳機(jī)制的服務(wù)端
public class Server {
    public static void main(String[] args) {
        EventLoopGroup group = new NioEventLoopGroup();
        ServerBootstrap server = new ServerBootstrap();

        server.group(group);
        server.channel(NioServerSocketChannel.class);
        // 在這里開(kāi)啟了長(zhǎng)連接配置,以及配置了自定義的初始化器
        server.childOption(ChannelOption.SO_KEEPALIVE, true);
        server.childHandler(new ServerInitializer());
        server.bind("127.0.0.1",8888);
    }
}

// 服務(wù)端的初始化器
public class ServerInitializer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        ChannelPipeline pipeline = socketChannel.pipeline();
        // 配置如果5s內(nèi)未觸發(fā)讀事件,就會(huì)觸發(fā)讀閑置事件
        pipeline.addLast("IdleStateHandler", 
                new IdleStateHandler(5,0,0,TimeUnit.SECONDS));
        pipeline.addLast("Encoder",new StringEncoder(CharsetUtil.UTF_8));
        pipeline.addLast("Decoder",new StringDecoder(CharsetUtil.UTF_8));
        // 裝載自定義的服務(wù)端心跳處理器
        pipeline.addLast("HeartbeatHandler",new HeartbeatServerHandler());
    }
}
復(fù)制代碼

重點(diǎn)注意看:在服務(wù)端配置的是讀閑置事件,如果在5s內(nèi)未觸發(fā)讀事件,就會(huì)觸發(fā)對(duì)應(yīng)通道的讀閑置事件,但這里是5s,為何不配置成客戶端的3s呢?因?yàn)槿绻麅啥说拈e置超時(shí)時(shí)間配置成一樣,就會(huì)造成客戶端正在發(fā)心跳包、服務(wù)端正在關(guān)閉連接的這種情況出現(xiàn),最終導(dǎo)致心跳機(jī)制無(wú)法正常工作,對(duì)于這點(diǎn)大家也可以自行演示。

2.2.4、普通的客戶端實(shí)現(xiàn)

最后,為了方便觀看效果,這里咱們?cè)賱?chuàng)建一個(gè)不會(huì)發(fā)送心跳包的客戶端B,同樣打開(kāi)它的長(zhǎng)連接選項(xiàng),然后來(lái)對(duì)比測(cè)試效果,如下:

// 演示心跳機(jī)制的客戶端(不會(huì)發(fā)送心跳包)
public class ClientB {
    public static void main(String[] args) {
        EventLoopGroup worker = new NioEventLoopGroup();
        Bootstrap client = new Bootstrap();
        try {
            client.group(worker);
            client.channel(NioSocketChannel.class);
            client.option(ChannelOption.SO_KEEPALIVE, true);
            client.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel socketChannel) 
                                                        throws Exception {
                    ChannelPipeline pipeline = socketChannel.pipeline();
                    pipeline.addLast("Encoder",new StringEncoder(CharsetUtil.UTF_8));
                    pipeline.addLast("Decoder",new StringDecoder(CharsetUtil.UTF_8));
                    pipeline.addLast(new ChannelInboundHandlerAdapter(){
                        @Override
                        public void channelActive(ChannelHandlerContext ctx) 
                                                            throws Exception {
                            // 建立連接成功之后,先向服務(wù)端發(fā)送一條數(shù)據(jù)
                            ctx.channel().writeAndFlush("我是不會(huì)發(fā)心跳包的客戶端-B!");
                        }
                        @Override
                        public void channelInactive(ChannelHandlerContext ctx) 
                                                            throws Exception {
                            System.out.println("因?yàn)闆](méi)發(fā)送心跳包,俺被開(kāi)除啦!");
                            // 當(dāng)通道被關(guān)閉時(shí),停止前面啟動(dòng)的線程池
                            worker.shutdownGracefully();
                        }
                    });
                }
            });
            client.connect("127.0.0.1", 8888).sync();
        } catch (Exception e){
            e.printStackTrace();
        }
    }
}
復(fù)制代碼

上述這段代碼中,僅構(gòu)建出了一個(gè)最基本的客戶端,其中主要干了兩件事情:

  • ①在連接建立成功之后,先向服務(wù)端發(fā)送一條數(shù)據(jù)。
  • ②在連接(通道)被關(guān)閉時(shí),輸出一句“俺被開(kāi)除啦!”的信息,并優(yōu)雅停止線程池。

除此之外,該客戶端并未裝載自己實(shí)現(xiàn)的客戶端心跳處理器,這也就意味著:客戶端B并不會(huì)主動(dòng)給服務(wù)端發(fā)送心跳包。

2.2.5、Netty心跳機(jī)制測(cè)試

接著分別啟動(dòng)服務(wù)端、客戶端A、客戶端B,然后查看控制臺(tái)的日志,如下:
[圖片上傳失敗...(image-bccdea-1671678429579)]

從上圖的運(yùn)行結(jié)果來(lái)看,在三方啟動(dòng)之后,整體過(guò)程如下:

  • ClientA:先與服務(wù)端建立連接,并且在建立連接之后發(fā)送一條數(shù)據(jù),后續(xù)持續(xù)發(fā)送心跳包。
  • ClientB:先與服務(wù)端建立連接,然后在建立連接成功后發(fā)送一條數(shù)據(jù),后續(xù)不會(huì)再發(fā)數(shù)據(jù)。
  • Server:與ClientA、B保持連接,然后定期檢測(cè)閑置連接,關(guān)閉未發(fā)送心跳包的連接。

在上述這個(gè)過(guò)程中,由于ClientB建立連接后,未主動(dòng)向服務(wù)端發(fā)送心跳包,所以在一段時(shí)間之后,服務(wù)端主動(dòng)將ClientB的連接(通道)關(guān)閉了,有人會(huì)問(wèn):明明ClientB還活著呀,這樣做合理嗎?

其實(shí)這個(gè)問(wèn)題是合理的,因?yàn)檫@里只是模擬線上環(huán)境測(cè)試,所以ClientB沒(méi)有主動(dòng)發(fā)送數(shù)據(jù)包,但在線上環(huán)境,每個(gè)客戶端都會(huì)定期向服務(wù)端發(fā)送心跳包,都會(huì)為每個(gè)客戶端配置心跳處理器。在都配置了心跳處理器的情況下,如果一個(gè)客戶端長(zhǎng)時(shí)間沒(méi)發(fā)送心跳包,這意味著這個(gè)客戶端十有八九涼涼了,所以自然需要將其關(guān)閉,防止這類“廢棄連接”占用服務(wù)端資源。

不過(guò)上述的心跳機(jī)制僅實(shí)現(xiàn)了最基礎(chǔ)的版本,還未徹底將其完善,但我這里就不繼續(xù)往下實(shí)現(xiàn)了,畢竟主干已經(jīng)搭建好了,剩下的只是一些細(xì)枝末節(jié),我這里提幾點(diǎn)完善思路:

  • ①在檢測(cè)到某個(gè)客戶端未發(fā)送心跳包的情況下,服務(wù)端應(yīng)當(dāng)主動(dòng)再發(fā)起一個(gè)探測(cè)包,二次確認(rèn)客戶端是否真的掛了,這樣做的好處在于:能夠有效避免網(wǎng)絡(luò)抖動(dòng)造成的“客戶端假死”現(xiàn)象。
  • ②客戶端、服務(wù)端之間交互的數(shù)據(jù)包,應(yīng)當(dāng)采用統(tǒng)一的格式進(jìn)行封裝,也就是都遵守同一規(guī)范包裝數(shù)據(jù),例如{msgType:"Heartbeat", msgContent:"...", ...}
  • ③在客戶端被關(guān)閉的情況下,但凡不是因?yàn)槲锢硪蛩兀鐧C(jī)房斷電、網(wǎng)線被拔、機(jī)器宕機(jī)等情況造成的客戶端下線,客戶端都必須具備斷線重連功能。

將上述三條完善后,才能夠被稱為是一套相對(duì)健全的心跳檢測(cè)機(jī)制,所以大家感興趣的情況下,可基于前面給出的源碼接著實(shí)現(xiàn)~

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容