這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
背景
現在寫用戶端或者網頁的時候, 越來越多的需要與長串連打交道, 尤其是在這個老闆動不動就要搞一個聊天系統的時代, 後端大哥們於是分分鐘就能造一個基於TCP或者WebSockets的訊息協議出來. 但是問題在於每做一個新項目, 後端大哥們就能造出一個新協議, 而且能有各種神奇的限制. 比如說要在長串連當中保持一個狀態機器, 發送某條訊息後收到的下一條訊息一定是XXX, 或者完全一個JSON就直接丟了出來等等. 雖然都能用, 但是卻需要在各種地方維護著不同的底層通訊庫, 沒有章法可依, 所以草擬了這個協議.
簡介
協議取名STMP, 意思是最簡單的訊息協議(The simplest message protocol). 項目託管在GitHub上, 包含了完整的協議文檔以及相關實現, 詳細瞭解請移步GitHub, 同時歡迎提交PR/Issue, 地址是https://github.com/acrazing/stmp.
簡單來說, STMP有以下特點:
- 非常精簡的固定頭部, 僅有一位元組(二進位序列化)
- 支援二進位序列化(TCP)以及文本序列化(WebSockets), 文本序列化支援訊息分包傳送(傳遞位元據)
- 與IP協議掩碼類似的上層路由控制
- 負載編碼格式對協議透明
- 心跳檢測
- 四種訊息類型: 心跳, 請求, 通知, 回複
- 與HTTP協議類似的返回狀態代碼控制
目前最熱門的訊息協議莫過於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中, 完整的訊息欄位列表如下, 需要注意的是並不是每條訊息都會包含所有的這些欄位, 需要根據網路環境以及訊息類型確定應該包含的欄位列表. 但是如果某條訊息包含了以下這些欄位中的某一些欄位的話,排序次序一定與欄位在下面出現的順序相同.
訊息類型(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決定, 如果ENCODING為0, 或者網路環境能夠正確的分包(比如WebSockets環境), 則一定不存在此欄位, 否則一定存在此欄位.
負載(PAYLOAD): 實際的負載, 長度由PS或者網路分包結果確定, 編碼方式由ENCODING決定, 協議本身不負責負載的編解碼, 需要交由上層的應用進行解釋.
訊息類型
如前所述, STMP中訊息分類四種類型, 不同的訊息類型可能包含的欄位及含義有所不同, 詳細如下:
心跳訊息
雙端為了保證對方串連有效性, 必需定期發送一個心跳訊息給對方, 此訊息一定不包含任何除了KIND外的其它任何欄位. 同時此訊息不需要 回複, 如果一方在約定的時間內沒有收到對方發送的心跳訊息, 則表明對方已經中斷連線或者出現異常, 應該立即中斷連線.
請求訊息
此訊息表示發送方請求接收方返回某一個資源, 如果在指定的時間內未收到接收方的回複, 則放棄等待, 並向上層應用返回一個STATUS為0x25的回複, 表示請求逾時.
此訊息一定包含KIND, ENCODING, ID, ACTION欄位, 可能包含PS, PAYLOAD欄位, 一定不包含STATUS欄位.
通知訊息
此訊息表示發送方向接收方發送一個通知, 接收方無需回複此訊息.
此訊息一定包含KIND, ENCODING, ACTION欄位, 可能包含PS, PAYLOAD欄位, 一定不包含ID, STATUS欄位.
回複訊息
此訊息表示發送方向接收方發送一個回複訊息以回複對方曾經發送的某一條請求訊息, 此訊息的ID為接收方發送的此條請求訊息的ID. 如果上層應用在指定的時間內未返回訊息, 則向發送方發送一個STATUS為0x34的回複訊息, 表明上層應用處理逾時.
此訊息一定包含KIND, ENCODING, ID, STATUS欄位, 可能包含PS, PAYLOAD欄位, 一定不包含ACTION欄位.
訊息序列化
針對不同的網路環境, 協議制定了兩套不同的序列化方式以應對, 主要原因是瀏覽器環境中將字串轉換成ArrayBuffer再通過WebSockets發送效能實在無法直視(實現方式可以參考stmp/impl/js/stmp/text.ts, 主要是將UTF-16編碼和字串轉換成UTF-8的Uint8Array), 同時為了更好的Web端調試, 所以制定了一套文本序列化方案.
二進位序列化
二進位序列化中, 固定頭部佔一個位元組, 包含KIND以及ENCODING欄位, 如果KIND為0, 則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.
版本協商
協議版本有兩個欄位, 分別為MAJOR和MINOR, 二者取值範圍均為0到15, 即0x0到0xF, 可以序列化為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就好了?????.