深入WebSocket协议

无论是 HTTP 还是 HTTPS协议,它们一出场就已经定死了只能是短连接,而诸如基于 Jquery 或 Ajax 靠定时器轮询的请求来实现长连接的方法却会加大服务端的资源消耗,只能是旁门左道。即便服务端有 HTTP Keepalive 这种连接复用,仍旧无法避免一段空闲时间后就断开连接的尴尬,尤其是中间还有不可预知的网关也可能会关闭这个连接,并且服务端一旦配置不当,TCP长连接在服务端未能及时释放造成资源消耗加剧,就会出现类似雪崩效应。

并且基于短连接,HTTP/HTTPS 协议就产生了一个巨大缺陷,即请回只能由客户端发起,服务端只能做回应,不能做主动推送,因为服务端不知道客户端何时会将连接关闭(TCP是双工的,而各个浏览器的实现也不一样)

因此就急需一种能够让浏览器支持并兼任HTTP/HTTPS协议的TCP长连接。

时代造就英雄,这种环境下便有了WebSocket

WebSocket 协议在2008年诞生,由谷歌提出(话说Ajax也是谷歌提出的),是 HTML5 种的一个协议规范,2011 年成为国际互联网标准规范 RFC 6455,跟随着 HTML5 的步伐一同面世,主流浏览器早已全部支持了。

WebSocket 的优点有:

  • 底层基于 TCP 实现的长连接协议,双工的性质使得服务端能主动推送信息

  • 同样可以使用 80 和 443 端口,协议转换通过 HTTP/HTTPS 的 opening handshake 数据包,因此能与现有的 HTTP/HTTPS 保持兼容

  • 协议的数据格式前置开销少,最小为 6 Bytes(48 bits),最大为 14 Bytes(112 bits);少量的协议开销就实现稳定可靠的数据传输

  • 协议可传输多种数据类型,如 binary 数据类型, text 数据类型,还带有心跳与回应等数据类型

WebSocket 的 URL 除了连接标识符为ws(未加密)wss (tls证书加密) ,其后的 域名/端口/路径 都与 HTTP/HTTPS协议一致:

ws://example.com:8080/somepath
wss://example.com/somepath

WebSocket 本质是一个独立于 HTTP/HTTPS 的TCP协议,与 HTTP/HTTPS 的关系只在于发起请求阶段的试探。最初客户端会发送一个HTTP/HTTPS 协议格式的 opening handshake 请求;服务端收到后,则响应一个 HTTP/HTTPS 的数据包。服务端如果支持则客户端直接就转为 WebSocket 协议通信;服务端如果不支持则不会以标准的协议升级的Header格式回应。


WebSocket握手包

客户端 opening handshake 请求包的Header结构:

GET /somepath 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

客户端 opening handshake 的 Header 要求:

  1. request method 必须为 GET
  2. Request URI 可以自定义,可以用于识别不同 WebSocket 服务;例如想要连接到ws://example.com/somepath则格式为GET /somepath HTTP/1.1
  3. HTTP协议不可小于 1.1
  4. Header 必须包含 Host 字段
  5. Header 必须包含 Upgrade 字段,且值必须为 websocket
  6. Header 必须包含 Connection 字段,且值必须为 Upgrade
  7. Header 必须包含 Sec-WebSocket-Key 字段,值为随机 16 字节长的字符并经过base64编码
  8. Header 中的 Origin 字段可用于保护未授权的跨域攻击,服务端可以根据该值选择是否拒绝本次的请求;如果请求来自浏览器则必须包含 Origin 字段;对于非浏览器客户端,如果有特殊含义也可选择发送
  9. Header 必须包含 Sec-webSocket-Version,标准版的值必须为 13 ;其他草案版本的 9, 10, 11 和 12 作为未登记的保留值使用
  10. Header 可选择性包含 Sec-WebSocket-Protocol 字段,值为非空字符串,可以包含一个或多个子协议,使用分隔号分割,并按照优先顺序排列,用于交给 WebSocket 上层的应用处理。
  11. Header 可选择性包含 Sec-WebSocket-Extensions 字段,用作协议层的扩展,值可为List格式,如Sec-WebSocket-Extensions: foo1;foo2;foo3=bar
  12. 可存在其他的字段,如 cookies 可以当作给服务端验证授权用途;而 Header 中未被服务端识别的字段将被忽略

服务端 opening handshake 响应包 Header 结构:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

