mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2315 字
6 分钟
为什么 WebSocket 需要握手
2024-05-09

WebSocket 是现代 Web 应用实现实时通信的核心技术。与 HTTP 的「请求-响应」模式不同,WebSocket 提供了全双工、低延迟的通信能力。然而,WebSocket 连接的建立并非凭空产生——它需要一个精心设计的握手过程。这个握手过程不仅是技术实现的需要,更是协议设计哲学的体现。

一、HTTP 轮询的困境#

在 WebSocket 出现之前,Web 应用实现「实时」功能主要依赖 HTTP 轮询技术。先理解这些方案及其局限性。

1.1 短轮询(Short Polling)#

最直观的方案是客户端定时发送请求:

// 短轮询:每隔一段时间主动询问
setInterval(async () => {
const response = await fetch("/api/messages");
const data = await response.json();
if (data.hasNew) {
updateUI(data.messages);
}
}, 3000); // 每 3 秒轮询一次

问题

sequenceDiagram participant C as 客户端 participant S as 服务端 loop 每 3 秒 C->>S: GET /api/messages S->>C: 200 OK (无新消息) end Note over C,S: 大量无效请求浪费资源

假设服务端每 10 秒才有一次更新,那么 70% 的请求都是无效的。更糟糕的是,每次请求都携带完整的 HTTP 头部:

GET /api/messages HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0...
Accept: application/json
Cookie: session=abc123...
Authorization: Bearer xxx...

这些头部数据可能高达几百字节,而实际有效载荷可能只有几个字节。

1.2 长轮询(Long Polling)#

为了减少无效请求,长轮询应运而生:

// 长轮询:服务端持有请求直到有数据或超时
async function longPoll() {
const response = await fetch("/api/messages?timeout=30");
const data = await response.json();
updateUI(data.messages);
longPoll(); // 立即发起新的长轮询
}
sequenceDiagram participant C as 客户端 participant S as 服务端 C->>S: GET /api/messages?timeout=30 Note over S: 等待数据(最多 30 秒) S-->>C: 200 OK (有数据时返回) C->>S: GET /api/messages?timeout=30 Note over S: 竉待数据... S-->>C: 200 OK (有数据时返回)

长轮询减少了请求次数,但仍有显著缺陷:

问题影响
连接占用服务端需要维护大量挂起的连接
超时处理请求超时后需要重新建立连接
头部开销每次新请求仍需发送完整 HTTP 头
双向延迟服务端有数据时无法主动推送,需等客户端发起请求

1.3 对比总结#

graph LR subgraph 短轮询 A1[请求] --> A2[响应] A2 --> A3[等待] A3 --> A4[请求] end subgraph 长轮询 B1[请求] --> B2[等待数据] B2 --> B3[响应] B3 --> B4[新请求] end subgraph WebSocket C1[握手] --> C2[双向通信] C2 --> C3[持续连接] C3 --> C2 end

WebSocket 的核心优势:一次握手后,建立持久的双向通道,无需重复发送头部,服务端可随时主动推送数据。

二、WebSocket 的设计目标#

WebSocket 协议(RFC 6455)的设计有几个关键目标:

2.1 兼容 HTTP 基础设施#

WebSocket 的设计者们面临一个现实约束:现有的 Web 基础设施(代理、防火墙、负载均衡器)都基于 HTTP。

flowchart LR subgraph 现实约束 A[代理服务器] --> B[HTTP 协议] C[防火墙] --> B D[负载均衡器] --> B end subgraph 设计选择 E[WebSocket] --> F[复用 HTTP 端口 80/443] E --> G[HTTP Upgrade 机制] E --> H[兼容现有设施] end

如果 WebSocket 使用全新的端口或协议格式,将面临:

  • 企业防火墙可能阻止非标准端口
  • 代理服务器无法正确处理
  • 需要部署全新的基础设施

解决方案:WebSocket 连接始于 HTTP 请求,通过 HTTP Upgrade 机制完成协议切换。

2.2 全双工通信#

与 HTTP 的半双工(一次只能一个方向传输)不同,WebSocket 是真正的全双工:

flowchart TB subgraph HTTP 半双工 direction LR H1[客户端请求] --> H2[等待] H2 --> H3[服务端响应] H3 --> H4[连接关闭或复用] end subgraph WebSocket 全双工 direction LR W1[客户端发送] --> W3[同时进行] W2[服务端推送] --> W3 W3 --> W1 W3 --> W2 end

2.3 低开销帧格式#

WebSocket 定义了轻量级的帧格式,最小帧头部仅 2 字节:

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 |
+-------------------------------- - - - - - - - - - - - - - - - +

