造輪子系列(二): 史上最簡單的長連線通訊協議及實現

acrazing發表於2019-02-16

背景

現在寫客戶端或者網頁的時候, 越來越多的需要與長連線打交道, 尤其是在這個老闆動不動就要搞一個聊天系統的時代, 後端大哥們於是分分鐘就能造一個基於TCP或者WebSockets的訊息協議出來. 但是問題在於每做一個新專案, 後端大哥們就能造出一個新協議, 而且能有各種神奇的限制. 比如說要在長連線當中保持一個狀態機, 傳送某條訊息後收到的下一條訊息一定是XXX, 或者完全一個JSON就直接丟了出來等等. 雖然都能用, 但是卻需要在各種地方維護著不同的底層通訊庫, 沒有章法可依, 所以草擬了這個協議.

目前最熱門的訊息協議莫過於MQTT和gRPC了, 前者被定義為A lightweight messaging protocol for small sensors and mobile devices, optimized for high-latency or unreliable networks, 即一個為感測器和移動裝置定製的訊息協議. 最大的特點莫過於其固定訊息頭只有2位元組, 以及QoS服務質量控制了. 對於前者, 無可厚非, 任何一個長連線的訊息協議都應該可以做到如此, 甚至更簡單(STMP便是如此), 其次其QoS設計使得通訊層面就變得很複雜, 使得其更像一個訊息佇列協議, 而不是簡單的通訊協議. 而gRPC則是一個基於ProtocolBuffers發展起來的RPC協議以實現. 整合度很高, 底層基於HTTP 2, 所以通用性很好, 如果是做大專案並且團隊有一定的技術/運維積累的話, 是非常推薦的選擇, 但是這和STMP不衝突, STMP面向的是對協議健壯性要求不高, 只需要一個能用的規範的企業/團隊中, 你可以用在Web端, 也可以用在客戶端, 或者智慧家居等嵌入式裝置中, 反觀gRPC, 則顯得過於龐雜.

簡介

協議取名STMP, 意思是最簡單的訊息協議(The simplest message protocol). 專案託管在GitHub上, 包含了完整的協議文件以及相關實現, 詳細瞭解請移步GitHub, 同時歡迎提交PR/Issue, 地址是https://github.com/acrazing/stmp.

簡單來說, STMP有以下特點:

  • 非常精簡的固定頭部, 僅有一位元組(二進位制序列化)
  • 支援二進位制序列化(TCP)以及文字序列化(WebSockets), 文字序列化支援訊息分包傳送(傳遞二進位制資料)
  • 與IP協議掩碼類似的上層路由控制
  • 負載編碼格式對協議透明
  • 心跳檢測
  • 四種訊息型別: 心跳, 請求, 通知, 回覆
  • 與HTTP協議類似的返回狀態碼控制

訊息欄位定義

