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 向服务器发送精心构造的数据包进行缓存投毒攻击。
为什么需要掩码
理解掩码机制的目的对于深入掌握 WebSocket 安全模型至关重要。RFC 6455 规定客户端发送给服务器的帧必须掩码,而服务器发送给客户端的帧不掩码。这种非对称设计有其特定的安全考量。
缓存投毒攻击防护是掩码机制的核心目的。考虑这样一个场景:恶意网页通过 WebSocket 连接到一个代理服务器,然后发送一段精心构造的数据。如果没有掩码机制,这段数据的字节序列可能恰好符合 HTTP 请求的格式,例如:
GET /innocent-page HTTP/1.1
Host: target-server.com
Cookie: victim-session-id
攻击者的目标是让这段伪造的 HTTP 请求被代理服务器缓存,之后当真正的受害者访问这个 URL 时,代理会返回攻击者注入的恶意内容。这就是著名的缓存投毒攻击。
掩码机制通过强制客户端对所有数据进行异或处理,使得攻击者无法控制发送数据的最终字节序列。由于掩码密钥是随机生成的,攻击者无法预测最终的数据形态,从而从根本上阻止了这类攻击。
掩码对性能的影响相对较小。异或操作是最简单的位运算之一,现代 CPU 可以高效执行。4 字节的掩码密钥循环使用,算法复杂度为 O(n),其中 n 是负载数据长度。
// JavaScript 中的掩码实现示例
function maskData(data, maskingKey) {
const result = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
result[i] = data[i] ^ maskingKey[i % 4];
}
return result;
}
// 浏览器 WebSocket API 自动处理掩码
// 开发者无需手动实现
const socket = new WebSocket('wss://example.com');
socket.send('这条消息会被浏览器自动掩码处理');
服务器端的处理则需要先解除掩码再处理数据。成熟的 WebSocket 库都会自动处理这个过程,开发者通常不需要关心细节。
// Node.js ws 库自动处理解掩码
// 收到的消息已经是解掩码后的原始数据
ws.on('message', (data) => {
// data 已经是解掩码后的原始数据
console.log('收到消息:', data.toString());
});
为什么服务器发送的数据不需要掩码? 这是一个常见的疑问。原因在于服务器通常是可信的,不会主动发起缓存投毒攻击。更重要的是,如果服务器发送的数据也需要掩码,那么浏览器在接收数据时需要先解掩码才能处理,这会增加客户端的复杂度和处理延迟。而在服务器端处理解掩码更加高效,因为服务器通常有更强的计算能力。
消息分段
当消息很大时,可以将其分成多个帧发送。分段规则:
- 第一个帧的 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)可以插入在消息分段之间,不会被当作消息的一部分。
为什么需要消息分段? 消息分段的设计主要解决两个实际问题。首先是内存效率:发送方无需一次性将整个大消息加载到内存中,可以边生成边发送,这对于流式数据处理场景尤其重要。其次是公平性:在大消息传输期间,可以插入控制帧(如心跳),避免长消息阻塞其他通信,保证连接的活跃状态检测不会被延迟。
分段的工作流程:假设客户端要发送一条很长的文本消息"and a happy new year!",可以将其分成多个帧:
客户端: FIN=0, opcode=0x1, msg="and a"
服务端: (等待,收到新文本消息的开始部分)
客户端: FIN=0, opcode=0x0, msg="happy new"
服务端: (等待,将负载追加到之前收到的部分)
客户端: FIN=1, opcode=0x0, msg="year!"
服务端: (处理完整消息 "and a happy new year!")
注意第一个帧使用 opcode=0x1 表示这是文本消息的开始,后续帧都使用 opcode=0x0 表示继续帧,只有最后一帧的 FIN=1 标记消息结束。
消息分段的设计目的主要有两个方面。首先是内存效率:发送方无需一次性将整个大消息加载到内存中,可以边生成边发送。其次是公平性:在大消息传输期间,可以插入控制帧(如心跳),避免长消息阻塞其他通信。
实际应用场景中,消息分段常用于传输大型文件或实时视频流。例如,当传输一个 10MB 的文件时,可以将其分成多个较小的帧,每个帧 64KB。这样即使传输过程中连接中断,接收方也可能已经处理了部分数据,而不是全部丢失。
浏览器自动处理分段,开发者通常不需要手动关注。浏览器会根据需要自动对发送的消息进行分段,也会自动将接收到的分帧重组为完整的消息。
// 发送大消息时,浏览器可能自动分段
// 接收时,onmessage 收到的是完整消息
const largeData = new ArrayBuffer(10 * 1024 * 1024); // 10MB
socket.send(largeData);
// 接收方无需关心分段细节
socket.onmessage = (event) => {
// event.data 是完整的 10MB 数据
console.log('收到完整数据:', event.data.byteLength);
};
服务端处理分段时,大多数 WebSocket 库会自动重组消息。但在某些需要流式处理的场景下,可以选择逐帧处理。
// Node.js ws 库示例
// 默认情况下,ws 库会自动重组消息
// 如果需要流式处理,可以使用 stream 模式
连接状态
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 握手失败(保留) |
关闭码范围规范
根据 RFC 6455 第 7.4.2 节定义,关闭码分为以下范围:
| 范围 | 用途 | 说明 |
|---|---|---|
| 0-999 | 保留 | 不使用 |
| 1000-2999 | 协议定义 | 由 WebSocket 协议或未来扩展定义 |
| 3000-3999 | 库/框架 | 由 WebSocket 库、框架注册使用 |
| 4000-4999 | 应用自定义 | 由应用程序自行定义 |
重要规范要点:
-
保留码不可发送:1005(No Status Rcvd)、1006(Abnormal Closure)、1015(TLS Handshake)这三个码仅由浏览器/服务器在特定情况下设置,应用程序不应主动发送。
-
关闭帧格式:关闭帧的负载数据前 2 字节为关闭码(网络字节序),后续为 UTF-8 编码的关闭原因,最大 123 字节。
-
无状态码关闭:如果应用程序调用
close()时不传参数,默认使用 1000。
// 正确的自定义关闭码使用
socket.close(4000, '用户主动登出'); // 应用自定义
socket.close(3000, '框架内部错误'); // 框架定义
// 错误的使用
socket.close(1006, '...'); // 保留码,不应主动发送
socket.close(200, '...'); // 无效范围
异常关闭
当连接异常断开时(如网络中断),无法完成正常的关闭握手。这种情况下:
- 关闭码为 1006
wasClean属性为 false- 可能没有关闭原因
socket.onclose = (event) => {
if (event.code === 1006) {
console.log('连接异常断开');
}
};
心跳机制
WebSocket 协议提供了 Ping/Pong 帧用于保持连接活跃和检测连接状态。心跳机制对于维护长连接的稳定性至关重要,特别是在存在代理服务器、负载均衡器或 NAT 设备的网络环境中。
为什么需要心跳
在典型的网络部署中,WebSocket 连接会经过多个中间层,这些中间层通常会设置连接超时:
客户端 <---> NAT/防火墙 <---> 负载均衡器 <---> 反向代理 <---> WebSocket服务器
每个中间层都可能有自己的空闲连接超时设置(通常为 60-300 秒)。如果没有数据传输,连接可能被中间层主动断开,导致"假连接"现象——客户端认为连接存在,但服务器端已经断开。
心跳的三个核心作用:
- 保活:定期发送数据,防止中间层因超时关闭连接
- 检测:及时发现已经断开的连接,清理资源
- 测量:通过心跳往返时间评估网络延迟
Ping 和 Pong 帧
WebSocket 协议层的心跳机制:
- Ping 帧(opcode 0x9):一方发送 Ping,可以携带最多 125 字节的负载数据
- Pong 帧(opcode 0xA):收到 Ping 后必须尽快返回 Pong,负载数据与 Ping 相同
协议层面的特点:
浏览器会自动响应 Ping 帧,JavaScript API 不直接暴露 Ping/Pong 的控制能力。这意味着:
服务器端可以使用协议层 Ping:
// Node.js ws 库
wss.on('connection', (ws) => {
// 定期发送 Ping
ws.ping();
// 监听 Pong 响应
ws.on('pong', () => {
ws.isAlive = true;
});
});
客户端无法使用协议层 Ping,需要应用层心跳:
// 浏览器端必须使用应用层心跳
const socket = new WebSocket('wss://example.com');
// 发送心跳
function sendHeartbeat() {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'ping' }));
}
}
// 响应心跳
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'ping') {
socket.send(JSON.stringify({ type: 'pong' }));
}
};
协议层 vs 应用层心跳对比
| 特性 | 协议层 Ping/Pong | 应用层心跳 |
|---|---|---|
| 客户端发起 | 不支持(浏览器限制) | 支持 |
| 服务器发起 | 支持 | 支持 |
| 浏览器自动响应 | 是(Pong) | 否,需手动处理 |
| 携带数据 | 最多 125 字节 | 任意大小 |
| 跨代理兼容 | 可能被过滤 | 通常不受影响 |
| 调试可见性 | 需要抓包 | 应用日志可见 |
实践建议:对于大多数应用,推荐使用应用层心跳,因为它更灵活且跨代理兼容性更好。
自定义心跳
由于浏览器 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 消息格式 | 简单的 JSON 序列化 |
protobuf | Protocol Buffers | Google 的高效二进制序列化 |
mqtt | MQTT over WebSocket | 物联网消息协议 |
stomp | STOMP 协议 | 简单文本消息协议 |
wamp | WAMP 协议 | WebSocket 应用消息协议 |
实际应用示例:
// 使用 STOMP 子协议(需要客户端库支持)
const socket = new WebSocket('ws://localhost:8080/stomp', 'v12.stomp');
socket.onopen = () => {
// 发送 STOMP CONNECT 帧
socket.send('CONNECT\naccept-version:1.2\n\n\x00');
};
socket.onmessage = (event) => {
// 解析 STOMP 帧
const frame = parseStompFrame(event.data);
console.log('收到 STOMP 消息:', frame);
};
子协议命名规范
根据 RFC 6455,子协议名称应该遵循以下规范:
- 反向域名格式:使用组织域名的反向形式,如
chat.example.com - 版本化命名:不兼容更新应使用新名称,如
v2.chat.example.com - 注册到 IANA:公开的子协议应注册到 IANA WebSocket 子协议注册表
// 推荐的子协议命名方式
const socket = new WebSocket('ws://api.example.com/ws', [
'v1.chat.example.com',
'v2.chat.example.com'
]);
子协议的作用
子协议主要解决以下问题:
消息格式约定:客户端和服务器约定使用 JSON、Protobuf 或其他格式,避免格式歧义。
语义约定:定义消息的类型、结构和处理规则。例如,STOMP 定义了 CONNECT、SUBSCRIBE、SEND 等操作。
版本兼容:通过不同的子协议名称支持多版本 API。
// 服务端根据子协议选择不同的处理器
function handleConnection(ws, request) {
const protocol = ws.protocol;
switch (protocol) {
case 'v1.chat.example.com':
handleV1Protocol(ws);
break;
case 'v2.chat.example.com':
handleV2Protocol(ws);
break;
default:
// 如果没有协商子协议,使用默认处理
handleDefault(ws);
}
}
扩展
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 应用,排查连接问题,以及进行性能优化。