CDP篇:解析 stream 流手搓 WebSocket 二进制解析器

1,076 阅读12分钟

1 前言

大家好,我是心锁,本期也可以特别称呼我为借(lin)鉴(mo)大神。

经过多期的迭代,以及持续跟踪 Node 社区的进展,我了解到社区中对于 WebSocket 的支持进度不佳,并且调研结果认为目前难以做到支持。

对此,我只会表示:没有做不到,只有成本高低。

所以本期来了,本期相当硬核,但是读完的你将对 websocket 有更深的了解。

2 所谓 WebSocket

这一次,我们真的需要知道什么是 WebSocket 了,因为本篇无法只流于概念,我们最终需要进行实践。

从上图可知,websocket 的基本阶段可以拆解为「握手」「双向通信」「关闭连接」。由于「关闭连接」的操作可以对应到「双向连接」中特定的控制帧,我们忽略最后这一部分。

2.1 握手🤝

其中「握手」对应的概念是:WebSocket 基于 HTTP 协议实现,为了实现兼容性,WebSocket 握手使用 HTTP1.1 Upgrade 标头从 HTTP 协议更改为 WebSocket 协议。

更多的细节,我们看的这份维基百科上的要求。

可以知道,根据这份规范或者说协议,WebSocket 客户端在发起请求时,需要携带「Sec-WebSocket-Protocol」「Upgrade=”websocket”」「Connection=“Upgrade”」以及「Sec-WebSocket-Version」「Sec-Websocket-Key」的信息。

  • 请求头示例:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

而对我们来说,相当于「只要检测到请求头的 Upgrade 字段为 websocket」,就可以确定将要发起的 http/https 请求实质上为 websocket 连接。其他的请求头,是对应的 websocket 库需要关心的。

而响应头这边,虽然我们本期实际上不用关注,但是也可以简单留意一下——面试的小 point:我们会注意到两个要点:

  1. HTTP 响应码为 101
  2. HTTP 响应文本为Switching Protocols
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

2.2 基于二进制帧的协议 ✍️

接下来我们看到双向通信部分,这一部分的重点是存在一个基于二进制帧的新协议,这一部分协议是不符合 HTTP 协议的通信协议。

在开始握手后,客户端和服务器可以随时相互发送消息,区分为数据消息(文本或二进制)和控制消息(close、ping、pong)。消息由一个或多个帧组成。

这其中还涉及一个概念,即帧是可能分段的。对应的,这是一些规则:

  • 未分段的消息由 FIN = 1 且**opcode ≠ 0** 的单个帧组成。
  • 分段消息由一个 FIN = 0 且**opcode ≠ 0** 的帧、零个或多个 FIN = 0 且**opcode = 0** 的帧以及 FIN = 1 且**opcode = 0** 的单个帧终止。注意,控制帧不可以分段

我们在这些规则中会看到 FINopcode 这些陌生的字段。这些即是「帧」中某个字节对应的字段。

可以看到这张图,我们说过这是二进制传输,对应的,数据内容即是 010101010 这种数据。而我们所说的 FIN、opcode,即是协议中对应到数据中某索引位置的值。

比如,假设我们的二进制数据为 data(Buffer 类型),那么 FIN 就是指 data[0] & 0x80 ,而 opcode 则是 data[0] & 0x0f,因为 Buffer 的每一个子项都是一个 **8 位(1 字节)**的无符号整数。

各个字段我们都需要了解,但我们会在实践章进行更细致的讲解,在这里的话,主要讲到一些比较重要的。

比如 opcodeopcode 字段用来标记这一帧是控制消息还是数据消息。

  • 0000(0):表示这是分段消息的中间帧。
  • 0001(1):数据消息,表示这是发送文本类型消息。
  • 0010(2):也是数据消息,但是表示的是二进制数据。
  • 1000(8):控制消息,表示close 。发送 Close 帧以开始结束握手,Close 帧之后,不能发送任何帧。如果收到 Close 帧,并且之前未发送 Close 帧,则必须发送具有相同负载的响应 Close 帧——⚠️ Close 帧如果携带 payload,则必须以 2 字节的 big-endian 无符号整数原因代码开头,可以携带不超过 123 字节的 UTF-8 编码原因的消息。
  • 1001(9) 以及 1010(10):控制消息,分别表示pingpong ,用于保活的控制消息。双方都可以发送 ping(可以携带不超过 125 字节的负载),收到 ping 的一方必须立即发回具有相同有效负载的 pong。
  • 其余:保留码,暂无意义。

