Socket.io 自我定位為一套實時、雙向且基於事件的通訊框架,意在打造快速且可靠的通訊模型。

我們姑且先不去評論目前的項目中,對於「快速」、「可靠」這兩個方針的實現(請容我這麼吐槽,這個框架還有許多空間可以變得更加成熟),Socket.io 為前後端應用的通訊過程提供了幾個有趣的特性,稍後會在文章中指出。

Socket.io 最初以 JavaScript 實現了後端及客戶端的模塊,有興趣的人可以參考 NPM 上的 socket.io (後端)及 socket.io-client (客戶端),另外官方也有 C++ 實現的客戶端 socket.io-client-cpp。在 Python 中也有相關的依賴可以使用,Python3 之後的版本可以參考 python-socketio(本牛在這個項目中發過 issue,充分感受到作者維護的用心)。

本文會將重心放在 Socket.io 的協議部分、Socket.io 所提供的特性,除了想更了解 Socket.io 的人之外,本文也對想要以其他語言自行實現 Socket.io 協議的人有些許幫助。然而,本文並不會介紹如何使用 Socket.io API,有需求的孩子請自行選擇上面幾個項目進去自行閱讀文件。

提要

廣義的 Socket.io 事實上包含了 Engine.io 和 Socket.io 兩個部分。在 TCP/IP 的網路模型[1]中,Engine.io 為搭建在應用層(Application layer)之上的一個層次,而 Socket.io 則搭建在 Engine.io 之上。

Engine.io

https://github.com/socketio/engine.io-protocol

Engine.io 是基於文本消息的協議,協議中抽象了傳輸(Transport)和連線(Connection)的概念;也就是說,一道連線的建立其實是可以透過不同的傳輸進行交握、溝通的。舉個最簡單的例子,所有連線都是從 HTTP 輪詢開始的,若客戶端支持 WebSocket 則會請求伺服器升級連線,接下來的訊息交換都會以 WebSocket 進行。

Payload

Engine.io 的傳輸方式,目前支持了 HTTP 輪詢和 WebSocket 兩種方式。

Engine.io 在使用不支持 framing 的傳輸方式時(如 HTTP 輪詢),其封包格式會依環境是否支持 XHR2(支持二進制傳輸)有所不同。在不支持 XHR2 的環境下,封包會以這個格式進行傳輸,此時若需要發送二進制的內文,則須將內文進行 Base64 編碼。

1
<length1>:<packet1>[...]
  • <length> 內文長度
  • <packet> 封包內文
  • [...] 重複格式

環境若支持 XHR2,則會採用以下格式發送訊息。

1
<content-type><Any number of numbers between 0 and 9><The number 255><packet1>[...]
  • <content-type> 為 0 表示字串內文,為 1 表示二進制內文。
  • <Any number of numbers between 0 and 9> 數個 0 至 9 的整數,直接表示了十進制的各個位數。如,依序收到 109 三個整數,則表示接下來內文有 109 個字。
  • <The number 255> 分隔符。
  • <packet> 封包內文
  • [...] 重複格式

由於 WebSocket 支持了 framing,因此 WebSocket 封包內文直接就是 Engine.io 封包內文,而不需要冠上封包長度。

封包內文

Engine.io 封包內文格式很簡單,在訊息前冠上封包類型的數字 ID 就好了。

1
<packet_type><body>
  • <packet_type> 封包類型 ID
  • <body> 訊息

封包類型

ID名稱方向說明
0openS → C傳輸建立。[2]
1closeS ⇄ C傳輸關閉。[3]
2pingC → S(乒)
3pongS → C(乓)
4messageS ⇄ C訊息
5upgradeC → S連線升級。
6noopS → C空訊息。通常用於連線升級後,補全前一個傳輸方式(輪詢)的週期。

S 表示伺服器;C 表示客戶端。

HTTP 輪詢

在 HTTP 輪詢傳輸中,GET 代表著 S → C 的消息,POST 代表著 C → S 的消息。

連線升級

所有連線都是源自 HTTP 輪詢開始的,同時客戶端會建立一道 WebSocket 連線,並發送 ping 封包(訊息為 probe)藉以測試連線是否成功,若接收到來自伺服器的 pong 封包(訊息為 probe),則會再發送一個 upgrade 封包通知伺服器升級連線,此時連線才算是正式改走 WebSocket 傳輸。

