跳到主要内容

WebSocket 协议原理

深入理解 WebSocket 协议的工作机制,对于正确使用和调试 WebSocket 应用至关重要。本章将详细讲解 WebSocket 协议的核心概念,包括握手过程、数据帧格式、连接状态管理和关闭流程。

握手过程

WebSocket 连接的建立需要一个握手过程,这个握手基于 HTTP 协议,使得 WebSocket 能够复用现有的 HTTP 基础设施(如代理服务器、防火墙等)。

客户端握手请求

客户端发起 WebSocket 连接时,会发送一个 HTTP GET 请求,请求中包含几个关键的头部字段:

GET /chat HTTP/1.1
Host: server.example.com:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

各头部字段的含义:

Upgrade: websocket 告诉服务器客户端希望将连接升级到 WebSocket 协议。

Connection: Upgrade 表示这是一个协议升级请求,与 Upgrade 头部配合使用。

Sec-WebSocket-Key 一个 Base64 编码的 16 字节随机值,用于服务器生成握手响应。这个值在每个连接中应该是唯一的,防止缓存代理误判。

Sec-WebSocket-Version WebSocket 协议版本号,当前标准版本是 13。

Origin 请求来源,用于服务器进行安全验证,防止跨站 WebSocket 劫持。

Sec-WebSocket-Protocol(可选) 客户端支持的子协议列表,按优先级排序。服务器可以选择其中一个返回。

Sec-WebSocket-Extensions(可选) 客户端支持的扩展列表,如压缩扩展。

服务器握手响应

服务器收到握手请求后,需要验证请求的有效性,然后返回 101 状态码表示协议切换成功:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
Sec-WebSocket-Extensions: permessage-deflate

Sec-WebSocket-Accept 的计算方式:

服务器将 Sec-WebSocket-Key 的值与固定的 GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 拼接,然后进行 SHA-1 哈希,最后 Base64 编码:

import base64
import hashlib

def compute_accept_key(key):
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
sha1 = hashlib.sha1((key + GUID).encode()).digest()
return base64.b64encode(sha1).decode()

key = "dGhlIHNhbXBsZSBub25jZQ=="
accept = compute_accept_key(key)
print(accept)

这个机制确保服务器真正理解 WebSocket 协议,而不是简单地返回 HTTP 响应。

握手验证要点

服务器在处理握手请求时应验证:

  1. 请求方法是 GET
  2. HTTP 版本至少是 1.1
  3. 包含 Upgrade: websocket 头部
  4. 包含 Connection: Upgrade 头部
  5. Sec-WebSocket-Key 头部存在且是有效的 Base64 值
  6. Sec-WebSocket-Version 是 13

数据帧格式

握手完成后,客户端和服务器之间的通信使用 WebSocket 数据帧格式。理解帧格式有助于调试和优化 WebSocket 应用。

基本帧结构

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 ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+

帧字段详解

FIN(1 bit) 表示这是消息的最后一个帧。如果消息被分成多个帧,只有最后一帧的 FIN 为 1。

RSV1, RSV2, RSV3(各 1 bit) 保留位,用于扩展协议。如果协商了扩展,这些位可能有特殊含义。没有扩展时必须为 0。

Opcode(4 bits) 操作码,定义帧的类型:

Opcode含义
0x0继续帧,表示这是分段消息的一部分
0x1文本帧,负载数据是 UTF-8 文本
0x2二进制帧,负载数据是二进制数据
0x8关闭帧,用于关闭连接
0x9Ping 帧,用于心跳检测
0xAPong 帧,响应 Ping
0x3-0x7保留给非控制帧
0xB-0xF保留给控制帧

MASK(1 bit) 表示负载数据是否经过掩码处理。客户端发送给服务器的帧必须掩码,服务器发送给客户端的帧不掩码。

Payload length(7 bits 或更多) 负载长度,有三种情况:

  • 0-125:负载长度就是该值
  • 126:接下来的 2 字节表示负载长度(16 位无符号整数)
  • 127:接下来的 8 字节表示负载长度(64 位无符号整数)

Masking-key(0 或 4 bytes) 如果 MASK 为 1,则包含 4 字节的掩码密钥,用于解码负载数据。

Payload Data 实际的数据内容。

掩码算法

客户端发送数据时必须使用掩码处理,算法如下:

def mask_data(data, masking_key):
"""使用掩码密钥处理数据"""
result = bytearray(len(data))
for i in range(len(data)):
result[i] = data[i] ^ masking_key[i % 4]
return bytes(result)

