從 BIO、NIO 聊到 Netty,最後還要實現個 RPC 框架!
覺得不錯的話,歡迎 star!ღ( ´・ᴗ・` )比心
- Netty 從入門到實戰系列文章地址:https://github.com/Snailclimb/netty-practical-tutorial 。
- RPC 框架原始碼地址:https://github.com/Snailclimb/guide-rpc-framework
老套路,學習某一門技術或者框架的時候,第一步當然是要了解下面這幾樣東西。
- 是什麼?
- 有哪些特點?
- 有哪些應用場景?
- 有哪些成功使用的案例?
- .....
為了讓你更好地瞭解 Netty 以及它誕生的原因,先從傳統的網路程式設計說起吧!
還是要從 BIO 說起
傳統的阻塞式通訊流程
早期的 Java 網路相關的 API(java.net
包) 使用 Socket(套接字)進行網路通訊,不過只支援阻塞函式使用。
要通過網際網路進行通訊,至少需要一對套接字:
- 運行於伺服器端的 Server Socket。
- 運行於客戶機端的 Client Socket
Socket 網路通訊過程如下圖所示:
https://www.javatpoint.com/socket-programming
Socket 網路通訊過程簡單來說分為下面 4 步:
- 建立服務端並且監聽客戶端請求
- 客戶端請求,服務端和客戶端建立連線
- 兩端之間可以傳遞資料
- 關閉資源
對應到服務端和客戶端的話,是下面這樣的。
伺服器端:
- 建立
ServerSocket
物件並且繫結地址(ip)和埠號(port):server.bind(new InetSocketAddress(host, port))
- 通過
accept()
方法監聽客戶端請求 - 連線建立後,通過輸入流讀取客戶端傳送的請求資訊
- 通過輸出流向客戶端傳送響應資訊
- 關閉相關資源
客戶端:
- 建立
Socket
物件並且連線指定的伺服器的地址(ip)和埠號(port):socket.connect(inetSocketAddress)
- 連線建立後,通過輸出流向伺服器端傳送請求資訊
- 通過輸入流獲取伺服器響應的資訊
- 關閉相關資源
一個簡單的 demo
為了便於理解,我寫了一個簡單的程式碼幫助各位小夥伴理解。
服務端:
public class HelloServer {
private static final Logger logger = LoggerFactory.getLogger(HelloServer.class);
public void start(int port) {
//1.建立 ServerSocket 物件並且繫結一個埠
try (ServerSocket server = new ServerSocket(port);) {
Socket socket;
//2.通過 accept()方法監聽客戶端請求, 這個方法會一直阻塞到有一個連線建立
while ((socket = server.accept()) != null) {
logger.info("client connected");
try (ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream());
ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream())) {
//3.通過輸入流讀取客戶端傳送的請求資訊
Message message = (Message) objectInputStream.readObject();
logger.info("server receive message:" + message.getContent());
message.setContent("new content");
//4.通過輸出流向客戶端傳送響應資訊
objectOutputStream.writeObject(message);
objectOutputStream.flush();
} catch (IOException | ClassNotFoundException e) {
logger.error("occur exception:", e);
}
}
} catch (IOException e) {
logger.error("occur IOException:", e);
}
}
public static void main(String[] args) {
HelloServer helloServer = new HelloServer();
helloServer.start(6666);
}
}
ServerSocket
的 accept()
方法是阻塞方法,也就是說 ServerSocket
在呼叫 accept()
等待客戶端的連線請求時會阻塞,直到收到客戶端傳送的連線請求才會繼續往下執行程式碼,因此我們需要要為每個 Socket 連線開啟一個執行緒(可以通過執行緒池來做)。
上述服務端的程式碼只是為了演示,並沒有考慮多個客戶端連線併發的情況。
客戶端:
/**
* @author shuang.kou
* @createTime 2020年05月11日 16:56:00
*/
public class HelloClient {
private static final Logger logger = LoggerFactory.getLogger(HelloClient.class);
public Object send(Message message, String host, int port) {
//1. 建立Socket物件並且指定伺服器的地址和埠號
try (Socket socket = new Socket(host, port)) {
ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream());
//2.通過輸出流向伺服器端傳送請求資訊
objectOutputStream.writeObject(message);
//3.通過輸入流獲取伺服器響應的資訊
ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream());
return objectInputStream.readObject();
} catch (IOException | ClassNotFoundException e) {
logger.error("occur exception:", e);
}
return null;
}
public static void main(String[] args) {
HelloClient helloClient = new HelloClient();
helloClient.send(new Message("content from client"), "127.0.0.1", 6666);
System.out.println("client receive message:" + message.getContent());
}
}
傳送的訊息實體類:
/**
* @author shuang.kou
* @createTime 2020年05月11日 17:02:00
*/
@Data
@AllArgsConstructor
public class Message implements Serializable {
private String content;
}
首先執行服務端,然後再執行客戶端,控制檯輸出如下:
服務端:
[main] INFO github.javaguide.socket.HelloServer - client connected
[main] INFO github.javaguide.socket.HelloServer - server receive message:content from client
客戶端:
client receive message:new content
資源消耗嚴重的問題
很明顯,我上面演示的程式碼片段有一個很嚴重的問題:只能同時處理一個客戶端的連線,如果需要管理多個客戶端的話,就需要為我們請求的客戶端單獨建立一個執行緒。 如下圖所示:
對應的 Java 程式碼可能是下面這樣的:
new Thread(() -> {
// 建立 socket 連線
}).start();
但是,這樣會導致一個很嚴重的問題:資源浪費。
我們知道執行緒是很寶貴的資源,如果我們為每一次連線都用一個執行緒處理的話,就會導致執行緒越來越好,最好達到了極限之後,就無法再建立執行緒處理請求了。處理的不好的話,甚至可能直接就宕機掉了。
很多人就會問了:那有沒有改進的方法呢?
執行緒池雖可以改善,但終究未從根本解決問題
當然有! 比較簡單並且實際的改進方法就是使用執行緒池。執行緒池還可以讓執行緒的建立和回收成本相對較低,並且我們可以指定執行緒池的可建立執行緒的最大數量,這樣就不會導致執行緒建立過多,機器資源被不合理消耗。
ThreadFactory threadFactory = Executors.defaultThreadFactory();
ExecutorService threadPool = new ThreadPoolExecutor(10, 100, 1, TimeUnit.MINUTES, new ArrayBlockingQueue<>(100), threadFactory);
threadPool.execute(() -> {
// 建立 socket 連線
});
但是,即使你再怎麼優化和改變。也改變不了它的底層仍然是同步阻塞的 BIO 模型的事實,因此無法從根本上解決問題。
為了解決上述的問題,Java 1.4 中引入了 NIO ,一種同步非阻塞的 I/O 模型。
再看 NIO
Netty 實際上就基於 Java NIO 技術封裝完善之後得到一個高效能框架,熟悉 NIO 的基本概念對於學習和更好地理解 Netty 還是很有必要的!
初識 NIO
NIO 是一種同步非阻塞的 I/O 模型,在 Java 1.4 中引入了 NIO 框架,對應 java.nio
包,提供了 Channel , Selector,Buffer 等抽象。
NIO 中的 N 可以理解為 Non-blocking,已經不在是 New 了(已經出來很長時間了)。
NIO 支援面向緩衝(Buffer)的,基於通道(Channel)的 I/O 操作方法。
NIO 提供了與傳統 BIO 模型中的 Socket
和 ServerSocket
相對應的 SocketChannel
和 ServerSocketChannel
兩種不同的套接字通道實現,兩種通道都支援阻塞和非阻塞兩種模式:
- 阻塞模式 : 基本不會被使用到。使用起來就像傳統的網路程式設計一樣,比較簡單,但是效能和可靠性都不好。對於低負載、低併發的應用程式,勉強可以用一下以提升開發速率和更好的維護性
- 非阻塞模式 : 與阻塞模式正好相反,非阻塞模式對於高負載、高併發的(網路)應用來說非常友好,但是程式設計麻煩,這個是大部分人詬病的地方。所以, 也就導致了 Netty 的誕生。
NIO 核心元件解讀
NIO 包含下面幾個核心的元件:
- Channel
- Buffer
- Selector
- Selection Key
這些元件之間的關係是怎麼的呢?
- NIO 使用 Channel(通道)和 Buffer(緩衝區)傳輸資料,資料總是從緩衝區寫入通道,並從通道讀取到緩衝區。在面向流的 I/O 中,可以將資料直接寫入或者將資料直接讀到 Stream 物件中。在 NIO 庫中,所有資料都是通過 Buffer(緩衝區)處理的。 Channel 可以看作是 Netty 的網路操作抽象類,對應於 JDK 底層的 Socket
- NIO 利用 Selector (選擇器)來監視多個通道的物件,如資料到達,連線開啟等。因此,單執行緒可以監視多個通道中的資料。
- 當我們將 Channel 註冊到 Selector 中的時候, 會返回一個 Selection Key 物件, Selection Key 則表示了一個特定的通道物件和一個特定的選擇器物件之間的註冊關係。通過 Selection Key 我們可以獲取哪些 IO 事件已經就緒了,並且可以通過其獲取 Channel 並對其進行操作。
Selector(選擇器,也可以理解為多路複用器)是 NIO(非阻塞 IO)實現的關鍵。它使用了事件通知相關的 API 來實現選擇已經就緒也就是能夠進行 I/O 相關的操作的任務的能力。
簡單來說,整個過程是這樣的:
- 將 Channel 註冊到 Selector 中。
- 呼叫 Selector 的
select()
方法,這個方法會阻塞; - 到註冊在 Selector 中的某個 Channel 有新的 TCP 連線或者可讀寫事件的話,這個 Channel 就會處於就緒狀態,會被 Selector 輪詢出來。
- 然後通過 SelectionKey 可以獲取就緒 Channel 的集合,進行後續的 I/O 操作。
NIO 為啥更好?
相比於傳統的 BIO 模型來說, NIO 模型的最大改進是:
- 使用比較少的執行緒便可以管理多個客戶端的連線,提高了併發量並且減少的資源消耗(減少了執行緒的上下文切換的開銷)
- 在沒有 I/O 操作相關的事情的時候,執行緒可以被安排在其他任務上面,以讓執行緒資源得到充分利用。
使用 NIO 編寫程式碼太難了
一個使用 NIO 編寫的 Server 端如下,可以看出還是整體還是比較複雜的,並且程式碼讀起來不是很直觀,並且還可能由於 NIO 本身會存在 Bug。
很少使用 NIO,很大情況下也是因為使用 NIO 來建立正確並且安全的應用程式的開發成本和維護成本都比較大。所以,一般情況下我們都會使用 Netty 這個比較成熟的高效能框架來做(Apace Mina 與之類似,但是 Netty 使用的更多一點)。
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-VQnDTw9w-1598268167131)(https://gitee.com/SnailClimb/netty-practical-tutorial/raw/master/pictures/Nio-Server.png)]
重要角色 Netty 登場
簡單用 3 點概括一下 Netty 吧!
- Netty 是一個基於 NIO 的 client-server(客戶端伺服器)框架,使用它可以快速簡單地開發網路應用程式。
- 它極大地簡化並簡化了 TCP 和 UDP 套接字伺服器等網路程式設計,並且效能以及安全性等很多方面甚至都要更好。
- 支援多種協議如 FTP,SMTP,HTTP 以及各種二進位制和基於文字的傳統協議。
用官方的總結就是:Netty 成功地找到了一種在不妥協可維護性和效能的情況下實現易於開發,效能,穩定性和靈活性的方法。
Netty 特點
根據官網的描述,我們可以總結出下面一些特點:
- 統一的 API,支援多種傳輸型別,阻塞和非阻塞的。
- 簡單而強大的執行緒模型。
- 自帶編解碼器解決 TCP 粘包/拆包問題。
- 自帶各種協議棧。
- 真正的無連線資料包套接字支援。
- 比直接使用 Java 核心 API 有更高的吞吐量、更低的延遲、更低的資源消耗和更少的記憶體複製。
- 安全性不錯,有完整的 SSL/TLS 以及 StartTLS 支援。
- 社群活躍
- 成熟穩定,經歷了大型專案的使用和考驗,而且很多開源專案都使用到了 Netty 比如我們經常接觸的 Dubbo、RocketMQ 等等。
- ......
使用 Netty 能做什麼?
這個應該是老鐵們最關心的一個問題了,憑藉自己的瞭解,簡單說一下,理論上 NIO 可以做的事情 ,使用 Netty 都可以做並且更好。Netty 主要用來做網路通訊 :
- 作為 RPC 框架的網路通訊工具 : 我們在分散式系統中,不同服務節點之間經常需要相互呼叫,這個時候就需要 RPC 框架了。不同服務指點的通訊是如何做的呢?可以使用 Netty 來做。比如我呼叫另外一個節點的方法的話,至少是要讓對方知道我呼叫的是哪個類中的哪個方法以及相關引數吧!
- 實現一個自己的 HTTP 伺服器 :通過 Netty 我們可以自己實現一個簡單的 HTTP 伺服器,這個大家應該不陌生。說到 HTTP 伺服器的話,作為 Java 後端開發,我們一般使用 Tomcat 比較多。一個最基本的 HTTP 伺服器可要以處理常見的 HTTP Method 的請求,比如 POST 請求、GET 請求等等。
- 實現一個即時通訊系統 : 使用 Netty 我們可以實現一個可以聊天類似微信的即時通訊系統,這方面的開源專案還蠻多的,可以自行去 Github 找一找。
- 訊息推送系統 :市面上有很多訊息推送系統都是基於 Netty 來做的。
- ......
哪些開源專案用到了 Netty?
我們平常經常接觸的 Dubbo、RocketMQ、Elasticsearch、gRPC 等等都用到了 Netty。
可以說大量的開源專案都用到了 Netty,所以掌握 Netty 有助於你更好的使用這些開源專案並且讓你有能力對其進行二次開發。
實際上還有很多很多優秀的專案用到了 Netty,Netty 官方也做了統計,統計結果在這裡:https://netty.io/wiki/related-projects.html 。
後記
RPC 框架原始碼已經開源了,地址:https://github.com/Snailclimb/guide-rpc-framework