超時

交握時,伺服器會給客戶端兩個數值:pingTimeoutpingInterval,客戶端須每隔 pingInterval 時間發送一次 ping 封包以確認連接,且伺服器若未在 pingTimeout 時間內回應 pong 封包,則此視同斷線。由此可知,不論是伺服器端或是客戶端,在理想的網路環境下(幾乎沒有延遲),每隔 pingInterval 就會收到 ping 或 pong 封包,最久於 pingTimeout + pingInterval 時間內可以判定另一端失去連線。

Socket.io

https://github.com/socketio/socket.io-protocol

Socket.io 是建構在 Engine.io 之上、基於事件的通訊協議,在 Socket.io 協議中包含了幾個特性,包括自訂事件以及命名空間(Namespace),且 Socket.io 協議支持二進制的傳輸。

封包格式

1
<packet_type>[<attachment>-][<namespace>][,[<ack_id>]<data>]
  • <packet_type> 封包類型
  • <attachment> 二進制封包數量,後綴特殊符 -。見二進制傳輸說明。
  • <namespace> 命名空間,前綴符 / 不能省略。
  • [<ack_id>] ACK 響應 ID。見ACK 訊息說明。
  • <data> 為 JSON 陣列(array),第一個元素為事件名稱,隨後是該事件的參數。

封包類型

ID名稱說明
0CONNECT連線建立
1DISCONNECT連線斷開
2EVENT自訂事件
3ACKACK 訊息
4ERROR錯誤
5BINARY_EVENT自訂事件(含二進制參數)
6BINARY_ACKACK 訊息(含二進制參數)

ACK 訊息

當收到封包格式中含有 <ack_id> 欄位,若此封包為 EVENT 封包或 BINARY_EVENT 封包,則表示另一端希望在此封包處理完畢之後,收到一個 ACK (Acknowledge,確認)訊息作為回應,回應的 ACK 訊息內文格式基本上與 EVENT 封包或 BINARY_EVENT 封包無異,也就是可於內文中夾帶複數個參數給另一端;若收到 ACK 封包或 BINARY_ACK 封包,則須藉 <ack_id> 於本端的回調表中查詢回調函數以處理此訊息。

二進制傳輸

這邊主要提及的是藉 WebSocket 作為傳輸方式的連線,WebSocket 封包依照內文類型分為了 UTF8 封包及二進制封包。

以下舉個例子說明封包如何解讀:

1
2
3
4
5
6
/* server side */
const buffer // Buffer
const nspTest = io.of('test')
nspTest.binary(true).emit('bin', buffer, () => {
console.log('Received an ACK from the client')
})

於是透過 Chrome 主控台我們可以在 Network > WS 標籤觀察到以下訊息。

1
451-/test,1["bin", { num: 0, _placeholder: true }]

在 Socket.io 傳遞二進制參數時,<data> 欄位仍須為一個 JSON 陣列,因此會發現相應的二進制參數的位置被替換為 { num: 0, _placeholder: true } 物件,而二進制參數的內容則會在下一個封包中傳送過來。

在上面的例子中,我們先看開頭的 451-/test,4 表示的是 Engine.io 的 message 封包,5 表示 Socket.io 的 BINARY_EVENT 封包,1 表示後續還有一個二進制封包, /test 是命名空間的名稱。

JSON data 前面那個 1<ack_id>,當我們在伺服器端執行 emit() 時,會將最後一個參數那個方法註冊到一張表上,並給予一個 <ack_id>,一旦客戶端響應這個 <ack_id> 回來時,就可以回到表上查詢回調函數並處理這個 ACK 訊息。

交握過程

以下示例假設了客戶端支持 WebSocket 及 XHR2。