masking_key = bytes([0x12, 0x34, 0x56, 0x78])
original = b"Hello"
masked = mask_data(original, masking_key)
unmasked = mask_data(masked, masking_key)
print(f"原始数据: {original}")
print(f"掩码后: {masked}")
print(f"解掩码: {unmasked}")

掩码是对称操作,应用两次就会还原原始数据。这个机制防止恶意网页通过 WebSocket 向服务器发送精心构造的数据包进行缓存投毒攻击。

为什么需要掩码

理解掩码机制的目的对于深入掌握 WebSocket 安全模型至关重要。RFC 6455 规定客户端发送给服务器的帧必须掩码,而服务器发送给客户端的帧不掩码。这种非对称设计有其特定的安全考量。

缓存投毒攻击防护是掩码机制的核心目的。考虑这样一个场景:恶意网页通过 WebSocket 连接到一个代理服务器,然后发送一段精心构造的数据。如果没有掩码机制,这段数据的字节序列可能恰好符合 HTTP 请求的格式,例如:

GET /innocent-page HTTP/1.1
Host: target-server.com
Cookie: victim-session-id

攻击者的目标是让这段伪造的 HTTP 请求被代理服务器缓存,之后当真正的受害者访问这个 URL 时,代理会返回攻击者注入的恶意内容。这就是著名的缓存投毒攻击。

掩码机制通过强制客户端对所有数据进行异或处理,使得攻击者无法控制发送数据的最终字节序列。由于掩码密钥是随机生成的,攻击者无法预测最终的数据形态,从而从根本上阻止了这类攻击。

掩码对性能的影响相对较小。异或操作是最简单的位运算之一,现代 CPU 可以高效执行。4 字节的掩码密钥循环使用,算法复杂度为 O(n),其中 n 是负载数据长度。

// JavaScript 中的掩码实现示例
function maskData(data, maskingKey) {
const result = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
result[i] = data[i] ^ maskingKey[i % 4];
}
return result;
}

// 浏览器 WebSocket API 自动处理掩码
// 开发者无需手动实现
const socket = new WebSocket('wss://example.com');
socket.send('这条消息会被浏览器自动掩码处理');

服务器端的处理则需要先解除掩码再处理数据。成熟的 WebSocket 库都会自动处理这个过程,开发者通常不需要关心细节。

// Node.js ws 库自动处理解掩码
// 收到的消息已经是解掩码后的原始数据
ws.on('message', (data) => {
// data 已经是解掩码后的原始数据
console.log('收到消息:', data.toString());
});

为什么服务器发送的数据不需要掩码? 这是一个常见的疑问。原因在于服务器通常是可信的,不会主动发起缓存投毒攻击。更重要的是,如果服务器发送的数据也需要掩码,那么浏览器在接收数据时需要先解掩码才能处理,这会增加客户端的复杂度和处理延迟。而在服务器端处理解掩码更加高效,因为服务器通常有更强的计算能力。

消息分段

当消息很大时,可以将其分成多个帧发送。分段规则:

  1. 第一个帧的 FIN 为 0,opcode 表示消息类型(文本或二进制)
  2. 后续帧的 FIN 为 0,opcode 为 0(继续帧)
  3. 最后一个帧的 FIN 为 1,opcode 为 0
帧1: FIN=0, opcode=1 (文本开始)
帧2: FIN=0, opcode=0 (继续)
帧3: FIN=1, opcode=0 (结束)

控制帧(Ping、Pong、Close)可以插入在消息分段之间,不会被当作消息的一部分。

为什么需要消息分段? 消息分段的设计主要解决两个实际问题。首先是内存效率:发送方无需一次性将整个大消息加载到内存中,可以边生成边发送,这对于流式数据处理场景尤其重要。其次是公平性:在大消息传输期间,可以插入控制帧(如心跳),避免长消息阻塞其他通信,保证连接的活跃状态检测不会被延迟。

分段的工作流程:假设客户端要发送一条很长的文本消息"and a happy new year!",可以将其分成多个帧:

客户端: FIN=0, opcode=0x1, msg="and a"
服务端: (等待,收到新文本消息的开始部分)
客户端: FIN=0, opcode=0x0, msg="happy new"
服务端: (等待,将负载追加到之前收到的部分)
客户端: FIN=1, opcode=0x0, msg="year!"
服务端: (处理完整消息 "and a happy new year!")

注意第一个帧使用 opcode=0x1 表示这是文本消息的开始,后续帧都使用 opcode=0x0 表示继续帧,只有最后一帧的 FIN=1 标记消息结束。

