从零开始实现一个WebSocket Server

这篇文章我们将从协议层面实现一个WebSocket的Server从而了解WebScoket协议的内在如何工作的。它将涵盖客户端服务端之间的握手和基本的数据帧解析和传递。

RFC 6455(WebSocket官方协议规范)将作为我们主要的参考依据。

源代码链接:websocket-server

1. 背景

WebSocket是基于TCP/IP协议来交互的应用层协议,它为客户端和服务端之间提供了全双工双向的通道。

相对于HTTP请求响应的范式,它通过一个持久的连接提供了实时客户端-服务端的消息交换。因此特别适合那些实时交互要求高的应用。

本质上,WebSocketTCP/IP协议栈上一层简单、低开销的封装。

2. 协议概览

WebSocket协议在一个持久的连接上运行,但是它是通过一个HTTP请求Upgrade建立的,你通过Network面板调试WebSocket时可以看到101 Switch Protocals的http请求。

基本上,为了使用WebSocket协议我们先需要开启一个HTTP Server, 然后通过客户端发送一个Upgrade Request来将HTTP协议切换到WebSocket协议。

让我们创建一个ws.js的文件,来开始实现我们的WebSocket Server:

javascript 复制代码
const http = require('node:http');
const { EventEmitter } = require('node:events');

class WebSocketServer extends EventEmitter {
  constructor(options = {}) {
    super();
    this.port = options.port || 4000;
    this._init();
  }

  _init() {
    if (this._server) throw new Error('Server already initialized');

    this._server = http.createServer((req, res) => {
      const UPGRADE_REQUIRED = 426;
      const body = http.STATUS_CODES[UPGRADE_REQUIRED];
      res.writeHead(UPGRADE_REQUIRED, {
        'Content-Type': 'text/plain',
        'Upgrade': 'WebSocket',
      });
      res.end(body);
    });
  }
}

我们继承了EventEmitter类以便后续可以使用emit抛出事件,或者on监听事件。

_init函数创建了HTTP Server实例,这边所有非Upgrade的请求,全部返回UPGRADE_REQUIRED状态码

3. 握手连接

WebSocket通过客户端初始化一个opening handshake连接,服务端需要返回特定的header来完成捂手。然后这个HTTP连接会被WebSocket连接替换,并且在之后复用当前这个TCP连接。

客户端通过发送以下headerGET请求发起握手:

makefile 复制代码
GET /HTTP/1.1
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: kB2x1cO5zjL1ynwrLTSXUQ==
Sec-WebSocket-Version: 13

ConnectionUpgrade请求头告诉服务端这是一个要建立WebSocket的连接。

Sec-WebSocket-VersionWebSocket的版本目前固定13。

Sec-WebSocket-Key是客户端随机生成的字符串,根据RFC规定,这个header是一个16位的随机数,并且被base64编码过。这个随机数必须保证每个连接不一样。

这个Sec-WebSocket-Key在服务端创建握手的时候被使用,用以标志接受了该连接。

为了接受进来的连接,服务端必须返回101 Switching Protocols状态,同时必须包含Sec-WebSocket-Acceptheader

Sec-WebSocket-Accept这样产生

  • 服务端获取Sec-WebSocket-Key的值和一个固定的GUID字符串(258EDFA5-E914--47DA-95CA-C5AB0DC85B11)连接
  • 执行SHA-1哈希
  • 执行base64编码

基本上,为了确保客户端和服务端支持WebSocket协议,这是必须的。如果服务端接受WebSocket接受HTTP连接,但将数据解释成HTTP格式,可能会有潜在的安全问题。

握手的响应头也需要包含Connection: UpgradeUpgrade: websocket

当客户端收到服务端的响应后,一个WebSocket的连接就建立完成,并等待传输数据

修改我们的_init函数

javascript 复制代码
_init() {
  if (this._server) throw new Error('Server already initialized');

  this._server = http.createServer((req, res) => {
    const UPGRADE_REQUIRED = 426;
    const body = http.STATUS_CODES[UPGRADE_REQUIRED];
    res.writeHead(UPGRADE_REQUIRED, {
      'Content-Type': 'text/plain',
      'Upgrade': 'WebSocket',
    });
    res.end(body);
  });

  // connection = upgrade 触发
  this._server.on('upgrade', (req, socket) => {
    this.emit('headers', req);

    // 验证请求头upgrade是不是websocket 不是返回400错误
    if (req.headers.upgrade !== 'websocket') {
      socket.end('HTTP/1.1 400 Bad Request');
      return;
    }

    const acceptKey = req.headers['sec-websocket-key'];
    const acceptValue = this._generateAcceptValue(acceptKey);

    const responseHeaders = [
      'HTTP/1.1 101 Switching Protocols',
      'Upgrade: websocket',
      'Connection: Upgrade',
      `Sec-WebSocket-Accept: ${acceptValue}`,
    ];

    socket.write(responseHeaders.concat('\r\n').join('\r\n'));
  });

  // 生成Accept的header值
   _generateAcceptValue(key) {
    // 固定的字符串 和规范中提到的要一致
    const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
    const { createHash } = require('crypto');
    const digest = createHash('sha1')
    .update(key + GUID)
    .digest('base64');
    return digest
  }
}

4. 接受数据

首先我们需要了解协议的数据帧组成

帧数据

[ Fin, RSV1, RSV2, RSV3, opcode ]

[ Mask, Payload length, Extended payload length ]

[ Payload Data ]

含义

  • 第一个字节 Fin(1bit) + RSV1(1bit) + RSV2(1bit) + RSV3(1bit) + opcode(4bit)
  • 第二个字节 Mask(1bit) + Payload length(7bit)
  • Extended payload length(根据Payload length的值确定实际长度)
  • Mask key(如果Mask=1才存在)
  • Payload Data(根据Extended payload length确定长度)

