人人都会AI编程

22.1 WebSocket 原理与原生实现

更新时间:2026-07-11

在传统的 HTTP 模型中,客户端发起请求,服务端才能响应,这种“一问一答”的模式很难满足实时通信的需求。早期的轮询(Polling)或长轮询(Long Polling)虽然能模拟推送效果,但会带来巨大的冗余请求和延迟开销。WebSocket 协议的出现彻底改变了这一局面——它提供了一条在单个 TCP 连接上进行全双工通信的通道,让服务端能够主动向客户端推送数据,非常适合即时通讯、实时协作、游戏同步等场景。本节我们将深入 WebSocket 的原理,并探讨如何在 Node.js 中实现原生支持。

22.1.1 WebSocket 协议的本质

WebSocket 是 HTML5 规范的一部分,其核心思想是:利用 HTTP 建立连接,然后升级协议,在同一个 TCP 连接上切换到基于帧的全双工 WebSocket 协议。升级完成后,后续的数据交换不再采用 HTTP 头,而是采用一种紧凑的二进制帧格式,极大地减少了传输开销。

整个过程分为两个核心阶段:

1. 握手阶段(基于 HTTP Upgrade)

客户端发起一个特殊的 HTTP 请求,要求将连接升级为 WebSocket:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

关键头字段说明:

  • Upgrade: websocket 告知服务器我希望升级协议。
  • Connection: Upgrade 表示这是一个升级请求。
  • Sec-WebSocket-Key 是一个 Base64 编码的随机 16 字节值,用于防止意外缓存和协议确认。
  • Sec-WebSocket-Version: 13 指定协议版本(目前基本统一为 13)。

服务器接收到这样的请求后,如果支持 WebSocket 并愿意升级,会返回 101 状态码:

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

这里的 Sec-WebSocket-Accept 是根据客户端的 Sec-WebSocket-Key 加上一个固定的 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,进行 SHA-1 哈希后再 Base64 编码得到。客户端验证此值后,握手成功,双向通信通道建立。

2. 数据传输阶段(全双工帧协议)

升级完成后,双方都可以随时发送数据帧 (Frame),而不必等待对方请求。WebSocket 数据帧的结构如下:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|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 ...                :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +

各部分简要含义:

  • FIN:1 表示这是最后一帧。
  • RSV1-3:保留位,通常为 0。
  • opcode:操作码,指明帧类型,例如 0x1 表示文本帧,0x2 表示二进制帧,0x8 表示关闭连接,0x9 表示 Ping(心跳),0xA 表示 Pong(心跳回应)。
  • MASK:是否对负载进行掩码处理。客户端发往服务器的数据帧必须掩码,服务器发回的数据帧无需掩码(这是防止缓存投毒攻击的关键措施)。
  • Payload len:负载长度,可使用扩展字段(126 或 127)。
  • Masking-key:当 MASK 为 1 时,使用 4 字节掩码密钥对负载数据进行异或运算。
  • Payload Data:若被掩码,则为掩码后数据,接收方需用相同密钥解密。

读取帧时,需要解析这些字段,正确剥离掩码(来自客户端),然后根据 opcode 还原消息。对于太大或分片的消息,还需要将多个帧拼接重组(当 FIN 为 0 时表示后续还有帧)。

22.1.2 Node.js 原生实现 WebSocket 服务端

Node.js 在 v21 及以后版本实验性提供了基于浏览器规范的 WebSocket 全局对象,但多数 LTS 版本(如 v18, v20)尚未默认包含。目前生产环境中更常见的做法是使用 ws 库,但为了展示原生的实现原理,我们可以直接利用 http 模块完成握手,并手动解析数据帧。这样能深刻理解协议细节,而在实际项目中则可以基于此封装或直接使用成熟库。

下面我们一步步实现一个简易但完整的 WebSocket 服务器。

第一步:创建 HTTP 服务器并监听 Upgrade 事件

const http = require('http');
const crypto = require('crypto');

const server = http.createServer((req, res) => {
  // 普通 HTTP 请求,返回一个简单页面
  res.writeHead(200, { 'Content-Type': 'text/html' });
  res.end('<h1>WebSocket Test</h1>');
});

// 监听 Upgrade 事件
server.on('upgrade', (req, socket, head) => {
  if (req.headers['upgrade'] !== 'websocket') {
    socket.destroy();
    return;
  }
  // 处理 WebSocket 升级
  handleUpgrade(req, socket, head);
});

server.listen(3000, () => {
  console.log('Server running on port 3000');
});

第二步:完成协议升级握手

const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

function handleUpgrade(req, socket) {
  const key = req.headers['sec-websocket-key'];
  const acceptKey = crypto
    .createHash('sha1')
    .update(key + GUID)
    .digest('base64');

  const responseHeaders = [
    'HTTP/1.1 101 Switching Protocols',
    'Upgrade: websocket',
    'Connection: Upgrade',
    `Sec-WebSocket-Accept: ${acceptKey}`,
    '', // 空行表示头部结束
    ''
  ].join('\r\n');

  socket.write(responseHeaders);

  // 此后 socket 就作为 WebSocket 连接使用
  // 监听数据与关闭事件
  socket.on('data', buffer => {
    handleFrame(socket, buffer);
  });

  socket.on('close', () => {
    console.log('Connection closed');
  });
}

