在传统的 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 的工作原理,但距离生产环境还有不少距离:
- 分片消息处理:上面只处理了单帧,如果 FIN 为 0,需要缓存并在最后帧到达时组合。
- 控制帧处理:例如 Close 帧可以包含状态码和原因,应正确回复 Close 帧完成优雅关闭。
- Ping/Pong 心跳:定期发送 Ping,客户端自动回复 Pong,用于保持连接和检测存活。上面的代码只对 Ping 手动回复了 Pong,实际 ws 客户端会自动处理,但服务端仍应处理收到 Ping 的情况。
- 错误处理与资源清理:网络中间断开或格式错误应妥善销毁连接。
- 高并发性能:原生解析是同步的,每个 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 原理都是构建高性能实时应用的必备知识。