WebSocket 协议详解 (RFC 6455)
目录
- 概述
- 协议背景
- 协议概述
- [WebSocket URI](#WebSocket URI)
- 握手过程
- 数据帧格式
- 控制帧
- [Close 帧](#Close 帧)
- [Ping 和 Pong 帧](#Ping 和 Pong 帧)
- 数据帧
- 发送和接收数据
- 关闭连接
- 扩展机制
- 子协议
- 安全考虑
- 实际应用示例
- 总结
概述
WebSocket 协议(RFC 6455)是一种在单个 TCP 连接上进行全双工通信的协议。它提供了一种机制,使浏览器中的应用程序能够与远程服务器进行双向通信,而无需打开多个 HTTP 连接。
核心特点
- 全双工通信:客户端和服务器可以同时发送和接收数据
- 基于 TCP:建立在可靠的 TCP 连接之上
- HTTP 兼容:握手过程兼容 HTTP,可以共享端口
- 低开销:相比 HTTP 轮询,显著减少协议开销
- 实时性:支持实时双向数据传输
协议版本
- 当前标准版本:13(RFC 6455)
- 协议标识 :
Sec-WebSocket-Version: 13
协议背景
历史问题
在 WebSocket 出现之前,实现双向通信需要:
- HTTP 轮询:客户端定期向服务器发送请求
- 长轮询:客户端发送请求,服务器保持连接直到有数据
- HTTP 流:服务器保持连接打开,持续发送数据
这些方法的问题:
- 服务器被迫为每个客户端使用多个 TCP 连接
- 协议开销高,每个消息都包含完整的 HTTP 头
- 客户端脚本需要维护从出站连接到入站连接的映射
- 延迟高,无法实现真正的实时通信
WebSocket 的解决方案
WebSocket 协议通过以下方式解决这些问题:
- 单一 TCP 连接:双向通信使用同一个连接
- 低开销:帧格式最小化协议开销
- 实时性:支持真正的实时双向通信
协议概述
WebSocket 协议由两个主要部分组成:
- 握手(Handshake):建立连接的 HTTP 升级请求
- 数据传输(Data Transfer):基于帧的双向数据传输
握手示例
客户端请求:
http
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
服务器响应:
http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
数据传输
握手成功后,数据以**帧(Frame)**的形式传输。每条消息可以由一个或多个帧组成。
WebSocket URI
WebSocket 使用两种 URI 方案:
ws URI
ws-URI = "ws:" "//" host [ ":" port ] path [ "?" query ]
- 默认端口:80
- 示例 :
ws://example.com/chat
wss URI
wss-URI = "wss:" "//" host [ ":" port ] path [ "?" query ]
- 默认端口:443
- 加密连接:使用 TLS 加密
- 示例 :
wss://example.com/chat
URI 组件
- host:服务器主机名或 IP 地址
- port:端口号(可选,有默认值)
- path:资源路径
- query:查询参数(可选)
注意 :片段标识符(#)在 WebSocket URI 中无意义,不应使用。
握手过程
客户端握手
客户端发送一个 HTTP 升级请求,包含以下必需字段:
必需字段
-
请求行:
GET /resource HTTP/1.1 -
Host 头:
Host: server.example.com -
Upgrade 头:
Upgrade: websocket -
Connection 头:
Connection: Upgrade -
Sec-WebSocket-Key:
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==- 16 字节随机值,base64 编码
- 每次连接必须使用新的随机值
-
Sec-WebSocket-Version:
Sec-WebSocket-Version: 13- 当前标准版本为 13
可选字段
-
Origin:
Origin: http://example.com- 浏览器客户端必须包含
- 用于服务器验证请求来源
-
Sec-WebSocket-Protocol:
Sec-WebSocket-Protocol: chat, superchat- 客户端希望使用的子协议列表
- 多个协议用逗号分隔
-
Sec-WebSocket-Extensions:
Sec-WebSocket-Extensions: extension1, extension2- 客户端支持的扩展列表
服务器握手
服务器必须验证客户端握手,并返回相应的响应:
成功响应(101 Switching Protocols)
http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
必需字段:
-
状态码 :必须是
101 Switching Protocols -
Upgrade 头:
Upgrade: websocket -
Connection 头:
Connection: Upgrade -
Sec-WebSocket-Accept:
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=- 计算方式:
base64(sha1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) - 用于验证服务器理解 WebSocket 协议
- 计算方式:
可选字段:
-
Sec-WebSocket-Protocol:
Sec-WebSocket-Protocol: chat- 服务器选择的子协议(必须来自客户端请求的列表)
-
Sec-WebSocket-Extensions:
Sec-WebSocket-Extensions: extension1- 服务器选择的扩展(必须来自客户端请求的列表)
错误响应
如果握手失败,服务器返回相应的 HTTP 错误码:
- 400 Bad Request:握手格式错误
- 403 Forbidden:服务器拒绝连接(如 Origin 验证失败)
- 404 Not Found:请求的资源不存在
- 426 Upgrade Required:需要升级协议版本
握手验证
Sec-WebSocket-Accept 计算
服务器必须正确计算 Sec-WebSocket-Accept 值:
算法:
- 获取客户端发送的
Sec-WebSocket-Key值 - 将固定字符串
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"追加到该值 - 对结果进行 SHA-1 哈希
- 将哈希值进行 base64 编码
示例:
javascript
// 客户端发送
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
// 服务器计算
const key = "dGhlIHNhbXBsZSBub25jZQ==";
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const accept = base64(sha1(key + magic));
// 结果: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
代码实现(Node.js):
javascript
const crypto = require('crypto');
function generateAccept(key) {
const magic = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const hash = crypto.createHash('sha1')
.update(key + magic)
.digest('base64');
return hash;
}
数据帧格式
帧结构
WebSocket 数据以帧(Frame)的形式传输,帧结构如下:
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)
- 0:还有后续帧(消息未结束)
- 1:这是消息的最后一个帧
RSV1, RSV2, RSV3 (各 1 bit)
- 保留字段,必须为 0
- 除非协商了扩展,否则不能为非零值
- 扩展可以使用这些位
Opcode (4 bits)
定义帧的类型:
| Opcode | 类型 | 说明 |
|---|---|---|
| 0x0 | Continuation | 延续帧(分片消息的后续帧) |
| 0x1 | Text | 文本帧(UTF-8 编码) |
| 0x2 | Binary | 二进制帧 |
| 0x3-0x7 | - | 保留用于非控制帧 |
| 0x8 | Close | 连接关闭 |
| 0x9 | Ping | Ping 帧 |
| 0xA | Pong | Pong 帧 |
| 0xB-0xF | - | 保留用于控制帧 |
MASK (1 bit)
- 0:帧未掩码(服务器到客户端)
- 1:帧已掩码(客户端到服务器)
重要:客户端发送的所有帧必须设置 MASK=1,服务器发送的帧必须设置 MASK=0。
Payload Length (7 bits, 7+16 bits, 或 7+64 bits)
载荷长度编码:
- 0-125:直接表示载荷长度(字节)
- 126:后续 2 字节(16 位无符号整数)表示长度
- 127:后续 8 字节(64 位无符号整数)表示长度
注意:必须使用最小字节数编码长度。
Masking-key (0 或 4 bytes)
- 如果 MASK=1,包含 4 字节掩码键
- 如果 MASK=0,不包含此字段
Payload Data (可变长度)
实际数据载荷,包括:
- Extension data:扩展数据(如果协商了扩展)
- Application data:应用数据
客户端到服务器的掩码
掩码的目的
防止恶意脚本构造可能被中间代理误解为 HTTP 请求的数据,从而防止缓存投毒攻击。
掩码算法
客户端必须为每个帧选择一个新的随机 32 位掩码键。
掩码/解掩码算法:
j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j
代码实现:
javascript
function mask(data, maskingKey) {
const masked = Buffer.alloc(data.length);
for (let i = 0; i < data.length; i++) {
const j = i % 4;
masked[i] = data[i] ^ maskingKey[j];
}
return masked;
}
// 注意:掩码和解掩码使用相同的算法
掩码键生成
掩码键必须:
- 从强熵源生成
- 每个帧使用新的随机值
- 不可预测
示例(Node.js):
javascript
const crypto = require('crypto');
function generateMaskingKey() {
return crypto.randomBytes(4);
}
帧分片
分片的目的
- 发送未知大小的消息:可以在不知道总长度的情况下开始发送
- 多路复用:允许在输出通道上交错发送多个消息
分片规则
-
未分片消息:单个帧,FIN=1,opcode ≠ 0
-
分片消息:
- 第一帧:FIN=0,opcode ≠ 0(表示消息类型)
- 中间帧:FIN=0,opcode=0(延续帧)
- 最后帧:FIN=1,opcode=0(延续帧)
-
控制帧:
- 不能分片
- 可以在分片消息中间插入
-
消息顺序:
- 同一消息的帧必须按顺序发送
- 不同消息的帧不能交错(除非扩展支持)
分片示例
文本消息 "Hello World" 分片:
帧1: FIN=0, Opcode=0x1, Payload="Hello"
帧2: FIN=1, Opcode=0x0, Payload=" World"
二进制消息分片:
帧1: FIN=0, Opcode=0x2, Payload=[前4KB数据]
帧2: FIN=0, Opcode=0x0, Payload=[中间4KB数据]
帧3: FIN=1, Opcode=0x0, Payload=[最后2KB数据]
控制帧
控制帧用于协议级别的信令,不能包含应用数据。
控制帧特点
- Opcode 最高位为 1:0x8-0xF
- 载荷长度限制:≤ 125 字节
- 不能分片
Close 帧
Opcode: 0x8
用途:关闭 WebSocket 连接
载荷格式:
- 前 2 字节:状态码(网络字节序)
- 后续字节:UTF-8 编码的关闭原因(可选)
关闭握手流程:
- 一端发送 Close 帧
- 另一端收到后,发送 Close 帧响应
- 发送方收到响应后,关闭 TCP 连接
状态码示例:
1000:正常关闭1001:端点离开(如服务器关闭)1002:协议错误1003:不支持的数据类型1006:异常关闭(未收到 Close 帧)1007:数据格式错误(非 UTF-8)1008:策略违反1009:消息过大1010:扩展协商失败1011:服务器内部错误
Ping 和 Pong 帧
Ping 帧
Opcode: 0x9
用途:
- 保持连接活跃
- 检测远程端点是否响应
载荷:可选的应用程序数据
Pong 帧
Opcode: 0xA
用途:响应 Ping 帧
规则:
- 收到 Ping 后必须发送 Pong
- Pong 的载荷必须与对应的 Ping 相同
- 可以发送未请求的 Pong(作为单向心跳)
示例:
javascript
// 服务器发送 Ping
ws.send(pingFrame);
// 客户端自动响应 Pong
// 或手动响应
ws.pong(pingFrame.payload);
数据帧
数据帧用于传输应用数据。
文本帧 (Opcode 0x1)
- 载荷:UTF-8 编码的文本数据
- 验证:整个消息必须是有效的 UTF-8
- 处理:接收端必须验证 UTF-8 有效性
二进制帧 (Opcode 0x2)
- 载荷:任意二进制数据
- 解释:由应用程序决定
数据帧示例
单帧文本消息:
FIN=1, Opcode=0x1, Payload="Hello"
单帧二进制消息:
FIN=1, Opcode=0x2, Payload=[0x48, 0x65, 0x6c, 0x6c, 0x6f]
发送和接收数据
发送数据
步骤:
- 确保连接处于 OPEN 状态
- 将数据封装为 WebSocket 帧
- 设置适当的 opcode(文本或二进制)
- 设置 FIN 位(最后帧为 1)
- 如果是客户端,对帧进行掩码
- 通过底层网络连接传输
代码示例(伪代码):
javascript
function sendMessage(data, isText) {
if (connectionState !== OPEN) {
throw new Error('Connection not open');
}
const opcode = isText ? 0x1 : 0x2;
const frame = createFrame({
fin: 1,
opcode: opcode,
payload: data,
masked: isClient
});
sendToNetwork(frame);
}
接收数据
步骤:
- 从网络接收数据
- 解析 WebSocket 帧
- 如果是控制帧,立即处理
- 如果是数据帧:
- 检查 opcode 确定数据类型
- 累积载荷数据
- 如果 FIN=1,组装完整消息并传递给应用
代码示例(伪代码):
javascript
function receiveData(buffer) {
const frame = parseFrame(buffer);
if (isControlFrame(frame.opcode)) {
handleControlFrame(frame);
} else {
// 数据帧
messageBuffer.append(frame.payload);
if (frame.fin) {
// 消息完整
const message = messageBuffer.toString();
messageBuffer.clear();
onMessage(message, frame.opcode === 0x1);
}
}
}
关闭连接
正常关闭
关闭握手流程:
- 发起关闭:发送 Close 帧
- 等待响应:等待对方发送 Close 帧
- 关闭 TCP:关闭底层 TCP 连接
服务器关闭:
- 服务器应该立即关闭 TCP 连接
- 服务器持有 TIME_WAIT 状态
客户端关闭:
- 客户端应该等待服务器关闭 TCP 连接
- 如果超时,客户端可以主动关闭
异常关闭
情况:
- 底层 TCP 连接意外断开
- 协议错误导致连接失败
- 握手失败
恢复:
- 客户端应该使用退避策略重连
- 首次重连延迟:随机 0-5 秒
- 后续重连:使用指数退避
状态码
定义的状态码
| 状态码 | 含义 | 说明 |
|---|---|---|
| 1000 | Normal Closure | 正常关闭 |
| 1001 | Going Away | 端点离开(如服务器关闭、浏览器导航) |
| 1002 | Protocol Error | 协议错误 |
| 1003 | Unsupported Data | 不支持的数据类型 |
| 1004 | Reserved | 保留 |
| 1005 | No Status Rcvd | 未收到状态码(不能用于 Close 帧) |
| 1006 | Abnormal Closure | 异常关闭(不能用于 Close 帧) |
| 1007 | Invalid frame payload data | 无效的帧载荷数据 |
| 1008 | Policy Violation | 策略违反 |
| 1009 | Message Too Big | 消息过大 |
| 1010 | Mandatory Extension | 必需的扩展未协商 |
| 1011 | Internal Server Error | 服务器内部错误 |
| 1015 | TLS handshake | TLS 握手失败(不能用于 Close 帧) |
状态码范围
- 0-999:未使用
- 1000-2999:协议定义(需要标准或扩展规范)
- 3000-3999:库/框架/应用使用(可注册)
- 4000-4999:私有使用(无需注册)
扩展机制
扩展协商
扩展在握手时通过 Sec-WebSocket-Extensions 头协商。
客户端请求:
Sec-WebSocket-Extensions: extension1; param1=value1, extension2
服务器响应:
Sec-WebSocket-Extensions: extension1; param1=value1
扩展格式
extension = extension-token *( ";" extension-param )
extension-param = token [ "=" (token | quoted-string) ]
示例:
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits=15
已知扩展
- permessage-deflate:压缩扩展(RFC 7692)
- 其他扩展需要注册
子协议
子协议协商
子协议在握手时通过 Sec-WebSocket-Protocol 头协商。
客户端请求:
Sec-WebSocket-Protocol: chat, superchat
服务器响应:
Sec-WebSocket-Protocol: chat
子协议命名
建议使用域名格式避免冲突:
chat.example.comv2.bookings.example.net
安全考虑
Origin 验证
服务器应该验证 Origin 头,拒绝来自未授权源的连接:
javascript
const allowedOrigins = ['https://example.com', 'https://app.example.com'];
function validateOrigin(origin) {
return allowedOrigins.includes(origin);
}
数据掩码
- 客户端必须掩码所有发送的帧
- 防止缓存投毒攻击
- 掩码键必须随机且不可预测
TLS 使用
- 生产环境应该使用
wss://(TLS) - 提供连接机密性和完整性
- 使用强 TLS 算法
输入验证
- 服务器必须验证所有输入
- 防止注入攻击
- 验证 UTF-8 编码
实现限制
- 限制帧大小
- 限制消息大小
- 防止内存耗尽攻击
实际应用示例
JavaScript 客户端示例
javascript
// 创建 WebSocket 连接
const ws = new WebSocket('wss://example.com/chat');
// 连接打开
ws.onopen = () => {
console.log('WebSocket 连接已建立');
ws.send('Hello Server!');
};
// 接收消息
ws.onmessage = (event) => {
console.log('收到消息:', event.data);
};
// 错误处理
ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
};
// 连接关闭
ws.onclose = (event) => {
console.log('连接关闭:', event.code, event.reason);
};
Node.js 服务器示例
javascript
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws, req) => {
// 验证 Origin
const origin = req.headers.origin;
if (!isAllowedOrigin(origin)) {
ws.close(1008, 'Origin not allowed');
return;
}
console.log('新客户端连接');
// 接收消息
ws.on('message', (message) => {
console.log('收到消息:', message);
// 广播给所有客户端
wss.clients.forEach((client) => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
});
// 连接关闭
ws.on('close', (code, reason) => {
console.log('客户端断开:', code, reason);
});
// 发送欢迎消息
ws.send('欢迎连接到服务器!');
});
Python 服务器示例
python
import asyncio
import websockets
async def handle_client(websocket, path):
# 验证 Origin
origin = websocket.request_headers.get('Origin')
if not is_allowed_origin(origin):
await websocket.close(code=1008, reason='Origin not allowed')
return
print(f'新客户端连接: {websocket.remote_address}')
try:
# 发送欢迎消息
await websocket.send('欢迎连接到服务器!')
# 接收消息
async for message in websocket:
print(f'收到消息: {message}')
# 回显消息
await websocket.send(f'服务器收到: {message}')
except websockets.exceptions.ConnectionClosed:
print('客户端断开连接')
async def main():
async with websockets.serve(handle_client, 'localhost', 8765):
await asyncio.Future() # 永远运行
asyncio.run(main())
手动实现握手(Node.js)
javascript
const http = require('http');
const crypto = require('crypto');
function generateAccept(key) {
const magic = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
return crypto.createHash('sha1')
.update(key + magic)
.digest('base64');
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.headers.upgrade === 'websocket') {
const key = req.headers['sec-websocket-key'];
const accept = generateAccept(key);
res.writeHead(101, {
'Upgrade': 'websocket',
'Connection': 'Upgrade',
'Sec-WebSocket-Accept': accept
});
// 升级到 WebSocket 连接
// 后续需要处理 WebSocket 帧
} else {
res.writeHead(400);
res.end('Not a WebSocket request');
}
});
server.listen(8080);
总结
核心要点
-
握手过程:
- 基于 HTTP 升级请求
- 使用
Sec-WebSocket-Key和Sec-WebSocket-Accept验证 - 支持子协议和扩展协商
-
数据帧:
- 最小化协议开销
- 支持文本和二进制数据
- 支持消息分片
-
安全机制:
- Origin 验证
- 客户端数据掩码
- TLS 支持
-
控制帧:
- Close:正常关闭连接
- Ping/Pong:保持连接活跃
协议优势
- ✅ 低延迟:真正的双向实时通信
- ✅ 低开销:相比 HTTP 轮询显著减少开销
- ✅ 简单:易于实现和使用
- ✅ 兼容性:可以与 HTTP 共享端口
应用场景
- 实时聊天应用
- 在线游戏
- 实时数据推送
- 协作编辑
- 股票行情
- 监控仪表板
浏览器支持
- Chrome 4+
- Firefox 4+
- Safari 5+
- Edge 12+
- Opera 10.6+