WebSocket 协议

参考文章

websocket RFC github 中文翻译

Websocket RFC 文档

workerman websocket 协议实现

协议组成

协议由一个开放握手组成,其次是基本的消息成帧,分层的TCP.

解决的问题

基于浏览器的机制,实现客户端与服务端的双向通信.

协议概述

  1. 来自客户端握手
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
  1. 来自服务端的握手
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
// 可选的头,表示允许的通过的客户端
Sec-WebSocket-Protocol: chat

以上,头顺序无所谓.

一旦客户端和服务器都发送了握手信号,如果握手成功,数据传输部分启动。这是双方沟通的渠道,独立于另一方,可随意发送数据。

服务器的响应,不是随意的,需要遵循一定的规则 请参考RFC 文档 第 6/7页:

  1. 获取客户端请求的 Sec-Weboscket-Key 字段值,去除收尾空白字符
  2. 与全球唯一标识符拼接 258EAFA5-E914-47DA-95CA-C5AB0DC85B11
  3. sha1 加密(短格式)
  4. base64 加密

PHP 程序描述:

$client_key = 'dGhlIHNhbXBsZSBub25jZQ==';
$client_key = trim($client_key);
$guid       = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
$key        = $client_key . $guid;
$key        = sha1($key , true);
$key        = base64_encode($key);

上述结果得出的值即是服务端返回给客户端握手的 Sec-Websocket-Accept 头字段值.

关闭链接

接收到一个 0x8 控制帧后,链接也许立即断开,也许在接收完剩下的数据后断开。

  • 可以有消息体,指明消息原因,可作为日志进行记录。
  • 应用发送关闭帧后必须不在发送更多数据帧。
  • 如果一个端点接受到一个关闭帧且先前没有发送关闭帧,则必须发送一个关闭帧。
  • 端点在接受到关闭帧后,可以延迟响应关闭帧,继续发送或接受数据帧,但不保证一个已经发送关闭帧的端点继续处理数据。
  • 发送并接收了关闭帧的端点,被认为是关闭了 websocket 连接,其必须关闭底层的 TCP 连接。

设计理念

基于框架而不是基于流/文本或二进制帧.

链接要求

针对客户端要求

  • 握手必须是一个有效的 HTTP 请求
  • 请求的方法必须为 GET,且 HTTP 版本必须是 1.1
  • 请求的 REQUEST-URI 必须符合文档规定的要求(详情查看 Page 13)
  • 请求必须包含 Host
  • 请求必须包含 Upgrade: websocket 头,值必须为 websocket
  • 请求必须包含 Connection: Upgrade 头,值必须为 Upgrade
  • 请求必须包含 Sec-WebSocket-Key
  • 请求必须包含 Sec-WebSocket-Version: 13 头,值必须为 13
  • 请求必须包含 Origin
  • 请求可能包含 Sec-WebSocket-Protocol 头,规定子协议
  • 请求可能包含 Sec-WebSocket-Extensions ,规定协议扩展
  • 请求可能包含其他字段,如 cookie

不符合上述要求的服务器响应,客户端都会断开链接.

  • 如果响应不包含 Sec-WebSocket-Protocol 中指定的子协议,客户端断开
  • 如果响应 HTTP/1.1 101 Switching Protocols 状态码不是 101,客户端断开

针对服务端要求

  • 如果请求是 HTTP/1.1 或更高的 GET 请求,包含 REQUEST-URI 则应正确地按照文档要求进行解析.
  • 必须验证 Host 字段
  • Upgrade 头字段值必须是大小写不敏感的 websocket
  • Sec-WebSocket-Keyd 解码时长度为 16Byte
  • Sec-WebSocket-Version 值必须是 13
  • Host 如果没有被包含,则链接不应该被解释为浏览器发起的行为
  • Sec-WebSocket-Protocol 中列出的客户端请求的子协议,服务端应按照优先顺序排列,响应
  • 任选的其他字段

响应要求:

  • 验证 Origin 字段,如果不符合要求的请求则返回适当的错误代码(例如:403)
  • Sec-WebSocket-Key 值是一个 base64 加密后的值,服务端不需要对其进行解码,而仅是用来创建服务器的握手.
  • 验证 Sec-WebSocket-Version 值,如果不是 13,则返回一个适当的错误代码(例如:HTTP/1.1 426 Upgrade Required)
  • 资源名验证
  • 子协议验证
  • extensions 验证

如果通过了上述验证,则服务器表示接受该链接.那么起响应必须符合以下要求详情查看 Page 23:

  1. 必须,状态行 HTTP/1.1 101 Switching Protocols
  2. 必须,协议升级头 Upgrade: websocket
  3. 必须,表示连接升级的头字段 Connection: Upgrade
  4. 必须,Sec-Websocket-Accept 头字段,详情请查阅 协议概述 部分
  5. 可选:Sec-WebSocket-Protocols 头部

完整的响应代码如下(严格按照如下格式响应!!头部顺序无所谓!关键是后面的换行符注意了!严格控制数量!):

