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

背景

如今寫客戶端或者網頁的時候, 愈來愈多的須要與長鏈接打交道, 尤爲是在這個老闆動不動就要搞一個聊天系統的時代, 後端大哥們因而分分鐘就能造一個基於TCP或者WebSockets的消息協議出來. 可是問題在於每作一個新項目, 後端大哥們就能造出一個新協議, 並且能有各類神奇的限制. 好比說要在長鏈接當中保持一個狀態機, 發送某條消息後收到的下一條消息必定是XXX, 或者徹底一個JSON就直接丟了出來等等. 雖然都能用, 可是卻須要在各類地方維護着不一樣的底層通訊庫, 沒有章法可依, 因此草擬了這個協議.html

目前最熱門的消息協議莫過於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, 則顯得過於龐雜.git

簡介

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

簡單來講, STMP有如下特色:json

  • 很是精簡的固定頭部, 僅有一字節(二進制序列化)
  • 支持二進制序列化(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中消息分類四種類型, 不一樣的消息類型可能包含的字段及含義有所不一樣, 詳細以下:ui

心跳消息

雙端爲了保證對方鏈接有效性, 必需按期發送一個心跳消息給對方, 此消息必定不包含任何除了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就行了?????.

相關文章
相關標籤/搜索