跳到主要内容

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 向服务器发送精心构造的数据包进行缓存投毒攻击。

消息分段

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

  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)可以插入在消息分段之间,不会被当作消息的一部分。

连接状态

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 握手失败(保留)

自定义关闭码应在 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 协议的核心机制:

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

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