從 BIO、NIO 聊到 Netty,最後還要實現個 RPC 框架!
覺得不錯的話,歡迎 star!ღ( ´・ᴗ・` )比心
- Netty 從入門到實戰系列文章地址://github.com/Snailclimb/netty-practical-tutorial 。
- RPC 框架源碼地址://github.com/Snailclimb/guide-rpc-framework
老套路,學習某一門技術或者框架的時候,第一步當然是要了解下面這幾樣東西。
- 是什麼?
- 有哪些特點?
- 有哪些應用場景?
- 有哪些成功使用的案例?
- …..
為了讓你更好地了解 Netty 以及它誕生的原因,先從傳統的網絡編程說起吧!
還是要從 BIO 說起
傳統的阻塞式通信流程
早期的 Java 網絡相關的 API(java.net
包) 使用 Socket(套接字)進行網絡通信,不過只支持阻塞函數使用。
要通過互聯網進行通信,至少需要一對套接字:
- 運行於服務器端的 Server Socket。
- 運行於客戶機端的 Client Socket
Socket 網絡通信過程如下圖所示:
//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)(//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 官方也做了統計,統計結果在這裡://netty.io/wiki/related-projects.html 。
後記
RPC 框架源碼已經開源了,地址://github.com/Snailclimb/guide-rpc-framework