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

背景

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 161,326评论 4 369
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,228评论 1 304
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,979评论 0 252
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,489评论 0 217
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,894评论 3 294
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,900评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,075评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,803评论 0 205
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,565评论 1 249
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,778评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,255评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,582评论 3 261
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,254评论 3 241
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,151评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,952评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,035评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,839评论 2 277

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,106评论 18 139
  • 实时消息协议---流的分块 版权声明: 版权(c)2009 Adobe系统有限公司。全权所有。 摘要: 本备忘录描...
    一个人zy阅读 1,806评论 0 9
  • 个人翻译,转载请注明出处,谢谢! Adobe's Real Time Messaging Protocol 摘要 ...
    SniperPan阅读 2,638评论 1 17
  • 简介 用简单的话来定义tcpdump,就是:dump the traffic on a network,根据使用者...
    保川阅读 5,903评论 1 13
  • 国家电网公司企业标准(Q/GDW)- 面向对象的用电信息数据交换协议 - 报批稿:20170802 前言: 排版 ...
    庭说阅读 10,526评论 6 13