(一)WebSocket簡介
短連接:在傳統的Http協議中,客戶端和服務器端的通信方式是短連接的方式,也就是服務器端並不會保持一個和客戶端的連接,在消息發送后,會斷開這個連接,客戶端下次通信時,必須再建立和服務器的新連接,這就是短連接。在短鏈接的情況下,客戶端必須不停的主動發起請求,而服務器始終被動的響應請求,來推送回數據。這種方式用到游戲開發中,顯然是不適合的。
長連接:那么與之相對的就是長連接了。在長連接的情況下,客戶端和服務器端始終保持一條有效的連接,那么客戶端並不需要不停的主動發送消息,而服務器端也能主動的推送消息到客戶端。很類似前面介紹的Socket的收發方式。那么顯然長連接是我們游戲網絡開發所需要的。
WebSocket:正是有了這樣的需求,所以產生了WebSocket這一協議。注意,WebSocket只是一種協議,並不是一種Socket。WebSocket可以在客戶端和服務器端建立一種全雙工的通信連接。其協議是基於Tcp的方式實現的。
(二)WebSocket基礎知識
1.握手
WebSocket其實就是使用Tcp建立連接,那么當終端建立連接時,怎么才能知道是一般的Tcp方式還是WebSocket協議方式呢?這里就需要靠握手,簡單的說,通過握手機制,終端就能判別建立的是什么樣的連接,從而決定是以WebSocket方式來處理還是Tcp方式來處理消息。
如果我們是自己實現服務器端,其實我們在收包的時候,就是一般的Tcp Socket的收包,並沒有什么不同,該怎么處理還是怎么處理。但對於客戶端就不一樣了。因為大部分情況,客戶端是使用現有的瀏覽器來作為客戶端代碼的JS運行環境的(除非你連客戶端瀏覽器環境也是自己實現)。現有瀏覽器必須明確的知道協議類型,才能正確的建立長連接,並處理WebSocket包,並使用相關的JS代碼,所以握手就變的及其重要了。
當實現我們自己的服務器時,建立握手的意義在於正確的通知客戶端,服務器可以接收並允許建立一條基於WebSocket協議的連接
握手請求類似於下面這樣的一段信息,不同的瀏覽器可能不一樣,因為不同的瀏覽器遵循的WebSocket協議版本可能並不一致。
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Sec-WebSocket-Version: 13
Origin: http://localhost:5754
Sec-WebSocket-Extensions: permessage-deflate
Sec-WebSocket-Key: DC8b7Irs1RsyDvP2iEdsUQ==
Connection: keep-alive, Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
對於上面的內容,其實我們沒必要知道太多,其中關鍵的是“Sec-WebSocket-Key”中的內容。稍后將做解釋,我們先看服務器應該如何響應這樣的握手。當服務器決定接收這個WebSocket連接時,服務器必須回發一段有效的Http response消息給客戶端。這個很重要,因為只有發送正確的響應,客戶端瀏覽器才能確認WebSocket請求被接收,才能正確的建立起WebSocket連接(其實說白了就是因為瀏覽器不是我們自己開發,假設你有那閑工夫,自己開發整個瀏覽器和WebSocket環境,握手協議想怎么定是你自己的事,否則就要遵循標准)。
正確的服務器返回響應如下:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
同樣,不必過於關注具體內容,前面三行照抄就行了。我們只需要關注兩個地方,一個是換行,一個是Sec-WebSocket-Accept
換行:上述消息中,前三行后必須跟一個換行符,最后一行后則要跟兩個換行符
Sec-WebSocket-Accept*:這個值是一個經過加密處理的字符串,客戶端將驗證該值來判斷是否成功建立WebSocket連接,因為這個值的正確與否相當重要。對該值的計算方法是,將發來請求時的Sec-WebSocket-Key與GUID值“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”連接,然后將新字符串進行SHA1加密,將加密結果進行Base64的編碼轉換得到。(要注意的時,連接用的GUID值就是黑體標粗的這個值,時固定的,我第一次看文檔,還以為這個值只是個舉例,后來才發現原來是個常量字符串)
Tips:
處理握手協議時,除了以上兩點需要注意外,還有字符編碼格式也會影響建立連接的成功性。所以最好換行符使用Environment.NewLine,而不要使用”\r\n”。另外生成的響應消息字符串,最好使用Encoding.UTF8編碼,否則很容易因為編碼問題,導致客戶端無法識別,造成連接建立不成功。
附上生成加密key值和生成響應返回消息的代碼
private static byte[] PackHandShakeData(string secKeyAccept)
{
var responseBuilder = new StringBuilder();
responseBuilder.Append("HTTP/1.1 101 Switching Protocols" + Environment.NewLine);
responseBuilder.Append("Upgrade: websocket" + Environment.NewLine);
responseBuilder.Append("Connection: Upgrade" + Environment.NewLine);
responseBuilder.Append("Sec-WebSocket-Accept: " + secKeyAccept + Environment.NewLine + Environment.NewLine);
return Encoding.UTF8.GetBytes(responseBuilder.ToString());
}
private static string GetSecKeyAccetp(byte[] handShakeBytes, int bytesLength)
{
string handShakeText = Encoding.UTF8.GetString(handShakeBytes, 0, bytesLength);
string key = string.Empty;
var heads = handShakeText.Split("\n".ToCharArray());
foreach (var head in heads)
{
if (head.Contains("Sec-WebSocket-Key:"))
{
key = head;
key = head.Replace("Sec-WebSocket-Key:", "").Trim();
}
}
}
sc.Send(PackHandShakeData(GetSecKeyAccetp(buffer, length)));
2.幀數據
因為是基於Tcp Socket實現的,所以WebSocket實際的數據傳輸也是以流的方式傳輸。和Tcp一樣,WebSocket有自己的傳輸幀格式。在這個格式中,WebSocket定義了消息字節流開始部分的字節的用途及含義。下面我們可以看示意圖
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 ... |
+---------------------------------------------------------------+
byte1
(1)Fin代表數據是否結束,WebSocket會把較大的數據分成片發送,最后一片數據的Fin為1,代表數據片完結
(2)RSV1-RSV3是保留為,一般為0
(3)最后4bit代表Opcode,OpCode用來指示數據幀的類型。WebSocket的幀分為兩大類型,數據幀和控制幀。
0x0 代表連續幀,也就因為這該幀數據是分片數據中的一片,並不是完整數據
0x1 代表數據是文本內容
0x2 代表數據時二進制流
0x3-0x7 保留給日后的非控制幀使用
0x8 代表該數據時一個關閉命令通知(下面會解釋關閉)
0x9 代表Ping幀(同樣下面會解釋Ping)
0xA 代表Pong幀
0xB-0xF 保留給日后的控制幀使用
byte2
(1)Mask代表發來的幀中的數據,是否經過掩碼處理,1為true,0為false,一般在客戶端發給服務器端的數據中,該值都是1,也就是經過掩碼處理,服務器發往客戶端的不用掩碼。(注意,所謂的客戶端,服務端是相對的,接收WebSocket連接的那一端,也就是上面提到的回發加密處理的那一端是服務器端。這也解釋了,為什么我們要遵循WebSocket標准來進行握手,否則客戶端怎么知道自己發的數據得要掩碼處理呢)
(2)后面7位代表數據幀的數據長度或者是一個長度指示。我自己理解為是一個長度預判。當數據長度不超過125字節時,該值就是實際的數據長度,當長度在126~65535時,該值為固定的126,超過65535,該值固定為127
byte3~byte4
當Payload len = 126時,保存的是該幀數據的16位真實長度
byte3~byte10
當Payload len = 127時,保存的是該幀數據64位的真實長度
注意,如果長度不超過125,那么byte3~byte10就不代表數據長度了,也就是說不會預留給數據長度用,而是給后續的幀頭信息使用,后續幀頭的字節信息左移
byte11~byte14
這4個字節代表掩碼值,用客戶端指定,每個包都不一樣,只有經過掩碼值的解碼處理,才能獲得正確的數據
由此可以看到,WebSocket的消息封包,服務器端至少需要2個字節,客戶端至少6個字節
后續的字節就是實際發送的數據字節流了,下面是對數據幀解析的示例代碼
bool close = (buffer[0] & 0x08) == 0x08;
//暫時不處理,服務器端暫時只接收ping,不作服務器端主動發ping的考慮
bool ping = (buffer[0] & 0x09) == 0x09;
bool pong = (buffer[0] & 0x0A) == 0x0A;
bool fin = (buffer[0] & 0x80) == 0x80; // 1bit,1表示最后一幀
bool mask_flag = (buffer[1] & 0x80) == 0x80; // 是否包含掩碼
...
//足夠讀取分隔符
string data = null;
try
{
int payload_len = buffer[1] & 0x7F; // 數據長度
byte[] masks = new byte[4];
byte[] payload_data;
if (payload_len == 126)
{
Array.Copy(buffer, 4, masks, 0, 4);
payload_len = (UInt16)(buffer[2] << 8 | buffer[3]);
payload_data = new byte[payload_len];
Array.Copy(buffer, 8, payload_data, 0, payload_len);
}
else if (payload_len == 127)
{
Array.Copy(buffer, 10, masks, 0, 4);
byte[] uInt64Bytes = new byte[8];
for (int i = 0; i < 8; i++)
{
uInt64Bytes[i] = buffer[9 - i];
}
UInt64 len = BitConverter.ToUInt64(uInt64Bytes, 0);
payload_data = new byte[len];
for (UInt64 i = 0; i < len; i++)
{
payload_data[i] = buffer[i + 14];
}
}
else
{
Array.Copy(buffer, 2, masks, 0, 4);
payload_data = new byte[payload_len];
Array.Copy(buffer, 6, payload_data, 0, payload_len);
}
for (var i = 0; i < payload_len; i++)
{
payload_data[i] = (byte)(payload_data[i] ^ masks[i % 4]);
}
3.關閉連接
有握手,那么當然就講關閉了,網上很多教程往往只說明了建立握手,但是對於關閉WebSocket連接去只字未提。WebSocket的關閉,在實際操作中經常遇到的有三種情況,一種是瀏覽器的關閉,一種是我們js代碼主動關閉,還有一種是瀏覽器刷新(沒錯,刷新,我一開始沒注意這個問題)。而無論哪種方式,對於WebSocket來說,它必須發一個關閉的控制幀數據到對端。也就是上面提到的Opcode必須為0x8。
在發送了一個關閉的控制幀后,應用就不應該繼續發送數據,而對端在收到一個關閉控制幀后,也必須盡快發送一個關閉幀回應。(這里所謂盡快,其實是可控的,並不是立刻,你可以等到你的收發結束后,才立刻發送一個關閉回應)。發送關閉幀后的端,將不再處理收到的數據。
關閉幀可能會包含數據,如果其包含數據,那么前兩個字節一定是一個無符號整型所代表的狀態碼,代表了發生關閉的原因
4.Ping/Pong
WebSocket基於Tcp,同時它也改進了Tcp的一些實現特性。比如WebSocket自帶Ping/Pong,以此來實現其保持長連接的特性。使用Tcp時,我們往往要自己實現心跳,但WebSocket的Ping/Pong則完全替我們實現了心跳。不過很諷刺的是,雖然其WebSocket標准明確的實現了Ping/Pong但是現在各瀏覽器,或是WebSocket庫,並沒有提供發送Ping/Pong的API,也就是你如果不是自己實現WebSocket的協議的話,這Ping/Pong根本是沒法發的。
但目前的瀏覽器或者JS庫,雖然不同供發Ping的API,但它們可以接收Ping處理,並回發Pong數據。所以在我的項目里,由於我們自己實現WebSocket的服務器端協議,所以自己實現發Ping數據,然后處理瀏覽器返回的Pong數據來檢測了心跳。
另外,當一端收到多次ping時,並不需要返回每一個響應,只要返回最近一次Ping的Pong響應即可
(三)WebSocket理解誤區
1.分包,粘包,連包,半包
網上很多資料都說WebSocket不會粘包,半包。OK,這是正確的,因為上述將數據幀的時候我們已經看到WebSocket會將大的數據,自動分片發送。所以WebSocket會自動分包發送,因為這種分包發送,WebSocket的數據不會溢出接收緩沖區,所以也不會有半包的情況發送。
但是關於粘包,和連包,我看到一部分資料都說不會。因為WebSocket具有幀頭信息,所以不會粘包?這是不完全正確的,要知道Tcp的報文也是具有包頭信息的,只不過Socket已經處理了。而且經過我對我們項目服務器實際壓力測試,發現WebSocket會粘包,連包。不同的是,WebSocket的數據中擁有包頭信息,但Tcp沒有(實際開發中,我們自己一定會加個包頭來分割封包的,WebSocket只是替我們設計了一個包頭而已),但對這個包頭分割的處理,還是要我們自己完成,WebSocket不會代勞,如果我們自己不處理,抱歉,妥妥的粘包,連包
以上就是對WebSocket的一些簡單的理解心得和解釋,詳細的內容,大家可以去官網下載標准的文檔看,不過要注意一定要下最新的,我一開始下的是06版本,結果怎么弄都發現控制幀的數據代碼不對。
個人理解觀點,如有錯誤,歡迎討論指正