Fin

如果设置了,标识是消息的最后一帧

RSV

插件标志,本文忽略

opcode

该条消息对应的操作

opcode 描述
0x00 标识这个接着上一帧
0x01 文本帧
0x02 二进制帧
0x08 关闭连接
0x09,0x0a ping pong心跳

Mask, Mask key

是否使用掩码,默认客户端->服务端mask为1,服务端到客户端mask为0

Mask key用于对数据做编码的字符串,只有mask等于1时才生效

Payload length

确定Extended payload length长度

  • 如果Payload的数据长度在0 ~ 125之间, 则 Payload length等于Payload的实际长度
  • 如果Payload的数据长度在126 ~ 65535之间, 则 Payload length等于126,Extended payload length等于Payload的长度,占两个字节
  • 如果Payload的数据长度在65535 ~ 9223372036.85 G之间, 则 Payload length等于127,Extended payload length等于Payload的长度,占8个字节

Payload Data

实际传输的数据

帧解析

根据以上协议定义,我们可以实现帧的解析函数

javascript 复制代码
parseFrame(buffer) {
  // 第一个字节
  const firstByte = buffer.readUInt8(0);
  // 第一个字节后四位 opcode
  const opCode = firstByte & 0b00001111;

  if (opCode === this.OPCODES.close) {
    this.emit('close');
    return null;
  } else if (opCode !== this.OPCODES.text) {
    return;
  }



  // 第二个字节
  const secondByte = buffer.readUInt8(1);

  let offset = 2;
  // 获取payload的长度,即第二个字节后七位
  let payloadLength = secondByte & 0b01111111;

  if (payloadLength === 126) {
    offset += 2; // extended payload占两个字节
  } else if (payloadLength === 127) {
    offset += 8; // extended payload占四个字节
  }


  // 是否mask
  const isMasked = Boolean((secondByte >>> 7) & 0b00000001);

  if (isMasked) {
    // 读取四个字节的mask key
    const maskingKey = buffer.readUInt32BE(offset);
    offset += 4;
    const payload = buffer.subarray(offset);
    // 解码后返回数据
    const result = this._unmask(payload, maskingKey);
    return result.toString('utf-8');
  }

  // 直接返回数据
  return buffer.subarray(offset).toString('utf-8');
}

_unmask(payload, maskingKey) {
  const result = Buffer.alloc(payload.byteLength);

  for (let i = 0; i < payload.byteLength; ++i) {
    const j = i % 4;
    const maskingKeyByteShift = j === 3 ? 0 : (3 - j) << 3;
    const maskingKeyByte = (maskingKeyByteShift === 0 ? maskingKey : maskingKey >>> maskingKeyByteShift) & 0b11111111;
    const transformedByte = maskingKeyByte ^ payload.readUInt8(i);
    result.writeUInt8(transformedByte, i);
  }

  return result;
}

server监听dataclose事件

javascript 复制代码
this._server.on('upgrade', (req, socket) => {
  // ...upgrade request code...
  socket.on('data', (buffer) =>
    this.emit('data', this.parseFrame(buffer))
  );

  this.on('close', () => {
    console.log('closing....', socket);
    socket.destroy();
  });
});

这样我们的server就可以接受来自客户端的数据了

发送数据

知道了协议格式发送数据更简单因为不要对数据进行mask操作

我们这边写死发送text

javascript 复制代码
createFrame(data) {
  const payload = JSON.stringify(data);

  const payloadByteLength = Buffer.byteLength(payload);
  let payloadBytesOffset = 2;
  let payloadLength = payloadByteLength;

  if (payloadByteLength > 65535) { // length value cannot fit in 2 bytes
    payloadBytesOffset += 8;
    payloadLength = 127;
  } else if (payloadByteLength > 125) {
    payloadBytesOffset += 2;
    payloadLength = 126;
  }

  const buffer = Buffer.alloc(payloadBytesOffset + payloadByteLength);

  // first byte
  buffer.writeUInt8(0b10000001, 0); // [FIN (1), RSV1 (0), RSV2 (0), RSV3 (0), Opсode (0x01 - text frame)]

  buffer[1] = payloadLength; // second byte - actual payload size (if <= 125 bytes) or 126, or 127

  if (payloadLength === 126) { // write actual payload length as a 16-bit unsigned integer
    buffer.writeUInt16BE(payloadByteLength, 2);
  } else if (payloadByteLength === 127) { // write actual payload length as a 64-bit unsigned integer
    buffer.writeBigUInt64BE(BigInt(payloadByteLength), 2);
  }

  buffer.write(payload, payloadBytesOffset);
  return buffer;
}

原文链接

相关推荐
罔闻_spider18 分钟前
爬虫----webpack
前端·爬虫·webpack
吱吱鼠叔19 分钟前
MATLAB数据文件读写:1.格式化读写文件
前端·数据库·matlab
爱喝水的小鼠36 分钟前
Vue3(一) Vite创建Vue3工程,选项式API与组合式API;setup的使用;Vue中的响应式ref,reactive
前端·javascript·vue.js
WeiShuai1 小时前
vue-cli3使用DllPlugin优化webpack打包性能
前端·javascript
ice___Cpu1 小时前
Linux 基本使用和 web 程序部署 ( 8000 字 Linux 入门 )
linux·运维·前端
JYbill1 小时前
nestjs使用ESM模块化
前端
加油吧x青年1 小时前
Web端开启直播技术方案分享
前端·webrtc·直播
时之彼岸Φ1 小时前
Web:HTTP包的相关操作
网络·网络协议·http
秋已杰爱1 小时前
HTTP中的Cookie与Session
服务器·网络协议·http
W21551 小时前
LINUX网络编程:http
网络·网络协议·http