消息分段的设计目的主要有两个方面。首先是内存效率:发送方无需一次性将整个大消息加载到内存中,可以边生成边发送。其次是公平性:在大消息传输期间,可以插入控制帧(如心跳),避免长消息阻塞其他通信。

实际应用场景中,消息分段常用于传输大型文件或实时视频流。例如,当传输一个 10MB 的文件时,可以将其分成多个较小的帧,每个帧 64KB。这样即使传输过程中连接中断,接收方也可能已经处理了部分数据,而不是全部丢失。

浏览器自动处理分段,开发者通常不需要手动关注。浏览器会根据需要自动对发送的消息进行分段,也会自动将接收到的分帧重组为完整的消息。

// 发送大消息时,浏览器可能自动分段
// 接收时,onmessage 收到的是完整消息
const largeData = new ArrayBuffer(10 * 1024 * 1024); // 10MB
socket.send(largeData);

// 接收方无需关心分段细节
socket.onmessage = (event) => {
// event.data 是完整的 10MB 数据
console.log('收到完整数据:', event.data.byteLength);
};

服务端处理分段时,大多数 WebSocket 库会自动重组消息。但在某些需要流式处理的场景下,可以选择逐帧处理。

// Node.js ws 库示例
// 默认情况下,ws 库会自动重组消息
// 如果需要流式处理,可以使用 stream 模式

连接状态

WebSocket 连接在生命周期中会经历不同的状态。

状态定义

WebSocket 协议定义了以下状态:

CONNECTING(0) 连接正在建立中,尚未完成握手。

OPEN(1) 连接已建立,可以进行通信。

CLOSING(2) 连接正在关闭中,正在执行关闭握手。

CLOSED(3) 连接已关闭,无法再进行通信。

状态转换

JavaScript 中的状态检查

const socket = new WebSocket('ws://localhost:8080');

console.log('初始状态:', socket.readyState);

socket.onopen = () => {
console.log('连接状态:', socket.readyState);
};

socket.onclose = () => {
console.log('关闭后状态:', socket.readyState);
};

关闭流程

WebSocket 连接的关闭需要遵循特定的握手流程,确保双方都能正确处理未完成的消息。

正常关闭流程

正常关闭需要双方交换关闭帧:

  1. 一方发送关闭帧(opcode 0x8),可以携带关闭码和原因
  2. 另一方收到关闭帧后,发送自己的关闭帧作为响应
  3. 发送方收到响应后关闭 TCP 连接
const socket = new WebSocket('ws://localhost:8080');

socket.onclose = (event) => {
console.log('关闭码:', event.code);
console.log('关闭原因:', event.reason);
console.log('是否正常关闭:', event.wasClean);
};

socket.close(1000, '正常关闭');

关闭码

WebSocket 定义了标准的关闭码:

代码名称含义
1000Normal Closure正常关闭
1001Going Away端点离开(如页面关闭)
1002Protocol Error协议错误
1003Unsupported Data收到不支持的数据类型
1005No Status Received没有收到状态码(保留,不应发送)
1006Abnormal Closure连接异常关闭(保留,不应发送)
1007Invalid Frame Payload Data负载数据无效
1008Policy Violation违反策略
1009Message Too Big消息过大
1010Mandatory Ext缺少必要的扩展
1011Internal Error服务器内部错误
1012Service Restart服务重启
1013Try Again Later稍后重试
1014Bad Gateway网关错误
1015TLS HandshakeTLS 握手失败(保留)

关闭码范围规范

根据 RFC 6455 第 7.4.2 节定义,关闭码分为以下范围:

范围用途说明
0-999保留不使用
1000-2999协议定义由 WebSocket 协议或未来扩展定义
3000-3999库/框架由 WebSocket 库、框架注册使用
4000-4999应用自定义由应用程序自行定义

重要规范要点

  1. 保留码不可发送:1005(No Status Rcvd)、1006(Abnormal Closure)、1015(TLS Handshake)这三个码仅由浏览器/服务器在特定情况下设置,应用程序不应主动发送。

  2. 关闭帧格式:关闭帧的负载数据前 2 字节为关闭码(网络字节序),后续为 UTF-8 编码的关闭原因,最大 123 字节。

  3. 无状态码关闭:如果应用程序调用 close() 时不传参数,默认使用 1000。

// 正确的自定义关闭码使用
socket.close(4000, '用户主动登出'); // 应用自定义
socket.close(3000, '框架内部错误'); // 框架定义

