引言
在前面關(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)
ChannelOption
是Netty
提供的參數(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ù)需求,該類中提供的常量列表如下:
-
ALLOCATOR
:ByteBuf
緩沖區(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)。
- 一個(gè)數(shù)據(jù)
-
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)啟TCP
的Nagle
算法,會(huì)將多個(gè)小包合并成一個(gè)大包發(fā)送。 -
DATAGRAM_CHANNEL_ACTIVE_ON_REGISTRATION
:DatagramChannel
注冊(cè)的EventLoop
即表示已激活。 -
SINGLE_EVENTEXECUTOR_PER_GROUP
:Pipeline
是否由單線程執(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)啟TCP
的Nagle
算法,會(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
的連接。
- 假設(shè)目前有三個(gè)節(jié)點(diǎn),
- 客戶端主動(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)~