跳到内容

第 4 卷 / 第 6 章 / 第 1 课

6.1 WebSocket 握手、帧与连接生命周期 ​

HTTP 的核心交互是请求与响应,连接可以复用,却不会因此自动变成任意方向都能随时发消息的通道。如果服务端要持续推送事件,或两个服务需要强类型的远程调用契约,该怎样改变对话方式?WebSocket 与 gRPC 给出了两类不同答案。

WebSocket 为长时间、双向的消息交换提供协议。是否选它,先看消息如何流动,再看连接维护成本。

1. 先根据交互模式选协议 ​

需求常见起点关键边界
低频请求、可缓存资源普通 HTTP先别增加长连接维护成本
服务端持续向浏览器发事件Server-Sent Events单向文本事件流,浏览器自动重连
浏览器与服务端高频双向消息WebSocket自行定义消息结构、重连和背压策略
带类型合约的服务间 RPCgRPC关注截止时间、状态、合约演进与负载均衡

HTTP 采用请求与响应语义,但 HTTP/1.1 不能因此被称为“半双工传输”。TCP 本身全双工,HTTP/1.1 也允许流水线请求,只是响应须按请求顺序返回,浏览器和中间设施的实际支持还有限。WebSocket 则把升级后的通信改为独立双向的消息协议。

2. HTTP/1.1 Upgrade 握手 ​

客户端先发一条 HTTP/1.1 请求:

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

服务端同意切换协议时返回:

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

Sec-WebSocket-Accept 的计算是:

