×

一个基于TCP/WebSockets的超级精简的长连接消息协议

96
一路行歌
2017.07.28 23:05* 字数 2869

背景

现在写客户端或者网页的时候, 越来越多的需要与长连接打交道, 尤其是在这个老板动不动就要搞一个聊天系统的时代, 后端大哥们于是分分钟就能造一个基于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决定, 如果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就好了😂😂😂😂😂.

日记本
Web note ad 1