第三步:解析数据帧并处理消息

解析帧的函数需要逐字节解析,处理掩码,并触发业务逻辑。

function handleFrame(socket, buffer) {
  // 解析字节
  let offset = 0;
  const firstByte = buffer[offset++];
  const opcode = firstByte & 0x0f;       // 操作码
  const isMasked = (buffer[offset] & 0x80) !== 0; // 是否掩码
  let payloadLength = buffer[offset++] & 0x7f;

  // 扩展长度
  if (payloadLength === 126) {
    payloadLength = buffer.readUInt16BE(offset);
    offset += 2;
  } else if (payloadLength === 127) {
    // 64 位长度,取低 32 位足够(实战中根据业务决定)
    payloadLength = Number(buffer.readBigUInt64BE(offset));
    offset += 8;
  }

  // 掩码密钥
  let maskKey;
  if (isMasked) {
    maskKey = buffer.slice(offset, offset + 4);
    offset += 4;
  }

  // 负载数据
  let payload = buffer.slice(offset, offset + payloadLength);

  // 如果用掩码则需要解码
  if (isMasked) {
    for (let i = 0; i < payload.length; i++) {
      payload[i] ^= maskKey[i % 4];
    }
  }

  // 根据 opcode 处理
  switch (opcode) {
    case 0x1: // 文本帧
      const text = payload.toString('utf8');
      console.log('Received:', text);
      // 回显(发送文本帧)
      sendFrame(socket, text);
      break;
    case 0x8: // 关闭帧
      console.log('Client closed');
      socket.end(); // 也可以回复关闭帧
      break;
    case 0x9: // Ping
      // 回应 Pong(自动处理,也可以自定义)
      sendFrame(socket, '', 0xA);
      break;
    // 其他如二进制帧(0x2)等可按需扩展
    default:
      break;
  }
}

第四步:封装数据帧发送

function sendFrame(socket, message, opcode = 0x1) {
  const payload = Buffer.from(message);
  const length = payload.length;

  let headerBuffer;
  if (length < 126) {
    headerBuffer = Buffer.alloc(2);
    headerBuffer[0] = 0x80 | opcode; // FIN + opcode
    headerBuffer[1] = length;       // 无掩码
  } else if (length < 65536) {
    headerBuffer = Buffer.alloc(4);
    headerBuffer[0] = 0x80 | opcode;
    headerBuffer[1] = 126;
    headerBuffer.writeUInt16BE(length, 2);
  } else {
    // 大消息用64位长度
    headerBuffer = Buffer.alloc(10);
    headerBuffer[0] = 0x80 | opcode;
    headerBuffer[1] = 127;
    headerBuffer.writeBigUInt64BE(BigInt(length), 2);
  }

  // 服务端发送给客户端无需掩码
  socket.write(Buffer.concat([headerBuffer, payload]));
}

至此,一个基础的 WebSocket 服务端就完成了。客户端可通过以下方式连接:

const ws = new WebSocket('ws://localhost:3000');
ws.onopen = () => ws.send('Hello Server');
ws.onmessage = e => console.log('From server:', e.data);

注意事项与生产强化

上述实现完整地展示了原生 WebSocket 的工作原理,但距离生产环境还有不少距离:

  1. 分片消息处理:上面只处理了单帧,如果 FIN 为 0,需要缓存并在最后帧到达时组合。
  2. 控制帧处理:例如 Close 帧可以包含状态码和原因,应正确回复 Close 帧完成优雅关闭。
  3. Ping/Pong 心跳:定期发送 Ping,客户端自动回复 Pong,用于保持连接和检测存活。上面的代码只对 Ping 手动回复了 Pong,实际 ws 客户端会自动处理,但服务端仍应处理收到 Ping 的情况。
  4. 错误处理与资源清理:网络中间断开或格式错误应妥善销毁连接。
  5. 高并发性能:原生解析是同步的,每个 data 事件可能需要大量解析,可以使用状态机优化,或直接采用 ws 等经过充分优化的库。

22.1.3 Node.js 内置 WebSocket(实验性)与 ws 库对比

Node.js v21+ 提供了全局 WebSocket 服务端实现,使用方法类似于浏览器:

import { WebSocketServer } from 'ws'; // 实际上 Node.js 内置?
// 在 v22 中,可以这样用:
// const server = new WebSocketServer({ port: 3000 });

但截至本书撰写,绝大多数生产环境仍运行 v18 或 v20,它们并未内置 WebSocket 服务端,因此目前最成熟的选择仍是 ws 库。它提供了完整的帧控制、自动 Ping/Pong、事件驱动 API,且性能已被大量企业验证。而使用原生代码自行解析,更多用于学习和理解协议细节。

22.1.4 小结

WebSocket 通过 HTTP 升级握手与紧凑的二进制帧协议,实现了真正的全双工实时通信。它的优势在于低延迟、低控制开销,特别适合需要服务端主动推送的场景。在 Node.js 中实现 WebSocket 服务端可以通过 http 模块直接处理 Upgrade 和帧解析,这帮助开发者深入理解协议内核。但在实践项目中,更建议使用 ws 或 Socket.IO 等封装良好的库,以避免重复造轮子并确保稳定与安全。无论采用何种方式,掌握 WebSocket 原理都是构建高性能实时应用的必备知识。