原文链接:https://www.openmymind.net/WebSocket-Framing-Masking-Fragmentation-and-More/
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 ... |
+---------------------------------------------------------------+
以上图表摘自 RFC 6455 WebSocket 协议,描述了一个 WebSocket 帧的结构。
一个有效的 完整 WebSocket 消息最小是两个字节,比如这个来自服务器的、没有有效载荷的关闭消息:138, 0
。然而,最长可能的 头部 是 14 个字节,它代表了一个从客户端发送到服务器、有效载荷大于 64KB 的消息。
从客户端向服务器发送 "over9000" 将会是 14 个字节,最后的 12 个字节看似随机,类似于 129, 136, 136, 35, 93, 205, 231, 85, 56, 191, 177, 19, 109, 253
。但是同样的消息从服务器发送到客户端,总是这 10 个确切的字节:129, 8, 118, 101, 114, 57, 48, 48, 48
。
为什么最长的可能头部比最短的可能消息要长?为什么从客户端发送到服务器的消息与从服务器发送到客户端的同一消息看起来如此不同?还有,WebSocket 有没有其他的奇怪之处?
有效载荷长度(Payload Length)
作为面向流的协议,TCP 没有消息(或帧)的概念。它只是一序列字节。如果一方用 write
函数写入了四次,另一方可能需要 1-N 次 read
调用来获得这些字节,其中 N 是发送的字节总数(即每次读取只读取一个字节)。像 WebSocket 这样建立在 TCP 之上的解决方案需要提供它们自己的帧结构。一个常见的解决方案是在每条消息前加上长度前缀。例如,如果我们想发送两条消息,get power
和 set power 9000
,使用一个 2 字节的长度前缀,我们会发送:0, 9, 'g', 'e', 't', ' ', 'p', 'o', 'w', 'e', 'r'
和,0, 14, 's', 'e', 't', ' ','p','o','w','e','r', ' ', '9', '0', '0', '0'
.
(注意第一条消息以 0, 9
开头,是因为消息本身(不包括长度前缀)长度为 9 字节。同理,第二条消息以 0, 14
开头,因为它的长度是 14 字节。)
WebSocket 使用其第二个字节的前 7 个比特来支持可变长度的长度前缀。当这些比特等于或小于 125 时,则有效载荷的长度就是这个值本身。当比特等于 126 时,则接下来的 2 个字节指示有效载荷的长度。当这些比特等于 127 时,接下来的 8 个字节指示有效载荷的长度。
例如,如果我们想发送 "hello",那么第二个字节只包含所需的所有长度数据:byte2 & 127 == 5
。但如果我们想发送一个长度为 300 字节的有效载荷,那么会出现 byte2 & 127 == 126
,这会告诉我们要查看接下来的两个字节来获取有效载荷的长度,在这个例子中,它们是 1, 44
(即 256 + 44)。
拥有这种可变长度的前缀的好处是,有效载荷在 125 字节或更少的消息只需要一个字节。然而,大于 125 字节且小于 64K 的消息会需要 3 个字节(就像我们刚看到的 300 字节例子:126, 1, 44
)。更大的消息需要 9 个字节(byte2 & 127 == 127
后面跟着一个 8 字节长度)。
我更倾向于固定的 4 字节长度前缀。我认为可以假设大多数 WebSocket 消息都小于 16K,所以这会意味着大多数消息只会大 1 个字节。对于小消息,它会更长 3 个字节。但是,固定长度的前缀更容易处理,且错误情况更少。我不禁要问,那 1 个字节是否值得那些额外的 if 语句和错误情况。
掩码(Masking)
在我们帧的长度部分之后是一个 4 字节的掩码。根据规范:「掩码密钥需要不可预测;因此,掩码密钥必须来自一个强熵道的熵,并且给定帧的掩码密钥不得使得服务器/代理能简单地预测后续帧的掩码密钥。」
掩码是一个安全特性,旨在阻止恶意的客户端代码控制在线上出现的确切字节序列。第 10.3 节有更多细节。
掩码和有效载荷在从客户端发送到服务器之前会进行 XOR 运算,因此服务器必须反转这个过程。"hello" 的字节值(在 ASCII/UTF-8 中)是 104, 101, 108, 108, 111
。但是掩码是 1, 2, 3, 4
,它变成了:105, 103, 111, 104, 110
或者,如果你喜欢:'h' ^ 1, 'e' ^ 2, 'l' ^ 3, 'l' ^ 4, 'o' ^ 1
由于 4 字节的掩码是每个客户端启动的消息的一部分,服务器只需反转过程就可以获得未掩的有效载荷(('e' ^ 2) ^ 2) == 'e'
)。
我不知道在 2022 年掩码有多重要,也许威胁比以往更严重,也许浏览器的安全性或 WebSocket 支持已经改变了,应该重新评估 WebSocket 的安全性。但是如果你控制了客户端和服务器,并且不担心恶意的客户端代码(例如桌面应用),你可以破坏规范的加密要求,同时保持 WebSocket 帧的有效性,使用掩码 0, 0, 0, 0
。服务器可以检测到这个掩码并跳过解掩步骤,但请确保你知道自己在做什么 。
更烦人的是,客户端到服务器的所有消息必须包括一个掩码,且有效载荷必须被掩码(即使没有有效载荷的消息也必须包含掩码)。而且从服务器到客户端的所有消息都不得掩码。尽管有这些严格的要求,我们第二个字节的最高有效位(记住那个长度字节,只用了前 7 个比特吗?)被用来表示是否存在掩码。对我来说这似乎是 100% 多余的,并且只会引入错误情况。
消息类型(Message Type)
WebSocket 帧可以是 6 种类型之一:text
、binary
、ping
、pong
、close
和 continuation
。此外,每个帧要么是 fin
帧,要么不是。每个帧的第一个字节用于表示帧的类型(称为操作码)以及它是否是 fin
帧。
我们接下来会更多地讨论 fin
和 continuation
。
text
和 binary
之间的区别在于文本必须是有效的 UTF-8。我不在协议层面关心这种区别。这是浪费的处理。如果你关心 UTF-8 的有效性,你可能还会在你的应用程序中再次检查它(比如当你尝试将有效载荷解码为 JSON 时)。因此,尽可能使用 binary
来避免检查(特别是对于那些 2 exabyte 消息!)。
在我看来,ping
和 pong
也是不必要的。像 text
一样,最好直接在应用程序中处理它。
不过,我确实喜欢 close
类型。如果有有效载荷,它要对有效载荷提出具体要求。具体来说,它要求有效载荷的前两个字节是关闭代码。这对于调试很有用。
分片(Fragmentation)
WebSocket 支持帧分片。一个消息可以通过多个帧发送。考虑到 WebSocket 支持 8 字节长度前缀(或 exabyte 大小的消息),你可能想知道这是为了什么。我认为它存在的原因有 3 个,你很少会遇到。
第一种情况与流有关,服务器没有完整的有效载荷,不知道最终长度,但仍然想发送一些数据。第二种情况与能够用特殊控制帧,如 ping 中断大消息有关(后面会有更多讨论)。第三种,我认为是一个元原因,代理可以出于任何他们想要的原因对消息进行分片(例如使用较小的内存缓冲区),只要他们遵守分片的规则(以及 WebSocket 规范的其他所有部分)。
continuation
和第一个字节的 fin
部分用于控制分片。第一个片段会有一个 text
或 binary
类型,但 fin
不会被设置。然后你会得到 0 个或多个 continuation
帧,fin
仍不会被设置。最后,你会得到 1 个 fin
被设置的 continuation
帧。各个帧的有效载荷串联在一起形成最终消息。
我完全不喜欢这个功能。它让实现变得更加困难。有几个规则让我们的生活稍微轻松一点。只有 text
和 binary
帧可以被分片。只有控制帧(ping
、pong
和 close
)可以插入在分片帧之间。换句话说,我们一次只处理一个分片消息。
然而,即使这些对分片的限制意味着帧的生命周期变得更加复杂。我们不再得到一个帧然后传递给应用程序。我们需要累积帧,处理交织的控制帧,来创建消息。它不仅引入了许多错误情况,还让内存管理变得更加复杂(处理分片和交织而不分配更多内存是很困难的)。
Autobahn
我需要对开源的 Autobahn 测试套件项目表示感谢。它有许多测试用例,可验证客户端和服务器实现是否正确。我认为这是每个协议都应该追求的模式。
总结
最短的可能的 WebSocket 帧是一个没有有效载荷的服务器到客户端消息,它是 2 字节。因为没有有效载荷,长度为 0,因为它是服务器到客户端的,不需要掩码。最长可能的头部是 14 字节,用于客户端发往服务器的消息,有效载荷超过 16KB:8+1 字节用于长度和 4 字节用于掩码(加上第一个 fin/type 字节)。
"over9000" 从客户端发送到服务器时更长且不可预测,是因为掩码的缘故。由于服务器到客户端的消息没有掩码,所以它总是短 4 个字节,不会是随机的。
我很想更好地了解协议的特定部分是如何被设计出来的。为什么长度是可变的,它还有意义吗?为什么既有文本类型又有二进制类型?为什么分片?
特别是分片似乎没有用处,如果去掉它,仍然可以在库/应用层面实现。我对 ping 和 pong 的看法也是如此。当我查看 Slack 的网络流量时,我注意到他们实现了自己的 ping 和 pong 逻辑。
在 Apifox 中调试 WebSocket
如果你要调试 WebSocket 接口,并确保你的应用程序能够正常工作。这时,一个强大的接口测试工具就会派上用场。
Apifox 是一个比 Postman 更强大的接口测试工具,Apifox = Postman + Swagger + Mock + JMeter。它支持调试 http(s)、WebSocket、Socket、gRPC、Dubbo 等多种协议的接口,这使得它成为了一个非常全面的接口测试工具,所以强烈推荐去下载体验!
首先在 Apifox 中新建一个 HTTP 项目,然后在项目中添加 WebSocket 接口。
接着输入 WebSocket 的服务端 URL,例如:ws://localhost:3000
,然后保存并填写接口名称,然后确定即可。
点击“Message 选项”然后写入“你好啊,我是 Apifox”,然后点击发送,你会看到服务端和其它客户端都接收到了信息,非常方便,快去试试吧!
以下用 Node.js 写的 WebSocket 服务端和客户端均收到了消息。
知识扩展: