手把手教你寫 Socket 長連線

玉剛說發表於2018-06-29

本文由玉剛說寫作平臺[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,不需要經過握手,就可以直接傳送資料)。

下面我們簡單瞭解一下三次握手的過程。

tcp-three-way-handshake

  1. 首先,客戶向服務端傳送一個 SYN,假設此時 sequence number 為 x。這個 x 是由作業系統根據一定的規則生成的,不妨認為它是一個隨機數。
  2. 服務端收到 SYN 後,會向客戶端再傳送一個 SYN,此時伺服器的 seq number = y。與此同時,會 ACK x+1,告訴客戶端“已經收到了 SYN,可以傳送資料了”。
  3. 客戶端收到伺服器的 SYN 後,回覆一個 ACK y+1,這個 ACK 則是告訴伺服器,SYN 已經收到,伺服器可以傳送資料了。

經過這 3 步,TCP 連線就建立了。這裡需要注意的有三點:

  1. 連線是由客戶端主動發起的
  2. 在第 3 步客戶端向伺服器回覆 ACK 的時候,TCP 協議是允許我們攜帶資料的。之所以做不到,是 API 的限制導致的。
  3. TCP 協議還允許 “四次握手” 的發生,同樣的,由於 API 的限制,這個極端的情況並不會發生。

TCP/IP 相關的理論知識我們就先了解到這裡。關於 TCP,還有諸如可靠性、流量控制、擁塞控制等非常有趣的特性,強烈推薦讀者看一看 Richard 的名著《TCP/IP 詳解 - 卷1》(注意,是第1版,不是第2版)。

下面我們看一些偏實戰的東西。

Socket 基本用法

Socket 是 TCP 層的封裝,通過 socket,我們就能進行 TCP 通訊。

在 Java 的 SDK 中,socket 的共有兩個介面:用於監聽客戶連線的 ServerSocket 和用於通訊的 Socket。使用 socket 的步驟如下:

  1. 建立 ServerSocket 並監聽客戶連線
  2. 使用 Socket 連線服務端
  3. 通過 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
複製程式碼

在客戶端,我們會看到,輸入的所有字元都列印了出來。

最後需要注意的有幾點:

  1. 在上面的程式碼中,我們所有的異常都沒有處理。實際應用中,在發生異常時,需要關閉 socket,並根據實際業務做一些錯誤處理工作
  2. 在客戶端,我們沒有停止 readThread。實際應用中,我們可以通過關閉 socket 來讓執行緒從阻塞讀中返回。推薦讀者閱讀《Java併發程式設計實戰》
  3. 我們的服務端只處理了一個客戶連線。如果需要同時處理多個客戶端,可以建立執行緒來處理請求。這個作為練習留給讀者來完全。

Socket、ServerSocket 傻傻分不清楚

在進入這一節的主題前,讀者不妨先考慮一個問題:在上一節的例項中,我們執行 echo 服務後,在客戶端連線成功時,一個有多少個 socket 存在?

答案是 3 個 socket。客戶端一個,服務端有兩個。跟這個問題的答案直接關聯的是本節的主題——SocketServerSocket 的區別是什麼。

眼尖的讀者,可能會注意到在上一節我是這樣描述他們的:

在 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 將不再可用:

  1. 某一端關閉是 socket(這不是廢話嗎)。主動關閉的一方會傳送 FIN,通知對方要關閉 TCP 連線。在這種情況下,另一端如果去讀 socket,將會讀到 EoF(End of File)。於是我們知道對方關閉了 socket。
  2. 應用程式奔潰。此時 socket 會由核心關閉,結果跟情況1一樣。
  3. 系統奔潰。這時候系統是來不及傳送 FIN 的,因為它已經跪了。此時對方無法得知這一情況。對方在嘗試讀取資料時,最後會返回 read time out。如果寫資料,則是 host unreachable 之類的錯誤。
  4. 電纜被挖斷、網線被拔。跟情況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 就能夠把資料按原順序重新組裝。

一樣,我們也給每個任務賦一個值,根據進入工作佇列的順序依次遞增。工作執行緒完成任務後,在將結果放入結果佇列前,先檢查要放入物件的寫一個序列號是不是跟自己的任務相同,如果不同,這個結果就不能放進去。此時,最簡單的做法是等待,知道下一個可以放入佇列的結果是自己所執行的那一個。但是,這個執行緒就沒辦法繼續處理任務了。

更好的方法是,我們維護多一個結果佇列的緩衝,這個緩衝裡面的資料按序列號從小到大排序。工作執行緒要將結果放入,有兩種可能:

  1. 剛剛完成的任務剛好是下一個,將這個結果放入佇列。然後從緩衝的頭部開始,將所有可以放入結果佇列的資料都放進去。
  2. 所完成的任務不能放入結果佇列,這個時候就插入結果佇列。然後,跟上一種情況一樣,需要檢查緩衝。

如果測試表明,這個結果緩衝的資料不多,那麼使用普通的連結串列就可以。如果資料比較多,可以使用一個最小堆。

如何保證對方收到了訊息

我們說,TCP 提供了可靠的傳輸。這樣不就能夠保證對方收到訊息了嗎?

很遺憾,其實不能。在我們往 socket 寫入的資料,只要對端的核心收到後,就會返回 ACK,此時,socket 就認為資料已經寫入成功。然而要注意的是,這裡只是對方所執行的系統的核心成功收到了資料,並不表示應用程式已經成功處理了資料。

解決辦法還是一樣,我們學 TCP,新增一個應用層的 APP ACK。應用接收到訊息並處理成功後,傳送一個 APP ACK 給對方。

有了 APP ACK,我們需要處理的另一個問題是,如果對方真的沒有收到,需要怎麼做?

TCP 傳送資料的時候,訊息一樣可能丟失。TCP 傳送資料後,如果長時間沒有收到對方的 ACK,就假設資料已經丟失,並重新傳送。

我們也一樣,如果長時間沒有收到 APP ACK,就假設資料丟失,重新傳送一個。

附:

[1] renyugang.io/post/75

[2] jekton.github.io

[3] github.com/Jekton/Echo

手把手教你寫 Socket 長連線
歡迎關注微信公眾號,接收第一手技術乾貨

相關文章