在第一次輪詢中,由伺服器送給客戶端(HTTP GET)交握資料(pingTimeoutpingIntervalsidupgrades),並夾帶一個 Engine.io 的 open 封包,確認 Engine.io 連線建立。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// given `domain` and `t`, which is inspected from browser console
fetch(`http://${domain}/socket.io/?EIO=3&transport=polling&t=${t}`)
.then(msg => msg.arrayBuffer())
.then(buf => {
const payload = new Uint8Array(buf)
console.log(payload[0]) // 0
console.log(payload[1]) // 1
console.log(payload[2]) // 0
console.log(payload[3]) // 9
console.log(payload[4]) // 255
console.log(payload[5]) // 48

const payloadStr = Array.from(payload.slice(5, 5 + 109)) // content len = 109
.map(c => String.fromCharCode(c))
.join('')
console.log(payloadStr)
// 0{"sid":"c924c397634d41a89936bd13bfb10f9f","upgrades":["websocket"],"pingTimeout":60000,"pingInterval":25000}
})

透過以上 JS code ,我們模擬了第一次交握時客戶端發送的 GET,藉此觀察一下收到的內文。

payload[0] 為 Engine.io 的 <content-type>0 表示內文為字串類型。 接著我們觀察 255payload[4])出現之前的數字依序為 1payload[1])、0payload[2])、9payload[3]),表示內文長度為 109 個字元。 payload[5] 開始就是內文了,48 是 ASCII 編碼,轉換一下得到字符 "0""0" 是 Engine.io open 封包。 payload[5]payload[113] 的內容 ASCII 轉換一下可得到交握的 JSON 資料。

payload[114]payload[118] 是下一個 Engine.io 封包。 payload[114] 為 Engine.io 的 <content-type>0 表示內文為字串類型。0payload[115]2,表示內文長度為 2 個字元。 payload[116] 為分隔符 255payload[117] payload[118] 經過 ASCII 轉換為 "40"4 是 Engine.io message 封包,0 是 Socket.io CONNECT 封包,此為伺服器向客戶端發起的 Socket.io 連線(全域,不限於任何命名空間下)。

接著在第二次輪詢,由客戶端向伺服器(HTTP POST)建立 Socket.io 連線,內容一樣是 Socket.io CONNECT 封包,此時會多帶了 Socket.io 的命名空間、及自訂的參數。

第二次輪詢,POST 的內文如下。解讀也很容易,冒號之後的內文長度為 23 個字,4 為 Engine.io message 封包,0 為 Socket.io CONNECT 封包,對命名空間 /test 發起連線

1
23:40/test?token=eqepeuyh,

第三次輪詢為 HTTP GET,伺服器告訴客戶端,對 /test 的連線建立成功。(ÿ 是分隔符 255 的位置,前面空白是因為 ASCII 轉換後為不可打印的字元,實際上是內文長度的那幾個數字)

1
ÿ40/test

客戶端向伺服器發起 WebSocket 連線,進入連線升級的檢測階段。

連線升級

測試訊息,客戶端向伺服器發送 ping 封包,內文為 probe,伺服器回應 pong 封包,內文與 ping 相同。

檢測成功,因此客戶端又向伺服器發送 upgrade 封包。

伺服器收到 upgrade 封包後,把原先傳輸方式(HTTP 輪詢)的 buffer 中的訊息全部 flush 出來,以 noop 封包表示 buffer 已經清空。往後的訊息都改走 WebSocket。

交握時驗證

在收到客戶端發送的 CONNECT 時,伺服器可以藉客戶端提供的 query 參數進行驗證。若需要拒絕連線,可以使用 ERROR 封包拒絕 Socket.io 層的連線,這個拒絕的行為可是依據不同命名空間各自決定的。

Socket.io 特性整理

  • Events 自訂事件。
  • Rooms Room 的概念只存在於伺服器端。可以理解為訊息處理時的聽眾分組,可對同一個分組內的聽眾進行廣播。
  • Namespaces 命名空間,我理解為底層連線的分組管理,不同命名空間可以走同一條 Engine.io 連線或是各自連線,每個命名空間可以各自驗證是否接受連線。
  • ACK 回調 如同 HTTP 之於 TCP,HTTP 為 TCP 提供了一套請求與響應的模型。ACK 也為 Socket.io 提供了一套請求與響應的通訊模型。
  • 連線維護
    • 自動斷線重連
    • ping/pong 心跳

  1. https://en.wikipedia.org/wiki/Internet_protocol_suite#Key_architectural_principles ↩︎

  2. 官方文件並未詳盡的說明。原文:

    Sent from the server when a new transport is opened (recheck)

    ↩︎
  3. 官方文件並未詳盡的說明。原文

    Request the close of this transport but does not shutdown the connection itself.

    ↩︎