一個全雙工的通訊系統中, 雙端需要有效識別對方發來的訊息, 並作出相應的處理, 選擇是否回應等操作, 所以除了實際的負載之外, 還需要若干標誌欄位. STMP中, 完整的訊息欄位列表如下, 需要注意的是並不是每條訊息都會包含所有的這些欄位, 需要根據網路環境以及訊息型別確定應該包含的欄位列表. 但是如果某條訊息包含了以下這些欄位中的某一些欄位的話,排序順序一定與欄位在下面出現的順序相同.

  • 訊息型別(KIND): 表示一條訊息的型別, 可能的取值有:

    • 0: 心跳訊息(Ping Message)
    • 1: 請求訊息(Request Message)
    • 2: 通知訊息(Notify Message)
    • 3: 回覆訊息(Response Message)
  • 訊息編碼格式(ENCODING): 表示負載的編碼格式, 上層應用/編解碼層收到訊息後, 可以通過此欄位對負載進行解碼操作, 由於頭部長度限制, 可能的取值範圍為0-7, 已經約定的編碼格式如下:

    • 0: 保留格式, 表示不包含負載, 此時訊息中一定不存在PS以及PAYLOAD欄位
    • 1: Protocol Buffers, 參考 Protocol Buffers
    • 2: JSON, 參考 JSON
    • 3: MessagePack, 參考 MessagePack
    • 4: BSON, 參考 BSON
    • 5: 原始二進位制資料
  • 訊息ID(ID): 訊息的臨時ID, 取值範圍為0x0000-0xFFFF, 用於請求與回覆訊息當中, 請求方應該保證在超時的時限內此ID唯一, 回覆方在回覆時帶上此ID以供傳送方識別
  • 訊息請求動作(ACTION): 請求的動作, 用於上層應用進行路由控制, 取值範圍為0x00000000-0xFFFFFFFF, 即32位整型, 上層應用中可以寫成xxx.xxx.xxx.xxx的形式, 與IP類似. 接收方在收到相應的動作後必需能夠正確識別, 並轉交給相應的處理器進行處理. 其中0x00-0xFF為保留動作, 用於協議內部使用. 目前已使用的動作有:

    • 0x00: 版本協商(Check Versions)
  • 狀態碼(STATUS): 處理結果狀態碼, 用在回覆訊息中, 表明對請求的處理結果, 取值範圍為0x00-0xFF, 其中0x00-0x7F為保留取值, 含義與ACTION無關, 0x80-0xFF為使用者定義的狀態值, 含義根據ACTION不同有可能不同. 目前已定義的狀態碼有(和HTTP類似, 只不過換了個值而已):

    • 0x00: Ok, 200
    • 0x10: MovedPermanently, 301
    • 0x11: Found, 302
    • 0x12: NotModified, 304
    • 0x20: BadRequest, 400
    • 0x21: Unauthorized, 401
    • 0x22: PaymentRequired, 402
    • 0x23: Forbidden, 403
    • 0x24: NotFound, 404
    • 0x25: RequestTimeout, 408
    • 0x26: RequestEntityTooLarge, 413
    • 0x27: TooManyRequests, 429
    • 0x30: InternalServerError, 500
    • 0x31: NotImplemented, 501
    • 0x32: BadGateway, 502
    • 0x33: ServiceUnavailable, 503
    • 0x34: GatewayTimeout, 504
    • 0x35: VersionNotSupported, 505
  • 負載長度(PS): 表示PAYLOAD的長度, 以位元組為單位, 取值範圍為0x00000000-0xFFFFFFFF, 即負載最大長度為4Gb, 此欄位存在與否由網路環境與ENCODING決定, 如果ENCODING0, 或者網路環境能夠正確的分包(比如WebSockets環境), 則一定不存在此欄位, 否則一定存在此欄位.
  • 負載(PAYLOAD): 實際的負載, 長度由PS或者網路分包結果確定, 編碼方式由ENCODING決定, 協議本身不負責負載的編解碼, 需要交由上層的應用進行解釋.

訊息型別

如前所述, STMP中訊息分類四種型別, 不同的訊息型別可能包含的欄位及含義有所不同, 詳細如下:

心跳訊息

雙端為了保證對方連線有效性, 必需定期傳送一個心跳訊息給對方, 此訊息一定不包含任何除了KIND外的其它任何欄位. 同時此訊息不需要 回覆, 如果一方在約定的時間內沒有收到對方傳送的心跳訊息, 則表明對方已經斷開連線或者出現異常, 應該立即斷開連線.

請求訊息

此訊息表示傳送方請求接收方返回某一個資源, 如果在指定的時間內未收到接收方的回覆, 則放棄等待, 並向上層應用返回一個STATUS0x25的回覆, 表示請求超時.
此訊息一定包含KIND, ENCODING, ID, ACTION欄位, 可能包含PS, PAYLOAD欄位, 一定不包含STATUS欄位.

通知訊息

此訊息表示傳送方向接收方傳送一個通知, 接收方無需回覆此訊息.

此訊息一定包含KIND, ENCODING, ACTION欄位, 可能包含PS, PAYLOAD欄位, 一定不包含ID, STATUS欄位.

回覆訊息