相比之下,一个典型的 HTTP 请求头部可能占用 200-2000 字节。

三、握手协议详解#

WebSocket 握手是整个协议最精妙的部分——它巧妙地复用 HTTP 完成协议升级。

3.1 完整握手流程#

sequenceDiagram participant C as 客户端 participant S as 服务端 Note over C: 生成随机 Sec-WebSocket-Key C->>S: GET /chat HTTP/1.1<br/>Upgrade: websocket<br/>Connection: Upgrade<br/>Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==<br/>Sec-WebSocket-Version: 13 Note over S: 验证请求格式<br/>计算 Accept Key S->>C: HTTP/1.1 101 Switching Protocols<br/>Upgrade: websocket<br/>Connection: Upgrade<br/>Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Note over C,S: 握手完成,切换为 WebSocket 协议 C-->>S: WebSocket 数据帧 S-->>C: WebSocket 数据帧

3.2 客户端握手请求#

一个标准的 WebSocket 握手请求如下:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://example.com

关键字段解析

字段必需说明
Upgrade: websocket声明要升级到 WebSocket 协议
Connection: Upgrade告知代理/服务器这是一次协议升级请求
Sec-WebSocket-KeyBase64 编码的 16 字节随机值,用于安全验证
Sec-WebSocket-Version协议版本,当前为 13
Origin是(浏览器)防止跨站 WebSocket 劫持

3.3 服务端握手响应#

服务端验证请求后,返回 101 状态码:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

状态码 101 的含义是「协议切换」,表示服务器理解并同意切换到 WebSocket 协议。

3.4 为什么需要 Sec-WebSocket-Key?#

这是设计的核心问题。既然握手基于 HTTP,为什么不直接发送 Upgrade: websocket 就完成切换?

原因一:确保对方支持 WebSocket

flowchart TD A[客户端发送 Sec-WebSocket-Key] --> B{服务端支持 WebSocket?} B -->|是| C[返回 Sec-WebSocket-Accept] B -->|否| D[返回普通 HTTP 响应] C --> E[握手成功] D --> F[客户端识别:这不是 WebSocket 服务器]

如果服务端不支持 WebSocket,它会返回普通的 HTTP 响应(如 200 OK 或 404 Not Found),客户端可以据此判断。

原因二:防止缓存代理误判

sequenceDiagram participant C as 客户端 participant P as 缓存代理 participant S as 服务端 C->>P: GET /api (普通 HTTP) P->>S: 转发请求 S->>P: 200 OK (被缓存) P->>C: 返回缓存响应 Note over P: 之后... C->>P: WebSocket 握手请求 Note over P: 如果没有 Sec-WebSocket-Key<br/>代理可能返回缓存的 HTTP 响应!

Sec-WebSocket-Key 的存在确保代理不会返回缓存的 HTTP 响应。

原因三:提供基本的安全验证

虽然不是加密,但验证了对方确实理解 WebSocket 协议。

四、Sec-WebSocket-Key 的计算#

4.1 计算过程#

服务端收到 Sec-WebSocket-Key 后,必须按照 RFC 6455 规定的算法计算 Sec-WebSocket-Accept

import base64
import hashlib
def compute_accept_key(websocket_key):
# RFC 6455 定义的 GUID
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
# 拼接 Key 和 GUID
combined = websocket_key + GUID
# SHA-1 哈希
sha1_hash = hashlib.sha1(combined.encode()).digest()
# Base64 编码
accept_key = base64.b64encode(sha1_hash).decode()
return accept_key
# 示例
client_key = "dGhlIHNhbXBsZSBub25jZQ=="
accept_key = compute_accept_key(client_key)
print(accept_key) # s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

4.2 为什么要拼接固定 GUID?#

flowchart LR A[Sec-WebSocket-Key] --> B[拼接 GUID] B --> C[SHA-1 哈希] C --> D[Base64 编码] D --> E[Sec-WebSocket-Accept] subgraph 为什么需要 GUID? F[防止简单回显攻击] G[确保非 WebSocket 服务器无法通过验证] H[RFC 6455 标准化] end

这个 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 是 RFC 6455 规定的固定值,它的作用:

  1. 防止简单回显:如果只是原样返回 Key,任何 HTTP 服务器都能「假装」支持 WebSocket
  2. 协议标识:只有实现了 WebSocket 协议的服务器才知道要加上这个 GUID
  3. 非加密验证:提供一种轻量级的协议确认机制

4.3 安全性说明#

