WebSocket 协议学习笔记

工作项目用了 Socket.IO 来做客户端与服务端的实时通信,我看了 Socket.IO 是基于 WebSocket 协议进一步封装的。于是,我希望通过实现 WebSocket 客户端一小部分 功能,以达到了解 WebSocket 协议的目标!

  1. 教科书:RFC 6455
  2. 老师:Codex
  3. 代码仓库地址:WebSocketClient-Demo

WebSocket 协议简介

WebSocket 协议是一种能让客户端(浏览器)与服务端进行双向通信的协议。该协议由基于传输控制协议(Transmission Control Protocol, TCP)的打开握手与后续的基本消息成帧组成。

建立连接时的握手阶段

客户端发起握手

WebSocket 在握手阶段是利用 HTTP GET 请求来与服务端建立连接的,但对于 HTTP 有版本要求:至少大于等于 1.1。

常见的客户端发起握手请求头如下:

1
2
3
4
5
6
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

以下是握手中必须包含的关键头字段:UpgradeConnectionSec-WebSocket-KeySec-WebSocket-Version。且根据协议的定义:

  1. Upgrade: websocket
  2. Connection: Upgrade
  3. Sec-WebSocket-Version: 13

这三组键值对是固定的(大小写不敏感)。

如何计算出 Sec-WebSocket-Key?

首先,设客户端随机生成 16 个字节长度的随机数为 A,再对 A 做 Base64 编码,得出的结果就是该 Key 对应的 value。客户端每次开始握手都得重新生成一次,不可重复。

为什么需要 Sec-WebSocket-Key?

它用于让服务端根据该随机值生成 Sec-WebSocket-Accept,客户端再进行校验,从而确认对端确实理解并接受 WebSocket 握手。

实现客户端握手中产生的思考

因为是基于 NWConnection 利用 TCP 协议直接发送 GET 请求头,所以我们得自己 手动调整字符串 构造 Data,以遵循 HTTP 请求头的格式。这一开始把我难到了,意识到还需多复习 HTTP 协议。

服务端响应握手

服务端会阅读客户端请求头,并检查是否符合以下几点:

  1. GET 请求且 HTTP 版本至少为 1.1
  2. 得有键值对 Upgrade: websocket(大小写不敏感)
  3. 得有键值对 Connection: Upgrade(大小写不敏感)
  4. 得有键值对 Sec-WebSocket-Version: 13(大小写不敏感)
  5. Sec-WebSocket-Key 作 Base64 decode,检查长度得是 16 个字节

如果以上的检查通过且可以创建连接,服务端会发出 HTTP Response:

1
2
3
4
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
如何计算 Sec-WebSocket-Accept?

具体实现可见 Handshake.swift

拼接字符串

首先获取到客户端发来的 Sec-WebSocket-Key(dGhlIHNhbXBsZSBub25jZQ==),拼接上这段协议中定义好的 GUID(全局唯一标识符):258EAFA5-E914-47DA-95CA-C5AB0DC85B11。

结果为 dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11

对拼接后的字符串做 SHA‑1

结果是 20 字节(160 位)的散列值:b3 7a 4f 2c c0 62 4f 16 90 f6 46 06 cf 38 59 45 b2 be c4 ea

把 SHA‑1 结果做 Base64 编码

结果为 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=。客户端收到这个 accept key 后,还会自行校验一遍。如果与自己算出来的不一致,会导致握手失败。

解析数据帧(Data Frame)

具体实现可见 DataFrameParser.swift

在握手成功后,双端严格以数据帧格式通信,格式如下图:

image-20260123201016373

头部结构(2 个字节)

第 1 个字节:FIN(Final,是否是消息最后一个分片)、RSV1/RSV2/RSV3(Reserved,保留位)、opcode(操作码,4 位)。

FIN 为 1 表示这是消息的最后一个分片;0 表示后续还有分片。

RSV1/RSV2/RSV3:除非协商了扩展,否则必须为 0;否则接收方必须关闭连接。

opcode:

  • 0x0 续帧(Continuation)
  • 0x1 文本帧(Text)
  • 0x2 二进制帧(Binary)
  • 0x3–0x7 预留给未来非控制帧
  • 0x8 关闭(Close)
  • 0x9 Ping
  • 0xA Pong
  • 0xB–0xF 预留给未来控制帧

第 2 个字节:MASK(是否掩码)+ Payload len(负载长度,7 比特)。客户端向服务端发送的数据帧必须进行 masking,故 MASK 为 1,服务端向客户端发送的数据帧不进行 masking,故 MASK 为 0。

Payload Data

若 Payload len 为 126:后面跟 2 字节扩展长度(16 位无符号整数)。若 Payload len 为 127:后面跟 8 字节扩展长度(64 位无符号整数,最高位必须为 0)。扩展长度使用网络字节序(network byte order,大端序)。若 MASK 为 1:后面跟 4 字节 Masking-key(掩码键)。最后是 Payload Data(负载数据):由 Extension data(扩展数据)+ Application data(应用数据)拼接组成。Payload Data 的总长度等于负载长度;其中扩展数据长度由扩展协议定义,未协商扩展时为 0。