除此之外,我们需要了解 Masking key (掩码)字段,当第八位的 masked 字段为 1 时,则 Masking key 占据 32 位的大小。

此时,Masking key 表示客户端必须屏蔽发送到服务器的所有帧,而服务器不得屏蔽发送到客户端的任何帧。

并且,掩码根据 payload 进行计算,以保护 payload。从 wiki 上摘下来了下边这份算法,用于遮罩/取消遮罩帧:

for i = 0 to payload_length - 1
    payload[i] = payload[i] xor masking_key[i modulo 4]

3 WebSocket 和 CDP 喜结连理

3.1 基于 Upgrade 判断是否升级到 WebSocket

我们现在知道了,websocket 是基于 http 实现的,那么自然,对于 node 来说,最基本的 http 库就是 http/https ,我们可以在上一版的代码中继续改造。

首先,给我们的 RequestDetail 基础类套上一个 isWebSocket 方法,基于 Upgrade 头判断是否升级为 websocket 连接。

接着,我们要选定一个检测时机。

由于 CDP 协议中,websocket 相关的协议虽然同在 Network 域下,但是和 http 请求是完全分开的事件,所以自然要放在我们 requestWillBeSent 之前。对 websocket 来说,启动事件则对应 Network.webSocketCreated

我们上述的 isWebSocket 存在的情况下,我们可以有如下代码来区分开 httpwebsocket,并发起启动的请求:

  if (requestDetail.isWebSocket()) {
    actualRequest.on('upgrade', (res: IncomingMessage, socket: Socket, head: Buffer) => {
      const originalWrite = socket.write

      mainProcess.send({
        type: 'Network.webSocketCreated',
        data: {
          requestId: requestDetail.id,
          url: requestDetail.url,
          initiator: requestDetail.initiator,
          response: res
        }
      })
      ...
    })
  } else {
    mainProcess.registerRequest(requestDetail)
  }

这份代码,在确定请求升级为 websocket 的情况下,会监听 upgrade 事件,然后开始处理 webscoket 的 CDP 对接。

3.2 拦截 TCP socket 实现双向监听

通过 upgrade 事件,我们能拿到 socket 这个 TCP event bus。而我们知道 websocket 是双向通信协议,所以我们既要监听 TCP data,也要拦截到我们发起的消息。

    actualRequest.on('upgrade', (res: IncomingMessage, socket: Socket, head: Buffer) => {
      const originalWrite = socket.write

      let chunk

      socket.write = (data: any, ...rest: any[]) => {
        const buf = Buffer.from(data)
        console.log('sender', buf.toString());
        return originalWrite.call(socket, data, ...rest)
      }
      socket.addListener('data', (data) => {
        const buf = Buffer.from(data)
        console.log('receiver', buf.toString());
        receiver.write(buf)
      })
      socket.addListener('close', () => {
        mainProcess.send({
          method: 'Network.webSocketClosed',
          params: {
            requestId: requestDetail.id,
            timestamp: getTimestamp()
          }
        })
      })
    })

上边这份代码,是我们的库里一份很经典的拦截原始方法的操作。我们现在可以成功监听 socket 的双向通信了,我们可以看看打印。

为了方便调试,我们在 demo 项目中增加一个 ws 路由:

  let ws
  router.get('/ws', async (ctx) => {
    if (ws) {
      // 拿到 params 中的 message
      const message = ctx.query.message
      ws.send(message || 'Hello from Koa')
      ctx.body = 'WebSocket: Send'
      return
    }
    ws = new WebSocket('wss://echo.websocket.org/')
    ws.onopen = () => {
      ws.send('Hello from Koa')
    }
    ws.onmessage = (event) => {
      console.log('WebSocket message:', event.data)
    }
    ws.onclose = () => {
      console.log('WebSocket connection closed')
    }
    ctx.body = 'WebSocket connection established'
  })

wss://echo.websocket.org/ 是一个免费的公开 WebSocket API,我们向其发送任何数据,它都返回同样的数据。

那么当我们发送 Hello form Koa,它自然也返回Hello form Koa 。这可以在我们的日志中看到,虽然存在一些乱码,但是我们确实看到了我们的消息。

也就是我们已经实现了双向监听,现在最麻烦的部分才刚刚出现,只要我们解决了二进制解码的问题,websocket 就拿下了~

3.3 基于状态机的二进制解析器

好消息是,我们可能就差一点点🤏了。

坏消息是,这一点点有点难。

但是还有一件事,有人写了。

我们来借鉴一下优秀前辈的代码

可以看到,这个开源的代码库实现了一个 WebSocket 类,其同时支持作为服务端和客户端使用。

我们看到它的构造函数,我们需要关注其中的 receiversender 以及 socket

这之后,会来到 setSocket 的逻辑,WebSocket 客户端会在这里初始化一个非常关键的类 Receiver

先看 Receiver 吧。这些参数什么的都不重要,我们现在重点是确认 Receiver 是否就是我们期望的二进制数据解析器。

看到 receiver 的定义,可知它是 Writable 类的扩展。而 WritableStream 流传输中的重要抽象类。简单来说,Writable 流是一个你可以往里写入数据的目标,比如:

  • 文件写入流 (fs.createWriteStream())
  • HTTP 请求
  • TCP socket
  • 进程标准输出(stdout)

以下边的代码举例,这是一个 Writable 对象的基本使用方式,当我们往其中进行任意数据写入时,可以拿到流数据。

而如果,我们模仿 Receiver 类对 Writable 接口进行扩展,就会得到这份 demo,会发现,每当我们调用 write 方法时,实际上会调用的是 Writable 中的 _write 方法。

所以可以得出两个结论:

  1. Receiver 代码中的 _write 印证了我们前文说过的,如果 opcode 是 0x08,代表的正是WebSocket 帧中的「关闭帧」。

  2. 在有了第一点的结论的前提下,我们可以知道Receiver 正是我们需要的消息解析器。因为每当流写入新的 buffer 时,都会触发 _write

自然而然的,我们看到 startLoop,也就是我们旅程的起点:状态机。


  /**
   * Starts the parsing loop.
   *
   * @param {Function} cb Callback
   * @private
   */
  startLoop(cb: (err?: Error | null) => void): void {
    this._loop = true

    do {
      switch (this._state) {
        case GET_INFO:
          this.getInfo(cb)
          break
        case GET_PAYLOAD_LENGTH_16:
          this.getPayloadLength16(cb)
          break
        case GET_PAYLOAD_LENGTH_64:
          this.getPayloadLength64(cb)
          break
        case GET_MASK:
          this.getMask()
          break
        case GET_DATA:
          this.getData(cb)
          break
        case INFLATING:
        case DEFER_EVENT:
          this._loop = false
          return
      }
    } while (this._loop)

    if (!this._errored) cb()
  }

3.3.1 读取一帧的前两个字节

Receiver 类会基于 _state 进行状态机流转,默认情况下,_state 的值是 GET_INTO。这一部分代码的主要功能是基于帧的前两个字节决定后续的流程走向。

我们可以转头继续看到源代码,首先关注到buf[0]的部分,可以解析出各种标志位。

帧的第一个字节包含了这些信息:

 **0                   1
 0 1 2 3 4 5 6 7
+-+-+-+-+-+-+-+-+
|F|R|R|R|opcode |
|I|S|S|S| (4)   |
|N|V|V|V|       |
| |1|2|3|       |
+-+-+-+-+-+-+-+-+**
  • FIN (buf[0] & 0x80): 也就 & 0b10000000 ,所以取出了第一位。
  • RSV1 (buf[0] & 0x40): 用于扩展,这里用于标识是否启用压缩
  • RSV2,RSV3 (buf[0] & 0x30): 必须为 0
  • Opcode (buf[0] & 0x0f): 操作码,表示数据的类型

而第二个字节则可以解析出 mask 和 payload length:

 1
 0 1 2 3 4 5 6 7
+-+-+-+-+-+-+-+-+
|M|Payload len   |
|A| (7)         |
|S|             |
|K|             |
+-+-+-+-+-+-+-+-+
  • MASK (buf[1] & 0x80): 是否使用掩码
  • Payload length (buf[1] & 0x7f): 数据负载的长度