mindmap root((Sec-WebSocket-Key<br/>安全性)) 不是加密 明文传输 可被中间人读取 防止什么 非WebSocket服务器误响应 缓存代理干扰 跨协议攻击 不防止什么 数据窃听 数据篡改 需要WSS加密

重要:WebSocket 握手本身不提供加密。如果需要安全传输,必须使用 wss://(WebSocket Secure),即基于 TLS 的 WebSocket。

五、帧格式与掩码机制#

握手完成后,通信切换到 WebSocket 帧协议。帧格式的设计体现了协议的精巧。

5.1 帧结构详解#

flowchart TB subgraph 基本[基本帧格式 2-7 字节] direction LR B1["FIN (1 bit)<br/>是否最后一帧"] B2["RSV1-3 (3 bits)<br/>扩展保留"] B3["Opcode (4 bits)<br/>帧类型"] B4["MASK (1 bit)<br/>是否掩码"] B5["Payload len<br/>负载长度"] end subgraph 扩展[扩展长度 0/2/8 字节] E1["126: 后 2 字节为长度"] E2["127: 后 8 字节为长度"] end subgraph 掩码[掩码键 0/4 字节] M1["客户端发送必须掩码"] M2["服务端发送不掩码"] end subgraph 负载[负载数据] P1["实际数据"] end 基本 --> 扩展 --> 掩码 --> 负载

5.2 Opcode 帧类型#

Opcode类型说明
0x0Continuation连续帧,前一个帧未完成
0x1Text文本数据(UTF-8)
0x2Binary二进制数据
0x8Close关闭连接
0x9Ping心跳探测
0xAPong心跳响应

5.3 掩码机制:解决缓存污染攻击#

WebSocket 协议规定:客户端发送给服务端的数据必须掩码,服务端发送给客户端的数据不掩码

为什么这样设计?

缓存污染攻击原理

sequenceDiagram participant A as 攻击者 participant P as 缓存代理 participant S as 目标服务器 Note over A: 构造恶意 WebSocket 数据<br/>使其看起来像 HTTP 请求 A->>P: WebSocket 帧 (未掩码) Note over P: 缓存代理将其解析为 HTTP 请求 P->>S: 转发"HTTP 请求" S->>P: 响应被缓存 P->>P: 污染缓存 Note over P: 后续请求返回恶意响应

攻击者可以构造特殊的数据,使得缓存代理误以为这是 HTTP 请求,从而污染缓存。

掩码如何防护

def apply_mask(payload, masking_key):
"""应用掩码"""
masked = bytearray()
for i, byte in enumerate(payload):
masked.append(byte ^ masking_key[i % 4])
return bytes(masked)
# 示例
masking_key = [0x12, 0x34, 0x56, 0x78]
original = b"GET / HTTP/1.1"
masked = apply_mask(original, masking_key)
print(f"原数据: {original}")
print(f"掩码后: {masked}")
# 掩码后的数据不再是有效的 HTTP 请求

掩码后的数据看起来是随机的,无法被解析为有效的 HTTP 请求,从而防止了缓存污染攻击。

5.4 为什么要单向掩码?#

flowchart TD subgraph 客户端到服务端 C1[客户端发送] --> C2[必须掩码] C2 --> C3[缓存代理无法解析] C3 --> C4[防止缓存污染攻击] end subgraph 服务端到客户端 S1[服务端发送] --> S2[不掩码] S2 --> S3[服务端受信任] S3 --> S4[无需防护] end

服务端是可信的,不会发起缓存污染攻击,因此不需要掩码。

六、心跳与保活策略#

WebSocket 是持久连接,需要机制来检测连接状态。

6.1 为什么需要心跳?#

sequenceDiagram participant C as 客户端 participant N as 网络 participant S as 服务端 Note over C,S: 正常通信 C->>N: 数据帧 N->>S: 数据帧 Note over N: 网络中断(无 FIN/RST) C->>N: 数据帧 Note over C: TCP 层不知道连接已断 Note over S: 等待数据,不知道客户端已失联 Note over C,S: 资源泄漏!

TCP 连接可能因为网络中断而「假死」——双方都不知道连接已断开,但数据无法传递。

6.2 Ping/Pong 机制#

WebSocket 定义了 Ping/Pong 帧:

sequenceDiagram participant C as 客户端 participant S as 服务端 loop 心跳间隔 S->>C: Ping (0x9) C->>S: Pong (0xA) Note over C,S: 连接正常 end S->>C: Ping (0x9) Note over C: 无响应(超时) S->>S: 判定连接断开 S->>S: 清理资源

6.3 心跳策略实现#

