sufuwang / demo

My study note about technology
0 stars 0 forks source link

WebSocket #2

Open sufuwang opened 1 year ago

sufuwang commented 1 year ago

目标

  1. 不使用第三方库,解析数据帧
  2. 使用 socket.io 实现
sufuwang commented 1 year ago

数据帧

image
  1. 第 0 个字节常见标志位

    • FIN 表示当前数据帧是否是数据包的最后一帧,在分包的情况下,最后一帧的 FIN 才为 1,不分包(一个数据帧即一个数据包)则 FIN 为 0
    • RSV 表示经过协商的扩展字段,除非协商则为 0
    • opcode 表示负载类型,0 表示连续帧,1 表示 text 帧,2 表示 binary 帧,3-7 表示为非控制帧预留的空间,8 表示握手帧,9 表示 ping 帧,A 表示 pong 帧,B-F 表示为非控制帧预留的空间
  2. 第 1 个字节由掩码标志位和负载长度构成

    • 掩码标志位设为 0,则数据帧中可以不带有掩码(当前 demo 为降低复杂度,掩码标志位始终为 0)
    • 负载长度具有多种情况
    • 长度小于 126 时,直接使用当前字节表示
    • 长度大于 126 且小于 65536 时,从第 2 个字节开始,占用 2 个字节(即第 2 和 3 字节)表示负载长度,第 1 个字节使用 126 填充
    • 长度大于等于 65536 时,从第 2 个字节开始,占用 8 个字节(即第 2 ~ 9 字节)表示负载长度,第 1 个字节使用 127 填充

101 和它的响应

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 时)

sufuwang commented 1 year ago

WebSocket.send 发送数据超过 65536 个字节

数据帧容量被占满且数据还未发送完毕时,会出现分包,分包存在两种情况,第 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
image

Chrome NetWork 中标记的 Length 为 65531,这个是负载长度,还需要加上标志位、payload len,extension payload len、掩码的长度,当前负载长度是小于 65536 的,所以 payload len 被置为 126,所以整个数据包的长度为 65531 + 8 = 65539 byte,故封装后的数据包是需要被分包的,被分为两个数据包,大小依次是 65536 、3 byte

如何得知分帧结束

第一帧的负载长度表示的是整个数据包的长度,即第一帧负载长度 + 第二帧长度,利用这个原理对整个负载进行累积

sufuwang commented 1 year ago

分包带来的问题 - 粘包

image

如上图,前端两次调用 WebSocket.send 方法,按照上一个标题所描述的内容来看,后端应该接受三个数据包,前两个合并为题一次发送的数据,第三个数据包为第二次发送的数据,但实际效果不是,第一次调用 send 方法未发送完毕的数据和第二次的数据被合并为一个包交给了后端,所以后端收到了两个数据包,且与两次调用 send 中传入的数据不一一对应(这里偷个懒,在 demo 中前端发送时,要主动对数据进行分包)

sufuwang commented 1 year ago

解析 Binary 数据帧

前端使用 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&'  *** 这样也可以转字符串 ***
sufuwang commented 1 year ago

Socket.io

socket.io 实现