背景

以前,在网页中基于 HTTP 协议如果想要实现一个聊天功能,ClientA 与 ClientB 的聊天必须利用轮询的方式定时向聊天服务器拉取对方的消息,该方式的问题也很明显,就是存在聊天延迟,及如果聊天信息不多,客户端也会不停轮询访问服务器,对服务器造成不必要的网络资源浪费。为了解决 HTTP 单向通讯的问题,后来,一个为了解决双向通讯的协议 WebSocket 诞生了。

优点

  1. 减少轮询的开销,提高数据交互的实时性,为网页版应用提供更多的可能性
  2. 数据包可用率较 HTTP 有所提高,HTTP 的头部信息,其中 Cookie 就非常浪费网络 IO

    缺点

  3. 服务器维护的长连接大量增加时,服务器为了维持所有的长连接则会造成非常大的服务压力

  4. 与 HTTP 协议不同,WebSocket 具有状态(后面的报文分析将会提及),因此在 WebSocket 服务的横向扩展就会变得复杂,这对于 WebSocket 的负载均衡要求较高,主要体现在长连接的承受能力

    应用场景

  5. 聊天室

  6. 多人游戏
  7. 共享定位
  8. 邮件来信实时提醒

    试一试

    通过 Chrome 打开 http://www.websocket.org/echo.html 并打开开发者工具的 Network 面板即可看到相应文本报文,该页面也包含了 JavaScript 的 WebSocket 对象的 demo 代码,可以给大家体验。操作步骤如下图示

image.png

也可以通过 wireshark 抓包获取更多的 WebSocket 报文信息,在过滤器中输入 websocket 即可过滤出 WebSocket 协议的相关报文,如果你的 wireshark 不能过滤出 websocket 报文,那么请升级你的 wireshark
image.png

组成部分

URI

URI 在实际传输中并不需要,但这是创建 HTML5 WebSocket 对象的参数或其他客户端对接 WS server 的构造参数标准,通过 ws URL 可以获取 HTTP upgrade 所需要的信息,如 host, path 等,简单地说,ws URI 是建立连接的参数标准,WebSocket 的 URI 格式如下

  • ws-URI = “ws:” “//“ host [ “:” port ] path [ “?” query ] (使用 HTTP 协议 upgrade)
  • wss-URI = “wss:” “//“ host [ “:” port ] path [ “?” query ] (使用 HTTPS 协议 upgrade)

Port 是可选参数,ws 默认对应 80 端口,wss 默认对应 443 端口,可以看出这与 HTTP 和 HTTPS 所需的默认端口是一致,另外 query 也是可选参数。

建立连接

通过使用 ws URI 构建 WebSocket 对象所需的 HTTP Upgrade 请求参数,为什么 WebSocket 需要基于 HTTP 实现升级?一来,可以检测服务是否支持 WebSocket,二来,可以使用 HTTP 的基础设施进行通讯,三,则是可以利用 HTTP 的通讯端口(80 / 443)而无需额外的调整网络相关的策略,如防火墙,访问端口限制等。基于 HTTP 的升级请求与响应如下

1. Upgrade 请求 (Client -> Server)

  1. GET /chat HTTP/1.1
  2. Host: server.example.com
  3. Upgrade: websocket
  4. Connection: Upgrade
  5. Origin: http://example.com
  6. Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
  7. Sec-WebSocket-Protocol: chat, superchat
  8. Sec-WebSocket-Version: 13
  • Sec-WebSocket-Key 是客户端随机生成的 16 个字节长度的临时密钥,上例的是 base64encode(“the sample nonce”)
  • Sec-WebSocket-Protocol 这是一个可选参数,表明客户端想要升级的子协议,上例则是为 chat / superchat 这两个子协议之中的一个
  • Sec-WebSocket-Version 表明客户端选择的 WebSocket 版本,注意版本在规范中规定不需要向后兼容,上例的版本号为 13,如果服务器不支持版本号为 13 的请求,则需要返回给客户端能支持的版本号,且返回建立连接失败的说明