HTTP/1.1 101 Switching Protocols\r\n
Connection: Upgrade\r\n
Upgrade: websocket\r\n
Sec-WebSocket-Accept: 3nlEzv+LqVBYnTHclAqtk62uOTQ=\r\n
// 下面这个头字段为可选字段
Sec-WebSocket-Protocols: chat\r\n\r\n

基本框架协议

数据传输部分对 进行了分组!!由于是在bit层面上进行的数据封装,所以如果直接取出的话,获取到的将是处理后的数据,需要解密。下图是传输数据格式

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 ...                |
 +---------------------------------------------------------------+

1. 特殊名词含义介绍

  1. 1bit,FIN
  2. 每个 1bit, RSV1、RSV2、RSV3
  3. 4bit,opcode(以下定义在ABNF中)

    • %x0 连续帧
    • %x1 文本帧
    • %x2 二进制帧
    • %x3 - %x7 保留帧
    • %x8 链接关闭
    • %x9 ping
    • %xA pong
    • %xB-F 保留的控制帧
    • 以上表示的都是 16 进制数值
  4. 1bit, mask

    • 客户端发送给服务端的数据都需要设置为 1
    • 也就是说数据都是经过掩码处理过的
  5. 7bit、7 + 16bit、7 + 64bit,Payload length 具体范围请参阅 RFC 文档(Page 31)

    • Playload length = Extended Payload length + Application Payload length
    • 有效载荷长度 = 扩展数据长度 + 应用程序数据长度
    • 扩展数据长度有可能为 0,所以当 扩展数据长度 = 0 的时候,有效载荷长度 = 应用程序长度
    • 有效载荷数据的长度单位为 Byte
  6. 0/4 byte, masking-key

    • 客户端发送给服务端的数据都是经过掩码处理的,长度为 32bit
    • 服务端发送给客户端的数据都是未经过掩码处理的,长度为 0bit
  7. x + y Byte, Payload Data

    • 有效载荷数据
  8. x Byte, Extension Data

    • 扩展数据
  9. y Byte, Application Data

    • 应用数据

2. 理解

图中表示遵循 websocket 协议进行传输的数据,由于是经过 websocket 协议处理后的数据,所以无法直接获取有效数据。如果想要获取有效数据,就需要按照 websocket 协议规定进行解读。

图中从左往右,按高位到低位进行排列。

什么是低位、高位??

就像是十进制数字,如果有一个描述是这样的:3表示个位,2 表示十位,1表示百位,请问这个数字是??答案:123

这就很好理解了,个位、十位、百位 描述了排列顺序;同样的,在程序领域,低位到高位描述的也是排列顺序!不过 个位、十位、百位描述的是10进制的排列顺序,而 低位、高位描述的是 2进制 的排列顺序,具体描述是 位0、位1、位2.... 等(当前举例中的的排列顺序为低位到高位),以下是图片描述:

WebSocket 协议

理解了低位、高位,就清楚了上图描述的数据排列顺序。

众所周知,位(bit)是内存中的最小存储单位,仅能存 0、1两个数值。所以要想获取、设置某位的值,需要进行位操作。由于是在上进行操作者,所以,图中描述的内容是在补码的基础上进行的。

客户端发送给服务端的数据是经过掩码处理的! 需要进行解析,解析数据流程:

// 按照 websocket 规范解析客户端加密数据
function decode(string $buffer){
    // buffer[0] 获取第一个字节,8bit
    // 对照那张图,表示的是 fin + rsv1 + rsv2 + rsv 3 + opcode
    // 之所以要转换为 ASCII 码值
    // 是为了确保位运算结果正确!
    // php 位运算详情参考:https://note.youdao.com/share/?id=927bfc2f40a8d62f4c9165de30a41e75&type=note#/
    // 这边做一点简单解释
    // 后面的代码会有 $first_byte >> 7 这样的代码
    // php 中 << >> 都会将操作数当成是整型数(int) 
    // 所以如果不转换成 ascii 值的话,过程将会是
    // (int) $buffer[0] >> 7
    // 这样的结果将是错误的!!
    // ord((int) $buffer[0]) !== ord($buffer[0]) 就是最好的证明
    // 因为 ascii 值不一样,则二进制值(严格一点,我认为应该说成是:补码)也不一样
    // 这违反了 websocket 规定的协议
    // 会导致解析错误
    $first_byte  = ord($buffer[0]);
    // buffer[1] 获取第二个字节,8bit
    // 对照那张图,表示的是 mask + payload len
    $second_byte = ord($buffer[1]);
    
    // 获取左边第一位值
    $fin = $first_byte >> 7;
    // 对照那张图,要想获取 payload len 表示的值
    // 需要设置 位 7 为 0
    // 因为位 7 表示的是掩码,位 0 - 6 表示的是 paylaod len 的补码
    // 所以要想获取 payload len 的值
    // 0111 1111 => 127
    $payload_len = $second_byte & 127;
    
    // 客户端发送给服务端的数据是经过掩码处理的
    // 所以要获取 掩码键 + 掩码处理过后的客户端数据
    // 获取 mask-key + payload data
    if ($payload_len === 127) {
        // 如果 payload len = 127 byte
        // payload len 本身占据 7bit
        // extended payload lenght 占据 64bit
        $mask_key       = substr($buffer , 10 , 4);
        $encoded_data   = substr($buffer , 14);
    } else if ($payload_len === 126) {
        // 如果 payload len = 126 byte
        // payload length 本身占据 7bit
        // extended payload lenght 占据 16bit
        $mask_key       = substr($buffer , 4 , 4);
        $encoded_data   = substr($buffer , 8);
    } else {
        // 如果 payload len = 126 byte
        // payload length 本身占据 7bit
        // extended payload lenght 占据 0bit
        $mask_key       = substr($buffer , 2 , 4);
        $encoded_data   = substr($buffer , 6);
    }
    
    // 对 payload data 进行解码
    $decoded_data = "";
    
    // 对每一个有效载荷数据进行解码操作
    // 解码规则在 RFC 文档中有详细描述
    for ($index = 0; $index < count($encoded_data); ++$index)
    {
        $k              = $index % 4;
        $valid_data     = $encoded_data[$index] ^ $mask_data[$k];
        $decoded_data  .= $valid_data;
    }
    
    // 这个就是客户端发送的真实数据!!
    return $decoded_data;
}