// 客户端心跳实现示例
class WebSocketWithHeartbeat {
constructor(url) {
this.ws = new WebSocket(url);
this.heartbeatInterval = null;
this.missedHeartbeats = 0;
this.maxMissedHeartbeats = 3;
this.ws.onopen = () => this.startHeartbeat();
this.ws.onclose = () => this.stopHeartbeat();
}
startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.missedHeartbeats++;
if (this.missedHeartbeats > this.maxMissedHeartbeats) {
// 多次心跳无响应,关闭连接
this.ws.close();
return;
}
// 发送 Ping(浏览器自动响应 Pong)
this.ws.send(JSON.stringify({ type: "ping" }));
}
}, 30000); // 30 秒间隔
}
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
}
}

6.4 心跳时机选择#

时机间隔说明
应用层空闲30-60 秒常见选择
移动网络3-5 分钟省电考虑
即时通讯10-30 秒快速检测断连
游戏应用1-5 秒极低延迟要求

七、与其他技术对比#

WebSocket 不是实现实时通信的唯一选择,对比各种方案。

7.1 技术对比总览#

flowchart TB subgraph 传统方案 SP[短轮询<br/>简单但低效] LP[长轮询<br/>改进但仍有开销] end subgraph 现代方案 WS[WebSocket<br/>全双工低延迟] SSE[Server-Sent Events<br/>单向推送] end SP --> LP --> WS LP --> SSE

7.2 WebSocket vs SSE#

Server-Sent Events (SSE) 是 HTML5 提供的服务器推送技术:

flowchart LR subgraph WebSocket[WebSocket] direction TB W1[双向通信] W2[二进制+文本] W3[自定义协议] W4[需要握手] end subgraph SSE[Server-Sent Events] direction TB S1[单向:服务端→客户端] S2[仅文本] S3[HTTP 协议] S4[简单实现] end
特性WebSocketSSE
通信方向全双工单向(服务端→客户端)
数据格式文本、二进制仅文本
协议ws/wssHTTP
重连机制需自行实现浏览器自动重连
浏览器支持IE10+IE 不支持
适用场景聊天、游戏、协作通知、新闻推送

7.3 WebSocket vs 长轮询#

sequenceDiagram participant C as 客户端 participant S as 服务端 rect rgb(255, 240, 240) Note over C,S: 长轮询:服务端有消息时 C->>S: HTTP 请求 Note over S: 等待消息 S->>C: 响应 C->>S: 新 HTTP 请求 Note over C: 每次都需要新请求 end rect rgb(240, 255, 240) Note over C,S: WebSocket:服务端有消息时 C->>S: 握手(一次) S->>C: 消息 1 S->>C: 消息 2 S->>C: 消息 3 Note over C: 无需新请求 end

7.4 选型指南#

flowchart TD A[需要实时通信] --> B{需要双向通信?} B -->|是| C{需要二进制数据?} B -->|否| D{兼容性要求?} C -->|是| E[WebSocket] C -->|否| F{复杂度容忍度} F -->|高| E F -->|低| G[长轮询] D -->|需支持 IE| G D -->|现代浏览器| H[SSE] E --> I[聊天、游戏、协作编辑] G --> J[兼容旧系统] H --> K[通知推送、日志流]

八、安全考虑#

WebSocket 的安全性设计是协议设计的重要部分。

8.1 Origin 验证#

sequenceDiagram participant B as 浏览器 participant S as WebSocket 服务器 B->>S: 握手请求<br/>Origin: https://evil.com Note over S: 检查 Origin 白名单 alt Origin 不允许 S->>B: HTTP 403 Forbidden else Origin 允许 S->>B: 101 Switching Protocols end

跨站 WebSocket 劫持攻击

// 恶意网站尝试连接受害者的 WebSocket 服务
const ws = new WebSocket("wss://victim-bank.com/ws");
// 如果服务端不验证 Origin,恶意网站可以建立连接
// 并读取受害者的敏感数据(因为浏览器会自动携带 Cookie)

防御措施:

// 服务端验证 Origin
const WebSocket = require("ws");
const wss = new WebSocket.Server({ port: 8080 });
wss.on("connection", (ws, req) => {
const origin = req.headers.origin;
const allowedOrigins = ["https://example.com", "https://app.example.com"];
if (!allowedOrigins.includes(origin)) {
ws.close(1008, "Origin not allowed");
return;
}
// 正常处理连接
});

8.2 使用 WSS 加密#