// 错误的使用
socket.close(1006, '...'); // 保留码,不应主动发送
socket.close(200, '...'); // 无效范围

异常关闭

当连接异常断开时(如网络中断),无法完成正常的关闭握手。这种情况下:

  • 关闭码为 1006
  • wasClean 属性为 false
  • 可能没有关闭原因
socket.onclose = (event) => {
if (event.code === 1006) {
console.log('连接异常断开');
}
};

心跳机制

WebSocket 协议提供了 Ping/Pong 帧用于保持连接活跃和检测连接状态。心跳机制对于维护长连接的稳定性至关重要,特别是在存在代理服务器、负载均衡器或 NAT 设备的网络环境中。

为什么需要心跳

在典型的网络部署中,WebSocket 连接会经过多个中间层,这些中间层通常会设置连接超时:

客户端 <---> NAT/防火墙 <---> 负载均衡器 <---> 反向代理 <---> WebSocket服务器

每个中间层都可能有自己的空闲连接超时设置(通常为 60-300 秒)。如果没有数据传输,连接可能被中间层主动断开,导致"假连接"现象——客户端认为连接存在,但服务器端已经断开。

心跳的三个核心作用

  1. 保活:定期发送数据,防止中间层因超时关闭连接
  2. 检测:及时发现已经断开的连接,清理资源
  3. 测量:通过心跳往返时间评估网络延迟

Ping 和 Pong 帧

WebSocket 协议层的心跳机制:

  • Ping 帧(opcode 0x9):一方发送 Ping,可以携带最多 125 字节的负载数据
  • Pong 帧(opcode 0xA):收到 Ping 后必须尽快返回 Pong,负载数据与 Ping 相同

协议层面的特点

浏览器会自动响应 Ping 帧,JavaScript API 不直接暴露 Ping/Pong 的控制能力。这意味着:

服务器端可以使用协议层 Ping

// Node.js ws 库
wss.on('connection', (ws) => {
// 定期发送 Ping
ws.ping();

// 监听 Pong 响应
ws.on('pong', () => {
ws.isAlive = true;
});
});

客户端无法使用协议层 Ping,需要应用层心跳:

// 浏览器端必须使用应用层心跳
const socket = new WebSocket('wss://example.com');

// 发送心跳
function sendHeartbeat() {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'ping' }));
}
}

// 响应心跳
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'ping') {
socket.send(JSON.stringify({ type: 'pong' }));
}
};

协议层 vs 应用层心跳对比

特性协议层 Ping/Pong应用层心跳
客户端发起不支持(浏览器限制)支持
服务器发起支持支持
浏览器自动响应是(Pong)否,需手动处理
携带数据最多 125 字节任意大小
跨代理兼容可能被过滤通常不受影响
调试可见性需要抓包应用日志可见

实践建议:对于大多数应用,推荐使用应用层心跳,因为它更灵活且跨代理兼容性更好。

自定义心跳

由于浏览器 API 不直接支持 Ping/Pong,通常在应用层实现心跳:

class WebSocketWithHeartbeat {
constructor(url) {
this.url = url;
this.socket = null;
this.heartbeatInterval = null;
this.missedHeartbeats = 0;
this.maxMissedHeartbeats = 3;
this.connect();
}

connect() {
this.socket = new WebSocket(this.url);

this.socket.onopen = () => {
this.startHeartbeat();
};

this.socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'pong') {
this.missedHeartbeats = 0;
} else {
this.onMessage(data);
}
};

this.socket.onclose = () => {
this.stopHeartbeat();
setTimeout(() => this.connect(), 3000);
};
}

startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.socket.readyState === WebSocket.OPEN) {
this.missedHeartbeats++;
if (this.missedHeartbeats > this.maxMissedHeartbeats) {
this.socket.close();
return;
}
this.socket.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
}

stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}

onMessage(data) {
console.log('收到消息:', data);
}
}

子协议

WebSocket 允许客户端和服务器协商使用特定的子协议,子协议定义了消息的格式和语义。

协商过程

客户端在握手时声明支持的子协议:

const socket = new WebSocket('ws://localhost:8080', ['chat', 'json']);

服务器选择一个返回:

Sec-WebSocket-Protocol: chat

检查选定的协议

const socket = new WebSocket('ws://localhost:8080', ['chat', 'json']);

socket.onopen = () => {
console.log('选定的协议:', socket.protocol);
};

如果服务器不支持任何客户端声明的协议,连接会失败。

常见子协议

标准子协议

