原生模块打造一个简单的 WebSocket 服务器

javascript/jquery

浏览数:621

2019-3-6

众所周知,在 HTTP2 之前,受制于协议本身的原因,HTTP 并不支持服务端主动推送。客户端需要采用轮询等方式与服务端进行实时通信,但产生的开销比较大。而 HTML5 中提出了一套 WebSocket 协议规范,使得客户端浏览器与服务端进行双向实时通信成为可能,具体请看 RFC 6455。本文将介绍用 Node.js 原生模块打造一个简单的 ws 服务器,点击这里获取完整代码。

WebSocket Protocol分为两部分:握手和数据传输。

握手阶段

  • 客户端握手请求报头:

GET /chat HTTP/1.1  // 请求行
Host: server.example.com
Upgrade: websocket  // required
Connection: Upgrade // required
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== // required
Origin: http://example.com  // 用于防止未认证的跨域脚本使用浏览器 websocket api 与服务端进行通信
Sec-WebSocket-Protocol: chat, superchat  // optional, 子协议协商字段
Sec-WebSocket-Version: 13
  • 服务端响应报头:

HTTP/1.1 101 Switching Protocols  // 状态行
Upgrade: websocket   // required
Connection: Upgrade  // required
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= // required
Sec-WebSocket-Protocol: chat // 表明选择的子协议

握手阶段,具体来讲,就是当浏览器脚本 new WebSocket(url) 后,浏览器对服务器发送一个协议升级的请求,请求中带有 Sec-WebSocket-Key 字段。服务端接收到协议提升请求后对这个字段加上一个特定的 GUID 后做一次 sha1 运算,然后再获取结果的 base64 格式摘要,作为 Sec-WebSocket-Accept 响应头的值响应回客户端浏览器,就完成了握手。具体代码如下:

server.on('upgrade', (req, socket, head) => {
  // 固定GUID
  const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
  // 获取客户端返回的 key 与 GUID 进行 sha1 编码后获取 base64 格式摘要
  let key = req.headers['sec-websocket-key'];
  key = crypto.createHash('sha1').update(key + GUID).digest('base64');

  // 返回 101 协议切换响应
  const resMsg = [
    'HTTP/1.1 101 Switching Protocols',
    'Upgrade: websocket',
    'Connection: Upgrade',
    'Sec-WebSocket-Accept: ' + key,
    '\r\n'
  ].join('\r\n');

  socket.write(resMsg);
});

ws帧解码与编码

握手成功后,就可以进行数据传输了,然而不进行解码操作是得不到正确的结果的。

// 打印的数据类似是这样的格式 <Buffer aa bb cc>
socket.on('data', console.log.bind(console)); 

我们可以来看一下ws帧的完整格式 (@被欺负的大白 同学提供的帧格式更为直观的图):

1               2               3               4              
0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-------+-+-------------+-------------------------------+
|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: 表示帧是否结束,1 结束,0 没结束

  • RSV[1-3]: 通常来说置零即可,但可以根据扩展协商非零值的具体含义

  • opcode: 操作码,0, 1, 2 属于数据帧,8, 9, 10 属于控制帧,具体含义如下

    • 0: 附加帧

    • 1: 文本帧

    • 2: 二进制帧

    • 3-7: 保留作为未来的非控制帧

    • 8: 关闭帧

    • 9: ping 帧

    • 10: pong 帧

    • 11-15: 保留作为未来的控制帧

  • MASK: 掩码,0 表示不使用掩码,1 表示使用 Masking-key 对负载数据进行掩码运算

  • Payload len:

    • 0-125: 实际负载数据长度

    • 126: 接下来的两字节对应的无符号整数作为负载长度

    • 127: 扩展的 8 字节对应的无符号帧数作为负载长度

  • Masking-key: 如果 MASK 为 1 时,后续的四字节作为 Masking-key,MASK 为 0 时则缺省 Masking-key

  • Payload Data: (x+y) bytes 负载数据

    • Extension data (x bytes): 扩展数据通常来说是 0 字节,除非协商了一个扩展

    • Application data (y bytes): 应用数据

解码操作代码如下:

