[TOC]
## 一、IO簡介
> 在學習I/O技術時,需要了解幾個技術點,包括同步阻塞、同步非阻塞、異步阻塞及異步非阻塞。這些都是I/O模型,是學習I/O、NIO、AIO必須要了解的概念。只有清楚了這些概念,才能更好地理解不同I/O的優勢。
- BIO - BlockingIO - 同步式阻塞式IO
- NIO - NonBlockingIO - 同步式非阻塞式IO
### 同步、異步、阻塞與非阻塞之間的關系
同步、異步、阻塞與非阻塞可以組合成以下4種排列:
1.同步阻塞
在使用普通的InputStream、OutputStream類時,就是屬于同步阻塞,因為執行當前讀寫任務一直是當前線程,并且讀不到或寫不出去就一直是阻塞的狀態。阻塞的意思就是方法不返回,直到讀到數據或寫出數據為止。
2.同步非阻塞
NIO技術屬于同步非阻塞。當執行`serverSocketChannel.configureBlocking(false)`代碼后,也是一直由當前的線程在執行讀寫操作,但是讀不到數據或數據寫不出去時讀寫方法就返回了,繼續執行讀或寫后面的代碼。
3.異步阻塞
而異步當然就是指多個線程間的通信。例如,A線程發起一個讀操作,這個讀操作要B線程進行實現,A線程和B線程就是異步執行了。A線程還要繼續做其他的事情,這時B線程開始工作,如果讀不到數據,B線程就呈阻塞狀態了,如果讀到數據,就通知A線程,并且將拿到的數據交給A線程,這種情況是異步阻塞。
4.異步非阻塞
最后一種是異步非阻塞,是指A線程發起一個讀操作,這個讀操作要B線程進行實現,因為A線程還要繼續做其他的事情,這時B線程開始工作,如果讀不到數據,B線程就繼續執行后面的代碼,直到讀到數據時,B線程就通知A線程,并且將拿到的數據交給A線程。
> 鑒于NIO優秀的性能和可靠性,905.4王國實際上經歷過一次重構。
## 二、NIO實戰基礎(三大組件)
常規的I/O(如InputStream和OutputStream)存在很大的缺點,就是它們是阻塞的,而NIO解決的就是常規I/O執行效率低的問題。即采用非阻塞高性能運行的方式來避免出現以前“笨拙”的同步I/O帶來的低效率問題。NIO在大文件操作上相比常規I/O更加優秀。
NIO有三大組件:Buffer - 緩沖區,Channel - 通道,Selector - 選擇器。

### 2-1 緩沖區Buffer
> 本節介紹緩沖區的操作中,ByteBuffer的一些用法。
NIO中的Buffer是一個用于存儲基本數據類型值的容器,它以類似于數組有序的方式來存儲和組織數據。每個基本數據類型(除去boolean)都有一個子類與之對應。ByteBuffer是NIO中最常用的緩沖區。
#### 緩沖區操作
- clear(),還原緩沖區。還原緩沖區到初始的狀態,包含將位置設置為0,將限制設置為容量,并丟棄標記,即“回到默認”。
- flip()。反轉緩沖區。首先將限制設置為當前位置,然后將位置設置為0。為啥不叫做截取有效緩沖區呢?flip()常用在向緩沖區中寫入一些數據后,下一步讀取緩沖區中的數據之前調用,以改變limit與position的值。
- rewind(),重繞此緩沖區,將位置設置為0并丟棄標記。在重新讀取、重新寫入時可以使用。
- 使用allocateDirect()方法創建的直接緩沖區如何釋放內存呢?有兩種辦法,一種是手動釋放空間,另一種就是交給JVM進行處理。java進程分配直接緩沖區,在java進程結束后也不會馬上回收內存,而是會在某個時刻觸發GC垃圾回收器進行內存的回收。
- compact(),可以進行緩沖區壓縮。