服务端相应包 Header 要求:

  1. 响应码 101 通常代表服务端允许切换协议;如果不是其他响应码,比如 401 则可以代表验证失败,3xx 可以代表跳转(不要求客户端遵守)
  2. Header 必须包含 Upgrade 字段,其值在忽略大小的 ASICⅡ 码下必须匹配websocket字符,否则作连接失败处理
  3. Header 必须包含 Connection 字段,其值在忽略大小写的 ASICⅡ 码下必须匹配Upgrade字符,否则作连接失败处理
  4. Header 必须包含 Sec-WebSocket-Accept 字段,其值必须为处理过的 Sec-WebSocket-Key 的值,处理方式通常在为 Sec-WebSocket-Key 后跟上GUID,然后进行 SHA-1 加密后 Base64 编码为可见字符,否则作连接失败处理
  5. Header 可选包含 Sec-WebSocket-Protocol 字段,如果客户端请求时带有 Sec-WebSocket-Protocol 字段,且服务端 WebSocket 上层应用支持其中的子协议,则按照优先度,回应一个最优的子协议,否则作连接失败处理
  6. Header 可选包含 Sec-WebSocket-Extensions 字段,如果客户端请求时带有 Sec-WebSocket-Extensions ,且服务端支持扩展并回应包含了的扩展请求,否则作连接失败处理

WebSocket的帧格式

  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

表示是否是最后一个帧,控制粘包

RSV1, RSV2, RSV3: 都是 1 bit

如果未指定扩展协议则值必须都为 0 ;如果在未指定扩展的情况下仍旧接收到非 0 值,则接收端必须中断此连接

Opcode: 4 bits

表示载荷数据的类别,如果接收端收到未知的 Opcode ,则接收端必须中断此连接,以下为 Opcode 释义:
%x0 表示数据为连续帧
%x1 表示数据为 text 类型的帧
%x2 表示数据为 binary 类型的帧
%x3-7 为将来的非控制特性所保留的 Opcode
%x8 表示连接关闭
%x9 表示 ping
%xA 表示 pong
%xB-F 为将来的控制特性所保留的 Opcode
其中-%x1-7 为非控制帧,%x8-F 为控制帧

Mask: 1 bit

表示是否对 Payload Data 进行掩码处理,如果值为 1 ,则进行掩码处理,掩码的 key 放在 Masking-key 部位;来自客户端的所有帧都必须进行掩码处理

Masking-key: 0 or 4 bytes

表示掩码关键字,值为 32 bits长度的且不可预测的随机数值; 所有来自客户端的帧都必须经过掩码处理;如果 Mask 为 1 则表示此字段存在, Mask 为 0 则该表示字段不存在

Payload length: 7 bits, 7+16 bits, or 7+64 bits

表示 Payload Data 的长度,转为无符号数字处理,如果是 0-125 的范围内,则该值即为 Payload Data 的长度;如果是 126 ,则随后的 2 Bytes 作为 16 位无符号整型的值即为 Payload Data的长度;如果是 127 ,则随后的 8 Bytes 作为 64 位无符号整形的值即为 Payload Data的长度;多字节长度以网络字节顺序(大端)表示;Payload length 的值为 Extension data 长度与 Application data 长度之和,如果 Extension data 长度为 0 ,则为 此时就是指 Application data 的长度;需要注意的是,Payload length 不包括 Masking-key 的长度

Payload data: (x+y) bytes

表示载荷的数据,为 Extension data 加 Application data

Extension data: x bytes

表示扩展数据,存在于 Payload data 中;通常长度为 0 ,除非之前在 opening handshake 阶段有过协商;该数据必须指定长度,或指定如何计算长度;

Application data: y bytes
任意的应用数据,跟在 Extension data 后面;其长度为 Payload length 的值减去 Extension data 的长度

Mask 的算法

void maskData(char* data, short headerLength){
    char mask[4];
    uint32_t random = rand();
    data[1] |= 0x80;
    memcpy(mask, &random, 4);
    memcpy(data + headerLength, &random, 4);
    headerLength += 4;
    char *start = dst + headerLength;
    char *stop = start + length;
    int i = 0;
    while (start != stop)
        (*start++) ^= mask[i++ % 4];
}

void unmaskData(char *data, char *stop, char *mask) {
    while (data < stop) {
        *(data++) ^= mask[0];
        *(data++) ^= mask[1];
        *(data++) ^= mask[2];
        *(data++) ^= mask[3];
    }

通常情况下,WebSocket 在驱动层就做好了数据处理,用户只需读取数据即可。