Web前端之路

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

2017-07-28  本文已影响393人  一路行歌

背景

现在写客户端或者网页的时候, 越来越多的需要与长连接打交道, 尤其是在这个老板动不动就要搞一个聊天系统的时代, 后端大哥们于是分分钟就能造一个基于TCP或者WebSockets的消息协议出来. 但是问题在于每做一个新项目, 后端大哥们就能造出一个新协议, 而且能有各种神奇的限制. 比如说要在长连接当中保持一个状态机, 发送某条消息后收到的下一条消息一定是XXX, 或者完全一个JSON就直接丢了出来等等. 虽然都能用, 但是却需要在各种地方维护着不同的底层通信库, 没有章法可依, 所以草拟了这个协议.

简介

协议取名STMP, 意思是最简单的消息协议(The simplest message protocol). 项目托管在GitHub上, 包含了完整的协议文档以及相关实现, 详细了解请移步GitHub, 同时欢迎提交PR/Issue, 地址是https://github.com/acrazing/stmp.

简单来说, STMP有以下特点:

目前最热门的消息协议莫过于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中, 完整的消息字段列表如下, 需要注意的是并不是每条消息都会包含所有的这些字段, 需要根据网络环境以及消息类型确定应该包含的字段列表. 但是如果某条消息包含了以下这些字段中的某一些字段的话,排序顺序一定与字段在下面出现的顺序相同.

消息类型

如前所述, 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就好了😂😂😂😂😂.

上一篇下一篇

猜你喜欢

热点阅读