WebSocket 协议
WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议,由 RFC 6455 定义。它解决了 HTTP 无法实现服务器主动推送的问题,成为实时 Web 应用的核心技术。
WebSocket 概述
为什么需要 WebSocket?
在 WebSocket 出现之前,实现实时通信有以下方案:
短轮询:客户端定期发送 HTTP 请求,检查是否有新数据。问题:大量无效请求,浪费带宽和服务器资源。
长轮询:客户端发送请求,服务器保持连接直到有数据或超时。问题:连接开销大,实现复杂。
HTTP 流:服务器保持连接打开,持续发送数据。问题:只能单向通信,无法实现真正的双向通信。
这些方案都基于 HTTP,存在固有的局限性:HTTP 是请求-响应模型,服务器无法主动向客户端发送数据。
WebSocket 的特点
全双工通信:客户端和服务器可以同时发送和接收数据,真正的双向通信。
单一 TCP 连接:一次握手后,持续使用同一个连接,避免重复建立连接的开销。
低开销:数据帧首部最小只有 2 字节,比 HTTP 头部小得多。
实时性:服务器可以主动推送数据,无需客户端轮询。
与 HTTP 兼容:握手阶段使用 HTTP,可以复用端口和基础设施。
WebSocket 与 HTTP 的关系
WebSocket 起源于 HTTP,但独立于 HTTP:
客户端 服务器
| |
| HTTP GET /chat (Upgrade: websocket) |
|--------------------------------------->|
| |
| HTTP 101 Switching Protocols |
|<---------------------------------------|
| |
| WebSocket 数据帧 |
|<-------------------------------------->|
| WebSocket 数据帧 |
|<-------------------------------------->|
握手成功后,协议从 HTTP 切换到 WebSocket,后续通信使用 WebSocket 协议。
WebSocket 握手
客户端请求
WebSocket 握手使用 HTTP GET 请求,包含特殊的头部:
GET /chat HTTP/1.1
Host: 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
关键头部:
Upgrade: websocket:请求协议升级Connection: Upgrade:表示这是协议升级请求Sec-WebSocket-Key:Base64 编码的 16 字节随机值,用于握手验证Sec-WebSocket-Version:WebSocket 版本,当前为 13Origin:请求来源,用于安全验证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-Accept = Base64(SHA1(Sec-WebSocket-Key + GUID))
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
服务器将客户端发送的 Key 与固定 GUID 拼接,进行 SHA-1 哈希后 Base64 编码。客户端验证这个值,确保服务器真正支持 WebSocket。
握手验证示例
import base64
import hashlib
def compute_accept(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)
print(accept) # s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
WebSocket 数据帧
帧格式
WebSocket 数据帧格式:
0 1 2 3
0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-------+-+-------------+-------------------------------+
|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 位):表示是否是消息的最后一个帧。1 表示最后一帧,0 表示还有后续帧。
RSV1-3(各 1 位):保留位,用于扩展。如果协商了扩展,可以非零。
Opcode(4 位):操作码,表示帧类型:
| Opcode | 含义 |
|---|---|
| 0x0 | 继续帧 |
| 0x1 | 文本帧 |
| 0x2 | 二进制帧 |
| 0x3-7 | 保留 |
| 0x8 | 关闭帧 |
| 0x9 | Ping 帧 |
| 0xA | Pong 帧 |
| 0xB-F | 保留 |
MASK(1 位):是否使用掩码。客户端发送的帧必须掩码,服务器发送的帧不掩码。
Payload length(7 位):载荷长度:
- 0-125:实际长度
- 126:后 2 字节为实际长度
- 127:后 8 字节为实际长度
Masking-key(32 位):掩码密钥,仅当 MASK=1 时存在。
Payload Data:实际数据,如果 MASK=1,需要解码。
掩码算法
客户端发送的数据必须掩码:
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])
data = b"Hello"
masked = mask_data(data, masking_key)
unmasked = mask_data(masked, masking_key) # 解码使用相同算法
分片
大消息可以分成多个帧传输:
帧1: FIN=0, Opcode=0x1, 数据="Hel"
帧2: FIN=0, Opcode=0x0, 数据="lo "
帧3: FIN=1, Opcode=0x0, 数据="World"
第一帧使用实际的操作码,后续帧使用继续帧(0x0)。只有最后一帧 FIN=1。
控制帧
Ping 和 Pong
Ping 用于心跳检测,收到 Ping 必须回复 Pong:
客户端 -> 服务器: Ping (Opcode=0x9, 可携带数据)
服务器 -> 客户端: Pong (Opcode=0xA, 相同的数据)
Pong 也可以主动发送,表示仍然存活。
关闭帧
关闭帧用于优雅关闭连接:
0 1 2 3
0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Status code (if present) |
|I|S|S|S| (8) |A| (7) | (16) |
|N|V|V|V| |S| | |
| |1|2|3| |K| | Reason |
+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
常见状态码:
| 状态码 | 含义 |
|---|---|
| 1000 | 正常关闭 |
| 1001 | 端点离开 |
| 1002 | 协议错误 |
| 1003 | 不支持的数据类型 |
| 1007 | 无效数据 |
| 1008 | 策略违规 |
| 1009 | 消息过大 |
| 1010 | 缺少扩展 |
| 1011 | 内部错误 |
| 1012 | 服务重启 |
| 1013 | 稍后重试 |
WebSocket 编程
JavaScript 客户端
浏览器原生支持 WebSocket API:
const ws = new WebSocket('wss://example.com/chat');
ws.onopen = function(event) {
console.log('连接已建立');
ws.send('Hello Server!');
};
ws.onmessage = function(event) {
console.log('收到消息:', event.data);
if (event.data instanceof Blob) {
// 处理二进制数据
} else {
// 处理文本数据
}
};
ws.onerror = function(error) {
console.error('WebSocket 错误:', error);
};
ws.onclose = function(event) {
console.log('连接已关闭:', event.code, event.reason);
if (event.wasClean) {
console.log('正常关闭');
} else {
console.log('异常关闭');
}
};
ws.send('文本消息');
ws.send(new ArrayBuffer(10)); // 二进制数据
ws.send(new Blob(['blob data']));
ws.close(1000, '正常关闭');
Python 服务器
使用 websockets 库:
import asyncio
import websockets
async def handler(websocket):
print(f"客户端连接: {websocket.remote_address}")
try:
async for message in websocket:
print(f"收到消息: {message}")
await websocket.send(f"Echo: {message}")
except websockets.exceptions.ConnectionClosed:
print("客户端断开连接")
async def main():
async with websockets.serve(handler, "localhost", 8765):
print("WebSocket 服务器启动在 ws://localhost:8765")
await asyncio.Future()
asyncio.run(main())
Python 客户端
import asyncio
import websockets
async def client():
uri = "ws://localhost:8765"
async with websockets.connect(uri) as websocket:
await websocket.send("Hello Server!")
response = await websocket.recv()
print(f"收到响应: {response}")
asyncio.run(client())
Node.js 服务器
使用 ws 库:
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', function connection(ws, req) {
const ip = req.socket.remoteAddress;
console.log(`客户端连接: ${ip}`);
ws.on('message', function message(data) {
console.log(`收到消息: ${data}`);
ws.send(`Echo: ${data}`);
});
ws.on('close', function close(code, reason) {
console.log(`客户端断开: ${code} ${reason}`);
});
ws.on('error', function error(err) {
console.error(`错误: ${err}`);
});
ws.send('欢迎连接!');
});
console.log('WebSocket 服务器启动在 ws://localhost:8080');
WebSocket 子协议
WebSocket 允许使用子协议,定义消息格式和语义:
STOMP
STOMP(Simple Text Oriented Messaging Protocol)是基于帧的消息协议:
CONNECT
accept-version:1.2
host:stomp.example.com
^@
SUBSCRIBE
id:0
destination:/queue/messages
ack:client
^@
SEND
destination:/queue/messages
content-type:text/plain
Hello World^@
WAMP
WAMP(WebSocket Application Messaging Protocol)支持 RPC 和发布订阅:
// RPC 调用
[48, 123456, "com.example.add", [1, 2]]
// RPC 结果
[50, 123456, 3]
// 发布消息
[16, "com.example.topic", {"message": "hello"}]
WebSocket 扩展
压缩扩展
permessage-deflate 扩展支持消息压缩:
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
压缩可以显著减少数据传输量,但增加 CPU 开销。
心跳机制
WebSocket 本身没有内置心跳,需要应用层实现:
let heartbeatInterval;
function startHeartbeat() {
heartbeatInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
}
}, 30000);
}
ws.on('pong', () => {
console.log('收到 Pong');
});
ws.on('close', () => {
clearInterval(heartbeatInterval);
});
重连机制
连接断开后自动重连:
let ws;
let reconnectInterval = 1000;
let maxReconnectInterval = 30000;
function connect() {
ws = new WebSocket('wss://example.com/chat');
ws.onopen = () => {
console.log('连接成功');
reconnectInterval = 1000;
};
ws.onclose = () => {
console.log('连接断开,准备重连...');
setTimeout(connect, reconnectInterval);
reconnectInterval = Math.min(reconnectInterval * 2, maxReconnectInterval);
};
}
connect();
WebSocket 安全
wss 协议
使用 TLS 加密的 WebSocket 连接:
ws://example.com/chat # 不安全
wss://example.com/chat # 加密
Origin 验证
服务器验证请求来源:
async def handler(websocket):
origin = websocket.request_headers.get('Origin')
if origin not in allowed_origins:
await websocket.close(1008, 'Origin not allowed')
return
认证
WebSocket 没有内置认证,需要在握手时处理:
HTTP 头部认证:
async def handler(websocket):
auth = websocket.request_headers.get('Authorization')
if not validate_auth(auth):
await websocket.close(1008, 'Unauthorized')
return
URL 参数认证:
const ws = new WebSocket('wss://example.com/chat?token=xxx');
Cookie 认证:
浏览器发送 WebSocket 请求时会携带同域 Cookie。
WebSocket vs HTTP 长轮询
| 特性 | WebSocket | HTTP 长轮询 |
|---|---|---|
| 连接 | 持久连接 | 每次请求建立连接 |
| 通信方式 | 全双工 | 半双工 |
| 延迟 | 低 | 高 |
| 带宽 | 低开销 | HTTP 头部开销 |
| 实现复杂度 | 中等 | 简单 |
| 浏览器支持 | 现代浏览器 | 所有浏览器 |
| 代理兼容性 | 可能有问题 | 兼容性好 |
小结
WebSocket 是实时 Web 应用的核心技术:
- 全双工通信:客户端和服务器可以同时发送数据
- 握手过程:基于 HTTP 升级,使用 Sec-WebSocket-Key 验证
- 数据帧:支持文本和二进制数据,支持分片
- 控制帧:Ping/Pong 心跳,Close 关闭连接
- 安全:使用 wss 加密,验证 Origin
WebSocket 适用于聊天应用、实时协作、在线游戏、股票行情等需要实时通信的场景。
练习
- 描述 WebSocket 握手过程和 Sec-WebSocket-Accept 的计算方法
- 解释 WebSocket 数据帧格式和掩码算法
- 实现 WebSocket 客户端和服务器的心跳机制
- 比较 WebSocket 和 HTTP 长轮询的优缺点