📘 第九章 基于HTTP的功能追加协议
📄 一句话概括

WebSocket 的帧头只有 2 字节,比 HTTP 那 232 字节的首部小了两位数量级。 客户端发的帧必须掩码,服务端发的不能掩码; 一个消息可以拆成多帧,ping/pong 用来探活,关闭也有专门的帧。

📝 本节和前面几节的关系

上一节把连接谈下来了,这一节看这条连接上真正跑的东西。 重点只有四个:帧结构、掩码、分片、控制帧。

一、帧头只有 2 字节

第一个字节:最高位 FIN(这是不是最后一帧),接着三位保留,低四位是操作码(1 文本、2 二进制、 0 续帧、8 关闭、9 ping、10 pong)。 第二个字节:最高位 MASK(有没有掩码),低七位是负载长度。长度小于 126 就直接用; 等于 126 时后面跟 2 字节真长度;等于 127 时跟 8 字节。 就这么点东西。 对比 9.1 里那条 232 字节的 HTTP 请求首部——一条「你好」的消息, 整帧才 12 字节(2 字节头 + 4 字节掩码 + 6 字节负载)。 代价是:帧里没有 Content-Type、没有 Content-Length、没有 Host。 所以「这是什么格式的数据」得在应用层自己约好,协议不管。

二、掩码是强制的,而且只有客户端做

客户端发出去的每一帧,都必须把负载和一个 4 字节随机数做异或。服务端收到先异或回来,再处理。 反过来,服务端发的帧不许掩码。 为什么要多这一步? RFC 6455 给的理由是防「中间人劫持」—— 如果攻击者能让受害者的浏览器把带某些字节的流量发到别的服务上,那些字节可能被当成别的协议指令。 加了随机掩码,字节就不可控了。 这段解释来自规范本身,不是测出来的。本机测不到那个攻击场景,所以这里只说机制,不编数字。 代价很实在:Demo 里 6 字节的「你好」,不掩码整帧 8 字节,掩码整帧 12 字节,多花 4 字节, 相对负载涨了 67%。短消息上这笔开销不小,长消息就摊薄了。

三、一个消息可以拆成几帧

发一帧太长的话可以拆:第一帧操作码写 1(文本)但 FIN=0,后面几帧操作码写 0(续帧), 最后一帧 FIN=1。接收端拼起来才算一个完整消息。 Demo 里 Hello 和 World 分两帧发, 服务端收到的两帧分别是「文本 fin=0 长度 6」和「续帧 fin=1 长度 5」,拼起来回了个 Hello World。 分片的意义不是省字节,是边生成边发:服务端算出一部分就能先送出去,不必等整个消息凑齐。 流式输出(比如 AI 回答一个字一个字往外蹦)靠的就是这个。

四、控制帧:ping、pong、关闭

ping(9)和 pong(10)是探活用的:发一个 ping,对方必须原样回一个 pong。 Demo 里发 ping 带 beat,回来的 pong 内容也是 beat。 为什么要探活?因为中间的路由器和 NAT 会悄悄把闲置连接扔掉,两边都以为还连着,其实早断了。 定期 ping 一下就能发现。 关闭(8)的负载里前 2 字节是状态码,1000 表示正常关闭,后面可以跟一句原因。 Demo 发的是 code=1000 加 bye,服务端原样回一个关闭帧然后断开。 注意关闭是双向的:一方发关闭帧,另一方也要回一个,然后才断 TCP。 只发不等回就断,对端会认为连接异常中断。

五、动手 Demo:四个动作,四种帧

本机服务端握手之后进帧循环:收到文本就攒着,FIN 到了整段回;收到 ping 回 pong;收到关闭回关闭。 客户端依次发短文本、分片、ping、关闭。

def build(op, payload, mask=True, fin=True):
    n = len(payload)
    b0 = (0x80 if fin else 0) | op
    hdr = struct.pack(u'>BB', b0, (0x80 if mask else 0) | n)
    if not mask:
        return hdr + payload
    key = secrets.token_bytes(4)
    return hdr + key + bytes(payload[i] ^ key[i % 4]
                              for i in range(n))

实跑输出:

--------------------------------------------------------------------
帧:掩码、分片、ping/pong、关闭
--------------------------------------------------------------------
  动作       上行       下行       下行帧      下行内容
  短文本      12       8        文本       你好
  分片两帧     23       13       文本       Hello World
  ping     10       6        pong     beat
  关闭       11       7        关闭       code=1000 bye

  文本「你好」本身           6 字节
  不掩码的整帧               8 字节(2 字节帧头 + 6 负载)
  掩码的整帧                 12 字节(2 字节帧头 + 4 字节掩码 + 6 负载)
  掩码多花                   4 字节,67%

  服务端收到的帧数           5 个
    文本     fin=1 掩码=1 长度=6  你好
    文本     fin=0 掩码=1 长度=6  Hello 
    续帧     fin=1 掩码=1 长度=5  World
    ping   fin=1 掩码=1 长度=4  beat
    关闭     fin=1 掩码=1 长度=5  �bye

  掩码这件事只有客户端做,服务端发下来的帧不带掩码——这是强制规定,
  因为掩码的用途是防「中间人把客户端流量当成别的协议去污染缓存」,
  而服务端的流量走的方向不同,不需要。
  分片那两帧注意:第一帧 op=1(文本)、FIN=0;第二帧 op=0(续帧)、FIN=1。

短文本:上行 12 字节,下行 8 字节回 你好。下行比上行小 4 字节,因为服务端不带掩码。 分片两帧:上行 23 字节(12 加 11),下行 13 字节回 Hello World。 上行两帧、下行一帧——服务端拼好才发。 ping:上行 10 字节,下行 6 字节 pong,内容还是 beat,一个字节没改。 关闭:上行 11 字节(2 字节状态码 1000 加 3 字节 bye),下行 7 字节原样回来。 整轮下来服务端一共收到 5 个帧。

30 秒实验

  1. 把 你好 换成 200 个汉字,看帧头长度字段从 7 位变成 16 位的那个分界点。
  2. 故意给服务端发一个带掩码的帧,看它会怎么处理(规范说服务端必须断开)。
  3. 把 ping 的负载改成 126 字节,看帧头会不会多出 2 字节。

常见误区

  1. 「掩码是加密」——只是异或一个随机数,收到就能还原,防的是协议混淆不是窃听。
  2. 「服务端也要掩码」——不许,服务端带掩码是协议违规。
  3. 「分片是为了压缩」——是为了边生成边发,字节数反而多了一点帧头。
自测三问(点开对答案) 1. WebSocket 帧头最少几个字节? —— 2 个字节:FIN 加操作码,掩码位加长度。 2. 掩码是谁做的?为什么? —— 客户端做,服务端发的帧不许掩码。目的是防中间人把流量当成别的协议利用。 3. ping 和 pong 的用途是什么? —— 探活:发现被中间设备悄悄丢掉的闲置连接。

WebSocket 换掉了应用层。真正的正主在后面:不改语义、只改传输的 HTTP/2.0。

相关笔记