那么自然而然的,我们可以看懂 getInfo 代码解析出了 masked、fin、payloadLength,并根据 WebSocket 协议对帧信息进行校验。

需要注意其中这一部分,通过这部分代码我们可以知道,Receiver 类既可用于服务端帧解析,也可以用于客户端帧解析。

this._masked = (buf[1] & 0x80) === 0x80;
// 客户端发送的消息必须使用掩码
// 服务器发送的消息不能使用掩码
if (this._isServer) {
    if (!this._masked) {
        // 错误:客户端消息必须使用掩码
    }
} else if (this._masked) {
    // 错误:服务器消息不能使用掩码
}

而再到后边,则会根据 payload 长度修改状态,继续来到下一个状态机函数:

    if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16
    else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64
    else this.haveLength(cb)

前两个状态机函数,会根据以下协议读取出 payloadLength,然后都来到 haveLength 函数

此时,我们再根据是否为服务端消息(是否 masked)来决定走向,如果来到 getMask,则会根据剩下的 bytes 长度决定是否结束,或者如果未结束,则消费掉 4 位数字(4*8=32),并调整状态机状态位 GET_DATA

3.3.2 根据 payloadLength 读取 data

到了这一步,主流程我们很快就能读懂了。根据 opcode,控制帧走控制帧函数,非控制帧,根据是否压缩,压缩则解压后来到 dataMessage,否则直接来到 dataMessage。

dataMessage 中,需要根据 opcode 是 1 还是 2,来进行数据组合,组合后通过 emit 触发 message 。至此,循环结束,触发 callback。

也就是说,我们完全可以将 Receiver 类作为解析器来使用。

3.4 解析器 🔗 CDP

到了这一步,我们再进行解析就好办了。我们通过 isServer 参数可以拆分出两个解析器。

      const receiver = new Receiver({
        allowSynchronousEvents: true,
        binaryType: BINARY_TYPES[0],
        isServer: false
      })
      const sender = new Receiver({
        allowSynchronousEvents: true,
        binaryType: BINARY_TYPES[0],
        isServer: true
      })

我们将两者用于之前完成的 socket data 等监听器中,让解析器可以接收到数据流。

      let chunk
      socket.write = (data: any, ...rest: any[]) => {
        const buf = Buffer.from(data)
        sender.write(buf)
        return originalWrite.call(socket, data, ...rest)
      }
      socket.addListener('data', (data) => {
        const buf = Buffer.from(data)
        receiver.write(buf)
      })
      socket.addListener('close', () => {
        chunk = socket.read()
        if (chunk !== null) {
          receiver.write(chunk)
          sender.write(chunk)
        }
        receiver.end()
        sender.end()
        receiver.removeAllListeners()
        sender.removeAllListeners()
        ...
      })
      socket.addListener('end', () => {
        receiver.end()
        sender.end()
        receiver.removeAllListeners()
        sender.removeAllListeners()
      })

然后我们再接入 CDP 协议,mask 对应上,opcode 则可以填写 1,代表这是 UTF-8 的字符串。


      const receiverHandler = (data: any) => {
        const str = data.toString()
        mainProcess.send({
          type: 'Network.webSocketFrameReceived',
          data: {
            requestId: requestDetail.id,
            response: {
              payloadData: str,
              opcode: 1,
              mask: false
            }
          }
        })
      }

      const senderHanlder = (data: any) => {
        const str = data.toString()
        mainProcess.send({
          type: 'Network.webSocketFrameSent',
          data: {
            requestId: requestDetail.id,
            response: {
              payloadData: str,
              opcode: 1,
              mask: true
            }
          }
        })
      }

      receiver.on('message', receiverHandler)
      sender.on('message', senderHanlder)

那么至此,我们完成了 WebSocket 在我们的 devtools 上显示的过程,让我们来看看演示效果~

4 演示

5 后话

那么至此,CDP 篇章就暂时告一段落啦。本来还想再讲讲实现的插件系统,但是后来逐渐发现了其中的不足,打算等到测试能力建设完毕,再系统性讲解。

希望本系列文章,能让大家对 HTTP 通信协议、CDP 协议有比较好的理解,感谢大家~