function decodeWsFrame(data) {
  // 游标
  let start = 0;
  // 定义帧字段格式
  let frame = {
    isFinal: (data[start] & 0x80) === 0x80,
    opcode: data[start++] & 0xF,
    masked: (data[start] & 0x80) === 0x80,
    payloadLen: data[start++] & 0x7F,
    maskingKey: '',
    payloadData: null
  };
  // 接下来的两字节对应的无符号整数作为负载长度
  if (frame.payloadLen === 126) {
    frame.payloadLen = (data[start++] << 8) + data[start++];
  } else if (frame.payloadLen === 127) { // 扩展的 8 字节对应的无符号整数作为负载长度
    frame.payloadLen = 0;
    for (let i = 7; i >= 0; --i) {
      frame.payloadLen += (data[start++] << (i * 8));
    }
  }

  if (frame.payloadLen) {
    // 如果使用了掩码
    if (frame.masked) {
      // 掩码键
      const maskingKey = [
        data[start++],
        data[start++],
        data[start++],
        data[start++]
      ];

      frame.maskingKey = maskingKey;
      // 负载数据与四字节的掩码键的每一个字节轮流进行按位抑或运算
      frame.payloadData = data
        .slice(start, start + frame.payloadLen)
        .map((byte, idx) => byte ^ maskingKey[idx % 4]);
    } else {
      frame.payloadData = data.slice(start, start + frame.payloadLen);
    }
  }

  return frame;
}

解码数据帧结果

/* 打印结果
 * { isFinal: true,
 * opcode: 0,
 * masked: false,
 * payloadLen: 3,
 * maskingKey: '',
 * payloadData: Buffer [68 65 6c 6c 6f 20 67 65 65 6d 6f] }
 * hello geemo
 */
socket.on('data', data => {
    data = decodeWsFrame(data); // 数据帧解码
    console.log(data);  // 打印帧
    console.log(String(data.payloadData)) // 打印帧负载字符串格式结果
});

既然已经能解码客户端发送的帧后,我们接着来实现服务端编码帧响应回客户端。rfc文档中说服务端响应回客户端的帧不能进行掩码操作,那太好了!然而服务端虽然不需要考虑mask,但是还需要考虑分片问题……

// 编码ws帧
function encodeWsFrame(data) {
  const isFinal = data.isFinal !== undefined ? data.isFinal : true, // 没有 isFinal 字段默认为终止帧
    opcode = data.opcode !== undefined ? data.opcode : 1, // 默认编码为文本帧
    payloadData = data.payloadData ? new Buffer(data.payloadData) : null,
    payloadLen = payloadData ? payloadData.length : 0;

  let frame = [];

  // 帧的第一个字节
  if (isFinal) frame.push((1 << 7) + opcode);
  else frame.push(opcode);

  // 帧的负载长度处理
  if (payloadLen < 126) {
    frame.push(payloadLen);
  } else if (payloadLen < 65536) {
    frame.push(126, payloadLen >> 8, payloadLen & 0xFF);
  } else {
    frame.push(127);
    for (let i = 7; i >= 0; --i) {
      frame.push((payloadLen & (0xFF << (i * 8))) >> (i * 8));
    }
  }

  // 合并头部和负载数据
  frame = payloadData ? Buffer.concat([new Buffer(frame), payloadData]) : new Buffer(frame);

  console.dir(decodeWsFrame(frame));
  return frame;
}

分片

最后是处理分片情况,所谓分片,就是一个完整数据分为多个数据帧进行发送,其可以分为三个部分:

  • 起始帧(数量==1): FIN == 0, opcode != 0

  • 附加帧(数量>=0): FIN == 0, opcode == 0

  • 终止帧(数量==1): FIN == 1, opcode == 0

具体分片处理代码实现如下:

function rawFrameParseHandle(socket) {
  let frame,
    frameArr = [], // 用来保存分片帧的数组
    totalLen = 0;  // 记录所有分片帧负载叠加的总长度
  socket.on('data', rawFrame => {
    frame = decodeWsFrame(rawFrame);

    if (frame.isFinal) {
      // 分片的终止帧
      if (frame.opcode === 0) {
        frameArr.push(frame);
        totalLen += frame.payloadLen;

        let frame = frameArr[0],
          payloadDataArr = [];
        payloadDataArr = frameArr
          .filter(frame => frame.payloadData)
          .map(frame => frame.payloadData);
        // 将所有分片负载合并
        frame.payloadData = Buffer.concat(payloadDataArr);
        frame.payloadLen = totalLen;
        // 根据帧类型进行处理
        opHandle(socket, frame);
        frameArr = [];
        totalLen = 0;
      } else { // 普通帧
        opHandle(socket, frame);
      }
    } else { // 分片起始帧与附加帧
      frameArr.push(frame);
      totalLen += frame.payloadLen;
    }
  });
}

进行测试

// 测试代码
// 客户端将三个帧进行拼接为 'bbbcccddd'
socket.write(encodeWsFrame({isFinal: false, opcode: 1, payloadData: 'bbb'}));
socket.write(encodeWsFrame({isFinal: false, opcode: 0, payloadData: 'ccc'}));
socket.write(encodeWsFrame({isFinal: true, opcode: 0, payloadData: 'ddd'}));

结尾

好啦,大致实现基本完成,当然还有各种子协议,响应状态码等还没有研究,毕竟只是实现一个玩具嘛。