WebSocket 协议
参考文章
协议组成
协议由一个开放握手组成,其次是基本的消息成帧,分层的TCP.
解决的问题
基于浏览器的机制,实现客户端与服务端的双向通信.
协议概述
- 来自客户端握手
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
- 来自服务端的握手
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= // 可选的头,表示允许的通过的客户端 Sec-WebSocket-Protocol: chat
以上,头顺序无所谓.
一旦客户端和服务器都发送了握手信号,如果握手成功,数据传输部分启动。这是双方沟通的渠道,独立于另一方,可随意发送数据。
服务器的响应,不是随意的,需要遵循一定的规则 请参考RFC 文档 第 6/7页:
- 获取客户端请求的
Sec-Weboscket-Key
字段值,去除收尾空白字符 - 与全球唯一标识符拼接
258EAFA5-E914-47DA-95CA-C5AB0DC85B11
sha1
加密(短格式)- 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-Key
d 解码时长度为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:
- 必须,状态行
HTTP/1.1 101 Switching Protocols
- 必须,协议升级头
Upgrade: websocket
- 必须,表示连接升级的头字段
Connection: Upgrade
- 必须,
Sec-Websocket-Accept
头字段,详情请查阅 协议概述 部分 - 可选:
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. 特殊名词含义介绍
- 1bit,FIN
- 每个 1bit, RSV1、RSV2、RSV3
4bit,opcode(以下定义在ABNF中)
- %x0 连续帧
- %x1 文本帧
- %x2 二进制帧
- %x3 - %x7 保留帧
- %x8 链接关闭
- %x9 ping
- %xA pong
- %xB-F 保留的控制帧
- 以上表示的都是 16 进制数值
1bit, mask
- 客户端发送给服务端的数据都需要设置为 1
- 也就是说数据都是经过掩码处理过的
7bit、7 + 16bit、7 + 64bit,Payload length 具体范围请参阅 RFC 文档(Page 31)
- Playload length = Extended Payload length + Application Payload length
- 有效载荷长度 = 扩展数据长度 + 应用程序数据长度
- 扩展数据长度有可能为 0,所以当 扩展数据长度 = 0 的时候,有效载荷长度 = 应用程序长度
- 有效载荷数据的长度单位为
Byte
0/4 byte, masking-key
- 客户端发送给服务端的数据都是经过掩码处理的,长度为 32bit
- 服务端发送给客户端的数据都是未经过掩码处理的,长度为 0bit
x + y Byte, Payload Data
- 有效载荷数据
x Byte, Extension Data
- 扩展数据
y Byte, Application Data
- 应用数据
2. 理解
图中表示遵循 websocket
协议进行传输的数据,由于是经过 websocket
协议处理后的数据,所以无法直接获取有效数据。如果想要获取有效数据,就需要按照 websocket
协议规定进行解读。
图中从左往右,按高位到低位进行排列。
什么是低位、高位??
就像是十进制数字,如果有一个描述是这样的:3
表示个位,2
表示十位,1
表示百位,请问这个数字是??答案:123
。
这就很好理解了,个位、十位、百位
描述了排列顺序;同样的,在程序领域,低位到高位描述的也是排列顺序!不过 个位、十位、百位
描述的是10进制
的排列顺序,而 低位、高位
描述的是 2进制
的排列顺序,具体描述是 位0、位1、位2....
等(当前举例中的的排列顺序为低位到高位),以下是图片描述:
理解了低位、高位,就清楚了上图描述的数据排列顺序。
众所周知,位(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
- 可以在接收到
ping(0x9)
控制帧后,作为响应消息返回。 - 也可以单向发送
pong
帧,表示发送方进程还在,作为单向心跳
状态码
- 1000,正常关闭
- 1001,正在离开
- 1003,正在关闭连接
- 1004,保留
- 1005,保留
- 1006,保留
- 1007,端点正在终止连接,因为它收到的消息中没有与消息类型一致。
- 1008,端点正在终止链接,因为接收到了违反其规则的消息。
- 1009,端点正在终止链接,因为接受到的消息太大
- 1010,端点正在终止链接,因为扩展问题
- 1011,端点正在终止链接,发生了以外错误
- 1015,保留
- .....省略了部分,详情参考 rfc 文档
尾部
以上个人理解,仅供参考,有错欢迎纠正,未完待续 ....