從零講解搭建一個NIO訊息服務端
本文首發於貓叔的部落格 | MySelf,如需轉載,請申明出處.
假設
假設你已經瞭解並實現過了一些OIO訊息服務端,並對非同步訊息服務端更有興趣,那麼本文或許能帶你更好的入門,並瞭解JDK部分原始碼的關係流程,正如題目所說,筆者將竟可能還原,以初學者能理解的角度,講訴並構建一個NIO訊息服務端。
啟動通道並註冊選擇器
啟動模式
感謝Java一直在持續更新,對應的各個API也做得越來越好了,我們本次生成 服務端套接字通道 也是使用到JDK提供的一個方式 open ,我們將啟動一個 ServerSocketChannel ,他是一個 支援同步非同步模式 的 服務端套接字通道 。
它是一個抽象類,官方給了推薦的方式 open 來開啟一個我們需要的 服務端套接字通道例項 。(如下的官方原始碼相關注釋)
/**
* A selectable channel for stream-oriented listening sockets.
*/
public abstract class ServerSocketChannel
extends AbstractSelectableChannel
implements NetworkChannel
{
/**
* Opens a server-socket channel.
*/
public static ServerSocketChannel open() throws IOException {
return SelectorProvider.provider().openServerSocketChannel();
}
}
複製程式碼
那麼好了,我們現在可以確定我們第一步的程式碼是什麼樣子的了!沒錯,和你想象中的一樣,這很簡單。
public class NioServer {
public void server(int port) throws IOException{
//1、開啟伺服器套接字通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
}
}
複製程式碼
本節的重點是 啟動模式 ,那麼這意味著,我們需要向 ServerSocketChannel 進行標識,那麼它是否提供了對用的方法設定 同步非同步(阻塞非阻塞) 呢?
這很明顯,它是提供的,這也是它的核心功能之一,其實應該是它繼承的 父抽象類AbstractSelectableChannel 的實現方法: configgureBlocking(Boolean),這個方法將標識我們的 服務端套接字通道 是否阻塞模式。(如下的官方原始碼相關注釋)
/**
* Base implementation class for selectable channels.
*/
public abstract class AbstractSelectableChannel
extends SelectableChannel
{
/**
* Adjusts this channel's blocking mode.
*/
public final SelectableChannel configureBlocking(boolean block)
throws IOException
{
synchronized (regLock) {
if (!isOpen())
throw new ClosedChannelException();
if (blocking == block)
return this;
if (block && haveValidKeys())
throw new IllegalBlockingModeException();
implConfigureBlocking(block);
blocking = block;
}
return this;
}
}
複製程式碼
那麼,我們現在可以進行 啟動模式的配置 了,讀者很聰明。我們的專案Demo可以這樣寫: false為非阻塞模式、true為阻塞模式 。
public class NioServer {
public void server(int port) throws IOException{
//1、開啟伺服器套接字通道
ServerSocketChannel serverSocketzhannel = ServerSocketChannel.open();
//2、設定為非阻塞、調整此通道的阻塞模式。
serverSocketChannel.configureBlocking(false);
}
}
複製程式碼
若未配置阻塞模式,註冊選擇器 會報
java.nio.channels.IllegalBlockingModeException
異常,相關將於該小節大致講解說明。
套接字地址埠繫結
做過訊息通訊伺服器的朋友應該都清楚,我們需要向服務端 指定IP與埠 ,即使是NIO伺服器也是一樣的,否則,我們的客戶端會報 java.net.ConnectException: Connection refused: connect
異常
對於NIO的地址埠繫結,我們也需要用到 ServerSocket伺服器套接字 。我們知道在寫OIO服務端的時候,我們可能僅僅需要寫一句即可,如下。
//將伺服器繫結到指定埠
final ServerSocket socket = new ServerSocket(port);
複製程式碼
當然,JDK在實現NIO的時候就已經想到了,同樣,我們可以使用 伺服器套接字通道 來獲取一個 ServerSocket伺服器套接字 。這時的它並沒有繫結埠,我們需要對應繫結地址,這個類自身就有一個 bind 方法。(如下原始碼相關注釋)
/**
* This class implements server sockets. A server socket waits for
* requests to come in over the network. It performs some operation
* based on that request, and then possibly returns a result to the requester.
*/
public class ServerSocket implements java.io.Closeable {
/**
*
* Binds the {@code ServerSocket} to a specific address
* (IP address and port number).
*/
public void bind(SocketAddress endpoint) throws IOException {
bind(endpoint, 50);
}
}
複製程式碼
通過原始碼,我們知道,繫結iP與埠 需要一個SocketAddress類,我們僅需要將 IP與埠配置到對應的SocketAddress類 中即可。其實JDK中,已經有了一個更加方便且繼承了SocketAddress的類:InetSocketAddress。
InetSocketAddress有一個需要一個port為引數的構造方法,它將建立 一個ip為萬用字元、埠為指定值的套接字地址 。這很方便我們的開發,對吧?(如下原始碼相關注釋)
/**
*
* This class implements an IP Socket Address (IP address + port number)
* It can also be a pair (hostname + port number), in which case an attempt
* will be made to resolve the hostname. If resolution fails then the address
* is said to be <I>unresolved</I> but can still be used on some circumstances
* like connecting through a proxy.
*/
public class InetSocketAddress
extends SocketAddress
{
/**
* Creates a socket address where the IP address is the wildcard address
* and the port number a specified value.
*/
public InetSocketAddress(int port) {
this(InetAddress.anyLocalAddress(), port);
}
}
複製程式碼
好了,那麼接下來我們的專案程式碼可以繼續新增繫結IP與埠了,我想聰明的你應該有所感覺了。
public class NioServer {
public void server(int port) throws IOException{
//1、開啟伺服器套接字通道
ServerSocketChannel serverSocketzhannel = ServerSocketChannel.open();
//2、設定為非阻塞、調整此通道的阻塞模式。
serverSocketChannel.configureBlocking(false);
//3、檢索與此通道關聯的伺服器套接字。
ServerSocket serverSocket = serverSocketChannel.socket();
//4、此類實現 ip 套接字地址 (ip 地址 + 埠號)
InetSocketAddress address = new InetSocketAddress(port);
//5、將伺服器繫結到選定的套接字地址
serverSocket.bind(address);
}
}
複製程式碼
正如開頭我們所說的,你的專案中不新增3-5環節的程式碼並沒有問題,但是當客戶端接入時,則會報錯,因為客戶端將要 接入的地址是連線不到的 ,如會報這樣的錯誤。
java.net.ConnectException: Connection refused: connect
at sun.nio.ch.Net.connect0(Native Method)
at sun.nio.ch.Net.connect(Net.java:457)
at sun.nio.ch.Net.connect(Net.java:449)
at sun.nio.ch.SocketChannelImpl.connect(SocketChannelImpl.java:647)
at com.github.myself.WebClient.main(WebClient.java:16)
複製程式碼
註冊選擇器
接下來會是 NIO實現的重點 ,可能有點難理解,如果希望大家能一次理解,完全深入有點難講明白,不過先大致點一下。
首先要先介紹以下JDK實現NIO的核心:多路複用器(Selector)——選擇器
先簡單並抽象的理解下,Java通過 選擇器來實現處理多個Channel連結 ,將空閒未進行資料操作的擱置,優先執行有需求的資料傳輸,即 通過一個選擇器來選擇誰需要誰不需要使用共享的執行緒 。
由此,理所當然,這樣的選擇器應該也有Java自己定義的獲取方法, 其自身的 open 就是啟動一個這樣的選擇器。(如下原始碼相關注釋)
/**
* A multiplexor of {@link SelectableChannel} objects.
*/
public abstract class Selector implements Closeable {
/**
* Opens a selector.
*/
public static Selector open() throws IOException {
return SelectorProvider.provider().openSelector();
}
}
複製程式碼
那麼現在,我們還要考慮一件事情,我們的 伺服器套接字通道 要如何與 選擇器 相關聯呢?
ServerSocketChannel 有一個註冊的方法,這個方法就是將它們兩個進行了關聯,同時這個註冊方法 除了關聯選擇器外,還標識了註冊的狀態 ,讓我們先看看原始碼吧。
以下的 ServerSocketChannel 繼承 ---》 AbstractSelectableChannel 繼承 ---》 SelectableChannel
/**
* A channel that can be multiplexed via a {@link Selector}.
*/
public abstract class SelectableChannel
extends AbstractInterruptibleChannel
implements Channel
{
/**
* Registers this channel with the given selector, returning a selection
* key.
*/
public final SelectionKey register(Selector sel, int ops)
throws ClosedChannelException
{
return register(sel, ops, null);
}
}
複製程式碼
我們一般需要將選擇器註冊上去,並將 ServerSocketChannel 標識為 接受連線 的狀態。我們先看看我們的專案程式碼應該如何寫。
public class NioServer {
public void server(int port) throws IOException{
//1、開啟伺服器套接字通道
ServerSocketChannel serverSocketzhannel = ServerSocketChannel.open();
//2、設定為非阻塞、調整此通道的阻塞模式。
serverSocketChannel.configureBlocking(false);
//3、檢索與此通道關聯的伺服器套接字。
ServerSocket serverSocket = serverSocketChannel.socket();
//4、此類實現 ip 套接字地址 (ip 地址 + 埠號)
InetSocketAddress address = new InetSocketAddress(port);
//5、將伺服器繫結到選定的套接字地址
serverSocket.bind(address);
//6、開啟Selector來處理Channel
Selector selector = Selector.open();
//7、將ServerSocket註冊到Selector已接受連線,註冊會判斷是否為非阻塞模式
SelectionKey selectionKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
ByteBuffer readBuff = ByteBuffer.allocate(1024);
final ByteBuffer msg = ByteBuffer.wrap("Hi!\r\n".getBytes());
while(true){
//下方程式碼.....
}
}
}
複製程式碼
注意: 我們前面說到,如果 ServerSocketChannel 沒有啟動非阻塞模式,那麼我們在啟動的時候會報 java.lang.IllegalArgumentException
異常,這是為什麼呢? 我想我們可能需要更深入底層去看看 register 這個方法(如下原始碼註釋)
/**
* Base implementation class for selectable channels.
*/
public abstract class AbstractSelectableChannel
extends SelectableChannel
{
/**
* Registers this channel with the given selector, returning a selection key.
*/
public final SelectionKey register(Selector sel, int ops,
Object att)
throws ClosedChannelException
{
synchronized (regLock) {
if (!isOpen())
throw new ClosedChannelException();
if ((ops & ~validOps()) != 0)
throw new IllegalArgumentException();
if (blocking)
throw new IllegalBlockingModeException();
SelectionKey k = findKey(sel);
if (k != null) {
k.interestOps(ops);
k.attach(att);
}
if (k == null) {
// New registration
synchronized (keyLock) {
if (!isOpen())
throw new ClosedChannelException();
k = ((AbstractSelector)sel).register(this, ops, att);
addKey(k);
}
}
return k;
}
}
}
複製程式碼
我想我們終於真相大白了,原來註冊這個方法會對 ServerSocketChannel 的一系列引數進行 校驗 ,只有通過,才能註冊成功,所以我們也明白了,為什麼 非阻塞是false,同時我們也可以看到,它還對我們所給的標識做了校驗,一點要優先註冊 接受連線(OP_ACCEPT) 這個狀態才行,不然依舊會報 java.lang.IllegalArgumentException
異常。
這裡解釋一下,之所以只接受 OP_ACCEPT ,是因為如果沒有一個接受其他連結的主服務,那麼通訊根本無從說起,同時這樣的標識在我們的NIO服務端中 只允許標識一次(一個ServerSocketChannel) 。
可能大家還會好奇有什麼標識,我想原始碼的說明確實寫的很清楚了。
/**
* Operation-set bit for read operations.
*/
public static final int OP_READ = 1 << 0;
/**
* Operation-set bit for write operations.
*/
public static final int OP_WRITE = 1 << 2;
/**
* Operation-set bit for socket-connect operations.
*/
public static final int OP_CONNECT = 1 << 3;
/**
* Operation-set bit for socket-accept operations.
*/
public static final int OP_ACCEPT = 1 << 4;
複製程式碼
好了,這裡給一個除錯截圖,希望大家也可以慢慢的摸索一下。
注意這裡的服務端並沒有構建完成哦,我們還需要下面的幾個步驟。
NIO選擇例項與興趣點
客戶端程式碼
說到這裡,我們暫時先休息下,轉頭看看 客戶端的程式碼 吧,這裡就簡單的介紹下,我們將建立 一個針對服務地址埠的連線 ,然後不停的迴圈 寫操作與讀操作 ,沒有對客戶端進行 關閉操作。
大家如果有興趣的話,也可以自己除錯,並看看部分類的JDK原始碼,如下給出本專案案例的客戶端程式碼。
public class WebClient {
public static void main(String[] args) throws IOException {
try {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("0.0.0.0",8090));
ByteBuffer writeBuffer = ByteBuffer.allocate(32);
ByteBuffer readBuffer = ByteBuffer.allocate(32);
writeBuffer.put("hello".getBytes());
writeBuffer.flip();
while (true){
writeBuffer.rewind();
socketChannel.write(writeBuffer);
readBuffer.clear();
socketChannel.read(readBuffer);
readBuffer.flip();
System.out.println(new String(readBuffer.array()));
}
}catch (IOException e){
e.printStackTrace();
}
}
}
複製程式碼
準備IO接入操作
這裡有點複雜,我也儘可能的思考了表達的方式,首先我們先明確一下,所有的連線都會被Selector所囊括,即我們要獲取新接入的連線,也要通過Selector來獲取,我們一開始啟動的 伺服器套接字通道ServerSocketChannel 起到一個接入\入口(或許不夠準確)的作用,客戶端連線通過IP與埠進入後,會 被註冊的Selector所獲取 到,成為 Selector 其中的一員。
但是這裡的一員 並不會包括一開始註冊並被標誌為接收連線 的 ServerSocketChannel 。
Selector有這樣一個方法,它會自動去等待新的連線事件,如果沒有連線接入,那麼它將一直處於阻塞狀態。通過字面意思我們可以大致這樣寫程式碼。
while(true){
try{
//1、等到需要處理的新事件:阻塞將一直持續到下一個傳入事件
selector.select();
}catch(IOException e){
e.printStackTrace();
break;
}
}
複製程式碼
那麼這樣寫好像有點像樣,畢竟異常我們也捕獲了,同時也使用了剛剛 開啟並註冊完畢的選擇器Selector。
讓我們看看原始碼中對於這個方法 select 的註釋吧。
/**
* A multiplexor of {@link SelectableChannel} objects.
*/
public abstract class Selector implements Closeable {
/**
* Selects a set of keys whose corresponding channels are ready for I/O
* operations.
*/
public abstract int select() throws IOException;
}
複製程式碼
好的,看樣子是對的,它將返回一組套接字通道已經準備好執行I/O操作的鍵。那麼這個Key究竟是什麼呢?
這裡可能直觀的感受下會更好。如下圖是我除錯下看到的key物件,我想大家應該可以理解了,這個Key中也會 存放對應連線的Channel與Selector 。
具體的內部更深層的就探討了。那麼這也解決了我們接下來的 一個疑問 ,我們要怎麼向Selector拿連線進來的例項呢?
答案很明顯,我們僅需要 獲取到這個Keys 就好了。
選擇鍵集合操作
對於獲取Keys這個現在應該已經不是什麼問題了,通過上面章節的瞭解,我想大家也可以想到這樣的大致語法。
//獲取所有接收事件的SelectionKey例項
Set<SelectionKey> readykeys = selector.selectedKeys();
複製程式碼
大家或許會好奇,這裡的Key物件居然是前面的 SelectionKey.OP_ACCEPT
物件,是的,這也是接下來要講的,這很奇妙,也很好玩。
前面說到的標識,這是每一個Key自有的,並且是可以 改變的狀態 ,在剛剛連線的時候,或許我應該大致的描述一下 一個新連線進入選擇器後的流程 :select方法將接受到新接入的連線事件,它會被Selector以Key的形式儲存,這時我們需要 對其進行判斷 ,是否是已經就緒可以被接受的連線,如果是,這時我們需要 獲取這個連線 ,同時也將其設定為 非阻塞的狀態 ,並將它 註冊到選擇器上(當然,這時的標識就不能是一開始的 OP_ACCEPT
),你可以選擇性的 註冊它的標識 ,之後我們可以通過迴圈遍歷Keys來,讓 某一標識的連線去執行對應的操作 。
說到這裡,我想部分新手可能會有點模糊,我想我還是把接下來的程式碼都一起放出來吧,大家先看看是否能夠再次結合文字進行了解。
while (true){
try {
//等到需要處理的新事件:阻塞將一直持續到下一個傳入事件
selector.select();
}catch (IOException e){
e.printStackTrace();
break;
}
//獲取所有接收事件的SelectionKey例項
Set<SelectionKey> readykeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = readykeys.iterator();
while(iterator.hasNext()){
SelectionKey key = iterator.next();
iterator.remove();
try {
//檢查事件是否是一個新的已經就緒可以被接受的連線
if (key.isAcceptable()){
//channel:返回為其建立此鍵的通道。 即使在取消金鑰後, 此方法仍將繼續返回通道。
ServerSocketChannel server = (ServerSocketChannel)key.channel();
//可選擇的通道, 用於面向流的連線插槽。
SocketChannel client = server.accept();
//設定為非阻塞
client.configureBlocking(false);
//接受客戶端,並將它註冊到選擇器,並新增附件
client.register(selector,SelectionKey.OP_WRITE | SelectionKey.OP_READ,msg.duplicate());
System.out.println("Accepted connection from " + client);
}
//檢查套接字是否已經準備好讀資料
if (key.isReadable()){
SocketChannel client = (SocketChannel)key.channel();
readBuff.clear();
client.read(readBuff);
readBuff.flip();
System.out.println("received:"+new String(readBuff.array()));
//將此鍵的興趣集設定為給定的值。 OP_WRITE
key.interestOps(SelectionKey.OP_WRITE);
}
//檢查套接字是否已經準備好寫資料
if (key.isWritable()){
SocketChannel client = (SocketChannel)key.channel();
//attachment : 檢索當前附件
ByteBuffer buffer = (ByteBuffer)key.attachment();
buffer.rewind();
client.write(buffer);
//將此鍵的興趣集設定為給定的值。 OP_READ
key.interestOps(SelectionKey.OP_READ);
}
}catch (IOException e){
e.printStackTrace();
}
}
}
複製程式碼
提示:讀到此處,還請各位讀者能執行整個demo,並除錯下,看看與自己理解的是否有差別。
流程效果
以下我簡單敘述一下,我在除錯時的理解與效果。
-
1、啟動服務端後,執行到
selector.select();
後阻塞,因為沒有監聽到新的連線。 -
2、啟動客戶端後,
selector.select()
監聽到新連線,往下執行獲取到的Keys的size為1,進入Key標識分支判斷 -
3、
key.isAcceptable()
首次接入為true,設定為非阻塞,並註釋到選擇器中修改標識為SelectionKey.OP_WRITE | SelectionKey.OP_READ
,同時新增附件資訊msg.duplicate()
,首次迴圈結束 -
4、二次迴圈,連線未關閉,獲取到的Keys的size為1,進入Key標識分支判斷。
-
5、由於第一次該Key標識改變,所以這次
key.isAcceptable()
為false,而由於改了標識,所以接下來的key.isReadable()
、key.isWritable()
都為true,執行讀寫操作,迴圈結束。 -
6、接下來的迴圈,基本上是
key.isReadable()
、key.isWritable()
都為true,執行讀寫操作。 -
7、設想一下,如果多加一條連結是什麼效果。
回顧
這裡給出幾個程式碼的注意點,希望大家可以自己去了解學習。
- 1、關於 ByteBuffer 本文並不重點講解,大家可以自行了解
- 2、關於Key標識判斷的程式碼,以下兩句的刪減是否會對程式碼有所影響呢?
key.interestOps(SelectionKey.OP_WRITE);
key.interestOps(SelectionKey.OP_READ);
複製程式碼
- 3、如果刪除了2中的程式碼,並把客戶端註冊選擇器並給標識的程式碼改為以下,那麼專案執行效果怎麼樣呢?
client.register(selector, SelectionKey.OP_READ,msg.duplicate());
複製程式碼
- 4、如果改了3的程式碼,可是不刪除2的程式碼,那麼效果又是怎麼樣呢?
答案留給讀者去揭曉吧,如果你有答案,歡迎留言。
個人相關專案
InChat : 一個輕量級、高效率的支援多端(應用與硬體Iot)的非同步網路應用通訊框架
公眾號:Java貓說
現架構設計(碼農)兼創業技術顧問,不羈平庸,熱愛開源,雜談程式人生與不定期乾貨。