最近在網上查找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