子协议用途说明
jsonJSON 消息格式简单的 JSON 序列化
protobufProtocol BuffersGoogle 的高效二进制序列化
mqttMQTT over WebSocket物联网消息协议
stompSTOMP 协议简单文本消息协议
wampWAMP 协议WebSocket 应用消息协议

实际应用示例

// 使用 STOMP 子协议(需要客户端库支持)
const socket = new WebSocket('ws://localhost:8080/stomp', 'v12.stomp');

socket.onopen = () => {
// 发送 STOMP CONNECT 帧
socket.send('CONNECT\naccept-version:1.2\n\n\x00');
};

socket.onmessage = (event) => {
// 解析 STOMP 帧
const frame = parseStompFrame(event.data);
console.log('收到 STOMP 消息:', frame);
};

子协议命名规范

根据 RFC 6455,子协议名称应该遵循以下规范:

  1. 反向域名格式:使用组织域名的反向形式,如 chat.example.com
  2. 版本化命名:不兼容更新应使用新名称,如 v2.chat.example.com
  3. 注册到 IANA:公开的子协议应注册到 IANA WebSocket 子协议注册表
// 推荐的子协议命名方式
const socket = new WebSocket('ws://api.example.com/ws', [
'v1.chat.example.com',
'v2.chat.example.com'
]);

子协议的作用

子协议主要解决以下问题:

消息格式约定:客户端和服务器约定使用 JSON、Protobuf 或其他格式,避免格式歧义。

语义约定:定义消息的类型、结构和处理规则。例如,STOMP 定义了 CONNECT、SUBSCRIBE、SEND 等操作。

版本兼容:通过不同的子协议名称支持多版本 API。

// 服务端根据子协议选择不同的处理器
function handleConnection(ws, request) {
const protocol = ws.protocol;

switch (protocol) {
case 'v1.chat.example.com':
handleV1Protocol(ws);
break;
case 'v2.chat.example.com':
handleV2Protocol(ws);
break;
default:
// 如果没有协商子协议,使用默认处理
handleDefault(ws);
}
}

扩展

WebSocket 协议支持扩展,用于添加新功能。

常见扩展

permessage-deflate 消息压缩扩展,可以显著减少传输数据量:

Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

服务器响应:

Sec-WebSocket-Extensions: permessage-deflate

Node.js 中启用压缩

const WebSocket = require('ws');

const wss = new WebSocket.Server({
port: 8080,
perMessageDeflate: {
zlibDeflateOptions: {
level: 3
},
zlibInflateOptions: {
chunkSize: 10 * 1024
},
clientNoContextTakeover: true,
serverNoContextTakeover: true,
clientMaxWindowBits: 10,
serverMaxWindowBits: 10
}
});

安全考虑

使用 WSS

在生产环境中,应该始终使用加密的 WebSocket 连接(wss://),而不是明文连接(ws://)。

WSS 提供以下保护:

  • 数据加密,防止窃听
  • 服务器身份验证,防止中间人攻击
  • 防止缓存投毒攻击

Origin 验证

服务器应该验证请求的 Origin 头部,防止跨站 WebSocket 劫持攻击:

const WebSocket = require('ws');

const wss = new WebSocket.Server({
port: 8080,
verifyClient: (info, callback) => {
const origin = info.origin;
const allowedOrigins = ['https://example.com', 'https://app.example.com'];

if (allowedOrigins.includes(origin)) {
callback(true);
} else {
callback(false, 403, 'Forbidden');
}
}
});

认证

WebSocket 握手时可以携带认证信息:

通过 URL 参数

const token = 'user-auth-token';
const socket = new WebSocket(`wss://example.com/chat?token=${token}`);

通过 HTTP 头部(需要自定义实现):

浏览器 WebSocket API 不支持自定义头部,但可以通过 Cookie 或 URL 参数传递认证信息。

小结

本章详细讲解了 WebSocket 协议的核心机制:

  1. 握手过程:基于 HTTP 的协议升级,通过 Sec-WebSocket-Key 和 Accept 验证
  2. 数据帧格式:理解帧结构、操作码、掩码算法和消息分段
  3. 连接状态:从 CONNECTING 到 CLOSED 的状态转换
  4. 关闭流程:正常关闭握手和关闭码的含义
  5. 心跳机制:Ping/Pong 帧和应用层心跳实现
  6. 子协议和扩展:协议协商和压缩扩展
  7. 安全考虑:WSS 加密、Origin 验证和认证机制

理解这些底层原理,有助于更好地调试 WebSocket 应用,排查连接问题,以及进行性能优化。