特殊的数据帧——控制帧(Control Frame)

  • 定义与类型:操作码(opcode)最高位为 1 的帧即控制帧,已定义的有 0x8 关闭、0x9 Ping、0xA Pong;0xB–0xF 预留给未来控制帧。
  • 不能分片:控制帧必须 FIN = 1,且不得分片;如果接收方收到分片的控制帧,必须按协议失败处理(Fail the WebSocket Connection)。
  • 负载长度限制:控制帧负载长度必须 ≤ 125 字节;超过上限必须按协议失败处理。
  • 可穿插发送:控制帧可以在分片消息的中间插入发送,用于及时传达连接状态;接收方必须能在分片过程中处理控制帧。
  • 掩码规则:客户端发送的所有帧(包括控制帧)都必须带掩码;服务器发送的帧不得掩码。
Close(0x8)
  • 作用:发起关闭握手,告知对端准备关闭连接。
  • 负载结构:可选负载由状态码(2 字节)+ 关闭原因(可选 UTF‑8 文本)组成;若带原因文本,必须是有效 UTF‑8。
  • 行为要求:
    • 一端发送 Close 后,不得再发送数据帧。
    • 一端收到 Close 且自己尚未发送 Close,必须回送 Close 作为响应。
    • 完成双方 Close 交换后,连接关闭。
Ping(0x9)与 Pong(0xA)
  • Ping 可携带应用数据;收到 Ping 必须尽快回送 Pong。
  • Pong 的负载必须与对应 Ping 完全一致(逐字节相同)。
  • Pong 可以主动发送(非响应),作为单向心跳;对主动 Pong 不要求再回 Pong。
实现数据帧解析中产生的思考

复习了位运算,原来位运算发光发热的地方是这里!(例如获取 FIN 位)

复习了大端序和小端序:

  • 大端序(Big Endian):最高有效字节放在最低内存地址;符合人类的阅读习惯
  • 小端序(Little Endian):最低有效字节放在最低内存地址

强调「有效」是为了排除前导零或无效位造成的歧义。

当 payloadLength 等于 126 或 127 时,真正的 payloadLength 是要从紧接的 2 个字节或 8 个字节去读。这时候协议规定这段字节序是 network byte order(即大端序)。

在研究如何操作 Data 时,我仔细看了 Data 目前提供的 API,发现有 Span types!WWDC 25 Improve memory usage and performance with Swift 中有对它的介绍!Apple 也推出了 Swift Binary Parsing,后面计划抽空看看,应该能用来优化目前 DataFrameParser.swift 的实现。

分片(Fragmentation)

WebSocket 允许把一个消息拆成多个帧发送,降低内存压力与延迟,支持流式处理。数据帧也可以是不分片的。一个不分片的数据帧,FIN 位总是 1,opcode 肯定不为 0。

规则:

  • 第一个分片帧的 opcode 必须是文本帧(0x1)或二进制帧(0x2),FIN 位为 0。
  • 后续分片帧必须是续帧(0x0)。
  • 最后一个分片帧 FIN 为 1,opcode 仍为续帧(0x0)。
  • 消息边界:接收方用 FIN 判断一条消息结束;所有分片负载拼接成完整消息。
  • 控制帧:控制帧不能分片,但可以插入在分片序列中;接收方必须能在分片过程中处理控制帧。
  • 一致性约束:在一个消息分片未结束前,不能开始新的数据消息分片序列;否则属于协议错误。

关闭连接

正常关闭连接

任一端可发起:发送 Close 控制帧(可带状态码与原因),连接进入 CLOSING;双方都发送并接收 Close 后,应关闭底层传输控制协议(Transmission Control Protocol,TCP)连接,进入 CLOSED 状态。如果完成关闭握手后再关闭 TCP 连接,可称之为「干净关闭」,否则视为「非干净关闭」。

异常断开连接

出现错误即需关闭连接(握手阶段失败、协议错误、传输层连接意外中断等)。服务器在握手阶段可直接「中止连接(Abort)」并关闭 TCP 连接,不必发送 Close 帧。

用 Wireshark 分析

因为 WebSocket 的数据帧直接跑在 TCP 之上,很多只关注 HTTP 的抓包工具无法直接解析,所以我使用 Wireshark 来分析 WebSocketClient-Demo 发送的数据帧。我使用的是 websocat,用它来运行一个本地的 WebSocket 回声(echo)服务器。你给它发「hello」,它也会默认回你「hello」。

1
2
// 回声服务器在本地 8080 端口监听
websocat -t ws-listen:127.0.0.1:8080 mirror:

在另一个终端里连接回声服务器:

1
websocat -t ws://127.0.0.1:8080

建立连接时的握手阶段

Xnip2026-01-24_14-58-02

点击 Connect,客户端主动发送 HTTP 升级请求,并收到服务端的同意协议升级响应。

解析数据帧(Data Frame)

建立连接成功后,发送 “hello” 字符串