2. Upgrade 响应 (Server -> Client)

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
  • Sec-WebSocket-Protocol 对请求中的子协议进行选择,请参考请求中的 Sec-WebSocket-Protocol,该选项是可选参数
  • Sec-WebSocket-Accept 计算的算法为 Base64(SHA-1(${Sec-WebSocket-Key}${GUID})),以上述为例 Base64(SHA-1(dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11)) 后得到 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=,需要说明的是 ${GUID} 为固定值 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,可参考 RFC6455#Page-7 中的说明
  • SHA-1 转 Hex 请到 http://www.sha1-online.com/
  • Hex 转 Base64 请到 http://tomeko.net/online_tools/hex_to_base64.php?lang=en

    数据帧

    完整的 WebSocket 报文,请参考如下,摘自 RFC6455#Section-5.2。与 HTTP 报文类型,分别由控制头报文,其中控制头还包含了包体长度,唯一特殊的是掩码计算,计算的算法下面将会分析 ```bash 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 … | +———————————————————————————————-+ ``` | 字段 | 长度 | 说明 | 选项 | | :—-: | :—-: | :—-: | :—-: | | FIN | 1 bit | 是否为最终报文 | 0: 非最终报文,表示报文片段
                                                                  1: 最终报文 | | RSV1 | 1 bit | 保留位 | | | RSV2 | 1 bit | 保留位 | | | RSV3 | 1 bit | 保留位 | | | opcode | 4 bits | 操作码 | 0: 持续帧
                                                                  1: 文本帧
                                                                  2: 二进制帧
                                                                  8: 连接关闭帧
                                                                  9: 服务器向客户端发送的 Ping 帧
                                                                  10: 客户端向服务器响应的 Pong 帧 | | MASK | 1 bit | 是否对 Payload Data 根据掩码(Masking-key)进行异或计算 | 0: 否
                                                                  1: 是 | | Payload len | 7 bits | 有效负载的数据长度 | 0~125: 使用该长度
                                                                  126: 使用 16 位扩展长度
                                                                  127: 使用 64 位扩展长度 | | Extended payload length | 16 bits | 扩展长度,当 Payload len 等于 126 时使用 | 总长度 ,也就是说 Payload len 的 7 位不纳入计算 | | Extended payload length | 64 bits | 扩展长度,当 Payload len 等于 127 时使用 | 总长度 ,也就是说 Payload len 的 7 位不纳入计算 | | Masking-key | 32 bits | 进行异或运算的掩码 | 对 Payload Data 每 4 字节计算一次 | | Payload Data | Payload len | 有效的负载数据 | |

数据分片

结合 FIN / opcode 两个控制帧连接前后的数据帧,至于数据帧分片的应用场景,大概可分为如下情况。

1. 单次发送数据

Client: FIN = 1 / opcode = 1 / playload = "hello websocket"
Server: 收到 "hello websocket" 并并对信息处理

2. 分开多次发送连续数据

Client: FIN = 0 / opcode = 1 / payload = "hello"
Server: 收到 "hello" 并继续监听

Client: FIN = 0 / opcode = 0 / payload = " web"
Server: 收到 "hello web" 并继续监听

Client: FIN = 1 / opcode = 0 / payload = "socket"
Server: 收到 "hello websocket" 并对信息进行处理

掩码

掩码对应报文中的 Masking-key 字段,长度 4 字节(32 位),如果 MASK 为 1 才需要掩码计算。而有趣的是规定客户端给服务器发送数据的时候必须根据掩码对 Payload Data 进行异或(XOR)运算,为什么这样?这是为了防止代理缓存攻击而产生的安全问题,但仅能防止浏览器相关的攻击,也就是如果通过编写 WebSocket 客户端程序也能够进行攻击,更多的说明可参考 RFC6455#Section-10.3

异或运算的原理,我这里稍微提及一下,异或符号是 ^(或者使用 XOR 表示),只有 1 XOR 0 == 1,其他都是 0,如 1^0=1,1^1=0,0^0=0。

理解了异或运算的原理之后,我们再来看两组二进制数字的异或运算,12 和 3,对应二进制为 1100 / 0011,1100^0011 = 1111,那么再利用 1111(15) 分别对 1100(12) 和 0011(3) 进行异或运算 1111^0011=1100(15^3=12) / 1111^1100=0011(15^12=3),有趣的事情发生了,相当于 12^3^3=12 / 12^3^12=3,于是简单的推导出 a^b^a=b / a^b^b=a 的公式。

现利用掩码对 Payload Data 进行异或运算可得到 PayloadData^Masking-key = MaskedData,MaskedData^Masking-key=PayloadData。以下是使用 Java 写的一段对 Payload Data 与 Masking-key 进行掩码计算的代码。

byte[] data = wsdata(); // original data
byte[] maskingKey = new byte[4]; // 32 bits masking key (4 bytes)
for (int i = 0; i < data.length; i++) {
  data[i] ^= maskingKey[i % maskingKey.length];
}
// sendMaskedWsData(data);

心跳帧


1. Ping**
服务器发送 Ping 帧给客户端,检查客户端是否存活,发送 Ping 帧后,客户端必须响应 Pong 帧,除非客户端已经关闭。对应的控制帧参数如下

  • FIN = 1 / opcode = 9

2. Pong

当客户端收到服务器的 Ping 帧后必须响应 Pong 帧表明自己依然存活,如果 Ping 帧包含 Payload 数据,那么 Pong 帧也必须响应相同的 Payload 数据。如果接受到多个 Ping 帧,则只需要回复最后一个即可,即响应一个 Pong 帧。对应的控制帧参数如下

  • FIN = 1 / opcode = 10

    关闭帧

    当客户端或服务器想要关闭 WebSocket 长连接时,就会发送关闭帧,值得注意的是关闭帧可以包含数据用以表明关闭的原因。更多的关闭状态码可参考 RFC6455#Section-7.4.1。对应的控制帧参数如下

  • FIN=1 / opcode = 8

参考

https://tools.ietf.org/html/rfc6455
http://xor.pw/
http://www.websocket.org/