WebSocket 工作原理

握手过程

WebSocket 握手过程是一个将 HTTP 升级为 WebSocket 的过程

  1. 首先由客户端发送一个 GET 请求

    1
    2
    3
    4
    5
    6
    7
    8
    GET /chat HTTP/1.1
    Host: server.example.com
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    Origin: http://example.com
    Sec-WebSocket-Protocol: chat, superchat
    Sec-WebSocket-Version: 13
    • Upgrade: 规定必需字段,值必需为 websocket,如果不是,则握手会失败
    • Connection: 规定必需字段,值必需为 Upgrade,如果不是,则握手会失败
    • Sec-WebSocket-Key: 必需字段,随机字符串
    • Sec-WebSocket-Protocol: 可选字段,表示应用层的协议
    • Sec-WebSocket-Version: 必需字段,代表 WebSocket 协议的版本,值必需为13,否则握手失败
  2. 服务端会返回一个状态码为101的响应

    1
    2
    3
    4
    5
    HTTP/1.1 101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
    Sec-WebSocket-Protocol: chat
    • Upgrade: 规定必需字段,值必需为 websocket,如果不是,则握手会失败
    • Connection: 规定必需字段,值必需为 Upgrade,如果不是,则握手会失败
    • Sec-WebSocket-Accept: 规定必需字段,该字段是通过固定字符串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 加上请求中的 Sec-WebSocket-Key 值,计算 SHA1 哈希摘要的结果
    • Sec-WebSocket-Protocol: 对应请求中的 Sec-WebSocket-Protocol

WebSocket 协议数据帧

数据帧的定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 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 ... |
+---------------------------------------------------------------+

以上数据,一行代表 32 bit(位),也就是 4 bytes,总体包含以上两部分,帧头部和数据内容。每个从 WebSocket 链接中接受到的数据帧,都要按照以上格式进行解析,这样才能知道该数据帧是用于控制的还是用于传送数据的。部分字段解释:

  • FIN: 1 bit,当该比特位值为 %x0 时,表示后面还有更多的数据帧,%x1 表示为最后一个数据帧
  • RSV1, RSV2, RSV3: 各占 1 bit,一般全为 0,当客户端和服务端协商采用 WebSocket 扩展时,三个标志位为非 0,且该值由扩展进行定义。如果没有采用 WebSocket 扩展,且为非零时,连接出错
  • opcode - 4 bit,用于表明数据帧的类型,一共可以表示 16 种帧类型
    • %x0 - 表示这是一个分片的帧,属于前面帧的后续帧
    • %x1 - 表示数据帧携带的数据类型是文本类型,且编码为 UTF-8
    • %x2 - 表示携带的是二进制数据
    • %x3-7 - 保留未使用
    • %x8 - 表示该帧用于关闭 WebSocket 链接
    • %x9 - 表示该帧代表了 Ping 操作
    • %xA - 表示该帧代表了 Pong 回应
    • %xB-F - 保留未使用
  • MASK - 1 bit,%x0 表示数据帧没有经过掩码计算,%x1 表示数据帧经过了掩码计算,需要解码才能得到真正的数据,通常浏览器发送给服务器才会进行掩码计算
  • Payload len - 7 bit,表示数据帧携带的数据长度,7 bit 代表的最大值为 127,会存在三种情况
    • %x0-7D - 0-125,表示数据长度,数据总长度的就是 7 bit 表示的长度
    • %x7E - 126,则后续的 2 个字节(16 bit)表示一个16位的无符号数,这个数用来表示数据长度
    • %x7F - 127,则后续的 8 个字节(64 bit)表示一个64位的无符号数,这个数用来表示数据长度
  • Masking-key - 32 bit,表示用于解码的 key,当只有 MASK 比特位的值为 %x1 时使用
  • Payload Data - 余下的比特位用于存储具体的数据

通过分析可知,WebSocket 的最大头部为 14 bytes,要实现 WebSocket,最主要的工作就是实现对数据帧的解析

WebSocket 协议中的一些算法

Sec-WebSocket-Accept 的计算

Sec-WebSocket-Accept 值的计算方法是通过 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 加上请求中的 Sec-WebSocket-Accept 的值,然后对结果求 SHA1 哈希摘要,最后再转为base64。代码如下

1
2
3
4
5
6
7
8
var keyGUID = []byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11")

func computeAcceptKey(challengeKey string) string {
h := sha1.New()
h.Write([]byte(challengeKey))
h.Write(keyGUID)
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
1
2
3
4
5
6
7
KEY_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'

def compute_accept_key(challenge_key: str) -> str:
m = hashlib.sha1()
m.update(challenge_key.encode('utf-8'))
m.update(KEY_GUID.encode('utf-8'))
return base64.b64encode(m.digest()).decode('utf-8')

掩码处理

如果浏览器发送的数据帧中 MASK 比特位的值为 %x1,那么就需要对数据进行掩码处理

掩码处理的流程为以下几个步骤

  1. 将数据和 Masking-key 处理按字节处理
  2. 取出数据中的第 i 个字节 item1
  3. 通过 j = i MOD 4 计算获得 j,从 Masking-key 中取出第 j 个字节的数据 item2
  4. item1item2 进行异或得到最后结果
1
2
3
4
5
6
7
func maskBytes(key [4]byte, pos int, b[]byte) int {
for i := range b {
b[i] ^= key[pos & 3]
pos++
}
return pos & 3
}
1
2
3
4
5
def mask_bytes(key: str, pos: int, b: list) -> int:
for i in range(len(b)):
b[i] ^= key[pos & 3]
pos += 1
return pos & 3

注意:

  • pos & 3 的结果和 pos % 4 的结果是一样的,因为 a % (2^n) 等价于 a & (2^n - 1) ,但是 &% 的效率要高
  • 此处返回的 pos 是为了下次继续使用,如果数据帧中的数据长度大于 125,那么就需要多次进行掩码处理

代码实现

参考 websocket