websocket原理解析和php原生实现websocket服务端

最近在网上查找websocket资料发现并没有很深入的讲解只是做了一些概念大纲性讲解和php的demo,在查找相关详细资料后这里深入讲解一下。

一、了解tcp、socket、websocket

讲解websocket之前我们首先来了解下tcp、socket、websocket之间的关系。
网上有很多相关资料参差不齐 ,
这里因为篇幅问题,进行简单介绍详细介绍参考:https://blog.csdn.net/weixin_40155271/article/details/80869542
tcp是传输层协议,服务端与客户端之间想要用websocket通信需要先建立tcp连接。
socket是一套操作tcp的接口,通过各个socket函数和接口 可以对tcp进行操作(比如建立连接,关闭连接,接收数据都是通过语言的socket函数去操作)
websocket是tcp连接建立后对传输数据的解析和封装。tcp连接中传递的数据都是二进制数据流,websocket协议会对二进制数据做规定,告诉两端这些二进制按什么格式解析,传输的时候按什么格式进行传输,让我们知道什么样的数据代表什么含义以得到正确的结果,这里举个简单例子,比如传过来的数据为 0010 0001,如果用字符解析会得到‘!’,用整数解析会得到十进制数041。哪个到底是我们想要的结果就需要根据websocket协议的规定来判断。

二、websocket协议剖析

下面详细讲解websocket协议。
资料来源于:https://infoq.cn/article/deep-in-websocket-protocol

1.建立连接

websocket协议规定,服务端和客户端建立tcp连接后,客户端会主动发送一组特定格式的消息
格式如下:

GET / HTTP/1.1
Host: localhost:8080
Origin: http://127.0.0.1:3000
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw==

这组报文消息每一行以结尾\r\n并且最后一行会额外附加一组\r\n
我们可以看出这是一组get方式的标准的 HTTP 报文格式。
重点请求首部意义如下:
Connection: Upgrade:表示要升级协议
Upgrade: websocket:表示要升级到 websocket 协议。
Sec-WebSocket-Version: 13:表示 websocket 的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号。
Sec-WebSocket-Key:与后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。
注意,上面请求省略了部分非重点请求首部。由于是标准的 HTTP 请求,类似 Host、Origin、Cookie 等请求首部会照常发送。在握手阶段,可以通过相关请求首部进行 安全限制、权限校验等。
服务端在接收到上面的消息后需要返回对应格式的报文消息
格式如下:

HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=

返回报文中可能含有Sec-WebSocket-Versionheader当服务端不支持客户端请求报文中Sec-WebSocket-Version所标识的版本时则在返回报文中添加Sec-WebSocket-Versionheader值为服务端支持的版本。
Sec-WebSocket-Accept的值是根据客户端请求报文中的Sec-WebSocket-Key的值计算来的,规则如下:
1.将Sec-WebSocket-Key的值跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
2.通过 SHA1 计算出摘要,并转成 base64 字符串。
php范例代码:

//$headArray['Sec-WebSocket-Key']为请求报文中的Sec-WebSocket-Key的sha1函数一定要传true得到原始20字符二进制格式
base64_encode( sha1( $headArray['Sec-WebSocket-Key'].'258EAFA5-E914-47DA-95CA-C5AB0DC85B11',true))

其余字段值写死即可。
当服务端返回对应报文后,就成功建立websocket连接,可以按websocket的规则进行传输数据了。

2.传输数据

WebSocket 传输数据和各种操作信息都是根据数据帧传输的,由 1 个或多个帧组成一条完整的消息(message)。
发送端:将消息切割成多个帧,并发送给服务端;
接收端:接收消息帧,并将关联的帧重新组装成完整的消息;

数据帧

1.1 数据帧格式概览

下面给出了 WebSocket 数据帧的统一格式。熟悉 TCP/IP 协议的同学对这样的图应该不陌生。
从左到右,单位是比特。比如FIN、RSV1各占据 1 比特,opcode占据 4 比特。
内容包括了标识、操作代码、掩码、数据、数据长度等。

  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.2数据帧格式详解

针对前面的格式概览图,这里逐个字段进行讲解,如有不清楚之处,可参考协议规范,或留言交流。
FIN:1 个比特。
如果是 1,表示这是消息(message)的最后一个分片(fragment),如果是 0,表示不是是消息(message)的最后一个分片(fragment)。

RSV1, RSV2, RSV3:各占 1 个比特。
一般情况下全为 0。当客户端、服务端协商采用 WebSocket 扩展时,这三个标志位可以非 0,且值的含义由扩展进行定义。如果出现非零的值,且并没有采用 WebSocket 扩展,连接出错。