- order(ByteOrder bo)方法,作用:設置字節的排列順序。不同的CPU在讀取字節時的順序是不一樣的,有的CPU從高位開始讀,而有的CPU從低位開始讀,當這兩種CPU傳遞數據時就要將字節排列的順序進行統一。
> 字節順序
> ByteOrder order()方法的作用:獲取此緩沖區的字節順序。新創建的字節緩沖區的順序始終為BIG_ENDIAN。在讀寫多字節值以及為此字節緩沖區創建視圖緩沖區時,使用該字節順序。
> 1. `public static final ByteOrder BIG_ENDIAN`:表示BIG-ENDIAN字節順序的常量。按照此順序,多字節值的字節順序是從最高有效位到最低有效位的。
> 2. `public static final ByteOrder LITTLE_ENDIAN`:表示LITTLE-ENDIAN字節順序的常量。按照此順序,多字節值的字節順序是從最低有效位到最高有效位的
- 比較緩沖區的內容是否相同有兩種方法:equals()和compareTo()。
- 分配直接內存;
- 包裝wrap數據的處理。
#### ByteBuffer
##### 從ByteBuffer中讀取數據
第一種方法,使用ByteBuffer原生方法get(byte[] dst, int offset, int length)
使用方式:
```java
//讀取channel中數據
SocketChannel channel = (SocketChannel) skey.channel();
ByteBuffer bf = ByteBuffer.allocate(1024);
int read = channel.read(bf);
bf.flip();
byte[] bytes = new byte[read];
bf.get(bytes);
```
查看一下ByteBuffer的`put(byte[] dst)`源碼:
```java
public final ByteBuffer put(byte[] src) {
return put(src, 0, src.length);
}
public ByteBuffer put(byte[] src, int offset, int length) {
checkBounds(offset, length, src.length);
if (length > remaining())
throw new BufferOverflowException();
int end = offset + length;
for (int i = offset; i < end; i++)
this.put(src[i]);
return this;
}
```
第二種方法,借助工具類取子數組:
```java
//讀取channel中數據
SocketChannel channel = (SocketChannel) skey.channel();
ByteBuffer bf = ByteBuffer.allocate(1024);
int read = channel.read(bf);
bf.flip();
byte[] bytes = ByteUtil.subBytes(bf.array(),bf.position(),bf.limit());
```
### 2-2 選擇器技術——NIO的核心
選擇器實現了I/O通道的多路復用,使用它可以節省CPU資源,提高程序運行效率。多路復用的核心目的就是使用最少的線程去操作更多的通道。
在使用選擇器技術時,主要由3個對象以合作的方式來實現線程選擇某個通道進行業務處理,這3個對象分別是`Selector`、`SelectionKey`和`SelectableChannel`。
- SelectionKey類的作用是一個標識,這個標識代表SelectableChannel類已經向Selector類注冊了。
- ServerSocketChannel類是針對面向流的偵聽套接字的可選擇通道。
> ServerSocketChannel類、Selector和SelectionKey的詳細使用可以參考《NIO與Socket編程技術指南》5.7節。
#### SelectionKey
通過SelectionKey對象來表示SelectableChannel(可選擇通道)到選擇器的注冊。選擇器維護了3種SelectionKey-Set(選擇鍵集)。
1. 鍵集:包含的鍵表示當前通道到此選擇器的注冊,也就是通過某個通道的register()方法注冊該通道時,所帶來的影響是向選擇器的鍵集中添加了一個鍵。此集合由keys()方法返回。鍵集本身是不可直接修改的。
2. 已選擇鍵集:在首先調用select()方法選擇操作期間,檢測每個鍵的通道是否已經至少為該鍵的相關操作集所標識的一個操作準備就緒,然后調用selectedKeys()方法返回已就緒鍵的集合。已選擇鍵集始終是鍵集的一個子集。
3. 已取消鍵集:表示已被取消但其通道尚未注銷的鍵的集合。不可直接訪問此集合。已取消鍵集始終是鍵集的一個子集。在select()方法選擇操作期間,從鍵集中移除已取消的鍵。
### 2-3 通道(Channel)
通道是用于I/O操作的連接,更具體地講,通道代表數據到硬件設備、文件、網絡套接字的連接。通道可處于打開或關閉這兩種狀態,當創建通道時,通道就處于打開狀態,一旦將其關閉,則保持關閉狀態。一旦關閉了某個通道,則試圖對其調用I/O操作時就會導致ClosedChannel Exception異常被拋出,但可以通過調用通道的isOpen()方法測試通道是否處于打開狀態以避免出現ClosedChannelException異常。一般情況下,通道對于多線程的訪問是安全的。
在每次向選擇器注冊通道時,就會創建一個選擇鍵(SelectionKey);
channel類似于stream,它是讀寫數據的**雙向通道**,可以從channel將數據讀入buffer,也可以將buffer的數據寫入channel。
常見Channel:
- FileChannel
- DatagramChannel
- SocketChannel
- ServerSocketChannel
#### ServerSocketChannel
> 如何切換ServerSocketChannel通道的阻塞與非阻塞的執行模式呢?
`public final SelectableChannel configureBlocking(boolean block)`方法的作用是調整此通道的阻塞模式,傳入true是阻塞模式,傳入false是非阻塞模式。默認為**阻塞**模式。
設置非阻塞后,read和write方法也呈現此特性(非阻塞)。
```java
//打開通信信道,ServerSocketChannel類是針對面向流的偵聽套接字的可選擇通道。
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//設置為非阻塞
serverSocketChannel.configureBlocking(false);
```
通道注冊與選擇器總結:
- 相同的通道可以注冊到不同的選擇器,返回的SelectionKey不是同一個對象;
- 相同的通道重復注冊相同的選擇器,返回的SelectionKey是同一個對象;
## 三、NIO SocketServer
> 這是一個短連接的demo,我們會在后面的部分提供長連接的源碼。
### 3-1 基于使用NIO技術實現一個SocketServer
```
import com.zihan.evm.location.enums.LoginResponseEnum;
import com.zihan.evm.location.parser.LocationParserHelper;
import com.zihan.evm.location.parser.ResponseHelper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.Set;
/**
* nio socket服務端
*/
@Component
@Slf4j
public class NIOSocketServer {
//接受數據緩沖區
private static ByteBuffer sBuffer = ByteBuffer.allocate(1024);
//發送數據緩沖區
private static ByteBuffer rBuffer = ByteBuffer.allocate(1024);
//選擇器(叫監聽器更準確些吧應該)
private static Selector selector;
//解碼buffer
private Charset cs = Charset.forName("UTF-8");
/**
* 啟動socket服務,開啟監聽
*
* @param port
* @throws IOException
*/
public void startSocketServer(int port) {
try {
//打開通信信道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//設置為非阻塞
serverSocketChannel.configureBlocking(false);
//獲取套接字
ServerSocket serverSocket = serverSocketChannel.socket();
//綁定端口號
serverSocket.bind(new InetSocketAddress(port));
//打開監聽器
selector = Selector.open();
//將通信信道注冊到監聽器
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//監聽器會一直監聽,如果客戶端有請求就會進入相應的事件處理
while (true) {
selector.select();//select方法會一直阻塞直到有相關事件發生或超時
Set<SelectionKey> selectionKeys = selector.selectedKeys();//監聽到的事件
for (SelectionKey key : selectionKeys) {
handle(key);
}
selectionKeys.clear();//清除處理過的事件
}
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 處理不同的事件
*
* @param selectionKey
* @throws IOException
*/
private void handle(SelectionKey selectionKey) throws IOException {
ServerSocketChannel serverSocketChannel = null;
SocketChannel socketChannel = null;
int count = 0;
if (selectionKey.isAcceptable()) {
//每有客戶端連接,即注冊通信信道為可讀
serverSocketChannel = (ServerSocketChannel) selectionKey.channel();
socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (selectionKey.isReadable()) {
byte[] response = new byte[0];
try {
socketChannel = (SocketChannel) selectionKey.channel();
rBuffer.clear();
count = socketChannel.read(rBuffer);
//讀取數據
if (count > 0) {
rBuffer.flip();
byte[] bytes = rBuffer.array();
//根據bytes完成業務解析功能
response = LocationParserHelper.buildResponse(bytes);
Assert.notNull(response,"不能為空");
}
}catch (Exception e){
log.error(e.getMessage());
response = ResponseHelper.loginError(LoginResponseEnum.RSP_OTHER.getMsgId());
}finally {
//返回數據
sBuffer = ByteBuffer.allocate(response.length);
sBuffer.put(response);
sBuffer.flip();
socketChannel.write(sBuffer);
socketChannel.close();
}
}
}
}
```
這個NIOServer的末尾使用了`socketChannel.close()`的方法關閉通道,因此是短連接的實現。我們在后面的例子中,還是進行長連接的實現。
### 3-2 Springboot項目中啟動socket server
905.4王國構建過程中,下級平臺的賬號密碼管理、數據的入庫都需要數據庫的參與。因此,我們可以先構建一個Springboot+Mybatis-plus項目,在此Springboot項目基礎上啟動Socket Server。
> 如果大家已經有自己的框架了,選取部分代碼進行一定程度的改寫即可。
那么,如何在Springboot項目中啟動用于數據接收的服務端呢?這里有兩個點比較重要:
- 第一,服務端Server綁定的端口號應該可以設置成可配置的,代碼應該實現復用;
- 我們知道,Springboot項目中的配置文件是通過yml或者properties文件管理的。想要在服務端Server中讀取到配置文件中的配置,首先服務端Server應交給Spring管理——即使用`@Component`標簽修飾服務端Server類。
這樣,我們就可以在Springboot的main函數中,通過getBean(TCPServer.class)的方式啟動服務端了。
```
@Configuration
@EnableLogging
@SpringBootApplication
@EnableApiIdempotent
@ComponentScan("com.zihan.evm.*")
@MapperScan("com.zihan.evm.*.mapper,com.zihan.evm.*.dao")
public class LocationApplicationMain {
public static void main(String[] args) {
// SocketServer啟動
ApplicationContext applicationContext = SpringApplication.run(LocationApplicationMain.class, args);
//使用NIO實現長連接服務端
// applicationContext.getBean(ChannelServer.class).startSocketServer();
//使用Netty實現長連接服務端
applicationContext.getBean(TcpNettyServer.class).startSocketServer();
}
```
- 第一章 開篇寄語
- 1-1 技術選型要點
- 1-2 認識905.4王國的交流規范
- 1-3 聯系作者
- 第二章 Socket編程的基礎知識
- 2-1 Socket家族的基石
- 2-2 byte數組基礎
- 2-3 緩沖區基礎
- 2-4 NIO Socket通訊的工作原理
- 第三章 905.4規范解讀
- 3-1 基于通道選擇器的Socket長連接及消息讀寫框架
- 3-2 嚴格的信件收發員
- 3-3 負責消息處理的一家子
- 3-4 負責認證的大兒子(AuthWorker)
- 3-5 啞巴老二(PingWoker)
- 3-6 勤奮的定位匯報員老三(LocationReportWorker)
- 3-7 精明的老四(BusinessReportWorker)
- 3-8 數據檢察官——CRC16-CCITT校驗
- 3-11 數據的加密官
- 3-12 頭尾標識轉義
- 第四章 測試方法
- 4-1 測試數據樣例
- 4-2 客戶端鏈路保持功能實現
- 4-3 使用Socket短連接進行功能測試
- 4-4 NIO服務端性能分析
- 4-5 http測試方法(推薦)
- 第五章 從NIO到netty
- 5-1 編程進階——Netty核心基礎
- 5-2 Netty使用常見問題
- 5-3 使用Netty重寫Server端
- 5-4 Netty之鏈路管理
- 5-5 netty堆外內存泄漏如何應對?
- 第六章 統計與監控
- 6-1 Grafana監控面板
- 第七章 售后服務
- 7-1 勘誤與優化
- 7-2 獲取源碼