Netty系列之Netty 服務端建立
- 背景
1.1. 原生NIO類庫的複雜性
在開始本文之前,我先講一件自己親身經歷的事:大約在2011年的時候,周邊的兩個業務團隊同時進行新版本開發,他們都需要基於NIO非阻塞特性構建高效能、非同步和高可靠性的底層通訊框架。
當時兩個專案組的設計師都諮詢了我的意見,在瞭解了兩個專案團隊的NIO程式設計經驗和現狀之後,我建議他們都使用Netty構建業務通訊框架。令人遺憾的是其中1個專案組並沒有按照我的建議做,而是選擇直接基於JDK的NIO類庫構建自己的通訊框架。在他們看來,構建業務層的NIO通訊框架並不是件難事,即便當前他們還缺乏相關經驗。
兩個多月過去之後,自研NIO框架團隊的通訊框架始終無法穩定的工作,他們頻繁遭遇客戶端斷連、控制代碼洩露和訊息丟失等問題。專案的進度出現了嚴重的延遲;形成鮮明對比的是,另一個團隊由於基於Netty研發,在通訊框架上節省了大量的人力和時間,加之Netty自身的可靠性和穩定性非常好,他們的專案進展非常順利。
這兩個專案組的不同遭遇告訴我們:開發高質量的NIO程式並不是一件簡單的事情,除去NIO類庫的固有複雜性和Bug,作為NIO服務端,需要能夠處理網路的閃斷、客戶端的重連、安全認證和訊息的編解碼、半包處理等。如果沒有足夠的NIO程式設計經驗積累,自研NIO框架往往需要半年甚至數年的時間才能最終穩定下來,這種成本即便對一個大公司而言也是個嚴重的挑戰.
1.2. Netty的優勢
Netty是業界最流行的NIO框架,它的可靠性、高效能和可擴充套件性已經得到了上百上千的商用專案驗證,它的優點總結如下:
API使用簡單,開發門檻低;
功能強大,內聚了很多實用的功能,簡化使用者的開發;
定製性好,通過ChannelPipeline機制可以靈活的進行功能定製和擴充套件;
效能高;
成熟穩定,社群活躍,Bug的修復週期比較短,新功能不斷的被加入,使用者可以體驗到更多、更實用的功能。
經歷了大規模不同行業的商用考驗,架構質量得到了充分的驗證。
1.3. Netty服務端的學習
對於想要深入學習Netty原理的人而言,通過閱讀原始碼是最有效的學習方式之一。儘管Netty使用起來並不複雜,但是對於原始碼層面的分析和學習,掌握一些必備的基礎知識還是很有必要的,否則即便讀完程式碼也仍然會是一知半解。
Netty服務端建立需要的必備知識如下:
熟悉JDK NIO主要類庫的使用,例如ByteBuffer、Selector、ServerSocketChannel等;
熟悉JDK的多執行緒程式設計;
瞭解Reactor模式。
本文首先對Java NIO服務端的建立進行分析和介紹,然後對Netty服務端的建立進行原理講解和原始碼分析,以期讓更多希望瞭解Netty底層原理的讀者可以快速入門。
2. NIO服務端建立
2.1. Java NIO服務端建立
首先,我們通過一個時序圖來看下如何建立一個NIO服務端並啟動監聽,接收多個客戶端的連線,進行訊息的非同步讀寫。
圖2-1 Java NIO服務端建立時序圖
上圖對Java NIO服務端建立的典型流程進行了說明,相比於實際生產系統中的商用程式碼,省略了半包的讀寫、異常處理、安全認證和訊息快取等過程。在實際商用專案中,讀者需要根據實際情況進行處理,本文不再贅述。
下面我們結合實際程式碼,對Java NIO服務端的建立進行解說。
步驟1:開啟ServerSocketChannel,用於監聽客戶端的連線,它是所有客戶端連線的父通道,程式碼示例如下:
ServerSocketChannel acceptorSvr = ServerSocketChannel.open();
步驟2:繫結監聽埠,設定客戶端連線方式為非阻塞模式,示例程式碼如下
acceptorSvr.socket().bind(new InetSocketAddress(InetAddress.getByName(“IP”), port));
acceptorSvr.configureBlocking(false);
步驟3:建立Reactor執行緒,開啟多路複用器並啟動服務端監聽執行緒,通常情況下,可以採用執行緒池的方式建立Reactor執行緒。示例程式碼如下:
Selector selector = Selector.open();
New Thread(new ReactorTask()).start();
步驟4:將ServerSocketChannel註冊到Reactor執行緒的多路複用器Selector上,監聽ACCEPT狀態位,示例程式碼如下:
SelectionKey key = acceptorSvr.register( selector, SelectionKey.OP_ACCEPT, ioHandler);
步驟5:多路複用器線上程run方法的無限迴圈體內輪詢準備就緒的Key,
通常情況下需要設定一個退出狀態檢測位,用於優雅停機。程式碼如下:
int num = selector.select();
Set selectedKeys = selector.selectedKeys();
Iterator it = selectedKeys.iterator();
while (it.hasNext()) {
SelectionKey key = (SelectionKey)it.next();
// ... deal with I/O event ...
}
步驟6:多路複用器監聽到有新的客戶端接入,處理新的接入請求,完成TCP三次握手後,與客戶端建立物理鏈路,示例程式碼如下:
SocketChannel channel = svrChannel.accept();
步驟7:設定客戶端鏈路的TCP引數,示例程式碼如下:
channel.configureBlocking(false);
channel.socket().setReuseAddress(true);
步驟8:將新接入的客戶端連線註冊到Reactor執行緒的多路複用器上,監聽讀操作位,用來讀取客戶端傳送的網路訊息,示例程式碼如下:
SelectionKey key = socketChannel.register( selector, SelectionKey.OP_READ, ioHandler);
步驟9:非同步讀取客戶端請求訊息到服務端緩衝區,示例程式碼如下:
int readNumber = channel.read(receivedBuffer);
步驟10:對ByteBuffer進行解碼,如果有半包訊息指標Reset,繼續讀取後續的報文,將解碼成功的訊息封裝成Task,投遞到業務執行緒池中,進行業務邏輯編排,示例程式碼如下:
Object message = null;
while(buffer.hasRemain())
{
byteBuffer.mark();
Object message = decode(byteBuffer);
if (message == null)
{
byteBuffer.reset();
break;
}
messageList.add(message );
}
if (!byteBuffer.hasRemain())
byteBuffer.clear();
else
byteBuffer.compact();
if (messageList != null & !messageList.isEmpty())
{
for(Object messageE : messageList)
handlerTask(messageE);
}
步驟11:將POJO物件encode成ByteBuffer,呼叫SocketChannel的非同步write介面,將訊息非同步傳送給客戶端,示例程式碼如下:如果傳送區TCP緩衝區滿,會導致寫半包,此時,需要註冊監聽寫操作位,迴圈寫,直到整包訊息寫入TCP緩衝區。
socketChannel.write(buffer);
2.2. Netty服務端建立
當我們直接使用JDK NIO的類庫開發基於NIO的非同步服務端時,需要使用到多路複用器Selector、ServerSocketChannel、SocketChannel、ByteBuffer、SelectionKey等等,相比於傳統的BIO開發,NIO的開發要複雜很多,開發出穩定、高效能的非同步通訊框架,一直是個難題。
Netty為了向使用者遮蔽NIO通訊的底層細節,在和使用者互動的邊界做了封裝,目的就是為了減少使用者開發工作量,降低開發難度。ServerBootstrap是Socket服務端的啟動輔助類,使用者通過ServerBootstrap可以方便的建立Netty的服務端。
2.2.1. Netty服務端建立時序圖
圖2-2 Netty服務端建立時序圖
下面我們對Netty服務端建立的關鍵步驟和原理進行講解。
步驟1:建立ServerBootstrap例項。ServerBootstrap是Netty服務端的啟動輔助類,它提供了一系列的方法用於設定服務端啟動相關的引數。底層通過門面模式對各種能力進行抽象和封裝,儘量不需要使用者跟過多的底層API打交道,降低使用者的開發難度。
我們在建立ServerBootstrap例項時,會驚訝的發現ServerBootstrap只有一個無參的建構函式,作為啟動輔助類這讓人不可思議,因為它需要與多個其它元件或者類互動。ServerBootstrap建構函式沒有引數的根本原因是因為它的引數太多了,而且未來也可能會發生變化,為了解決這個問題,就需要引入Builder模式。《Effective Java》第二版第2條建議遇到多個構造器引數時要考慮用構建器,關於多個引數建構函式的缺點和使用構建器的優點大家可以查閱《Effective Java》,在此不再詳述。
步驟2:設定並繫結Reactor執行緒池。Netty的Reactor執行緒池是EventLoopGroup,它實際就是EventLoop的陣列。EventLoop的職責是處理所有註冊到本執行緒多路複用器Selector上的Channel,Selector的輪詢操作由繫結的EventLoop執行緒run方法驅動,在一個迴圈體內迴圈執行。值得說明的是,EventLoop的職責不僅僅是處理網路I/O事件,使用者自定義的Task和定時任務Task也統一由EventLoop負責處理,這樣執行緒模型就實現了統一。從排程層面看,也不存在在EventLoop執行緒中再啟動其它型別的執行緒用於非同步執行其它的任務,這樣就避免了多執行緒併發操作和鎖競爭,提升了I/O執行緒的處理和排程效能。
步驟3:設定並繫結服務端Channel。作為NIO服務端,需要建立ServerSocketChannel,Netty對原生的NIO類庫進行了封裝,對應實現是NioServerSocketChannel。對於使用者而言,不需要關心服務端Channel的底層實現細節和工作原理,只需要指定具體使用哪種服務端Channel即可。因此,Netty的ServerBootstrap方法提供了channel方法用於指定服務端Channel的型別。Netty通過工廠類,利用反射建立NioServerSocketChannel物件。由於服務端監聽埠往往只需要在系統啟動時才會呼叫,因此反射對效能的影響並不大。相關程式碼如下所示:
步驟4:鏈路建立的時候建立並初始化ChannelPipeline。ChannelPipeline並不是NIO服務端必需的,它本質就是一個負責處理網路事件的職責鏈,負責管理和執行ChannelHandler。網路事件以事件流的形式在ChannelPipeline中流轉,由ChannelPipeline根據ChannelHandler的執行策略排程ChannelHandler的執行。典型的網路事件如下:
鏈路註冊;
鏈路啟用;
鏈路斷開;
接收到請求訊息;
請求訊息接收並處理完畢;
傳送應答訊息;
鏈路發生異常;
發生使用者自定義事件。
步驟5:初始化ChannelPipeline完成之後,新增並設定ChannelHandler。ChannelHandler是Netty提供給使用者定製和擴充套件的關鍵介面。利用ChannelHandler使用者可以完成大多數的功能定製,例如訊息編解碼、心跳、安全認證、TSL/SSL認證、流量控制和流量整形等。Netty同時也提供了大量的系統ChannelHandler供使用者使用,比較實用的系統ChannelHandler總結如下:
系統編解碼框架-ByteToMessageCodec;
通用基於長度的半包解碼器-LengthFieldBasedFrameDecoder;
碼流日誌列印Handler-LoggingHandler;
SSL安全認證Handler-SslHandler;
鏈路空閒檢測Handler-IdleStateHandler;
流量整形Handler-ChannelTrafficShapingHandler;
Base64編解碼-Base64Decoder和Base64Encoder。
建立和新增ChannelHandler的程式碼示例如下:
步驟6:繫結並啟動監聽埠。在繫結監聽埠之前系統會做一系列的初始化和檢測工作,完成之後,會啟動監聽埠,並將ServerSocketChannel註冊到Selector上監聽客戶端連線,相關程式碼如下:
步驟8:當輪詢到準備就緒的Channel之後,就由Reactor執行緒NioEventLoop執行ChannelPipeline的相應方法,最終排程並執行ChannelHandler,程式碼如下:
步驟9:執行Netty系統ChannelHandler和使用者新增定製的ChannelHandler。ChannelPipeline根據網路事件的型別,排程並執行ChannelHandler,相關程式碼如下所示:
2.2.2. Netty服務端建立原始碼分析
首先通過建構函式建立ServerBootstrap例項,隨後,通常會建立兩個EventLoopGroup(並不是必須要建立兩個不同的EventLoopGroup,也可以只建立一個並共享),程式碼如下圖所示:
NioEventLoopGroup實際就是Reactor執行緒池,負責排程和執行客戶端的接入、網路讀寫事件的處理、使用者自定義任務和定時任務的執行。通過ServerBootstrap的group方法將兩個EventLoopGroup例項傳入,程式碼如下:
其中父NioEventLoopGroup被傳入了父類建構函式中:
該方法會被客戶端和服務端重用,用於執行和排程網路事件的讀寫。
執行緒組和執行緒型別設定完成後,需要設定服務端Channel,Netty通過Channel工廠類來建立不同型別的Channel,對於服務端,需要建立NioServerSocketChannel,所以,通過指定Channel型別的方式建立Channel工廠。ServerBootstrapChannelFactory是ServerBootstrap的內部靜態類,職責是根據Channel的型別通過反射建立Channel的例項,服務端需要建立的是NioServerSocketChannel例項,程式碼如下:
指定NioServerSocketChannel後,需要設定TCP的一些引數,作為服務端,主要是要設定TCP的 backlog引數,底層C的對應介面定義如下:
int listen(int fd, int backlog);
backlog指定了核心為此套介面排隊的最大連線個數,對於給定的監聽套介面,核心要維護兩個佇列,未連結佇列和已連線佇列,根據TCP三路握手過程中三個分節來分隔這兩個佇列。伺服器處於listen狀態時收到客戶端syn 分節(connect)時在未完成佇列中建立一個新的條目,然後用三路握手的第二個分節即伺服器的syn 響應及對客戶端syn的ack,此條目在第三個分節到達前(客戶端對伺服器syn的ack)一直保留在未完成連線佇列中,如果三路握手完成,該條目將從未完成連線佇列搬到已完成連線佇列尾部。當程序呼叫accept時,從已完成佇列中的頭部取出一個條目給程序,當已完成佇列為空時程序將睡眠,直到有條目在已完成連線佇列中才喚醒。backlog被規定為兩個佇列總和的最大值,大多數實現預設值為5,但在高併發web伺服器中此值顯然不夠,lighttpd中此值達到128*8 。需要設定此值更大一些的原因是未完成連線佇列的長度可能因為客戶端SYN的到達及等待三路握手第三個分節的到達延時而增大。Netty預設的backlog為100,當然,使用者可以修改預設值,使用者需要根據實際場景和網路狀況進行靈活設定。
TCP引數設定完成後,使用者可以為啟動輔助類和其父類分別指定Handler,兩類Handler的用途不同,子類中的Hanlder是NioServerSocketChannel對應的ChannelPipeline的Handler,父類中的Hanlder是客戶端新接入的連線SocketChannel對應的ChannelPipeline的Handler。兩者的區別可以通過下圖來展示:
圖2-3 ServerBootstrap的Hanlder模型
本質區別就是:ServerBootstrap中的Handler是NioServerSocketChannel使用的,所有連線該監聽埠的客戶端都會執行它,父類AbstractBootstrap中的Handler是個工廠類,它為每個新接入的客戶端都建立一個新的Handler。
服務端啟動的最後一步,就是繫結本地埠,啟動服務,下面我們來分析下這部分程式碼:
先看下NO.1, 首先建立Channel,createChannel由子類ServerBootstrap實現,建立新的NioServerSocketChannel,它有兩個引數,引數1是從父類的NIO執行緒池中順序獲取一個NioEventLoop,它就是服務端用於監聽和接收客戶端連線的Reactor執行緒。第二個引數就是所謂的workerGroup執行緒池,它就是處理IO讀寫的Reactor執行緒組,相關程式碼如下:
NioServerSocketChannel建立成功後對它進行初始化,初始化工作主要有三點:
1、設定Socket引數和NioServerSocketChannel的附加屬性,程式碼如下:
2、將AbstractBootstrap的Handler新增到NioServerSocketChannel的ChannelPipeline中,程式碼如下:
3、將用於服務端註冊的Handler ServerBootstrapAcceptor新增到ChannelPipeline中,程式碼
到此處,Netty服務端監聽的相關資源已經初始化完畢,就剩下最後一步-註冊NioServerSocketChannel到Reactor執行緒的多路複用器上,然後輪詢客戶端連線事件。在分析註冊程式碼之前,我們先通過下圖看看目前NioServerSocketChannel的ChannelPipeline的組成:
圖2-4 NioServerSocketChannel的ChannelPipeline
最後,我們看下NioServerSocketChannel的註冊。當NioServerSocketChannel初始化完成之後,需要將它註冊到Reactor執行緒的多路複用器上監聽新客戶端的接入,程式碼如下:
首先判斷是否是NioEventLoop自身發起的操作,如果是,則不存在併發操作,直接執行Channel註冊;如果由其它執行緒發起,則封裝成一個Task放入訊息佇列中非同步執行。此處,由於是由ServerBootstrap所線上程執行的註冊操作,所以會將其封裝成Task投遞到NioEventLoop中執行,程式碼如下:
將NioServerSocketChannel註冊到NioEventLoop的Selector上,程式碼如下:
大夥兒可能會很詫異,應該註冊OP_ACCEPT(16)到多路複用器上,怎麼註冊0呢?0表示只註冊,不監聽任何網路操作。這樣做的原因如下:
註冊方法是多型的,它既可以被NioServerSocketChannel用來監聽客戶端的連線接入,也可以用來註冊SocketChannel,用來監聽網路讀或者寫操作;
通過SelectionKey的interestOps(int ops)方法可以方便的修改監聽操作位。所以,此處註冊需要獲取SelectionKey並給AbstractNioChannel的成員變數selectionKey賦值。
註冊成功之後,觸發ChannelRegistered事件,方法如下:
Netty的HeadHandler不需要處理ChannelRegistered事件,所以,直接呼叫下一個Handler,程式碼如下:
當ChannelRegistered事件傳遞到TailHandler後結束,TailHandler也不關心ChannelRegistered事件,因此是空實現,程式碼如下:
ChannelRegistered事件傳遞完成後,判斷ServerSocketChannel監聽是否成功,如果成功,需要出發NioServerSocketChannel的ChannelActive事件,程式碼如下:
isActive()也是個多型方法,如果是服務端,判斷監聽是否啟動,如果是客戶端,判斷TCP連線是否完成。ChannelActive事件在ChannelPipeline中傳遞,完成之後根據配置決定是否自動觸發Channel的讀操作,程式碼如下:
AbstractChannel的讀操作觸發ChannelPipeline的讀操作,最終呼叫到HeadHandler的讀方法,程式碼如下:
繼續看AbstractUnsafe的beginRead方法,程式碼如下:
由於不同型別的Channel對讀操作的準備工作不同,因此,beginRead也是個多型方法,對於NIO通訊,無論是客戶端還是服務端,都是要修改網路監聽操作位為自身感興趣的,對於NioServerSocketChannel感興趣的操作是OP_ACCEPT(16),於是重新修改註冊的操作位為OP_ACCEPT,程式碼如下:
在某些場景下,當前監聽的操作型別和Chanel關心的網路事件是一致的,不需要重複註冊,所以增加了&操作的判斷,只有兩者不一致,才需要重新註冊操作位。
JDK SelectionKey有四種操作型別,分別為:
OP_READ = 1 << 0;
OP_WRITE = 1 << 2;
OP_CONNECT = 1 << 3;
OP_ACCEPT = 1 << 4。
由於只有四種網路操作型別,所以用4 bit就可以表示所有的網路操作位,由於JAVA語言沒有bit型別,所以使用了整形來表示,每個操作位代表一種網路操作型別,分別為:0001、0010、0100、1000,這樣做的好處是可以非常方便的通過位操作來進行網路操作位的狀態判斷和狀態修改,提升操作效能。
由於建立NioServerSocketChannel將readInterestOp設定成了OP_ACCEPT,所以,在服務端鏈路註冊成功之後重新將操作位設定為監聽客戶端的網路連線操作,初始化NioServerSocketChannel的程式碼如下:
到此,服務端監聽啟動部分原始碼已經分析完成,接下來,讓我們繼續分析一個新的客戶端是如何接入的。
2.3. 客戶端接入原始碼分析
負責處理網路讀寫、連線和客戶端請求接入的Reactor執行緒就是NioEventLoop,下面我們分析下NioEventLoop是如何處理新的客戶端連線接入的。當多路複用器檢測到新的準備就緒的Channel時,預設執行processSelectedKeysOptimized方法,程式碼如下:
由於Channel的Attachment是NioServerSocketChannel,所以執行processSelectedKey方法,根據就緒的操作位,執行不同的操作,此處,由於監聽的是連線操作,所以執行unsafe.read()方法,由於不同的Channel執行不同的操作,所以NioUnsafe被設計成介面,由不同的Channel內部的NioUnsafe實現類負責具體實現,我們發現read()方法的實現有兩個,分別是NioByteUnsafe和NioMessageUnsafe,對於NioServerSocketChannel,它使用的是NioMessageUnsafe,它的read方法程式碼如下:
對doReadMessages方法進行分析,發現它實際就是接收新的客戶端連線並建立NioSocketChannel程式碼如下:
接收到新的客戶端連線後,觸發ChannelPipeline的ChannelRead方法,程式碼如下:
執行headChannelHandlerContext的fireChannelRead方法,事件在ChannelPipeline中傳遞,執行ServerBootstrapAcceptor的channelRead方法,程式碼如下:
該方法包含三個主要步驟:
第一步:將啟動時傳入的childHandler加入到客戶端SocketChannel的ChannelPipeline中;
第二步:設定客戶端SocketChannel的TCP引數;
第三步:註冊SocketChannel到多路複用器。
channelRead主要執行如上圖所示的三個方法,下面我們展開看下NioSocketChannel的register方法,程式碼如下所示:
NioSocketChannel的註冊方法與ServerSocketChannel的一致,也是將Channel 註冊到Reactor執行緒的多路複用器上,由於註冊的操作位是0,所以,此時NioSocketChannel還不能讀取客戶端傳送的訊息,那什麼時候修改監聽操作位為OP_READ呢,彆著急,繼續看程式碼。
執行完註冊操作之後,緊接著會觸發ChannelReadComplete事件,我們繼續分析ChannelReadComplete在ChannelPipeline中的處理流程:Netty的Header和Tail本身不關注ChannelReadComplete事件就直接透傳,執行完ChannelReadComplete後,接著執行PipeLine的read()方法,最終執行HeadHandler的read()方法,程式碼如下:
後面的程式碼已經在之前的小節已經介紹過,用來修改網路操作位為讀操作,建立NioSocketChannel的時候已經將AbstractNioChannel的readInterestOp設定為OP_READ,這樣,執行selectionKey.interestOps(interestOps | readInterestOp)操作時就會把操作位設定為OP_READ。程式碼如下:
到此,新接入的客戶端連線處理完成,可以進行網路讀寫等I/O操作。