text
base64(SHA-1(Sec-WebSocket-Key +
             "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))

这里的 SHA-1 用于核对握手,不承担密码哈希或数字签名的安全职责。Key 与 GUID 帮助客户端确认服务端理解了 WebSocket 握手,并减少缓存响应或跨协议混淆。它不能认证服务端身份;需要加密和服务端认证时使用 wss://。

计算时拼接 Key 的 Base64 文本,不先把它解码;每次新握手应生成新的随机 16 字节 Key。下面固定使用规范里的测试向量:

python
import base64
import hashlib

key = "dGhlIHNhbXBsZSBub25jZQ=="
guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
accept = base64.b64encode(hashlib.sha1((key + guid).encode("ascii")).digest())
assert accept.decode("ascii") == "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="

客户端应解析状态码和必需的字段,包括 Upgrade、Connection 和 Sec-WebSocket-Accept。不要要求原因短语逐字相同,也不要只在字节串中搜索 accept 值。握手响应后可能紧接着第一个 WebSocket 帧,解析器要保留这些剩余字节。

HTTP/2 不使用上面的 101 升级;RFC 8441 定义扩展 CONNECT,HTTP/3 则采用 RFC 9220 的启动方式。这需要客户端、服务端和中间设施共同支持,站点支持 HTTP/2 或 HTTP/3 并不够。

3. 帧是消息的传输单元 ​

基本帧头包含:

  • FIN:是否为当前消息的最后一个帧;
  • RSV1..3:未协商扩展时必须为 0;
  • opcode:continuation 0x0、text 0x1、binary 0x2、close 0x8、ping 0x9、pong 0xA;
  • MASK:客户端发出的帧必须带掩码,服务端发出的帧不得带掩码;
  • payload length:7 位,或由后续 16/64 位扩展长度表示。

一条应用消息可以分为首个数据帧与多个 continuation 帧。控制帧不能分片,负载最多 125 字节,可以插在数据消息的分片中间。文本消息重组后必须是合法 UTF-8;单个分片可能恰好切在多字节字符中间。

TCP 不保证一次 recv(n) 返回 n 字节。下面的 Python 3 解码器用 recv_exact 处理短读,检查长度后才读取负载:

python
from dataclasses import dataclass
import socket
import struct

class ProtocolError(Exception):
    pass

@dataclass(frozen=True)
class Frame:
    fin: bool
    opcode: int
    payload: bytes

def recv_exact(sock: socket.socket, size: int) -> bytes:
    if size < 0:
        raise ValueError("negative read size")
    payload = bytearray()
    remaining = size
    while remaining:
        chunk = sock.recv(remaining)
        if not chunk:
            raise EOFError("connection closed inside a frame")
        payload.extend(chunk)
        remaining -= len(chunk)
    return bytes(payload)

def read_frame(
    sock: socket.socket,
    *,
    expect_masked: bool,
    max_payload: int = 1 << 20,
) -> Frame:
    if max_payload < 0:
        raise ValueError("negative payload limit")
    first, second = recv_exact(sock, 2)
    fin = bool(first & 0x80)
    rsv = first & 0x70
    opcode = first & 0x0F
    masked = bool(second & 0x80)
    length = second & 0x7F

    if rsv:
        raise ProtocolError("RSV bit set without an extension")
    if masked != expect_masked:
        raise ProtocolError("invalid masking direction")
    if opcode not in {0x0, 0x1, 0x2, 0x8, 0x9, 0xA}:
        raise ProtocolError("unknown or reserved opcode")

    if length == 126:
        length = struct.unpack("!H", recv_exact(sock, 2))[0]
        if length < 126:
            raise ProtocolError("non-minimal payload length")
    elif length == 127:
        raw = recv_exact(sock, 8)
        if raw[0] & 0x80:
            raise ProtocolError("payload length exceeds 63 bits")
        length = struct.unpack("!Q", raw)[0]
        if length < 65536:
            raise ProtocolError("non-minimal payload length")

    is_control = opcode >= 0x8
    if is_control and (not fin or length > 125):
        raise ProtocolError("invalid control frame")
    if opcode == 0x8 and length == 1:
        raise ProtocolError("close payload cannot be one byte")
    if length > max_payload:
        raise ProtocolError("frame payload exceeds configured limit")

    mask = recv_exact(sock, 4) if masked else b""
    payload = recv_exact(sock, length)
    if masked:
        payload = bytes(value ^ mask[i % 4] for i, value in enumerate(payload))
    return Frame(fin, opcode, payload)

这个函数只解一个帧,不重组消息,也不验证文本 UTF-8、关闭码和关闭原因,不支持扩展或关闭状态机。调用者还要检查 continuation 顺序和重组后的总大小。真实连接应由外层设置整帧或整条消息的截止时间;单次 socket 超时会被持续短读反复延后,本例没有处理这个时间预算。

掩码的 4 字节 key 随帧明文携带,用于改变客户端发出的字节模式,不能保密。客户端须为每个帧选择新的、不可预测的 key,连控制帧也一样。

下面用每次最多返回一个字节的读取器稳定复现短读。先解 RFC 6455 的 masked Hello,再读被 Ping 隔开的“中”字 UTF-8 分片。示例只检查这条已知序列,不是通用消息重组器:

python
class ShortReader:
    def __init__(self, data: bytes):
        self.data = data

    def recv(self, size: int) -> bytes:
        part = self.data[:min(size, 1)]
        self.data = self.data[len(part):]
        return part

masked = bytes.fromhex("81 85 37 fa 21 3d 7f 9f 4d 51 58")
assert read_frame(ShortReader(masked), expect_masked=True).payload == b"Hello"

reader = ShortReader(bytes.fromhex("01 01 e4 89 01 70 80 02 b8 ad"))
first, ping, last = [read_frame(reader, expect_masked=False) for _ in range(3)]
assert (first.opcode, first.fin, ping.opcode, last.opcode, last.fin) == (
    1, False, 9, 0, True,
)
assert ping.payload == b"p"
print((first.payload + last.payload).decode("utf-8"))
# 中

4. Ping/Pong 与业务处理状态 ​

尚未收到 Close 时,接收端收到 Ping 必须回复 Pong,并应尽快发送;回复的负载要与 Ping 相同。如果多个 Ping 等待回复,可以只回复最近处理的一个。Pong 也可以主动发送,无须再回复。

协议心跳能发现部分连接故障,不能证明业务事件已处理。WebSocket 保持一条连接内同一方向的消息顺序;若应用需要处理完成确认、跨重连排序或恢复,仍要定义消息 ID、ack 和重放策略。

5. 关闭握手与重连 ​

端点发送 Close 后不能再发送数据帧。收到 Close 且尚未发过 Close 时要回复;双方都发出并收到 Close 后完成关闭握手。对 HTTP/1.1 升级后的 TCP 连接,服务端随即关闭 TCP,客户端通常等待它关闭。等待超时或协议错误仍可能迫使端点异常终止,不能无限等对端配合。

Close 可以没有负载;有负载时,前两字节是关闭码,后面最多 123 字节是 UTF-8 原因。1005、1006、1015 是保留指示值,不能放进发出的 Close 帧。重连由应用或库负责,通常使用带抖动的指数退避,并决定断线期间的消息是丢弃、暂存还是按序号恢复。日志应区分正常关闭、TCP EOF 和协议错误。

6. 背压与大消息 ​

send() 成功通常只说明数据进入本地缓冲,不能证明对端应用已处理。生产者长期快于消费者时,队列会持续占用内存并放大延迟。

浏览器传统 WebSocket API 没有可等待的 drain()。bufferedAmount 只统计尚未交给网络的应用字节,不包含协议头、内核缓冲或对端处理进度。应用要在发送前结合这个值与新消息大小控制生产速率;接收侧也需限制处理队列。

生产服务应明确:

  • 单帧与重组消息的大小上限;
  • 单连接与全局发送队列上限;
  • 队列满时阻塞、丢弃、合并还是关闭连接;
  • 慢消费者的超时限制;
  • 压缩开启后的解压大小与资源限制。

7. 浏览器与服务端的安全边界 ​

浏览器握手带有 Origin。WebSocket 不依赖 Fetch 的 CORS 预检与响应字段来授权跨源访问,服务端必须按自己的策略核对 Origin,尤其是握手携带 cookie 时。否则可能发生跨站 WebSocket 劫持。非浏览器客户端能伪造 Origin,因此这项检查不能替代客户端认证。

浏览器 WebSocket API 不允许任意设置握手头。可采用 cookie、受约束的短期令牌方案或连接后的认证消息,并考虑日志泄漏、跨站风险、过期和重连。子协议字段本来用于协商协议,不能把“使用了子协议”本身当成认证;若用它承载令牌,还须遵守字段语法和日志处理规则。避免把长期凭据直接放在 URL 查询参数中。

8. 验收问题 ​

  1. 为什么一次 recv() 不能对应一个 WebSocket frame?
  2. 一条文本消息分成三个帧,中间收到 Ping 时解析器应如何处理?
  3. 为什么 Sec-WebSocket-Accept 不能认证服务端身份?
  4. 区分 协议 Ping/Pong、业务确认与重连恢复。
  5. 为慢消费者写出明确的队列上限和溢出策略。

下一课进入6.2 gRPC 合约、流式 RPC 与失败边界。

参考 ​

Built with VitePress | Software Systems Atlas