Opcode: 4 个比特。
操作代码,Opcode 的值决定了应该如何解析后续的数据载荷(data payload)。如果操作代码是不认识的,那么接收端应该断开连接(fail the connection)。可选的操作代码如下:
0x0:表示一个延续帧。当 Opcode 为 0 时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片。
0x1:表示这是一个文本帧(frame)
0x2:表示这是一个二进制帧(frame)
0x3-7:保留的操作代码,用于后续定义的非控制帧。
0x8:表示连接断开。
0x9:表示这是一个 ping 操作。
0xA:表示这是一个 pong 操作。
0xB-F:保留的操作代码,用于后续定义的控制帧。
0x8到0xF为控制帧(代表发送的为操作消息) 0x0-0x7为数据帧(代表的发送的为数据消息)

Mask: 1 个比特。
表示是否要对数据载荷进行掩码操作。从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作。
如果服务端接收到的数据没有进行过掩码操作,服务端需要断开连接。
如果 Mask 是 1,那么在 Masking-key 中会定义一个掩码键(masking key),并用这个掩码键来对数据载荷进行反掩码。所有客户端发送到服务端的数据帧,Mask 都是 1。
掩码的算法、用途在下一小节讲解。

Payload length:数据载荷的长度,单位是字节。为 7 位,或 7+16 位,或 1+64 位。
假设数 Payload length === x,如果
x 为 0~126:数据的长度为 x 字节。
x 为 126:后续 2 个字节代表一个 16 位的无符号整数,该无符号整数的值为数据的长度。
x 为 127:后续 8 个字节代表一个 64 位的无符号整数(最高位为 0),该无符号整数的值为数据的长度。
此外,如果 payload length 占用了多个字节的话,payload length 的二进制表达采用网络序(big endian,重要的位在前)。

Masking-key:0 或 4 字节(32 位)

所有从客户端传送到服务端的数据帧,数据载荷都进行了掩码操作,Mask 为 1,且携带了 4 字节的 Masking-key。如果 Mask 为 0,则没有 Masking-key。

备注:载荷数据的长度,不包括 mask key 的长度。

Payload data:(x+y) 字节
载荷数据:包括了扩展数据、应用数据。其中,扩展数据 x 字节,应用数据 y 字节。
扩展数据:如果没有协商使用扩展的话,扩展数据数据为 0 字节。所有的扩展都必须声明扩展数据的长度,或者可以如何计算出扩展数据的长度。此外,扩展如何使用必须在握手阶段就协商好。如果扩展数据存在,那么载荷数据长度必须将扩展数据的长度包含在内。
应用数据:任意的应用数据,在扩展数据之后(如果存在扩展数据),占据了数据帧剩余的位置。载荷数据长度 减去 扩展数据长度,就得到应用数据的长度。

扩展数据一般用不到我们这里只了解无扩展数据的情况,
上面可以看出其中最重要的就是通过消息得到
FIN、Opcode、Mask、Payload length、Masking-key、应用数据(应用数据根据Masking-key解码后即为要传输的真正数据),然后根据各字段对应值进行对应操作(等待后续消息/关闭连接/返回pong/ping消息)。

2.数据传递

一旦 WebSocket 客户端、服务端建立连接后,后续的操作都是基于数据帧的传递。
WebSocket 根据opcode来区分操作的类型。比如0x8表示断开连接,0x0-0x2表示数据交互。

2.1、数据分片

WebSocket 的每条消息可能被切分成多个数据帧。当 WebSocket 的接收方收到一个数据帧时,会根据FIN的值来判断,是否已经收到消息的最后一个数据帧。
FIN=1 表示当前数据帧为消息的最后一个数据帧,此时接收方已经收到完整的消息,可以对消息进行处理。FIN=0,则接收方还需要继续监听接收其余的数据帧。
此外,opcode在数据交换的场景下,表示的是数据的类型。0x01表示文本,0x02表示二进制。而0x00比较特殊,表示延续帧(continuation frame),顾名思义,就是完整消息对应的数据帧还没接收完。

