跟着RFC 6455标准来手写WebSocket服务

145 阅读5分钟

目前各大主流浏览器都已经实现 WebSocket,本文主要是实现 server端的WebSocket服务。

完整代码见文章末尾,代码受到了很多优秀开源项目的影响,比如本次就借用了不知道传了第几手并非原创的发布订阅模式。

所以项目已加上了MIT协议,希望也能对其他人有所帮助。如果看完后觉得能有一点点的帮助,希望能随手点个赞或者star

既然要手写WebSocket,那么首先需要了解它是什么?

一.WebSocket是什么?

WebSocket是一个通信协议,由IETF定义,标准为RFC 6455。那么我们这时候就去看看它的标准。 RFC 6455标准链接如下:www.rfc-editor.org/rfc/rfc6455…

首先看全文的摘要,摘要非常精炼,一共四句话。

image.png

第一句话说明它是一个双向通信协议。 第二句讲安全,是基于基于源的安全模型。 第三句表明它是基于消息帧,基于 TCP。所以我们可以导入http模块,用于创建WebSocket

import http from 'node:http'

class WebSocket extends EventEmitter {
    server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
    clients: Array<Client>
    constructor({ port = 30104 }: { port: number }) {
        super()
        this.server = http.createServer();
        this.server.listen(port);
        this.clients = []
    }
 }

最后一句说明目的,为了解决不使用多个 http 连接,也能实现客户端和服务端的双向通信。

紧接着我们看下目录:

image.png

目录也很清晰,从背景,协议概览,握手,挥手等等开始介绍。但我们为了实现一个最基本的WebSocket通信,不用去看那么全,只需要关注通信的最基本三要素即可:

  • 握手
  • 数据传递
  • 挥手

二.握手(Opening Handshake)

找到握手 Opening Handshake 这一章:www.rfc-editor.org/rfc/rfc6455…

image.png

这些是客户端的握手部分,可以后面再看,毕竟主流浏览器都已实现,我们要实现的serverWebSocket通信。

image.png

server端的握手部分比client端更加简单,只有以下几部分:

   HTTP/1.1 101 Switching Protocols
   Upgrade: websocket
   Connection: Upgrade
   Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

而且这几部分全是固定值

翻译下Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=的意思是:

image.png

将字符串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 使用 SHA-1 加密,取 base64-encoded

 socket.write([
    "HTTP/1.1 101 Switching Protocols",
    "Upgrade: websocket",
    "Connection: Upgrade",
    "Sec-WebSocket-Accept: " + crypto.createHash("sha1").update(req.headers['sec-websocket-key'] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64")
].join("\n") + "\n\n");

其中 "Connection: Upgrade",所以

import http from 'node:http'

class WebSocket extends EventEmitter {
    server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
    clients: Array<Client>
    constructor({ port = 30104 }: { port: number }) {
        super()
        this.server = http.createServer();
        this.server.listen(port);
        this.clients = []
        this.server.on("upgrade", (req, socket) => this.create(req, socket))
    }
 }

三.数据传递(Data Framing)

我们跳转到 Data Framing 这一章 image.png 可以知道

  1. WebSocket是通过帧(frames)来传递数据。
  2. 如果是client端向server通信,必须设置mask。如果是server端向client通信,不能设置mask

既然是通过帧来传递,那就必须知道帧的结构

image.png

帧的结构文档中都有解释,这里翻译一下:

  • FIN: 1bit 指这是消息的最后一个片段

  • RSV1, RSV2, RSV3: 1 bit each 必须为0

  • Opcode: 4 bits 定义传输的数据内容的解释

    • %x0表示延续帧

    • %x1表示文本帧

    • %x2表示二进制帧

    • %x3-7保留给更多的非控制帧

    • %x8表示连接关闭

    • %x9表示ping

    • %xA表示pong

    • %xB-F为进一步的控制帧保留

  • Mask: 1 bit上面解释过,如果是server端向client端通信,不能设置,即为0。但如果是clientserver端通信,需要设置为1

  • Payload length: 7 bits, 7+16 bits, or 7+64 bits 内容的大小,分三种情况。0-125表示内容小于7 bits,126表示内容大于7 bits但小于16 bits。如果是127,表示内容是64 bits

  • Masking-key: 0 or 4 bytes 没有Mask自然也没有Masking-key。如果有的情况下,

将上述标准翻译成代码

  const src = Buffer.from(source);
  // FIN 只占 1bit,buffer[n]取的是1byte,1byte = 8 bits,所以要和128进行比较,比较第一位
  fin: src[0] >= 128,
  // opcode 占 4 bits,15等于1111,目的要取后4位,用来判断是结束还是文本还是一些其他信息
  opcode: src[0] & 15,
  // mask为 1 byte 的第一位
  mask: src[1] >= 128
  // payloadSize为 1 byte 的后7位,所以要位运算 127 ,取后面7位
  payloadSize: src[1] & 127
  // metaSize 的大小与 payloadSize 直接相关,多个if else 判断
  metaSize
  // maskKey 与 mask 和 metaSize 相关
  maskKey: masked ? src.subarray(metaSize, metaSize + (masked ? 4 : 0)) : null,

payloadSize,帧大小,metaSize的大小的判断:

 if (payloadSize === 127) {
    size = src.readUInt32BE(6);
    metaSize = 10;
} else if (payloadSize === 126) {
    size = src.readUInt16BE(2);
    metaSize = 4;
} else {
    size = payloadSize;
    metaSize = 2;
}

读取元数据,处理帧meta:

  const handleFrameMeta = (meta: FrameMeta) => {
    const metaLength = meta.metaSize
    buf.subarray(0, metaLength)
    buf = buf.subarray(metaLength); 
    return buf
}

成功读取了元数据后,就需要去读取数据的内容,数据的内容就比较简单读取,直接获取帧的大小,从metaSizesize的部分就是帧的内容

 const handleFrameContent = (meta: FrameMeta) => {
    const size = meta.size
    const buffer = buf.subarray(0, size)
    data = Buffer.concat([data, this.iMask(buffer, meta.maskKey)]); 
    // 需要判断下是否是最后一帧,这些信息都记录在`meta`中,如果是的话,说明读取完毕,`emit`数据。
    if (meta.fin) { 
        if (meta.opcode === 8) return client.close();
        if (meta.opcode === 9) return client.pong();
        client.emit("data", data); 
        data = Buffer.allocUnsafe(0);
    }
    buf = buf.subarray(size);
}

四.挥手(Closing Handshake)

挥手比握手更加简单,只需要发送或者接收到一个关闭帧即可。比如发送 Opcode 关闭帧。

五.完整代码

代码地址和路径:github.com/chaxus/ran/…