前言
因为最近团队在改造Nodejs的Accs、MTop等中间件,其中在采集数据方面用到了Websocket,所以对其进行了研究和拓展:
1、WebSocket是什么
HTML5开始提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议。它基于TCP传输协议,并复用HTTP的握手通道。所遵守协议为RFC6455
RFC是什么?请求意见稿(英语:Request For Comments,缩写:RFC),是由互联网工程任务组(IETF)发布的一系列备忘录。功能是制定了一些互联网的规定和协议等等,例如HTTP是2616,IPv6是2460。
要了解上边的定义,首先要了解The WebSocket Handshake(Websocket 握手通讯),明白当建立一个ws时候,内部发生了些什么:
(1)当客户端建立连接是,通过HTTP GET发送请求报文,如下所示:
GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13 // websocket的版本
(2)当服务端在处理完请求后,返回的报文如下:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=
Sec-WebSocket-Protocol:chat //子协议的列表,用于自定义XML schema或文件类型声明
与普通的HTTP请求报文不同,ws报文多了Upgrade,Connection,Sec-WebSocket-*等字段,
Connection: Upgrade //表示要升级协议
Upgrade: websocket //表示要升级到websocket协议
这两个字段表示请求服务端升级协议为Websocket,当服务端接收到这个HTTP请求之后,会自动切换到ws协议进行通讯,正如服务端返回的状态码101(状态代码101表示协议切换)所示,已经切换协议规范。
请求报文中的Sec-WebSocket-Key和返回报文中Sec-WebSocket-Accept是一对加密对,Sec-WebSocket-Accept根据Sec-WebSocket-Key计算出来,用于安全校验。计算公式如下:
(1)将Sec-WebSocket-Key跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接
(2)然后将拼接串通过sha1散列算法计算结果后进行base64编码,返回给客户端
验证代码如下:
const crypto = require('crypto');
const defayltKey = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const secWebSocketKey = 'dGhlIHNhbXBsZSBub25jZQ==';
let secWebSocketAccept = crypto.createHash('sha1')
.update(secWebSocketKey + defayltKey)
.digest('base64');
console.log(secWebSocketAccept);
// Oy4NRAQ13jhfONC7bP8dTKb4PTU=
其中“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”是官方定义的固定字符串,也就是俗称写死的配置项
一旦Websocket握手成功,服务端和客户端就会呈现对等效果,就能双工发送和接收消息。并且当前连接不再进行HTTP交互,而是使用Websocket数据帧进行交互。
可以看出,ws建立会基于HTTP请求并进行协议切换,底层还是基于TCP传输,但是会在建立的时候,复用HTTP的请求。
2、数据帧讲解
既然是基于TCP连接的,那么在客户端、服务端数据的交换,离不开数据帧格式的定义。需要根据RFC6455定义的格式进行数据帧的设计,这是有固定格式的数据帧实现,即官方已经定义了数据帧格式,当然也可以自定义frame,实现私有协议传输等等。
下边我们一步步来看ws协议数据帧的设计思路和代码实现
(1)ws数据帧格式
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
从左到右,单位是比特。比如FIN、RSV1各占据1比特,opcode占据4比特。
分别说明各个字段的意义:
- FIN:标识这一帧数据是否是该分块的最后一帧
1 为最后一帧
0 不是最后一帧。需要分为多个帧传输
-
rsv1-3: 默认为0,用于扩展,当有已协商扩展可能被设置成1
-
opcode:长度为4的操作码,就是定义了该数据是什么,如果不为定义内的值则连接中断。
0 表示一个继续帧,顾名思义,就是完整消息对应的数据帧还没接收完
1 表示一个文本帧
2 表示一个二进制帧
3-7 为以后的非控制帧保留
8 表示一个连接关闭
9 表示一个ping(心跳检测使用,Ping Frame用来对验证对方是否有响应)
10 表示一个pong (Pong Frame就是对Ping的回应)
11-15 为以后的控制帧保留
- masked:是否掩码处理,长度1。安全用
1 客户端发送数据到服务端
0 服务端发送数据到客户端
- payload length:表示Payload data的总长度
0-125 则是payload的真实长度
126 则后边16位值是payload的真实长度,125<数据长度<65535
127 则后面64位值是payload的真实长度,数据长度>65535
-
masking key:0或4字节,当masked为1的时候才存在,为4个字节,否则为0,用于对我们需要的数据进行解密
-
payload data:真正数据。
说了这么多,不如举个例子:
发送方传递‘hello’,接收方回复‘Taobao’:
由于‘hello’比较短,不存在分帧形式,以文本形式发送,那么它的payload length长度是40,二进制表示为0101000,所以报文应该如下:
fin(1)+rsv(000)+opcode(0001)+masked(1)+payload length(0101000)+masking key(32位)+payload data(hello加密后的二进制)
当接收方通过data事件接收到这些二进制数据之后,会解析相应的帧,通过掩码将真正的数据解密出来,触发onmessage()执行。回复无须掩码,如下所示:
fin(1)+rsv(000)+opcode(0001)+masked(0)+payload length(0000110) +payload data(Taobao的二进制)
如果是多帧情况,会进行数据分帧操作:
-
FIN=0,opcode=0x1,表示发送的是文本类型,且消息还没发送完成,还有后续的数据帧。
-
FIN=0,opcode=0x0,表示消息还没发送完成,还有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。
-
FIN=1,opcode=0x0,表示消息已经发送完成,没有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。服务端可以将关联的数据帧组装成完整的消息。
发送一个“hello and a happy new !”进行分四帧操作
Client: FIN=1, opcode=0x1, msg="hello"
Server: (process complete message immediately) Hi.
Client: FIN=0, opcode=0x1, msg="and a"
Server: (listening, new message containing text started)
Client: FIN=0, opcode=0x0, msg="happy new"
Server: (listening, payload concatenated to previous message)
Client: FIN=1, opcode=0x0, msg="year!"
Server: (process complete message) Happy new year to you too!
(2)协议代码实现
可以看到ws的协议帧设计的还是比较复杂的,下边抽重要来讲解一下,那些背后的故事之如何来设计实现一套协议。
1、获取标志位
从报文格式可以看出,里面的最重要的就是怎么获取到相应的标志位,这里要用到的就是位运算,比如上文中的一串标志位:
fin(1)+rsv(000)+opcode(0001)+masked(1)+payload length(0101000)
我们先取前三个标志位,fin+rsv+opcode,这三个标志位,一共对应8位,那么利用readUInt8(0)可以读取前8 bit的值,怎么获取fin值呢,fin是里面的第一个数值,也是最高位,利用与0x80进行与运算,0x80中0x是十六进制,对应十进制就是816+01=128,二进制是10000000,与运算特点是当有1进行与运算时候,结果都是1,那么前八位和0x80进行与运算,就只有最高位有1被特别标明计算。
10000001
&10000000(0x80)
---------------
1...(fin只关注第一位)
同理,我们要获取opcode,后四位,它对应的关键钥匙是0x0f。
那么怎么找这种对应的关键钥匙,用反推法即可,比如我要找payload length对应的关键钥匙,那么可以看到它对应7位Bit,那么二进制应该是(01111111),对应的十六进制是0x7f,那么拿这个关键钥匙进行与运算就可以了。
processBuffer() {
const buf = this.buffer;
if (buf.length < 2) {
return;
}
let idx = 2; //操作指针,指针指哪里,就操作哪里
const byte1 = buf.readUInt8(0); // 读取数据帧的前 8 bit
const FIN = byte1 & 0x80; // 获取高位 bit
const opcode = byte1 & 0x0f; //截取第一个字节的后 4 位,即 opcode 码
const byte2 = buf.readUInt8(1); // 读取数据帧第二个字节
const MASK = byte2 & 0x80; // 判断是否有掩码,客户端必须要有,获取高位 bit
const length = byte2 & 0x7f; //获取length属性,也是小于126数据长度的数据真实值
......
}
}
2、数据帧分帧
当长度大于MAX_FRAME_SIZE,进行分帧处理
const MAX_FRAME_SIZE = 1024; // 最长长度限制
//在内部发送数据方法中进行分帧操作
_doSend(opcode, payload) {
const len = Buffer.byteLength(payload); // 获取buffer长度
// 分片的距离逻辑
let count = 0;
while (len > MAX_FRAME_SIZE) {
const framePayload = payload.slice(0, MAX_FRAME_SIZE);
payload = payload.slice(MAX_FRAME_SIZE);
this.socket.write(
encodeMessage(
count > 0 ? OPCODES.CONTINUE : opcode,//opcode 0的时候继续接收数据
framePayload,
false
)
); //编码后直接通过socket发送
count++;
len = Buffer.byteLength(payload);
}
this.socket.write(
encodeMessage(count > 0 ? OPCODES.CONTINUE : opcode, payload)
); //编码后直接通过socket发送
}
抽取了一些点进行了讲解,如果需要自己实现一套协议,还需要考虑很多方面,例如压缩方案(ws用的是deflatenode zlib模块自带,安全方案(ws里面的掩码和wss),断线重连(onclose中重连和心跳检测处理))
3、私有协议的思考
如果想设计私有协议,首先考虑使用的业务场景,根据需求点,设计相应的frame帧,然后编解码方法,最后考虑特殊情况。
大体思路步骤如下:
-
(1)数据帧格式设计,其中应该包括数据位、标志位(控制状态开启,记录长度、数据等)
-
(2)接收方和发送方的对数据帧的编码和解码,即拼接二进制buffer和解码二进制buffer数据
-
(3)特殊情况和异常情况处理,例如大数据下的分段发送、压缩报文,数据安全防篡改等等,多和frame特定字段有关,需要读取相应帧字段后再操作,根据帧字段值进行处理。
上边需要的技术储备:
-
(1)Node buffer的各种操作:新建、拼接、处理frame Node Buffer
-
(2)位操作:获取标志位等 MDN 位操作
-
(3)Node 流操作:在传输数据中,大数据情况下,stream优于buffer。很多协议都是基于双向流或者管道流进行封装
当然,技术选型上最好还是使用已有方案和协议,避免过多的雷和坑,但是事情不是绝对的~
未完待续:
上边是我从ws开始着手并对协议的一点思考,后续会根据实际的业务需求,再进行多方面的扩展,会在接下来几期进行其他方面的讲解,敬请期待
-
压缩策略
-
报文操作之算法篇
-
安全策略
网友评论