相反,如果服务器想要发送数据给 websocket 客户端,则也要对数据进行相应处理!处理流程:

// 按照 websocket 规范封装发送给客户端的消息
function encode($msg){
    if (!is_scalar($msg)) {
        print_r("只允许发送标量数据");
    }
    
    // 数据长度
    $len = strlen($msg);
    
    // 这边仅实现传输文本帧!第一个字节,文本帧 1000 0001 => 129
    // 如果需要例如二进制帧,用于传输大文件,请另行实现
    $first_byte = chr(129);
    
    if ($len <= 125) {
        // payload length = 7bit 支持的最大范围!
        $second_byte = chr($len);
    } else {
        if ($len <= 65535) {
            // payload length = 7 , extended payload length = 16bit,支持的最大范围 65535
            // 最后16bit 被解释为无符号整数,排序为:大端字节序(网络字节序)
            $second_byte = chr(126) . pack('n' , $len);
        } else {
            // payload length = 7,extended payload length = 64bit
            // 最后 64 位被解释为无符号整数,大端字节序(网络字节序)
            $second_byte = chr(127) . pack('J' , $len);
        }
    }
    
    // 注意了,发送给客户端的数据不需要处理
    // 详情查看 websocket 文档!!
    $encoded_data = $first_byte . $second_byte . $buffer;
    
    // 这个就是发送给客户端的数据!   
    return $encoded_data;
}

消息分片

分片目的

消息分片的主要目的是允许消息开始但不必缓冲整个消息时,发送一个未知大小的消息;未分片的消息需要缓冲整个消息,以便获取消息大小;

分片要求:

  • 首个分片 Fin = 0,opcode != 0x0,其后跟随多个 Fin = 0,opcode = 0x0的分片,终止于 Fin = 1,opcode = 0x0的片段
  • 扩展数据可能发生在分片中的任意一个分片中
  • 控制帧可能被注入到分片消息的中间,控制帧本身必须不被分割
  • 消息分片必须按照发送者发送顺序交付给收件人
  • 片段中的一个消息必须不能与片段中的另一个消息交替,除非已协商了一个能解释交替的扩展。
  • websocket服务器应能够处理分片消息中间的控制帧
  • 一个发送者可以为非控制消息(非控制帧)创建任何大小的片段
  • 不能处理控制帧
  • 如果使用了任何保留的位值且这些值的意思对中间件是未知的,一个中间件必须不改变一个消息的分片。
  • 在一个连接上下文中,已经协商了扩展且中间件不知道协商的扩展的语义,一个中间件必须不改变任何消息的分片。同样,没有看见WebSocket握手(且没被通知有关它的内容)、导致一个WebSocket连接的一个中间件,必须不改变这个链接的任何消息的分片。
  • 由于这些规则,一个消息的所有分片是相同类型,以第一个片段的操作码设置。因为控制帧不能被分片,用于一个消息中的所有分片的类型必须或者是文本、或者二进制、或者一个保留的操作码。

ping

接受到一个 ping(0x9) 控制帧,必须返回一个 pong(0xa) 控制帧,表示进程还在!!实际就是心跳检查

pong

  1. 可以在接收到 ping(0x9) 控制帧后,作为响应消息返回。
  2. 也可以单向发送 pong 帧,表示发送方进程还在,作为单向心跳

状态码

  1. 1000,正常关闭
  2. 1001,正在离开
  3. 1003,正在关闭连接
  4. 1004,保留
  5. 1005,保留
  6. 1006,保留
  7. 1007,端点正在终止连接,因为它收到的消息中没有与消息类型一致。
  8. 1008,端点正在终止链接,因为接收到了违反其规则的消息。
  9. 1009,端点正在终止链接,因为接受到的消息太大
  10. 1010,端点正在终止链接,因为扩展问题
  11. 1011,端点正在终止链接,发生了以外错误
  12. 1015,保留
  13. .....省略了部分,详情参考 rfc 文档

尾部

以上个人理解,仅供参考,有错欢迎纠正,未完待续 ....

相关推荐