分帧规则
1.一个未分帧的消息包含单个帧,FIN设置为1,opcode非0。
2.一个分帧了的消息包含:开始于:单个帧,FIN设为0,opcode非0;后接 :0个或多个帧,FIN设为0,opcode设为0;终结于:单个帧,FIN设为1,opcode设为0。一个分帧了消息在概念上等价于一个未分帧的大消息,它的有效载荷长度等于所有帧的有效载荷长度的累加;然而,有扩展时,这可能不成立,因为扩展定义了出现的Extension data的解释。例如,Extension data可能只出现在第一帧,并用于后续的所有帧,或者Extension data出现于所有帧,且只应用于特定的那个帧。在缺少Extension data时,下面的示例示范了分帧如何工作。举例:如一个文本消息作为三个帧发送,第一帧的opcode是0x1,FIN是0,第二帧的opcode是0x0,FIN是0,第三帧的opcode是0x0,FIN是1。
3.控制帧可能被插入到分帧了消息中,控制帧必须不能被分帧。如果控制帧不能插入,例如,如果是在一个大消息后面,ping的延迟将会很长。因此要求处理消息帧中间的控制帧。
4.消息的帧必须以发送者发送的顺序传递给接受者。
5.一个消息的帧必须不能交叉在其他帧的消息中,除非有扩展能够解释交叉。
6.一个终端必须能够处理消息帧中间的控制帧。
7.一个发送者可能对任意大小的非控制消息分帧。
8.客户端和服务器必须支持接收分帧和未分帧的消息。
9.由于控制帧不能分帧,中间设施必须不尝试改变控制帧。
10.中间设施必须不修改消息的帧,如果保留位的值已经被使用,且中间设施不明白这些值的含义。

在遵循了上述分帧规则之后,一个消息的所有帧属于同样的类型,由第一个帧的opcdoe指定。由于控制帧不能分帧,消息的所有帧的类型要么是文本、二进制数据或保留的操作码中的一个。
虽然客户端和服务端都遵循同样的分帧规则,但也是有些差异的。在客户端往服务端发送数据时,为防止客户端中运行的恶意脚本对不支持WebSocket 的中间设备进行缓存投毒攻击(cache poisoning attack),发送帧的净荷都要使用帧首部中指定的值加掩码。被标记的帧必须设置MASK域为1,Masking-key必须完整包含在帧里,它用于标记Payload data。Masking-key是由客户端随机选择的32位值,标记键应该是不可预测的,给定帧的Masking-key必须不能简单到服务器或代理可以预测Masking-key是用于一序列帧的,不可预测的Masking-key是阻止恶意应用的作者从wire上获取数据的关键。由于客户端发送到服务端的信息需要进行掩码处理,所以客户端发送数据的分帧开销要大于服务端发送数据的开销,服务端的分帧开销是2~10 Byte,客户端是则是6~14 Byte。

2.2、连接保持 + 心跳

WebSocket 为了保持客户端、服务端的实时双向通信,需要确保客户端、服务端之间的 TCP 通道保持连接没有断开。然而,对于长时间没有数据往来的连接,如果依旧长时间保持着,可能会浪费包括的连接资源。
但不排除有些场景,客户端、服务端虽然长时间没有数据往来,但仍需要保持连接。这个时候,可以采用心跳来实现。
发送方 -> 接收方:ping
接收方 -> 发送方:pong
websocket规定当收到ping消息后需要返回pong消息,收到pong消息后返回ping消息
ping、pong 的操作,对应的是 WebSocket 的两个控制帧,opcode分别是0x9、0xA。
举例,WebSocket 服务端向客户端发送 ping,只需要如下代码(采用ws模块)

ws.ping('', false, true);

2.3、处理帧消息