此訊息表示傳送方向接收方傳送一個回覆訊息以回覆對方曾經傳送的某一條請求訊息, 此訊息的ID為接收方傳送的此條請求訊息ID. 如果上層應用在指定的時間內未返回訊息, 則向傳送方傳送一個STATUS0x34的回覆訊息, 表明上層應用處理超時.

此訊息一定包含KIND, ENCODING, ID, STATUS欄位, 可能包含PS, PAYLOAD欄位, 一定不包含ACTION欄位.

訊息序列化

針對不同的網路環境, 協議制定了兩套不同的序列化方式以應對, 主要原因是瀏覽器環境中將字串轉換成ArrayBuffer再通過WebSockets傳送效能實在無法直視(實現方式可以參考stmp/impl/js/stmp/text.ts, 主要是將UTF-16編碼和字串轉換成UTF-8的Uint8Array), 同時為了更好的Web端除錯, 所以制定了一套文字序列化方案.

二進位制序列化

二進位制序列化中, 固定頭部佔一個位元組, 包含KIND以及ENCODING欄位, 如果KIND0, 則ENOCDING也必需為0, 表示一個心跳訊息. 完整的結構如下:

|   0 ... 7   |  8 ... 15  |  16 ... 23  |  24 ... 31  |
| FixedHeader |           ID             |    ACTION   |
|               ACTION                   |    STATUS   |
|                         PS                           |
|                 PAYLOAD    ...                       |

其中的多位元組欄位, 包括ID, ACTION, PS欄位, 如果存在的話, 一定BigEndian的方式傳遞. 此外, 固定頭部如下:

|   0   |   1   |   2   |   3   |   4   |   5   |   6   |   7   |
|     KIND      |       ENCODING        |   0   |   0   |   0   |

最後三個位為保留位(未用到), 全部置零.

文字就序列化

所有的欄位通過字元|連線, 即:

KIND(1)|ENCODING(1)|ID?(1-5)|ACTION?(1-10)|STATUS?(1-3)|PS?(1-10)|PAYLOAD?(...)

訊息分割, 在使用文字序列化方式傳遞二進位制資料時, 瀏覽器環境不能高效的將二者混雜在一起, 所以允許分成兩個包進行傳送, 前者傳遞頭部資訊, 後者傳遞實際的二進位制PAYLOAD, 此時ENCODING一定不0, 同時, PAYLOAD在頭部包中不存在. WebSockets自身保證了包的有序性.

對於一個心跳訊息, 只有一個KIND欄位, 所以其結果一定為"0".

區分文字訊息與二進位制訊息

這是比較有趣的地方, 文字訊息和二進位制訊息可以通過首位元組完全區別開來: 對於文字訊息, 首位元組為`0`, `1`, `2`, `3`中的一個, 即0x30-0x33, 而對於二進位制訊息, 要麼為0x00(心跳訊息), 要麼大於或者等於0x40, 因為KIND不為0時其值一定大於0b01000000.

版本協商

協議版本有兩個欄位, 分別為MAJORMINOR, 二者取值範圍均為015, 即0x00xF, 可以序列化為MAJOR.MINOR的形式.

當前協議版本為0.1.

客戶端在發起連線成功後, 需要傳送一個ACTION為0x00的訊息給服務端, 訊息ID必需為0, 負載編碼方式為Raw, 負載為客戶端可接受的版本號
列表. 服務端在收到此訊息後, 如果可以處理客戶端傳送過來的版本列表中的某一個, 則回覆一個STATUS為Ok的回覆訊息, 負載為所選擇的協議版本
號, 如果不能處理, 則返回一個VersionNotSupported錯誤訊息, 負載為空, 並且關閉連線.

版本號序列化

在二進位制訊息中, 一個版本號序列化為1位元組長度的資訊, 其中前4位為MAJOR, 後4位為MINOR值. 多個版本號直接連線在一起. 在文字訊息中, 一個版本號序列化為2位元組長度的資訊, 其中前1位元組為MAJOR, 後1位元組為MINOR值, 多個版本號直接相連.

實現

目前僅實現了Golang和JS的簡單的訊息編解碼部分, 地址在: go版本, js版本, 還有很多工作要做T_T, 如果有人提PR就好了?????.

相關文章