1. 程式人生 > >Netty之旅:你想要的NIO知識點,這裡都有!

Netty之旅:你想要的NIO知識點,這裡都有!

![NIO思維導圖.png](https://s1.ax1x.com/2020/07/21/UTeKc4.png) 高清思維導圖原件(`xmind/pdf/jpg`)可以關注公眾號:`一枝花算不算浪漫` 回覆`nio`即可。(文末有二維碼) ## 前言 抱歉好久沒更原創文章了,看了下上篇更新時間,已經拖更一個多月了。 這段時間也一直在學習`Netty`相關知識,因為涉及知識點比較多,也走了不少彎路。目前網上關於Netty學習資料玲琅滿目,不知如何下手,其實大家都是一樣的,學習方法和技巧都是總結出來的,我們在沒有找到很好的方法之前不如按部就班先從基礎開始,一般從總分總的漸進方式,既觀森林,又見草木。 之前恰巧跟杭州一個朋友**小飛**也提到過,兩者在這方面的初衷是一致的,也希望更多的朋友能夠加入一起學習和探討。**(PS:本篇文章是和小飛一起學習整理所得~)** `Netty`是一款提供非同步的、事件驅動的網路應用程式框架和工具,是基於`NIO`客戶端、伺服器端的程式設計框架。所以這裡我們先以`NIO`和依賴相關的基礎鋪墊來進行剖析講解,從而作為`Netty`學習之旅的一個開端。 ## 一、網路程式設計基礎回顧 ### 1. Socket `Socket`本身有“插座”的意思,不是Java中特有的概念,而是一個語言無關的標準,任何可以實現網路程式設計的程式語言都有`Socket`。在`Linux`環境下,用於表示程序間網路通訊的特殊檔案型別,其本質為核心藉助緩衝區形成的偽檔案。既然是檔案,那麼理所當然的,我們可以使用檔案描述符引用套接字。 與管道類似的,`Linux`系統將其封裝成檔案的目的是為了統一介面,使得讀寫套接字和讀寫檔案的操作一致。區別是管道主要應用於本地程序間通訊,而套接字多應用於網路程序間資料的傳遞。 可以這麼理解:`Socket`就是網路上的兩個應用程式通過一個雙向通訊連線實現資料交換的程式設計介面API。 `Socket`通訊的基本流程具體步驟如下所示: (1)服務端通過`Listen`開啟監聽,等待客戶端接入。 (2)客戶端的套接字通過`Connect`連線伺服器端的套接字,服務端通過`Accept`接收客戶端連線。在`connect-accept`過程中,作業系統將會進行三次握手。 (3)客戶端和服務端通過`write`和`read`傳送和接收資料,作業系統將會完成`TCP`資料的確認、重發等步驟。 (4)通過`close`關閉連線,作業系統會進行四次揮手。 針對Java程式語言,`java.net`包是網路程式設計的基礎類庫。其中`ServerSocket`和`Socket`是網路程式設計的基礎型別。 `SeverSocket`是服務端應用型別。`Socket`是建立連線的型別。當連線建立成功後,伺服器和客戶端都會有一個`Socket`物件示例,可以通過這個`Socket`物件示例,完成會話的所有操作。對於一個完整的網路連線來說,`Socket`是平等的,沒有伺服器客戶端分級情況。 ### 2. IO模型介紹 對於一次IO操作,資料會先拷貝到核心空間中,然後再從核心空間拷貝到使用者空間中,所以一次`read`操作,會經歷兩個階段: (1)等待資料準備 (2)資料從核心空間拷貝到使用者空間 基於以上兩個階段就產生了五種不同的IO模式。 1. 阻塞IO:從程序發起IO操作,一直等待上述兩個階段完成,此時兩階段一起阻塞。 2. 非阻塞IO:程序一直詢問IO準備好了沒有,準備好了再發起讀取操作,這時才把資料從核心空間拷貝到使用者空間。第一階段不阻塞但要輪詢,第二階段阻塞。 3. 多路複用IO:多個連線使用同一個select去詢問IO準備好了沒有,如果有準備好了的,就返回有資料準備好了,然後對應的連線再發起讀取操作,把資料從核心空間拷貝到使用者空間。兩階段分開阻塞。 4. 訊號驅動IO:程序發起讀取操作會立即返回,當資料準備好了會以通知的形式告訴程序,程序再發起讀取操作,把資料從核心空間拷貝到使用者空間。第一階段不阻塞,第二階段阻塞。 5. 非同步IO:程序發起讀取操作會立即返回,等到資料準備好且已經拷貝到使用者空間了再通知程序拿資料。兩個階段都不阻塞。 這五種IO模式不難發現存在這兩對關係:同步和非同步、阻塞和非阻塞。那麼稍微解釋一下: #### 同步和非同步 - **同步:** 同步就是發起一個呼叫後,被呼叫者未處理完請求之前,呼叫不返回。 - **非同步:** 非同步就是發起一個呼叫後,立刻得到被呼叫者的迴應表示已接收到請求,但是被呼叫者並沒有返回結果,此時我們可以處理其他的請求,被呼叫者通常依靠事件,回撥等機制來通知呼叫者其返回結果。 同步和非同步的區別最大在於非同步的話呼叫者不需要等待處理結果,被呼叫者會通過回撥等機制來通知呼叫者其返回結果。 #### 阻塞和非阻塞 - **阻塞:** 阻塞就是發起一個請求,呼叫者一直等待請求結果返回,也就是當前執行緒會被掛起,無法從事其他任務,只有當條件就緒才能繼續。 - **非阻塞:** 非阻塞就是發起一個請求,呼叫者不用一直等著結果返回,可以先去幹其他事情。 阻塞和非阻塞是針對程序在訪問資料的時候,根據IO操作的就緒狀態來採取的不同方式,說白了是一種讀取或者寫入操作方法的實現方式,阻塞方式下讀取或者寫入函式將一直等待,而非阻塞方式下,讀取或者寫入方法會立即返回一個狀態值。 如果組合後的同步阻塞(`blocking-IO`)簡稱`BIO`、同步非阻塞(`non-blocking-IO`)簡稱`NIO`和非同步非阻塞(`asynchronous-non-blocking-IO`)簡稱`AIO`又代表什麼意思呢? - **BIO** (同步阻塞I/O模式): 資料的讀取寫入必須阻塞在一個執行緒內等待其完成。這裡使用那個經典的燒開水例子,這裡假設一個燒開水的場景,有一排水壺在燒開水,BIO的工作模式就是, 叫一個執行緒停留在一個水壺那,直到這個水壺燒開,才去處理下一個水壺。但是實際上執行緒在等待水壺燒開的時間段什麼都沒有做。 - **NIO**(同步非阻塞): 同時支援阻塞與非阻塞模式,但這裡我們以其同步非阻塞I/O模式來說明,那麼什麼叫做同步非阻塞?如果還拿燒開水來說,NIO的做法是叫一個執行緒不斷的輪詢每個水壺的狀態,看看是否有水壺的狀態發生了改變,從而進行下一步的操作。 - **AIO**(非同步非阻塞I/O模型): 非同步非阻塞與同步非阻塞的區別在哪裡?非同步非阻塞無需一個執行緒去輪詢所有IO操作的狀態改變,在相應的狀態改變後,系統會通知對應的執行緒來處理。對應到燒開水中就是,為每個水壺上面裝了一個開關,水燒開之後,水壺會自動通知我水燒開了。 `java` 中的 `BIO`、`NIO`和`AIO`理解為是 `Java 語言`在作業系統層面對這三種 `IO` 模型的封裝。程式設計師在使用這些 封裝API 的時候,不需要關心作業系統層面的知識,也不需要根據不同作業系統編寫不同的程式碼,只需要使用`Java`的API就可以了。由此,為了使讀者對這三種模型有個比較具體和遞推式的瞭解,並且和本文主題`NIO`有個清晰的對比,下面繼續延伸。 #### Java BIO `BIO`程式設計方式通常是是Java的上古產品,自JDK 1.0-JDK1.4就有的東西。程式設計實現過程為:首先在服務端啟動一個`ServerSocket`來監聽網路請求,客戶端啟動`Socket`發起網路請求,預設情況下`SeverSocket`會建立一個執行緒來處理此請求,如果服務端沒有執行緒可用,客戶端則會阻塞等待或遭到拒絕。伺服器實現模式為一個連線一個執行緒,即客戶端有連線請求時伺服器端就需要啟動一個執行緒進行處理。大致結構如下: ![aJ2i9K.png](https://s1.ax1x.com/2020/08/02/aJ2i9K.png) 如果要讓 `BIO` 通訊模型能夠同時處理多個客戶端請求,就必須使用多執行緒(主要原因是 `socket.accept()`、`socket.read()`、 `socket.write()` 涉及的三個主要函式都是同步阻塞的),也就是說它在接收到客戶端連線請求之後為每個客戶端建立一個新的執行緒進行鏈路處理,處理完成之後,通過輸出流返回應答給客戶端,執行緒銷燬。這就是典型的 一請求一應答通訊模型 。我們可以設想一下如果這個連線不做任何事情的話就會造成不必要的執行緒開銷,不過可以通過**執行緒池機制**改善,執行緒池還可以讓執行緒的建立和回收成本相對較低。使用執行緒池機制改善後的 `BIO` 模型圖如下: ![aJ2NEn.png](https://s1.ax1x.com/2020/08/02/aJ2NEn.png) `BIO`方式適用於連線數目比較小且固定的架構,這種方式對伺服器資源要求比較高,併發侷限於應用中,是JDK1.4以前的唯一選擇,但程式直觀簡單易懂。`Java BIO`程式設計示例網上很多,這裡就不進行coding舉例了,畢竟後面`NIO`才是重點。 #### Java NIO `NIO`(New IO或者No-Blocking IO),從JDK1.4 開始引入的`非阻塞IO`,是一種`非阻塞`+ `同步`的通訊模式。這裡的`No Blocking IO`用於區分上面的`BIO`。 `NIO`本身想解決 `BIO`的併發問題,通過`Reactor模式`的事件驅動機制來達到`Non Blocking`的。當 `socket` 有流可讀或可寫入 `socket` 時,作業系統會相應的通知應用程式進行處理,應用再將流讀取到緩衝區或寫入作業系統。 也就是說,這個時候,已經不是一個連線就 要對應一個處理執行緒了,而是有效的請求,對應一個執行緒,當連線沒有資料時,是沒有工作執行緒來處理的。 當一個連線建立後,不需要對應一個執行緒,這個連線會被註冊到 `多路複用器`上面,所以所有的連線只需要一個執行緒就可以搞定,當這個執行緒中的`多路複用器` 進行輪詢的時候,發現連線上有請求的話,才開啟一個執行緒進行處理,也就是一個請求一個執行緒模式。 `NIO`提供了與傳統BIO模型中的`Socket`和`ServerSocket`相對應的`SocketChannel`和`ServerSocketChannel`兩種不同的套接字通道實現,如下圖結構所示。這裡涉及的`Reactor`設計模式、多路複用`Selector`、`Buffer`等暫時不用管,後面會講到。 ![aJ2a40.png](https://s1.ax1x.com/2020/08/02/aJ2a40.png) NIO 方式適用於連線數目多且連線比較短(輕操作)的架構,比如聊天伺服器,併發局 限於應用中,程式設計複雜,JDK1.4 開始支援。同時,`NIO`和普通IO的區別主要可以從儲存資料的載體、是否阻塞等來區分: ![NIO和普通IO區別.png](https://s1.ax1x.com/2020/07/21/UTmyL9.png) #### Java AIO 與 `NIO` 不同,當進行讀寫操作時,只須直接呼叫 API 的 `read` 或 `write` 方法即可。這兩種方法均為非同步的,對於讀操作而言,當有流可讀取時,作業系統會將可讀的流傳入 `read` 方 法的緩衝區,並通知應用程式;對於寫操作而言,當作業系統將 `write` 方法傳遞的流寫入完畢時,作業系統主動通知應用程式。即可以理解為,`read/write` 方法都是非同步的,完成後會主動呼叫回撥函式。在 `JDK7` 中,提供了非同步檔案通道和非同步套接字通道的實現,這部分內容被稱作 `NIO`. `AIO` 方式使用於連線數目多且連線比較長(重操作)的架構,比如相簿伺服器,充分呼叫 `OS` 參與併發操作,程式設計比較複雜,`JDK7` 開始支援。 目前來說 `AIO` 的應用還不是很廣泛,`Netty` 之前也嘗試使用過 `AIO`,不過又放棄了。 ## 二、NIO核心元件介紹 ### 1. Channel 在`NIO`中,基本所有的IO操作都是從`Channel`開始的,`Channel`通過`Buffer(緩衝區)`進行讀寫操作。 `read()`表示讀取通道中資料到緩衝區,`write()`表示把緩衝區資料寫入到通道。 ![Channel和Buffer互相操作.png](https://s1.ax1x.com/2020/07/22/UTcC26.png) `Channel`有好多實現類,這裡有三個最常用: - `SocketChannel`:一個客戶端發起TCP連線的Channel - `ServerSocketChannel`:一個服務端監聽新連線的TCP Channel,對於每一個新的Client連線,都會建立一個對應的SocketChannel - `FileChannel`:從檔案中讀寫資料 其中`SocketChannel`和`ServerSocketChannel`是網路程式設計中最常用的,一會在最後的示例程式碼中會有講解到具體用法。 ### 2. Buffer #### 概念 `Buffer`也被成為記憶體緩衝區,本質上就是記憶體中的一塊,我們可以將資料寫入這塊記憶體,之後從這塊記憶體中讀取資料。也可以將這塊記憶體封裝成`NIO Buffer`物件,並提供一組常用的方法,方便我們對該塊記憶體進行讀寫操作。 `Buffer`在`java.nio`中被定義為抽象類: ![Buffer結構體系.png](https://s1.ax1x.com/2020/07/22/UTcYIs.png) 我們可以將`Buffer`理解為一個數組的封裝,我們最常用的`ByteBuffer`對應的資料結構就是`byte[]` #### 屬性 `Buffer`中有4個非常重要的屬性:**capacity、limit、position、mark** ![Buffer中基本屬性.png](https://s1.ax1x.com/2020/07/22/UTcdzV.png) - `capacity`屬性:容量,Buffer能夠容納的資料元素的最大值,在Buffer初始化建立的時候被賦值,而且不能被修改。 ![capacity.png](https://s1.ax1x.com/2020/07/22/UTcTdH.png) 上圖中,初始化Buffer的容量為8(圖中從0~7,共8個元素),所以**capacity = 8** - `limit`屬性:代表Buffer可讀可寫的上限。 - 寫模式下:`limit` 代表能寫入資料的上限位置,這個時候`limit = capacity` 讀模式下:在`Buffer`完成所有資料寫入後,通過呼叫`flip()`方法,切換到讀模式,此時`limit`等於`Buffer`中實際已經寫入的資料大小。因為`Buffer`可能沒有被寫滿,所以**limit<=capacity** - `position`屬性:代表讀取或者寫入`Buffer`的位置。預設為0。 - 寫模式下:每往`Buffer`中寫入一個值,`position`就會自動加1,代表下一次寫入的位置。 - 讀模式下:每往`Buffer`中讀取一個值,`position`就自動加1,代表下一次讀取的位置。 ![NIO屬性概念.png](https://s1.ax1x.com/2020/07/22/UTcLWt.png) 從上圖就能很清晰看出,讀寫模式下**capacity、limit、position**的關係了。 - `mark`屬性:代表標記,通過mark()方法,記錄當前position值,將position值賦值給mark,在後續的寫入或讀取過程中,可以通過reset()方法恢復當前position為mark記錄的值。 這幾個重要屬性講完,我們可以再來回顧下: > 0 <= mark <= position <= limit <= capacity 現在應該很清晰這幾個屬性的關係了~ #### Buffer常見操作 ##### 建立Buffer - `allocate(int capacity)` ```java ByteBuffer buffer = ByteBuffer.allocate(1024); int count = channel.read(buffer); ``` 例子中建立的`ByteBuffer`是基於堆記憶體的一個物件。 - `wrap(array)` `wrap`方法可以將陣列包裝成一個`Buffer`物件: ```java ByteBuffer buffer = ByteBuffer.wrap("hello world".getBytes()); channel.write(buffer); ``` - `allocateDirect(int capacity)` 通過`allocateDirect`方法也可以快速例項化一個`Buffer`物件,和`allocate`很相似,這裡區別的是`allocateDirect`建立的是基於**堆外記憶體**的物件。 堆外記憶體不在JVM堆上,不受GC的管理。堆外記憶體進行一些底層系統的IO操作時,效率會更高。 ##### Buffer寫操作 `Buffer`寫入可以通過`put()`和`channel.read(buffer)`兩種方式寫入。 通常我們NIO的讀操作的時候,都是從`Channel`中讀取資料寫入`Buffer`,這個對應的是`Buffer`的**寫操作**。 ##### Buffer讀操作 `Buffer`讀取可以通過`get()`和`channel.write(buffer)`兩種方式讀入。 還是同上,我們對`Buffer`的讀入操作,反過來說就是對`Channel`的**寫操作**。讀取`Buffer`中的資料然後寫入`Channel`中。 ![Channel和Buffer互相操作.png](https://s1.ax1x.com/2020/07/22/UTcC26.png) ##### 其他常見方法 - `rewind()`:重置position位置為0,可以重新讀取和寫入buffer,一般該方法適用於讀操作,可以理解為對buffer的重複讀。 ```java public final Buffer rewind() { position = 0; mark = -1; return this; } ``` - `flip()`:很常用的一個方法,一般在寫模式切換到讀模式的時候會經常用到。也會將position設定為0,然後設定limit等於原來寫入的position。 ```java public final Buffer flip() { limit = position; position = 0; mark = -1; return this; } ``` - `clear()`:重置buffer中的資料,該方法主要是針對於寫模式,因為limit設定為了capacity,讀模式下會出問題。 ```java public final Buffer clear() { position = 0; limit = capacity; mark = -1; return this; } ``` - `mark()&reset()`: `mark()`方法是儲存當前`position`到變數`mark`z中,然後通過`reset()`方法恢復當前`position`為`mark`,實現程式碼很簡單,如下: ```java public final Buffer mark() { mark = position; return this; } public final Buffer reset() { int m = mark; if (m < 0) throw new InvalidMarkException(); position = m; return this; } ``` 常用的讀寫方法可以用一張圖總結一下: ![Buffer讀寫操作.png](https://s1.ax1x.com/2020/07/21/UTm956.png) ### 3. Selector #### 概念 `Selector`是NIO中最為重要的元件之一,我們常常說的`多路複用器`就是指的`Selector`元件。 `Selector`元件用於輪詢一個或多個`NIO Channel`的狀態是否處於可讀、可寫。通過輪詢的機制就可以管理多個Channel,也就是說可以管理多個網路連線。 ![Selector原理圖.png](https://s1.ax1x.com/2020/07/30/auU6XR.png) #### 輪詢機制 1. 首先,需要將Channel註冊到Selector上,這樣Selector才知道需要管理哪些Channel 2. 接著Selector會不斷輪詢其上註冊的Channel,如果某個Channel發生了讀或寫的時間,這個Channel就會被Selector輪詢出來,然後通過SelectionKey可以獲取就緒的Channel集合,進行後續的IO操作。 ![輪詢機制.png](https://s1.ax1x.com/2020/07/30/auBCGT.png) #### 屬性操作 1. 建立Selector 通過`open()`方法,我們可以建立一個`Selector`物件。 ```java Selector selector = Selector.open(); ``` 2. 註冊Channel到Selector中 我們需要將`Channel`註冊到`Selector`中,才能夠被`Selector`管理。 ```java channel.configureBlocking(false); SelectionKey key = channel.register(selector, SelectionKey.OP_READ); ``` 某個`Channel`要註冊到`Selector`中,那麼該Channel必須是**非阻塞**,所有上面程式碼中有個`configureBlocking()`的配置操作。 在`register(Selector selector, int interestSet)`方法的第二個引數,標識一個`interest`集合,意思是Selector對哪些事件感興趣,可以監聽四種不同型別的事件: ```java public static final int OP_READ = 1 << 0; public static final int OP_WRITE = 1 << ; public static final int OP_CONNECT = 1 << 3; public static final int OP_ACCEPT = 1 << 4; ``` - `Connect事件` :連線完成事件( TCP 連線 ),僅適用於客戶端,對應 SelectionKey.OP_CONNECT。 - `Accept事件` :接受新連線事件,僅適用於服務端,對應 SelectionKey.OP_ACCEPT 。 - `Read事件` :讀事件,適用於兩端,對應 SelectionKey.OP_READ ,表示 Buffer 可讀。 - `Write事件` :寫時間,適用於兩端,對應 SelectionKey.OP_WRITE ,表示 Buffer 可寫。 `Channel`觸發了一個事件,表明該時間已經準備就緒: - 一個Client Channel成功連線到另一個伺服器,成為“連線就緒” - 一個Server Socket準備好接收新進入的接,稱為“接收就緒” - 一個有資料可讀的Channel,稱為“讀就緒” - 一個等待寫資料的Channel,稱為”寫就緒“ 當然,`Selector`是可以同時對多個事件感興趣的,我們使用或運算即可組合多個事件: ```java int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE; ``` #### Selector其他一些操作 ##### 選擇Channel ```java public abstract int select() throws IOException; public abstract int select(long timeout) throws IOException; public abstract int selectNow() throws IOException; ``` 當Selector執行`select()`方法就會產生阻塞,等到註冊在其上的Channel準備就緒就會立即返回,返回準備就緒的數量。 `select(long timeout)`則是在`select()`的基礎上增加了超時機制。 `selectNow()`立即返回,不產生阻塞。 **有一點非常需要注意:** `select` 方法返回的 `int` 值,表示有多少 `Channel` 已經就緒。 自上次呼叫`select` 方法後有多少 `Channel` 變成就緒狀態。如果呼叫 `select` 方法,因為有一個 `Channel` 變成就緒狀態則返回了 1 ; 若再次呼叫 `select` 方法,如果另一個 `Channel` 就緒了,它會再次返回1。 ##### 獲取可操作的Channel ```java Set selectedKeys = selector.selectedKeys(); ``` 當有新增就緒的`Channel`,呼叫`select()`方法,就會將key新增到Set集合中。 ## 三、程式碼示例 前面鋪墊了這麼多,主要是想讓大家能夠看懂`NIO`程式碼示例,也方便後續大家來自己手寫`NIO` 網路程式設計的程式。建立NIO服務端的主要步驟如下: > ``` > 1. 開啟ServerSocketChannel,監聽客戶端連線 > 2. 繫結監聽埠,設定連線為非阻塞模式 > 3. 建立Reactor執行緒,建立多路複用器並啟動執行緒 > 4. 將ServerSocketChannel註冊到Reactor執行緒中的Selector上,監聽ACCEPT事件 > 5. Selector輪詢準備就緒的key > 6. Selector監聽到新的客戶端接入,處理新的接入請求,完成TCP三次握手,建立物理鏈路 > 7. 設定客戶端鏈路為非阻塞模式 > 8. 將新接入的客戶端連線註冊到Reactor執行緒的Selector上,監聽讀操作,讀取客戶端傳送的網路訊息 > 9. 非同步讀取客戶端訊息到緩衝區 > 10.對Buffer編解碼,處理半包訊息,將解碼成功的訊息封裝成Task > 11.將應答訊息編碼為Buffer,呼叫SocketChannel的write將訊息非同步傳送給客戶端 > ``` `NIOServer.java` : ```java public class NIOServer { private static Selector selector; public static void main(String[] args) { init(); listen(); } private static void init() { ServerSocketChannel serverSocketChannel = null; try { selector = Selector.open(); serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.configureBlocking(false); serverSocketChannel.socket().bind(new InetSocketAddress(9000)); serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); System.out.println("NioServer 啟動完成"); } catch (IOException e) { e.printStackTrace(); } } private static void listen() { while (true) { try { selector.select(); Iterator keysIterator = selector.selectedKeys().iterator(); while (keysIterator.hasNext()) { SelectionKey key = keysIterator.next(); keysIterator.remove(); handleRequest(key); } } catch (Throwable t) { t.printStackTrace(); } } } private static void handleRequest(SelectionKey key) throws IOException { SocketChannel channel = null; try { if (key.isAcceptable()) { ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel(); channel = serverSocketChannel.accept(); channel.configureBlocking(false); System.out.println("接受新的 Channel"); channel.register(selector, SelectionKey.OP_READ); } if (key.isReadable()) { channel = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); int count = channel.read(buffer); if (count > 0) { System.out.println("服務端接收請求:" + new String(buffer.array(), 0, count)); channel.register(selector, SelectionKey.OP_WRITE); } } if (key.isWritable()) { ByteBuffer buffer = ByteBuffer.allocate(1024); buffer.put("收到".getBytes()); buffer.flip(); channel = (SocketChannel) key.channel(); channel.write(buffer); channel.register(selector, SelectionKey.OP_READ); } } catch (Throwable t) { t.printStackTrace(); if (channel != null) { channel.close(); } } } } ``` `NIOClient.java`: ```java public class NIOClient { public static void main(String[] args) { new Worker().start(); } static class Worker extends Thread { @Override public void run() { SocketChannel channel = null; Selector selector = null; try { channel = SocketChannel.open(); channel.configureBlocking(false); selector = Selector.open(); channel.register(selector, SelectionKey.OP_CONNECT); channel.connect(new InetSocketAddress(9000)); while (true) { selector.select(); Iterator keysIterator = selector.selectedKeys().iterator(); while (keysIterator.hasNext()) { SelectionKey key = keysIterator.next(); keysIterator.remove(); if (key.isConnectable()) { System.out.println(); channel = (SocketChannel) key.channel(); if (channel.isConnectionPending()) { channel.finishConnect(); ByteBuffer buffer = ByteBuffer.allocate(1024); buffer.put("你好".getBytes()); buffer.flip(); channel.write(buffer); } channel.register(selector, SelectionKey.OP_READ); } if (key.isReadable()) { channel = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); int len = channel.read(buffer); if (len > 0) { System.out.println("[" + Thread.currentThread().getName() + "]收到響應:" + new String(buffer.array(), 0, len)); Thread.sleep(5000); channel.register(selector, SelectionKey.OP_WRITE); } } if(key.isWritable()) { ByteBuffer buffer = ByteBuffer.allocate(1024); buffer.put("你好".getBytes()); buffer.flip(); channel = (SocketChannel) key.channel(); channel.write(buffer); channel.register(selector, SelectionKey.OP_READ); } } } } catch (Exception e) { e.printStackTrace(); } finally{ if(channel != null){ try { channel.close(); } catch (IOException e) { e.printStackTrace(); } } if(selector != null){ try { selector.close(); } catch (IOException e) { e.printStackTrace(); } } } } } } ``` 列印結果: ```java // Server端 NioServer 啟動完成 接受新的 Channel 服務端接收請求:你好 服務端接收請求:你好 服務端接收請求:你好 // Client端 [Thread-0]收到響應:收到 [Thread-0]收到響應:收到 [Thread-0]收到響應:收到 ``` ## 四、總結 回顧一下使用 `NIO` 開發服務端程式的步驟: 1. 建立 `ServerSocketChannel` 和業務處理執行緒池。 2. 繫結監聽埠,並配置為非阻塞模式。 3. 建立 `Selector`,將之前建立的 `ServerSocketChannel` 註冊到 `Selector` 上,監聽 `SelectionKey.OP_ACCEPT`。 4. 迴圈執行 `Selector.select()`` 方法,輪詢就緒的 `Channel`。 5. 輪詢就緒的 `Channel` 時,如果是處於 `OP_ACCEPT` 狀態,說明是新的客戶端接入,呼叫 `ServerSocketChannel.accept` 接收新的客戶端。 6. 設定新接入的 `SocketChannel` 為非阻塞模式,並註冊到 `Selector` 上,監聽 `OP_READ`。 7. 如果輪詢的 `Channel` 狀態是 `OP_READ`,說明有新的就緒資料包需要讀取,則構造 `ByteBuffer` 物件,讀取資料。 那從這些步驟中基本知道開發者需要熟悉的知識點有: 1. `jdk-nio`提供的幾個關鍵類:`Selector` , `SocketChannel` , `ServerSocketChannel` , `FileChannel` ,`ByteBuffer` ,`SelectionKey` 2. 需要知道網路知識:tcp粘包拆包 、網路閃斷、包體溢位及重複傳送等 3. 需要知道`linux`底層實現,如何正確的關閉`channel`,如何退出登出`selector` ,如何避免`selector`太過於頻繁 4. 需要知道如何讓`client`端獲得`server`端的返回值,然後才返回給前端,需要如何等待或在怎樣作熔斷機制 5. 需要知道物件序列化,及序列化演算法 6. 省略等等,因為我已經有點不舒服了,作為程式設計師的我習慣了舒舒服服簡單的API,不用太知道底層細節,就能寫出比較健壯和沒有Bug的程式碼... **NIO 原生 API 的弊端 :** **① NIO 元件複雜 :** 使用原生 `NIO` 開發伺服器端與客戶端 , 需要涉及到 伺服器套接字通道 ( `ServerSocketChannel` ) , 套接字通道 ( `SocketChannel` ) , 選擇器 ( `Selector` ) , 緩衝區 ( `ByteBuffer` ) 等元件 , 這些元件的原理 和API 都要熟悉 , 才能進行 `NIO` 的開發與除錯 , 之後還需要針對應用進行除錯優化 **② NIO 開發基礎 :** `NIO` 門檻略高 , 需要開發者掌握多執行緒、網路程式設計等才能開發並且優化 `NIO` 網路通訊的應用程式 **③ 原生 API 開發網路通訊模組的基本的傳輸處理 :** 網路傳輸不光是實現伺服器端和客戶端的資料傳輸功能 , 還要處理各種異常情況 , 如 連線斷開重連機制 , 網路堵塞處理 , 異常處理 , 粘包處理 , 拆包處理 , 快取機制 等方面的問題 , 這是所有成熟的網路應用程式都要具有的功能 , 否則只能說是入門級的 Demo **④ NIO BUG :** `NIO` 本身存在一些 BUG , 如 `Epoll` , 導致 選擇器 ( `Selector` ) 空輪詢 , 在 JDK 1.7 中還沒有解決 `Netty` 在 `NIO` 的基礎上 , 封裝了 Java 原生的 `NIO API` , 解決了上述哪些問題呢 ? 相比 Java NIO,使用 `Netty` 開發程式,都簡化了哪些步驟呢?...等等這系列問題也都是我們要問的問題。不過因為這篇只是介紹`NIO`相關知識,沒有介紹`Netty API`的使用,所以介紹`Netty API`使用簡單開發門檻低等優點有點站不住腳。那麼就留到後面跟大家一起開啟`Netty`學習之旅,探討人人說好的`Netty`到底是不是江湖傳言的那麼好。 一起期待後續的`Netty`之旅吧! ![原創乾貨分享.png](https://user-gold-cdn.xitu.io/2020/6/22/172d9428e6afd974?w=900&h=383&f=png&s