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 响应。
握手验证要点
服务器在处理握手请求时应验证:
- 请求方法是 GET
- HTTP 版本至少是 1.1
- 包含 Upgrade: websocket 头部
- 包含 Connection: Upgrade 头部
- Sec-WebSocket-Key 头部存在且是有效的 Base64 值
- 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 | 关闭帧,用于关闭连接 |
| 0x9 | Ping 帧,用于心跳检测 |
| 0xA | Pong 帧,响应 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 向服务器发送精心构造的数据包进行缓存投毒攻击。
消息分段
当消息很大时,可以将其分成多个帧发送。分段规则:
- 第一个帧的 FIN 为 0,opcode 表示消息类型(文本或二进制)
- 后续帧的 FIN 为 0,opcode 为 0(继续帧)
- 最后一个帧的 FIN 为 1,opcode 为 0
帧1: FIN=0, opcode=1 (文本开始)
帧2: FIN=0, opcode=0 (继续)
帧3: FIN=1, opcode=0 (结束)
控制帧(Ping、Pong、Close)可以插入在消息分段之间,不会被当作消息的一部分。
连接状态
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 连接的关闭需要遵循特定的握手流程,确保双方都能正确处理未完成的消息。
正常关闭流程
正常关闭需要双方交换关闭帧:
- 一方发送关闭帧(opcode 0x8),可以携带关闭码和原因
- 另一方收到关闭帧后,发送自己的关闭帧作为响应
- 发送方收到响应后关闭 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 定义了标准的关闭码:
| 代码 | 名称 | 含义 |
|---|---|---|
| 1000 | Normal Closure | 正常关闭 |
| 1001 | Going Away | 端点离开(如页面关闭) |
| 1002 | Protocol Error | 协议错误 |
| 1003 | Unsupported Data | 收到不支持的数据类型 |
| 1005 | No Status Received | 没有收到状态码(保留,不应发送) |
| 1006 | Abnormal Closure | 连接异常关闭(保留,不应发送) |
| 1007 | Invalid Frame Payload Data | 负载数据无效 |
| 1008 | Policy Violation | 违反策略 |
| 1009 | Message Too Big | 消息过大 |
| 1010 | Mandatory Ext | 缺少必要的扩展 |
| 1011 | Internal Error | 服务器内部错误 |
| 1012 | Service Restart | 服务重启 |
| 1013 | Try Again Later | 稍后重试 |
| 1014 | Bad Gateway | 网关错误 |
| 1015 | TLS Handshake | TLS 握手失败(保留) |
自定义关闭码应在 3000-4999 范围内,其中 3000-3999 供库和框架使用,4000-4999 供应用使用。
异常关闭
当连接异常断开时(如网络中断),无法完成正常的关闭握手。这种情况下:
- 关闭码为 1006
wasClean属性为 false- 可能没有关闭原因
socket.onclose = (event) => {
if (event.code === 1006) {
console.log('连接异常断开');
}
};
心跳机制
WebSocket 协议提供了 Ping/Pong 帧用于保持连接活跃和检测连接状态。
Ping 和 Pong 帧
- Ping 帧(opcode 0x9):一方发送 Ping,可以携带负载数据
- Pong 帧(opcode 0xA):收到 Ping 后必须尽快返回 Pong,负载数据与 Ping 相同
浏览器会自动响应 Ping 帧,JavaScript API 不暴露 Ping/Pong 的直接控制。
自定义心跳
由于浏览器 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);
};
如果服务器不支持任何客户端声明的协议,连接会失败。
常见子协议
- json:消息使用 JSON 格式
- protobuf:消息使用 Protocol Buffers
- mqtt:MQTT over WebSocket
- stomp:STOMP 协议
扩展
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 协议的核心机制:
- 握手过程:基于 HTTP 的协议升级,通过 Sec-WebSocket-Key 和 Accept 验证
- 数据帧格式:理解帧结构、操作码、掩码算法和消息分段
- 连接状态:从 CONNECTING 到 CLOSED 的状态转换
- 关闭流程:正常关闭握手和关闭码的含义
- 心跳机制:Ping/Pong 帧和应用层心跳实现
- 子协议和扩展:协议协商和压缩扩展
- 安全考虑:WSS 加密、Origin 验证和认证机制
理解这些底层原理,有助于更好地调试 WebSocket 应用,排查连接问题,以及进行性能优化。