当前位置: 首页 > 后端技术 > Node.js

造轮子系列(二):史上最简单的长连接通信协议及其实现

时间:2023-04-03 16:15:19 Node.js

背景在写客户端或者网页的时候,越来越多的人需要处理长连接,尤其是现在这个老板动不动就想搭建一个聊天系统的时代,后端兄弟分分钟搭建一个TCP或者聊天系统.WebSockets的消息协议出来了。但是问题是,每做一个新的项目,后端大佬们就可以创建一个新的协议,而且可以有各种神奇的限制。比如在长连接中维护一个状态机,发送某条消息后收到的下一条消息必须是XXX,或者直接抛出一个JSON等。虽然可以使用,但是需要维护不同的底层各个地方的通讯库,并没有什么规律可循。所以起草了这个协议。目前最流行的消息协议是MQTT和gRPC。前者定义为Alightweightmessagingprotocolforsmallsensorsandmobiledevices,optimizedforhigh-latencyorunreliablenetworks,即为传感器和移动设备定制的消息协议。最大的特点是它的固定消息头只有2个字节,QoS服务质量控制。对于前者,没有什么不好的,任何长连接报文协议应该都可以做到,甚至更简单(STMP就是这样),其次,它的QoS设计使得通信层变得非常复杂,使得它更像消息队列协议而不是简单的通信协议。而gRPC是基于ProtocolBuffers实现开发的RPC协议。集成度高,底层基于HTTP2,所以通用性很好,如果是大项目,团队有一定的技术/运维积累,是非常推荐的选择,但是这个不与STMP冲突,STMP是面向协议的健壮性要求不高,只需要一个可用的、标准化的企业/团队。您可以在Web上使用它,也可以在客户端上使用它,也可以在智能家居等嵌入式设备中使用它。相比之下,gRPC就太复杂了。简介该协议被命名为STMP,意为最简单的消息协议(Thesimplestmessageprotocol)。项目托管在GitHub上,包括完整的协议文档和相关实现。更多信息请移步GitHub,欢迎提交PR/Issue,地址为https://github.com/acrazing/stmp。总之,STMP有以下特点:非常精简的固定头,只有一个字节(二进制序列化)支持二进制序列化(TCP)和文本序列化(WebSockets),文本序列化支持消息包传输(传递二进制数据)和IP协议掩码类似于上层路由控制payload编码格式透明心跳到协议检测四种类型ofmessages:Heartbeat,Request,Notification,Reply返回状态码控制消息字段类似于HTTP协议。在全双工通信系统中,两端都需要有效地识别对方发送的消息并进行相应的处理。、选择是否响应等,所以除了实际的payload之外,还需要几个标志字段。在STMP中,消息字段的完整列表如下。需要注意的是,并不是每条消息都会包含所有这些字段,需要根据网络环境和消息类型来确定应该包含的字段列表。但是,如果消息包含以下某些字段,则排序顺序必须与字段在下面出现的顺序相同。消息类型(KIND):表示一个消息类型,可能的值有:0:PingMessage1:RequestMessage2:NotifyMessage3:ResponseMessage消息编码格式(ENCODING):表示payload的编码格式。上层应用/编解码层收到消息后,可以通过该字段对payload进行解码。由于头部长度的限制,可能的取值范围为0-7。约定的编码格式如下:0:保留格式,表示不包含payload,此时报文中不能有PS和PAYLOAD字段。1:ProtocolBuffers,参考ProtocolBuffers2:JSON,参考JSON3:MessagePack,参考MessagePack4:BSON,参考BSON5:OriginalBinaryDataMessageID(ID):消息的临时ID,取值范围0x0000-0xFFFF,用在request和reply消息中,requester要保证这个ID在超时时间内是唯一的,replyer在回复时要带上这个ID,以便sender识别消息Requestaction(ACTION):使用请求的action供上层应用进行路由控制。取值范围为0x00000000-0xFFFFFFFF,为32位整数。在上层应用中可以写成xxx.xxx.xxx.xxx的形式,类似于IP。接收方接收到相应的动作后,必须能够正确识别,并传送给相应的处理器进行处理。其中,0x00-0xFF为保留动作,用于协议内部使用。目前使用的动作有:0x00:版本协商(CheckVersions)状态码(STATUS):处理结果状态码,用在回复消息中,表示请求的处理结果,取值范围为0x00-0xFF,其中0x00-0x7F为保留值,含义与ACTION无关,0x80-0xFF为用户自定义状态值,根据ACTION不同含义可能不同。目前定义的状态码有(类似于HTTP,只不过换了一个值而已):0x00:Ok,2000x10:MovedPermanently,3010x11:Found,3020x12:NotModified,3040x20:BadRequest,4000x21:Unauthorized,4010x22:Payment0x2:4010x22:Payment0x2:4010Forbidden,4030x24:NotFound,4040x25:RequestTimeout,4080x26:RequestEntityTooLarge,4130x27:TooManyRequests,4290x30:InternalServerError,5000x31:NotImplemented,5010x32:BadGateway,5020x33:ServiceUnavailable,5030x34:GatewayTimeout,5040x35:VersionNotSupported,505负载长度(PS):表示PAYLOAD的长度,以字节为单位,取值范围为0x00000000-0xFFFFFFFF,即payload最大长度为4Gb。该字段是否存在由网络环境和ENCODING决定。如果ENCODING为0,或者网络环境可以正确分包(如WebSockets环境),则该字段不得存在,否则该字段必须存在。载荷(PAYLOAD):实际载荷,长度由PS或网络包结果决定,编码方式由ENCODING决定。协议本身不负责payload的编码和解码,需要由上层应用程序来解释。消息类型上面说了,STMP中有四种类型的消息。不同的消息类型可能包含不同的字段和含义。具体如下:为了保证心跳报文两端连接的有效性,需要周期性的向对方发送心跳消息。此消息不得包含除KIND之外的任何其他字段。同时,这条消息不需要回复。如果一方在约定时间内没有收到对方发送的心跳报文,则说明对方已断开连接或出现异常,应立即断开连接。请求消息表示发送方请求接收方返回某个资源。如果接收方在规定时间内没有收到回复,则放弃等待,向上层申请返回一个STATUS为0x25的回复,表示请求超时。此消息必须包含KIND、ENCODING、ID、ACTION字段,可以包含PS、PAYLOAD字段,并且不得包含STATUS字段。Notificationmessage这条消息表示发送者向接收者发送通知,接收者不需要回复这条消息。此消息必须包含KIND、ENCODING、ACTION字段,可以包含PS、PAYLOAD字段,不得包含ID、STATUS字段。Replymessage这条消息是指发送方向接收方发送一条回复消息,回复对方一次发送的请求消息,这条消息的ID就是接收方发送的请求消息的ID。如果上层应用在规定的时间内没有返回消息,就会向发送方发送一个STATUS0x34的回复消息,表示上层应用处理超时。此消息必须包含KIND、ENCODING、ID、STATUS字段,可以包含PS、PAYLOAD字段,并且不得包含ACTION字段。消息序列化是针对不同的网络环境,协议制定了两种不同的序列化方式来应对,主要原因是在浏览器环境下,将字符串转成ArrayBuffer再通过WebSockets发送的性能确实很难直接看到(实现方法参考stmp/impl/js/stmp/text.ts,主要是UTF-16编码和Strings转换成UTF-8的Uint8Array),为了更好的在Web端调试,一个文本序列化方案已经制定。二进制序列化在二进制序列化中,固定头占一个字节,包括KIND和ENCODING字段,如果KIND为0,则ENOCDING也必须为0,表示心跳消息。完整结构如下:|0...7|8...15|16...23|24...31||固定页眉|编号|行动||行动|状态||附言||PAYLOAD...|多字节字段,包括ID、ACTION、PS字段,如果存在,必须以BigEndian方式传递。另外,固定的header如下:|01|2|3|4|5|6|7||实物|编码|0|0|0|最后三位是保留位(未使用),全部设置为零。文本会序列化所有字段按字符|连接,即:KIND(1)|ENCODING(1)|ID?(1-5)|ACTION?(1-10)|STATUS?(1-3)|PS?(1-10)|PAYLOAD?(...)报文切分,当使用文本序列化传输二进制数据时,浏览器环境不能高效地将两者混合在一起,所以允许分成两个包传输,前者传输header信息,后者传递实际的二进制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。客户端成功发起连接后,需要向服务器发送ACTION0x00的消息,消息ID必须为0,payload编码方式为Raw,payload为客户端可接受的版本号列表。服务器收到此消息后,如果可以处理客户端发送的其中一个版本列表,则回复一个STATUS是Ok的回复消息,payload是选择的协议版本号,如果不能处理,则回复一个VersionNotSupported返回错误消息,有效载荷为空,连接关闭。版本号在二进制消息中被序列化,一个版本号被序列化为1字节的信息,其中前4位为MAJOR,后4位为MINOR值。多个版本号直接连在一起。在短信中,一个版本号被序列化为2个字节的信息,其中前1个字节为MAJOR,后1个字节为MINOR值,多个版本号直接相连。目前只实现了Golang和JS的简单消息编解码部分。地址是:go版本,js版本,还有很多工作要做T_T,要是有人提一下PR就好了??????。