flowchart LR subgraph WS[ws:// 明文] W1[数据可被窃听] W2[可被中间人篡改] W3[仅用于开发环境] end subgraph WSS[wss:// 加密] W4[数据加密传输] W5[身份验证] W6[生产环境必需] end WS --> WSS

WSS = WebSocket over TLS,与 HTTPS 类似,提供:

  • 数据加密
  • 服务器身份验证
  • 防篡改

8.3 认证与授权#

WebSocket 握手是 HTTP 请求,可以使用标准的 HTTP 认证:

// 方式一:URL 参数(不推荐)
const ws = new WebSocket("wss://example.com/ws?token=xxx");
// 方式二:子协议(Subprotocol)传递 Token
const ws = new WebSocket("wss://example.com/ws", ["bearer", "xxx"]);
// 方式三:Cookie(推荐,使用 WSS)
// 浏览器自动携带 Cookie
const ws = new WebSocket("wss://example.com/ws");

服务端验证:

wss.on("connection", (ws, req) => {
// 从 Cookie 中提取会话
const cookies = req.headers.cookie;
const session = parseCookies(cookies).session;
// 验证会话
const user = validateSession(session);
if (!user) {
ws.close(1008, "Unauthorized");
return;
}
ws.user = user;
});

8.4 消息验证#

WebSocket 是应用层协议,消息格式需要自行定义和验证:

// 定义消息 Schema
const messageSchema = {
type: "string", // message, ping, pong, etc.
payload: "object", // 实际数据
timestamp: "number", // 时间戳
};
// 验证消息
function validateMessage(data) {
try {
const msg = JSON.parse(data);
// 类型检查
if (typeof msg.type !== "string") {
throw new Error("Invalid type");
}
// 大小限制
if (data.length > 1024 * 1024) {
// 1MB
throw new Error("Message too large");
}
return msg;
} catch (e) {
throw new Error("Invalid message format");
}
}
ws.on("message", data => {
try {
const msg = validateMessage(data);
handleMessage(msg);
} catch (e) {
ws.close(1003, "Invalid data");
}
});

九、连接状态与生命周期#

9.1 状态转换#

stateDiagram-v2 [*] --> CONNECTING: new WebSocket() CONNECTING --> OPEN: 握手成功 CONNECTING --> CLOSED: 握手失败 OPEN --> CLOSING: close() 调用 OPEN --> CLOSED: 连接错误 CLOSING --> CLOSED: 关闭完成 CLOSED --> [*]

9.2 优雅关闭#

WebSocket 定义了关闭握手:

sequenceDiagram participant A as 发起方 participant B as 接收方 A->>B: Close 帧 (code=1000, reason="Normal") Note over A: 状态: CLOSING B->>A: Close 帧 (code=1000) Note over B: 状态: CLOSING Note over A,B: 双方关闭 TCP 连接 Note over A,B: 状态: CLOSED

关闭帧格式:

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 | Close code (2 bytes) |
|I|S|S|S| (8) |A| (7) | |
|N|V|V|V| |S| | Reason (UTF-8 string) |
+-+-+-+-+-------+-+-------------+-------------------------------+

9.3 关闭代码#

代码名称说明
1000Normal正常关闭
1001Going Away端点离开(如页面关闭)
1002Protocol Error协议错误
1003Unsupported Data不支持的数据类型
1008Policy Violation策略违规
1009Message Too Big消息过大
1010Mandatory Extension缺少必需扩展
1011Internal Error服务器内部错误

十、总结#

WebSocket 握手的设计是协议工程的典范,它在约束条件下找到了最优解:

mindmap root((WebSocket 握手设计)) 兼容性 复用 HTTP 端口 HTTP Upgrade 机制 穿透代理和防火墙 可靠性 Sec-WebSocket-Key 验证 防止缓存污染 协议确认机制 安全性 Origin 验证 掩码防护 WSS 加密 效率 一次握手 轻量级帧格式 持久连接复用

核心设计原则

  1. 渐进式升级:从 HTTP 到 WebSocket 的平滑过渡
  2. 最小化开销:握手后无需重复发送头部
  3. 安全优先:掩码机制防止协议混淆攻击
  4. 向后兼容:利用现有 HTTP 基础设施

理解 WebSocket 握手的设计原理,不仅有助于正确使用 WebSocket,更能帮助我们理解网络协议设计的权衡艺术。在设计自己的协议或系统时,这些原则同样适用:兼容性、安全性、效率的平衡永远是核心考量。

参考引用#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

为什么 WebSocket 需要握手
https://blog.souloss.com/posts/why-the-design/why-websocket-needs-handshake/
作者
Souloss
发布于
2024-05-09
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时