Open sufuwang opened 1 year ago
第 0 个字节常见标志位
第 1 个字节由掩码标志位和负载长度构成
ws 的建立要借用 http 协议发起一个 101 的协议转换请求
new WebSocket('ws:localhost:3000')
前端实例话 WebSocket 时,浏览器会发起一个 101 请求,会带上几个特殊的 header
Upgrade: websocket
Connection: Upgrade
// 协议级扩展
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
// 密钥
Sec-WebSocket-Key: oJRfybS3KryE4Hvv2k1Qxw==
// 使用的 ws 版本
Sec-WebSocket-Version: 13
要重点关注一下 Sec-WebSocket-Key
header ,我们需要在后端对其加密(将 Sec-WebSocket-Key
和一个 MagicStr 结合,然后使用 sha 加密,最后对加密结果进行 base64 编码得到 Sec-WebSocket-Accept
),然后使用响应 header Sec-WebSocket-Accept
返回给浏览器,浏览器用这个 header 来校验后端身份,响应的 header 至少具有以下几个
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: yApHyMk7L+c5r/LGVXAcT4+edeg=
成功响应后,则当前 http 请求转换为 ws 链接
发送的数据接受两种类型,text 或 binary,需要做的就是将数据包装成一个数据帧 buffer 交由 socket 发送,以一个长度小于 126 的负载为例
const OPCODES = {
CONTINUE: 0,
TEXT: 1,
BINARY: 2,
CLOSE: 8,
PING: 9,
PONG: 10
}
function static send(socket: Socket, payload: string | Buffer, _opcode = OPCODES.BINARY) {
if (typeof payload === 'string') {
payload = Buffer.from(payload, 'utf8')
_opcode = OPCODES.TEXT
}
const length = payload.length;
const buf = Buffer.alloc(length + 2) // 创建一个长度为 length + 2 的 buf
buf.writeUInt8(0x80 | _opcode, 0) // 向 buf 的索引为 0 处写入标志位
buf.writeUInt8(length, 1) // 向 buf 的索引为 1 处写入 length
payload.copy(buf, 2) // 使用 payload 在 buf 索引为 2 处开始向后覆盖
socket.write(buf)
}
解释一下 0x80 | _opcode
0x80.toString(2) // 10000000
(0x80 | OPCODES.TEXT).toString(2) // 10000001, 负载为 text 类型的标志位
(0x80 | OPCODES.BINARY).toString(2) // 10000010, 负载为 binary 类型的标志位
socket.on('data', function (payload) {
ParseFrame(payload)
});
socket 可以通过 listener 回调来获取前端发送来的数据帧,我们需要对其进行解析来获得明文。用一个负载为 1212 来举例,即负载长度等于掩码长度
const ParseFrame = (payload: Buffer) => {
const length = payload.length // <Buffer 81 84 00 67 19 c0 31 55 28 f2>
const data = payload.slice(length - 4, length) // <Buffer 31 55 28 f2>
const maskCode = payload.slice(length - 8, length - 4) // <Buffer 00 67 19 c0>
const buffer = Buffer.alloc(4)
for (let i = 0; i < length; i++) {
buffer.writeUint8(maskCode[i] ^ data[i], i)
}
return buffer.toString('utf8') // <Buffer 31 32 31 32> => 1212
}
解析数据帧的逻辑是使用掩码与负载进行异或运算,然后将运算结果转为 16 进制,写入一个新的 buffer ,再转换为 utf8 编码即可
需要注意的是,当负载长度大于掩码长度时,掩码是可以反复使用的,索引为 4 的负载可以使用索引为 0 的掩码(掩码长度为 4 ,负载长度为 5 时)
数据帧容量被占满且数据还未发送完毕时,会出现分包,分包存在两种情况,第 1 个字节的 payload len 可能为 126 或 127,前 8 或 14 个字节,被标志位、payload len,extension payload len、掩码所使用,余下的容量为所发送数据占据,此时一个包已经不够用了,要使用第二个包进行发送,第二个包不会带有标志位、负载长度、掩码,整个包被用来传输负载
FIN~MASK | Payload Len | Extension Payload Len | 掩码 | 负载 |
---|---|---|---|---|
9 bit | 7 bit / 126 | 16 bit | 32 bit | 65536 byte - (64 / 8) byte = 65528 byte |
9 bit | 7 bit / 127 | 64 bit | 32 bit | 65536 byte - (112 / 8) byte = 65522 byte |
Chrome NetWork 中标记的 Length 为 65531,这个是负载长度,还需要加上标志位、payload len,extension payload len、掩码的长度,当前负载长度是小于 65536 的,所以 payload len 被置为 126,所以整个数据包的长度为 65531 + 8 = 65539 byte,故封装后的数据包是需要被分包的,被分为两个数据包,大小依次是 65536 、3 byte
第一帧的负载长度表示的是整个数据包的长度,即第一帧负载长度 + 第二帧长度,利用这个原理对整个负载进行累积
如上图,前端两次调用 WebSocket.send 方法,按照上一个标题所描述的内容来看,后端应该接受三个数据包,前两个合并为题一次发送的数据,第三个数据包为第二次发送的数据,但实际效果不是,第一次调用 send 方法未发送完毕的数据和第二次的数据被合并为一个包交给了后端,所以后端收到了两个数据包,且与两次调用 send 中传入的数据不一一对应(这里偷个懒,在 demo 中前端发送时,要主动对数据进行分包)
前端使用 Int8Array
来开辟一段内存空间,用来处理二进制数据,与 Text 类型一样,后端接收到后,也需要使用 Mask 进行处理,但两者有一些不同
const parsePayloadWithMask = (
mask: Buffer,
payload: Buffer,
type: 'TEXT' | 'BINARY' = OPCODE.TEXT as 'TEXT'
) => {
const buffer = [];
for (let i = 0; i < payload.length; i++) {
buffer.push(mask[i % 4] ^ payload[i])
}
if (type === 'BINARY') {
return buffer.join('')
} else if (type === 'TEXT') {
return Buffer.from(buffer).toString('utf8') // *** 这样可以转字符串 ***
}
return ''
};
For Example
在调用 WebSocket.send
发送字符串时,接口替我们把字符串转换为 binary 数据
// 前端
const str = 's1669102638320&' // 前端发送一个字符串
ws.send(str)
// 后端
const encodeByMask = [115, 49, 54, 54, 57, 49, 48, 50, 54, 51, 56, 51, 50, 48, 38] // 后端使用 Mask 解码得到
encodeByMask.map(s => String.fromCharCode(s)).join('') // 's1669102638320&'
// 前端
const str = 's1669102638320&'
const buffer = new Int8Array(str.length)
str.split('').forEach((s, i) => buffer[i] = s.codePointAt()) // 前端发送一个二进制数据
ws.send(buffer) // [115, 49, 54, 54, 57, 49, 48, 50, 54, 51, 56, 51, 50, 48, 38]
// 后端
const encodeByMask = [115, 49, 54, 54, 57, 49, 48, 50, 54, 51, 56, 51, 50, 48, 38] // 后端使用 Mask 解码得到
encodeByMask.map(s => String.fromCharCode(s)).join('') // 's1669102638320&' *** 这样也可以转字符串 ***
目标