本文由
玉剛說寫作平臺
[1]提供寫作贊助原作者:
水晶蝦餃
[2]版權宣告:本文版權歸微信公眾號
玉剛說
所有,未經許可,不得以任何形式轉載
本篇我們先簡單瞭解一下 TCP/IP,然後通過實現一個 echo 伺服器來學習 Java 的 Socket API。最後我們聊聊偏高階一點點的 socket 長連線和協議設計。
TCP/IP 協議簡介
IP
首先我們看 IP(Internet Protocol)協議。IP 協議提供了主機和主機間的通訊。
為了完成不同主機的通訊,我們需要某種方式來唯一標識一臺主機,這個標識,就是著名的IP地址。通過IP地址,IP 協議就能夠幫我們把一個資料包傳送給對方。
TCP
前面我們說過,IP 協議提供了主機和主機間的通訊。TCP 協議在 IP 協議提供的主機間通訊功能的基礎上,完成這兩個主機上程式對程式的通訊。
有了 IP,不同主機就能夠交換資料。但是,計算機收到資料後,並不知道這個資料屬於哪個程式(簡單講,程式就是一個正在執行的應用程式)。TCP 的作用就在於,讓我們能夠知道這個資料屬於哪個程式,從而完成程式間的通訊。
為了標識資料屬於哪個程式,我們給需要進行 TCP 通訊的程式分配一個唯一的數字來標識它。這個數字,就是我們常說的埠號。
TCP 的全稱是 Transmission Control Protocol,大家對它說得最多的,大概就是面向連線的特性了。之所以說它是有連線的,是說在進行通訊前,通訊雙方需要先經過一個三次握手的過程。三次握手完成後,連線便建立了。這時候我們才可以開始傳送/接收資料。(與之相對的是 UDP,不需要經過握手,就可以直接傳送資料)。
下面我們簡單瞭解一下三次握手的過程。
- 首先,客戶向服務端傳送一個
SYN
,假設此時 sequence number 為x
。這個x
是由作業系統根據一定的規則生成的,不妨認為它是一個隨機數。 - 服務端收到
SYN
後,會向客戶端再傳送一個SYN
,此時伺服器的seq number = y
。與此同時,會ACK x+1
,告訴客戶端“已經收到了SYN
,可以傳送資料了”。 - 客戶端收到伺服器的
SYN
後,回覆一個ACK y+1
,這個ACK
則是告訴伺服器,SYN
已經收到,伺服器可以傳送資料了。
經過這 3 步,TCP 連線就建立了。這裡需要注意的有三點:
- 連線是由客戶端主動發起的
- 在第 3 步客戶端向伺服器回覆
ACK
的時候,TCP 協議是允許我們攜帶資料的。之所以做不到,是 API 的限制導致的。 - TCP 協議還允許 “四次握手” 的發生,同樣的,由於 API 的限制,這個極端的情況並不會發生。
TCP/IP 相關的理論知識我們就先了解到這裡。關於 TCP,還有諸如可靠性、流量控制、擁塞控制等非常有趣的特性,強烈推薦讀者看一看 Richard 的名著《TCP/IP 詳解 - 卷1》(注意,是第1版,不是第2版)。
下面我們看一些偏實戰的東西。
Socket 基本用法
Socket 是 TCP 層的封裝,通過 socket,我們就能進行 TCP 通訊。
在 Java 的 SDK 中,socket 的共有兩個介面:用於監聽客戶連線的 ServerSocket
和用於通訊的 Socket
。使用 socket 的步驟如下:
- 建立
ServerSocket
並監聽客戶連線 - 使用
Socket
連線服務端 - 通過
Socket
獲取輸入輸出流進行通訊
下面,我們通過實現一個簡單的 echo 服務來學習 socket 的使用。所謂的 echo 服務,就是客戶端向服務端寫入任意資料,伺服器都將資料原封不動地寫回給客戶端。
1. 建立 ServerSocket 並監聽客戶連線
public class EchoServer {
private final ServerSocket mServerSocket;
public EchoServer(int port) throws IOException {
// 1. 建立一個 ServerSocket 並監聽埠 port
mServerSocket = new ServerSocket(port);
}
public void run() throws IOException {
// 2. 開始接受客戶連線
Socket client = mServerSocket.accept();
handleClient(client);
}
private void handleClient(Socket socket) {
// 3. 使用 socket 進行通訊 ...
}
public static void main(String[] argv) {
try {
EchoServer server = new EchoServer(9877);
server.run();
} catch (IOException e) {
e.printStackTrace();
}
}
}
複製程式碼
2. 使用 Socket 連線服務端
public class EchoClient {
private final Socket mSocket;
public EchoClient(String host, int port) throws IOException {
// 建立 socket 並連線伺服器
mSocket = new Socket(host, port);
}
public void run() {
// 和服務端進行通訊
}
public static void main(String[] argv) {
try {
// 由於服務端執行在同一主機,這裡我們使用 localhost
EchoClient client = new EchoClient("localhost", 9877);
client.run();
} catch (IOException e) {
e.printStackTrace();
}
}
}
複製程式碼
3. 通過 socket.getInputStream()/getOutputStream() 獲取輸入/輸出流進行通訊
首先,我們來實現服務端:
public class EchoServer {
// ...
private void handleClient(Socket socket) throws IOException {
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
byte[] buffer = new byte[1024];
int n;
while ((n = in.read(buffer)) > 0) {
out.write(buffer, 0, n);
}
}
}
複製程式碼
可以看到,服務端的實現其實很簡單,我們不停地讀取輸入資料,然後寫回給客戶端。
下面我們看看客戶端。
public class EchoClient {
// ...
public void run() throws IOException {
Thread readerThread = new Thread(this::readResponse);
readerThread.start();
OutputStream out = mSocket.getOutputStream();
byte[] buffer = new byte[1024];
int n;
while ((n = System.in.read(buffer)) > 0) {
out.write(buffer, 0, n);
}
}
private void readResponse() {
try {
InputStream in = mSocket.getInputStream();
byte[] buffer = new byte[1024];
int n;
while ((n = in.read(buffer)) > 0) {
System.out.write(buffer, 0, n);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
複製程式碼
客戶端會稍微複雜一點點,在讀取使用者輸入的同時,我們又想讀取伺服器的響應。所以,這裡建立了一個執行緒來讀伺服器的響應。
不熟悉 lambda 的讀者,可以把
Thread readerThread = new Thread(this::readResponse)
換成下面這個程式碼:
Thread readerThread = new Thread(new Runnable() {
@Override
public void run() {
readResponse();
}
});
複製程式碼
開啟兩個 terminal 分別執行如下命令:
$ javac EchoServer.java
$ java EchoServer
複製程式碼
$ javac EchoClient.java
$ java EchoClient
hello Server
hello Server
foo
foo
複製程式碼
在客戶端,我們會看到,輸入的所有字元都列印了出來。
最後需要注意的有幾點:
- 在上面的程式碼中,我們所有的異常都沒有處理。實際應用中,在發生異常時,需要關閉 socket,並根據實際業務做一些錯誤處理工作
- 在客戶端,我們沒有停止
readThread
。實際應用中,我們可以通過關閉 socket 來讓執行緒從阻塞讀中返回。推薦讀者閱讀《Java併發程式設計實戰》 - 我們的服務端只處理了一個客戶連線。如果需要同時處理多個客戶端,可以建立執行緒來處理請求。這個作為練習留給讀者來完全。
Socket、ServerSocket 傻傻分不清楚
在進入這一節的主題前,讀者不妨先考慮一個問題:在上一節的例項中,我們執行 echo 服務後,在客戶端連線成功時,一個有多少個 socket 存在?
答案是 3 個 socket。客戶端一個,服務端有兩個。跟這個問題的答案直接關聯的是本節的主題——Socket
和 ServerSocket
的區別是什麼。
眼尖的讀者,可能會注意到在上一節我是這樣描述他們的:
在 Java 的 SDK 中,socket 的共有兩個介面:用於監聽客戶連線的
ServerSocket
和用於通訊的Socket
。
注意,我只說 ServerSocket
是用於監聽客戶連線,而沒有說它也可以用來通訊。下面我們來詳細瞭解一下他們的區別。
注:以下描述使用的是 UNIX/Linux 系統的 API
首先,我們建立 ServerSocket
後,核心會建立一個 socket。這個 socket 既可以拿來監聽客戶連線,也可以連線遠端的服務。由於 ServerSocket
是用來監聽客戶連線的,緊接著它就會對核心建立的這個 socket 呼叫 listen
函式。這樣一來,這個 socket 就成了所謂的 listening socket,它開始監聽客戶的連線。
接下來,我們的客戶端建立一個 Socket
,同樣的,核心也建立一個 socket 例項。核心建立的這個 socket 跟 ServerSocket
一開始建立的那個沒有什麼區別。不同的是,接下來 Socket
會對它執行 connect
,發起對服務端的連線。前面我們說過,socket API 其實是 TCP 層的封裝,所以 connect
後,核心會傳送一個 SYN
給服務端。
現在,我們切換角色到服務端。服務端的主機在收到這個 SYN
後,會建立一個新的 socket,這個新建立的 socket 跟客戶端繼續執行三次握手過程。
三次握手完成後,我們執行的 serverSocket.accept()
會返回一個 Socket
例項,這個 socket 就是上一步核心自動幫我們建立的。
所以說,在一個客戶端連線的情況下,其實有 3 個 socket。
關於核心自動建立的這個 socket,還有一個很有意思的地方。它的埠號跟 ServerSocket
是一毛一樣的。咦!!不是說,一個埠只能繫結一個 socket 嗎?其實這個說法並不夠準確。
前面我說的TCP 通過埠號來區分資料屬於哪個程式的說法,在 socket 的實現裡需要改一改。Socket 並不僅僅使用埠號來區別不同的 socket 例項,而是使用 <peer addr:peer port, local addr:local port>
這個四元組。
在上面的例子中,我們的 ServerSocket
長這樣:<*:*, *:9877>
。意思是,可以接受任何的客戶端,和本地任何 IP。
accept
返回的 Socket
則是這樣:
<127.0.0.1:xxxx, 127.0.0.1:9877>
,其中xxxx
是客戶端的埠號。
如果資料是傳送給一個已連線的 socket,核心會找到一個完全匹配的例項,所以資料準確傳送給了對端。
如果是客戶端要發起連線,這時候只有 <*:*, *:9877>
會匹配成功,所以 SYN
也準確傳送給了監聽套接字。
Socket/ServerSocket
的區別我們就講到這裡。如果讀者覺得不過癮,可以參考《TCP/IP 詳解》卷1、卷2。
Socket 長連線的實現
背景知識
Socket 長連線,指的是在客戶和服務端之間保持一個 socket 連線長時間不斷開。
比較熟悉 Socket
的讀者,可能知道有這樣一個 API:
socket.setKeepAlive(true);
複製程式碼
嗯……keep alive,“保持活著”,這個應該就是讓 TCP 不斷開的意思。那麼,我們要實現一個 socket 的長連線,只需要這一個呼叫即可。
遺憾的是,生活並不總是那麼美好。對於 4.4BSD 的實現來說,Socket 的這個 keep alive 選項如果開啟並且兩個小時內沒有通訊,那麼底層會發一個心跳,看看對方是不是還活著。
注意,兩個小時才會發一次。也就是說,在沒有實際資料通訊的時候,我把網線拔了,你的應用程式要經過兩個小時才會知道。
在說明如果實現長連線前,我們先來理一理我們面臨的問題。假定現在有一對已經連線的 socket,在以下情況發生時候,socket 將不再可用:
- 某一端關閉是 socket(這不是廢話嗎)。主動關閉的一方會傳送
FIN
,通知對方要關閉 TCP 連線。在這種情況下,另一端如果去讀 socket,將會讀到EoF
(End of File)。於是我們知道對方關閉了 socket。 - 應用程式奔潰。此時 socket 會由核心關閉,結果跟情況1一樣。
- 系統奔潰。這時候系統是來不及傳送
FIN
的,因為它已經跪了。此時對方無法得知這一情況。對方在嘗試讀取資料時,最後會返回 read time out。如果寫資料,則是 host unreachable 之類的錯誤。 - 電纜被挖斷、網線被拔。跟情況3差不多,如果沒有對 socket 進行讀寫,兩邊都不知道發生了事故。跟情況3不同的是,如果我們把網線接回去,socket 依舊可以正常使用。
在上面的幾種情形中,有一個共同點就是,只要去讀、寫 socket,只要 socket 連線不正常,我們就能夠知道。基於這一點,要實現一個 socket 長連線,我們需要做的就是不斷地給對方寫資料,然後讀取對方的資料,也就是所謂的心跳。只要心還在跳,socket 就是活的。寫資料的間隔,需要根據實際的應用需求來決定。
心跳包不是實際的業務資料,根據通訊協議的不同,需要做不同的處理。
比方說,我們使用 JSON 進行通訊,那麼,我們可以加一個 type
欄位,表面這個 JSON 是心跳還是業務資料。
{
"type": 0, // 0 表示心跳
// ...
}
複製程式碼
使用二進位制協議的情況類似。要求就是,我們能夠區別一個資料包是心跳還是真實資料。這樣,我們便實現了一個 socket 長連線。
實現示例
這一小節我們一起來實現一個帶長連線的 Android echo 客戶端。
首先了介面部分:
public final class LongLiveSocket {
/**
* 錯誤回撥
*/
public interface ErrorCallback {
/**
* 如果需要重連,返回 true
*/
boolean onError();
}
/**
* 讀資料回撥
*/
public interface DataCallback {
void onData(byte[] data, int offset, int len);
}
/**
* 寫資料回撥
*/
public interface WritingCallback {
void onSuccess();
void onFail(byte[] data, int offset, int len);
}
public LongLiveSocket(String host, int port,
DataCallback dataCallback, ErrorCallback errorCallback) {
}
public void write(byte[] data, WritingCallback callback) {
}
public void write(byte[] data, int offset, int len, WritingCallback callback) {
}
public void close() {
}
}
複製程式碼
我們這個支援長連線的類就叫 LongLiveSocket
好了。如果在 socket 斷開後需要重連,只需要在對應的介面裡面返回 true 即可(在真實場景裡,我們還需要讓客戶設定重連的等待時間,還有讀寫、連線的 timeout等。為了簡單,這裡就直接不支援了。
另外需要注意的一點是,如果要做一個完整的庫,需要同時提供阻塞式和回撥式API。同樣由於篇幅原因,這裡直接省掉了。
首先我們看看 write()
方法:
public void write(byte[] data, int offset, int len, WritingCallback callback) {
mWriterHandler.post(() -> {
Socket socket = getSocket();
if (socket == null) {
// initSocket 失敗而客戶說不需要重連,但客戶又叫我們給他傳送資料
throw new IllegalStateException("Socket not initialized");
}
try {
OutputStream outputStream = socket.getOutputStream();
DataOutputStream out = new DataOutputStream(outputStream);
out.writeInt(len);
out.write(data, offset, len);
callback.onSuccess();
} catch (IOException e) {
Log.e(TAG, "write: ", e);
// 關閉 socket,避免資源洩露
closeSocket();
// 這裡我們把發生失敗的資料返回給客戶端,這樣客戶可以更方便地重新傳送資料
callback.onFail(data, offset, len);
if (!closed() && mErrorCallback.onError()) {
// 重連
initSocket();
}
}
});
}
複製程式碼
由於我們需要定時寫心跳,這裡使用一個 HandlerThread
來處理寫請求。通訊使用的協議,只是簡單地在使用者資料前加一個 len 欄位,用於確定訊息的長度。
下面我們看心跳的傳送:
private final Runnable mHeartBeatTask = new Runnable() {
private byte[] mHeartBeat = new byte[0];
@Override
public void run() {
// 我們使用長度為 0 的資料作為 heart beat
write(mHeartBeat, new WritingCallback() {
@Override
public void onSuccess() {
// 每隔 HEART_BEAT_INTERVAL_MILLIS 傳送一次
mWriterHandler.postDelayed(mHeartBeatTask, HEART_BEAT_INTERVAL_MILLIS);
mUIHandler.postDelayed(mHeartBeatTimeoutTask, HEART_BEAT_TIMEOUT_MILLIS);
}
@Override
public void onFail(byte[] data, int offset, int len) {
// nop
// write() 方法會處理失敗
}
});
}
};
private final Runnable mHeartBeatTimeoutTask = () -> {
Log.e(TAG, "mHeartBeatTimeoutTask#run: heart beat timeout");
closeSocket();
};
複製程式碼
傳送心跳使用我們上面實現的 write()
方法。在傳送成功後,post delay 一個 timeout task,如果到期後還沒收到伺服器的響應,我們將認為 socket 出現異常,這裡直接關閉 socket。最後是對心跳的處理:
int nbyte = in.readInt();
if (nbyte == 0) {
Log.i(TAG, "readResponse: heart beat received");
mUIHandler.removeCallbacks(mHeartBeatTimeoutTask);
}
複製程式碼
由於使用者資料的長度總是會大於 1,這裡我們就使用 len == 0
的資料作為心跳。收到心跳後,移除 mHeartBeatTimeoutTask
。
剩餘程式碼跟我們的主題沒有太大關係,讀者在這裡[3]可以找到完整的程式碼或者自己完成這個例子。
最後需要說明的是,如果想節省資源,在有客戶傳送資料的時候可以省略 heart beat。
我們對讀出錯時候的處理,可能也存在一些爭議。讀出錯後,我們只是關閉了 socket。socket 需要等到下一次寫動作發生時,才會重新連線。實際應用中,如果這是一個問題,在讀出錯後可以直接開始重連。這種情況下,還需要一些額外的同步,避免重複建立 socket。heart beat timeout 的情況類似。
跟 TCP/IP 學協議設計
如果僅僅是為了使用是 socket,我們大可以不去理會協議的細節。之所以推薦大家去看一看《TCP/IP 詳解》,是因為它們有太多值得學習的地方。很多我們工作中遇到的問題,都可以在這裡找到答案。
以下每一個小節的標題都是一個小問題,建議讀者獨立思考一下,再繼續往下看。如果你發現你的答案比我的更好,請一定傳送郵件到 ljtong64 AT gmail DOT com 告訴我。
協議版本如何升級?
有這麼一句流行的話:這個世界唯一不變的,就是變化。當我們對協議版本進行升級的時候,正確識別不同版本的協議對軟體的相容非常重要。那麼,我們如何設計協議,才能夠為將來的版本升級做準備呢?
答案可以在 IP 協議找到。
IP 協議的第一個欄位叫 version,目前使用的是 4 或 6,分別表示 IPv4 和 IPv6。由於這個欄位在協議的開頭,接收端收到資料後,只要根據第一個欄位的值就能夠判斷這個資料包是 IPv4 還是 IPv6。
再強調一下,這個欄位在兩個版本的IP協議都位於第一個欄位,為了做相容處理,對應的這個欄位必須位於同一位置。文字協議(如,JSON、HTML)的情況類似。
如何傳送不定長資料的資料包
舉個例子,我們用微信傳送一條訊息。這條訊息的長度是不確定的,並且每條訊息都有它的邊界。我們如何來處理這個邊界呢?
還是一樣,看看 IP。IP 的頭部有個 header length 和 data length 兩個欄位。通過新增一個 len 域,我們就能夠把資料根據應用邏輯分開。
跟這個相對的,還有另一個方案,那就是在資料的末尾放置終止符。比方說,想 C 語言的字串那樣,我們在每個資料的末尾放一個 \0
作為終止符,用以標識一條訊息的尾部。這個方法帶來的問題是,使用者的資料也可能存在 \0
。此時,我們就需要對使用者的資料進行轉義。比方說,把使用者資料的所有 \0
都變成 \0\0
。讀訊息的過程總,如果遇到 \0\0
,那它就代表 \0
,如果只有一個 \0
,那就是訊息尾部。
使用 len 欄位的好處是,我們不需要對資料進行轉義。讀取資料的時候,只要根據 len 欄位,一次性把資料都讀進來就好,效率會更高一些。
終止符的方案雖然要求我們對資料進行掃描,但是如果我們可能從任意地方開始讀取資料,就需要這個終止符來確定哪裡才是訊息的開頭了。
當然,這兩個方法不是互斥的,可以一起使用。
上傳多個檔案,只有所有檔案都上傳成功時才算成功
現在我們有一個需求,需要一次上傳多個檔案到伺服器,只有在所有檔案都上傳成功的情況下,才算成功。我們該如何來實現呢?
IP 在資料包過大的時候,會把一個資料包拆分成多個,並設定一個 MF (more fragments)位,表示這個包只是被拆分後的資料的一部分。
好,我們也學一學 IP。這裡,我們可以給每個檔案從 0 開始編號。上傳檔案的同時,也攜帶這個編號,並額外附帶一個 MF 標誌。除了編號最大的檔案,所有檔案的 MF 標誌都置位。因為 MF 沒有置位的是最後一個檔案,伺服器就可以根據這個得出總共有多少個檔案。
另一種不使用 MF 標誌的方法是,我們在上傳檔案前,就告訴伺服器總共有多少個檔案。
如果讀者對資料庫比較熟悉,學資料庫用事務來處理,也是可以的。這裡就不展開討論了。
如何保證資料的有序性
這裡講一個我曾經遇到過的面試題。現在有一個任務佇列,多個工作執行緒從中取出任務並執行,執行結果放到一個結果佇列中。先要求,放入結果佇列的時候,順序順序需要跟從工作佇列取出時的一樣(也就是說,先取出的任務,執行結果需要先放入結果佇列)。
我們看看 TCP/IP 是怎麼處理的。IP 在傳送資料的時候,不同資料包到達對端的時間是不確定的,後面傳送的資料有可能較先到達。TCP 為了解決這個問題,給所傳送資料的每個位元組都賦了一個序列號,通過這個序列號,TCP 就能夠把資料按原順序重新組裝。
一樣,我們也給每個任務賦一個值,根據進入工作佇列的順序依次遞增。工作執行緒完成任務後,在將結果放入結果佇列前,先檢查要放入物件的寫一個序列號是不是跟自己的任務相同,如果不同,這個結果就不能放進去。此時,最簡單的做法是等待,知道下一個可以放入佇列的結果是自己所執行的那一個。但是,這個執行緒就沒辦法繼續處理任務了。
更好的方法是,我們維護多一個結果佇列的緩衝,這個緩衝裡面的資料按序列號從小到大排序。工作執行緒要將結果放入,有兩種可能:
- 剛剛完成的任務剛好是下一個,將這個結果放入佇列。然後從緩衝的頭部開始,將所有可以放入結果佇列的資料都放進去。
- 所完成的任務不能放入結果佇列,這個時候就插入結果佇列。然後,跟上一種情況一樣,需要檢查緩衝。
如果測試表明,這個結果緩衝的資料不多,那麼使用普通的連結串列就可以。如果資料比較多,可以使用一個最小堆。
如何保證對方收到了訊息
我們說,TCP 提供了可靠的傳輸。這樣不就能夠保證對方收到訊息了嗎?
很遺憾,其實不能。在我們往 socket 寫入的資料,只要對端的核心收到後,就會返回 ACK
,此時,socket 就認為資料已經寫入成功。然而要注意的是,這裡只是對方所執行的系統的核心成功收到了資料,並不表示應用程式已經成功處理了資料。
解決辦法還是一樣,我們學 TCP
,新增一個應用層的 APP ACK
。應用接收到訊息並處理成功後,傳送一個 APP ACK
給對方。
有了 APP ACK
,我們需要處理的另一個問題是,如果對方真的沒有收到,需要怎麼做?
TCP 傳送資料的時候,訊息一樣可能丟失。TCP 傳送資料後,如果長時間沒有收到對方的 ACK
,就假設資料已經丟失,並重新傳送。
我們也一樣,如果長時間沒有收到 APP ACK
,就假設資料丟失,重新傳送一個。
附:
[2] jekton.github.io