了解到上述概念后我们就可以处理数据帧了,其中每一个数据帧都为一条tcp消息。
当接收到tcp消息后,我们先把消息作为二进制数据,按数据帧格式对其进行处理位处理,
得到FIN、Opcode、Mask、Masking-key、Payload data。
php代码示例

 	//获取第1个字节(前8bit)对应的十进制数(因为这里拿到数据后是按字符串读取的,所以需要ord函数让内存中的8位二进制数据按整数读取以方便后面的位操作、整数比较及16进制比较)从左到右分别包含FIN(1bit)和RSV(3bit)和Opcode(4bit)
 	$firstByte = ord($buf);
    $head['FIN'] = $firstByte >> 7;//右移七位得到第一bit二进制数对应的十进制数
    $head['Opcode'] = $firstByte & 15;//位于运算取最后4bit对应的数(于15即00001111进行&操作会把高四位置为0,不先左移四位再右移四位的原因是int数据长度不是1字节8bit而是根据操作系统位数决定4字节或8字节不会超过1字节(8bit)后溢出)
    //获取第2个字节(8bit)对应的十进制数,从左到右分别包含Mask(1bit)和PayloadLength(7bit)
    $secondByte = ord($buf[1]);
    $head['Mask'] = $secondByte >> 7;//右移七位得到第一bit二进制数对应的十进制数
    $head['PayloadLength'] = $secondByte & 127;//位于运算取最后7bit对应的数
    if($head['Mask'] == 1){
        $head['headLength'] = 6;//可变头部长度最小长度 FIN+RSV+Opcode 占一字节Mask+Payload length(最小长度7bit)占一字节  Masking-key占四字节
    }else{
	//出错了需要关闭连接 websocket规定客户端给服务端发送消息必须掩码。
	}
    //unpack采用大端字节序的原因是:计算机的内部处理都是小端字节序,其他的场合几乎都是大端字节序,比如网络传输和文件储存 这里属于网络传输所以采用大端字节序
    if($head['PayloadLength'] == 126){
        $head['headLength'] += 2; //可变头部长度为原来基础上加上2字节"数据长度表示位"
        $head['dataLen'] = unpack('n1',substr($buf,2,2))[1]; //对后续2个字节按16位的无符号整数取出得到数据长度(字节)
    }elseif ($config['PayloadLength'] == 127){
        $head['frameLen'] = unpack('J1',substr($buf,2,8))[1];//对后续8个字节按64位的无符号整数取出得到数据长度(字节)
        $head['dataLen'] += 8; //可变头部长度为原来基础上加上8字节"数据长度表示位"
    }else{
        $head['dataLen'] = $config['PayloadLength'] ;//PayloadLength对应的十进制的值即为数据长度(字节)
    }
    $head['Masking-key'] = substr($buf,$config['headLength'] - 4 ,4);//得到四字节长度掩码

然后根据Opcode进行相关操作:
当得到Opcode为0x8/0x9/0xA表示这是一个操作帧,FIN必须为1如果不为1则为非法请求(客户端不是websocket协议),断开连接。
然后分别处理:
0x8:表示连接断开。等待此客户端连接的当前所有消息发送完成后,发送一个关闭帧(Opcode为0x8的消息)然后关闭此客户端tcp连接
0x9:表示这是一个 ping 操作。解析出携带数据(解析方式下文会讲解),发送一个携带相同数据的Pong帧响应
0xA:表示这是一个 pong 操作。判断收到的ping帧是否都响应了,如果未响应则重新响应最后一个ping帧(一般无需处理)
当得到的Opcode 为0x0/0x1/0x2表示为一个数据帧,
0x0:表示一个延续帧。当 Opcode 为 0 时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片。
0x1:表示这是一个文本帧(frame)
0x2:表示这是一个二进制帧(frame)
我们需要判断Mask是否为1如果不为1则表示非法请求(客户端不是websocket协议),断开连接。
然后根据Masking-key解析出来携带数据。
Masking-key解析数据方法:
掩码键(Masking-key)是由客户端挑选出来的 32 位的随机数。掩码操作不会影响数据载荷的长度。
1.掩码操作都采用如下算法:
首先,假设:
original-octet-i:为原始数据的第 i 字节。
transformed-octet-i:为转换后的数据的第 i 字节。
j:为i mod 4的结果。
masking-key-octet-j:为 mask key 第 j 字节。
算法描述为: original-octet-i 与 masking-key-octet-j 异或后,得到 transformed-octet-i。
j = i % 4(取余)
transformed-octet-i = original-octet-i ^ masking-key-octet-j(异或)
2.反掩码得到原始数据
因为异或有a^ b ^ a = b的规律我们根据异或结果和其中一个操作数可倒推出另一个操作数,
所以由转换后的数据得到原始数据的方法和上面一样:
transformed-octet-i:为转换后的数据的第 i 字节。
original-octet-i:为原始数据的第 i 字节
j:为i mod 4的结果
masking-key-octet-j:为 mask key 第 j 字节。
original-octet-i = transformed-octet-i ^ masking-key-octet-j
得到的original-octet就是我们需要的原数据了,我们按字符串解析即可(这里需要注意根据FIN判断是否分帧如果分帧则需要得到每一帧的original-octet后按顺序拼接上original-octet才为最终所需数据)。

php解码示例:

	// $buf为接收到的完整的消息帧 $masking_key为消息帧中的掩码键(Masking-key)
	// $headLength为可变头部长度(单位字节,可能的值6、8、14(由Payload length决定))
	$data = substr($buf,$headLength);
	$decodeStr = '';
    for($i = 0;$i

websocket更详细说明参考:
https://www.jianshu.com/p/bc7d1a260c0c
https://infoq.cn/article/deep-in-websocket-protocol

你可能感兴趣的:(websocekt,php)