天天看點

websocket原了解析和php原生實作websocket服務端一、了解tcp、socket、websocket二、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<strlen($data);$i++){
        $decodeStr .= $data[$i] ^ $masking_key[$i%4];
    }
    return $decodeStr;
           

websocket更詳細說明參考:

https://www.jianshu.com/p/bc7d1a260c0c

https://infoq.cn/article/deep-in-websocket-protocol