Xnip2026-01-24_15-24-15

我们可以看到数据帧为:0x81 0x85 0x88 0x56 0xa3 0xf5 0xe0 0x33 0xcf 0x99 0xe7

数据帧头部为两个字节:

  1. 0x81 -> 0b1000_0001;可得 FIN 为 1,后面保留的三位均为 0,最后四位是 opcode,为 1,即代表该数据帧为文本。
  2. 0x85 -> 0b1000_0101;可得 MASK 为 1,因为这是客户端发送的数据,必须 masking。后面七位是 payload length,为 5,等于 “hello” 字符串的长度。

因为 MASK 为 1,所以接下来的四个字节是客户端随机生成的 4 字节 masking key:0x88 0x56 0xa3 0xf5;

因为从头部分析出 payload length 是 5,所以后续 5 个字节就是 masked payload。

客户端 masking:遍历 unmasked payload 中的每个字节,与 masking key 中每个字节(取余 4)作异或操作。

服务端从数据帧中取出 masking key,用其与 masked payload 再做一遍异或操作,就能还原出 unmasked payload。这是利用了异或的可逆性。设任意 bit 为 b,masking key 为 k,那么可得 b XOR k XOR k = b XOR (k XOR k) = b XOR 0 = b。

“hello” 对应的 16 进制的表达为:0x68 0x65 0x6c 0x6c 0x6f,与 masking key(0x88 0x56 0xa3 0xf5)异或后为:0xe0 0x33 0xcf 0x99 0xe7;与 Wireshark 上捕捉的结果相符!

服务端发来的数据帧

Xnip2026-01-24_22-27-48

因为是回声服务器,所以服务端也是发来 “hello”,没有做 masking,能直接看到 payload 是 “hello”。

客户端主动与服务端断开连接

Xnip2026-01-24_22-58-26

上图中:客户端发出关闭控制帧,FIN 位为 1,opcode 为 8。

Xnip2026-01-24_22-58-53

上图中:客户端收到服务端响应的关闭控制帧。

心得体会

首先得:

  1. 把各个类的类名取好,能把类名取得比较恰当,说明心里已经把它的职责划分好。
  2. 定好各自公开/私有的接口。
  3. 开始使用之前「空实现」的类,把逻辑写对。
  4. 在第三步中,会带来很多思考,会使你退回第二步,重新思考接口的定义。重复几轮下来,大概率能完善接口的设计!
  5. 补上一些单元测试
  6. 补上具体方法的实现

我感觉通过前四步的操作,能对全局的实现以及方向有个更清晰的认识,俗称把握大局观。第一步和第二步只要和 Codex 老师进入 Plan mode 猛聊一小时(实际取决于实现的功能大小)。第三第四步自己开始尝试,遇到新的思路,再回头与 Codex 继续聊。第五第六步已经是来到 Codex 老师的舒适区了,要么是 review 它的代码,要么就是虚心学习它的代码。

Q & A

WebSocket 的出现是为了解决什么问题?

在 WebSocket 出现前,大家想在浏览器里做「服务端能主动推、客户端也能随时发」的双向通信,常见是这些方案:

  1. Long Polling(长轮询):客户端发起一个超文本传输协议(Hypertext Transfer Protocol,HTTP)请求,服务器在新消息前「挂起」响应;一旦返回,客户端立即发起下一次请求,以此近似持续连接与服务端推送。
  2. 流式传输:服务器保持单个 HTTP 连接很久,持续写入分块数据,客户端边接收边处理,达到持续推送效果。

它们能用,但都有明显缺点:开销大、延迟不稳定、实现复杂。WebSocket 设计出来就是要用一个标准协议把这事正规化:一次升级握手后,真正实现全双工,不再靠「把 HTTP 请求拖得很长」来模拟。

WebSocket 握手为什么要借用 HTTP?

它通过 HTTP/1.1 Upgrade 发起请求,升级成功之后,同一条 TCP 连接上跑的是 WebSocket 帧协议,已经不是 HTTP 报文了。它「借用了 HTTP 这张门票」进入现有网络环境。WebSocket 想把那些「伪装成 HTTP 的双向通信方案」统一替换掉;它通过 HTTP Upgrade 来建立连接,从而继续利用现有的代理、过滤和鉴权等基础设施

TCP 不是已经有 FIN/ACK 关闭流程了吗?为什么 WebSocket 还要多此一举?

现实网络中的中间设备有很多,TCP 关闭原因/时机,不一定能可靠地表达「对端按应用层语义关了」。

TCP 的 FIN/ACK 只表达「字节流结束」,不表达「消息语义结束」。但 WebSocket 有「消息/帧语义」和「关闭码/原因」。Close 帧可以带:关闭状态码(比如正常关闭、协议错误、消息太大、服务端异常等)和可选原因字符串。

参考

WebSocketClient-Demo 中仅实现了:

  1. 握手过程
  2. 仅支持解析/构造控制帧和 text 数据帧

所以,还是很有必要去看看其他 WebSocket 客户端/服务端的实现,我搜集了如下几个: