1. 程式人生 > >遊戲網路程式設計(三)——WebSocket入門及實現自己的WebSocket協議

遊戲網路程式設計(三)——WebSocket入門及實現自己的WebSocket協議

(一)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版本,結果怎麼弄都發現控制幀的資料程式碼不對。

個人理解觀點,如有錯誤,歡迎討論指正。