老司機帶你用 PHP 實現 Websocket 協議

Dennis_Ritchie發表於2019-11-14

我為什麼會寫這篇文章?

當初作為程式設計小白的我,剛剛從事後臺工作,覺得http是個很牛逼的東西,然而後面隨著自己深入學習並實踐之後,覺得原來和我所想的天壤之別,沒大家想象的那麼複雜,僅僅是個協議嘛!。後面學習的東西多了,慢慢的就淡定了。今天這裡之所以要講websocket,而不是其它的協議,從某種意義上來說(請允許我裝個逼),更能說明問題,如果你把websocket都搞懂了,那麼http對於你來說,簡直就是雕蟲小技啊,關於websocket的程式碼,以前我使用C和C++寫的,但是為了PHP的coder(PHP是世界上最好的語言)能明白,我用PHP重新寫了一遍,但是個精簡版,對於我們徹底搞懂websocket,理解它的精華所在,已經足夠了。程式碼我已經上傳到了碼雲(php-websocket-base-implemention),請大家一定一定要下載下來,並親自執行實踐才是檢驗真理的唯一標準啊,程式碼是完全可以執行的,如果執行的時候有障礙,請聯絡我。該博文差不多修修改改了3天(幸虧公司裡面事不多),儘可能的給大家講清楚。突然感覺,寫文章好累啊,這都不重要,希望大家能夠看懂,不然我寫的就沒啥用了。更希望大家遇到不懂的,提出疑問。寫完之後,我再次審查了當前博文的內容,修改了一些拼寫錯誤,可能還會有一些漏網之魚,希望大家多多指正。

準備工作

在閱讀這篇博文之前,需要大家有一定的基礎知識儲備,下面我會給大家列出來,先裝一下逼

socket基礎

基本的socket程式設計技能,如果你不知道,也不要慌,以防萬一,我已經為大家準備好了,請參考PHP 編寫基本的 Socket 程式

位運算

因為在一般的php程式設計當中,很少遇到會有位操作的情況,所以遺忘和不熟悉就理所當然了,我們可以參考php官方文件,但是我還是要講一點,異或(^)操作,請看下面,這個結論很重要,請大家一定要記住,切記切記,重要的事情講三遍。

a ^ b = c  可以推匯出 c ^ b = a

二進位制資料和文字資料

是不是有的時候開啟一個檔案顯示亂碼,就像下面這樣

老司機帶你實現Websocket協議
因為你開啟的是二進位制資料,二進位制資料和文字資料的最根本的區別就是在數字的儲存,舉個例子,假設數字 int a=100,我們假設它會佔用4個位元組的空間,但是注意了,如果將它作為字串儲存,結果只需要三個位元組(每一位佔用一個位元組),文字軟體不管這些啊,都當做文字,顯示的內容就成了亂碼了。因此如果某個二進位制檔案不是你寫入的,想要解析它的內容,不太現實。

大端序和小端序,網路位元組序

之所以存在這種說法,是因為不同的CPU架構下,多位元組資料在內容中的儲存格式有所不同,這裡我們以int(假設為4位元組)資料m(資料採用16進位制格式)為例,m=0x12345678,來進行說明,請仔細體會a,b,c,d的記憶體地址依次增大。

老司機帶你實現Websocket協議

  • 小端序,低位元組儲存在低位地址,高位元組儲存在高位地址,什麼意思呢?此時0x78儲存在a,0x56儲存b,0x34儲存c,0x12儲存d。
  • 大端序,高位位元組儲存在低位,低位位元組儲存在高位,此時0x78儲存在d,0x56儲存c,0x34儲存b,0x12儲存a。
  • 網路位元組序,網路位元組序是大端位元組序,這已經成為標準。

從上面的分析可以知道,當我們從網路資料中解析多位元組資料時,是一定要考慮位元組的順序的,這就是我這裡著重強調的原因。

協議的誕生

Websocket協議如今應用非常廣泛,,造成這一現象的很大原因,在於http協議的短暫性,客戶端和伺服器之間每一次的請求應答都需要建立TCP三次握手,這對於流量很大的伺服器來說是非常恐怖的(系統級資源),所以這個時候websocket誕生了,具體的誕生日期是哪一年已經不得而知了,但是真正的標準化時間是在2011年,由IETF正式完成,具體請參考RFC6455

協議工作流程

下面有一張圖,可以說明這一點,改圖片來自Google,

老司機帶你實現Websocket協議

websocket協議和http協議都屬於應用層協議(在TCP/IP之上),但是websocket協議相對於http協議多了一個握手(這個握手不是平時所說的tcp三次握手啊,注意了)的過程,從上面的圖可以很清晰的看出來,http是是一個文字協議,但是websocket有所不同,它有自己嚴格的位元組格式,稍後會講到。

資料包格式

老司機帶你實現Websocket協議

看到這張圖,有沒有想到TCP、IP的協議,不過這個圖相對來說要簡單一些,後面我會給大家詳細的講解每一部分的含義,慢慢來,不要慌,慌個啥子額。

協議流程概覽

該協議由2部分組成,握手資料傳輸,握手部分並不複雜,並且握手是建立在HTTP協議之上的,下面我們先來看一下協議的握手過程。

    GET /chat HTTP/1.1
    Host: server.example.com
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    Origin: http://example.com
    Sec-WebSocket-Protocol: chat, superchat
    Sec-WebSocket-Version: 13

伺服器響應如下:

    HTTP/1.1 101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
    Sec-WebSocket-Protocol: chat

無論是請求或者是響應包,頭部欄位的順序是沒有要求的,這其中有些欄位相信大家都非常熟悉了,就算不熟悉,百度一下,還是很容易搞清楚的,我們來仔細的討論一下Websocket所特有的一些欄位:

Upgrade欄位

這個欄位表示需要升級到的協議,這個欄位是必須的,並且它的值必須是websocket。

Connection

這個欄位表示需要升級協議,也是必須的,它的值必須是Upgrade。

Sec-WebSocket-Key和Sec-WebSocket-Accept

這個是用來客戶端和伺服器握手使用的,必須傳遞,因為伺服器會使用這個值進行一定的轉換然後回傳給客戶端,客戶端再檢查這個值,
是否和自己計算的值一樣,如果不一樣,那麼客戶端會認為,服務端是有問題的,那麼結果只能是連線失敗了。在介紹具體的操作之前,我們還需要介紹一個常量GUID,它的值為258EAFA5-E914-47DA-95CA-C5AB0DC85B11,這個值是固定的,任何的Websocket伺服器和客戶端(包括瀏覽器)必須定義這個值。現在我們重點來看一下這個欄位,假如客戶端傳遞的值為 dGhlIHNhbXBsZSBub25jZQ==,那麼用PHP程式碼來表示的話,就會是下面這樣:

$GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
$sec_websocket_key = "dGhlIHNhbXBsZSBub25jZQ==";
$result = base64_encode(sha1($sec_websocket_key . $GUID));

這個計算出的$result值最終會被回傳給客戶端的http響應頭Sec-WebSocket-Accept,客戶端會驗證這個值,這個就是客戶端的事了。

Sec-WebSocket-Version

websocket協議的版本號,根據RFC6455的文件,我們知道,這個值必須是13,其它的任何值都不行,下面是它的描述:

The request MUST include a header field with the name |Sec-WebSocket-Version|. The value of this header field MUST be 13.
NOTE: Although draft versions of this document (-09, -10, -11,and -12) were posted (they were mostly comprised of editorial changes and clarifications and not changes to the wire protocol), values 9, 10, 11, and 12 were not used as valid values for Sec-WebSocket-Version. These values were reserved in the IANA registry but were not and will not be used.

Sec-WebSocket-Protocol

選擇websocket所使用的子協議,這個欄位不是必須的,取決於具體的實現,如果你使用的是Google瀏覽器的話,那麼這個值是不會傳遞的。

握手階段

在講解完了Websocket主要的http頭部欄位之後,我們來看一下服務端的檢查程式碼,這裡我把例項程式中的程式碼貼出來,給大家分析一哈

/**
     * @param $client_socket_handle
     * @throws Exception
     */
    private function shakehand($client_socket_handle)
    {
        if (socket_recv($client_socket_handle, $buffer, 1000, 0) < 0) {
            throw new Exception(socket_strerror(socket_last_error($this->socket_handle)));
        }
        while (1) {
            if (preg_match("/([^\r]+)\r\n/", $buffer, $match) > 0) {
                $content = $match[1];
                if (strncmp($content, "Sec-WebSocket-Key", strlen("Sec-WebSocket-Key")) == 0) {
                    $this->websocket_key = trim(substr($content, strlen("Sec-WebSocket-Key:")), " \r\n");
                }
                $buffer = substr($buffer, strlen($content) + 2);
            } else {
                break;
            }
        }
        //響應客戶端
        $this->writeToSocket($client_socket_handle, "HTTP/1.1 101 Switching Protocol\r\n");
        $this->writeToSocket($client_socket_handle, "Upgrade: websocket\r\n");
        $this->writeToSocket($client_socket_handle, "Connection: upgrade\r\n");
        $this->writeToSocket($client_socket_handle, "Sec-WebSocket-Accept:" . $this->calculateResponseKey() . "\r\n");
        $this->writeToSocket($client_socket_handle, "Sec-WebSocket-Version: 13\r\n\r\n");
    }

首先我們從客戶端socket中讀取1000位元組的內容,這1000的位元組足以讀出所有的頭部了(但是在企業級程式碼中,我們不能這麼寫,我們永遠不能假設整個http頭部有多大,在這片博文中,我們為了突出問題的重點,簡化了很多程式碼,但是你放心,對我們來說,絲毫沒有影響,socket_recv請參考我上面所說的),接下來的while迴圈遍歷我們讀取到的內容,要看懂迴圈裡面的程式碼,我們有必要提下http協議的格式了,看下圖

老司機帶你實現Websocket協議
我覺得上面的圖片,已經足以描述http協議的格式了,如果你還不懂,沒關係,給大家推薦一篇來自簡書的博文(HTTP協議格式詳解),現在對於我們來說,最關心的是當前請求的Sec-WebSocket-Key頭部,因為這個值需要返回給客戶端,獲取到這個值之後,我們把它儲存在當前物件中。緊接著我們需要回應客戶端吧,如果你不知道它的格式,我稍微講一下:

老司機帶你實現Websocket協議

對於websocket握手來說,如果服務端同意客戶端的連線的話,那麼返回的狀態碼必須是101 ,至於後面的文字,不一定得是 Switching Protocol,只是別人都這麼傳,那就這麼傳了。其次,Upgrade: websocket,Connection: upgrade還有Sec-WebSocket-Version: 13,必須傳遞給客戶端,這個是固定的,應該沒有啥難度吧,另外的,Sec-WebSocket-Accept我們前面已經說了,它的計算程式碼,我上面已經貼出來了,這個計算方式也是固定的,千萬不要忘記每一行後面得有\r\n啊,最後一行後面得有兩個\r\n

分析資料協議

看了上面握手的程式碼之後,是不是覺得自己要上天了,感覺真是太簡單了??騷年,醒醒,醒醒。哈哈,真實太年輕了,年輕就是好

看到我上面貼出來的websocket資料包格式了麼,是時候解開它面紗的時候了,這部分可能有點兒難度,不要怕,有我在。下面我來來個原子級別的分析。

FIN

FIN位,也是整個片段的第一個位元組的最高位,他只能是0或者是1,這個位的作用只有一個,如果它為1,表示這個片段是整個訊息的最後一個片段,如果是0,表示這個片段之後,還有其它的片段。是不是聽著直接懵逼了,啥是 片段?啥是 訊息?非常好,看來我裝逼的時候已經來臨了,廢話不多說。為了搞清楚這幾個概念,程式碼為敬

(new WebSocket()).send("我是奧巴馬");

這是一段JAVASCRIPT程式碼,send函式的引數就是一條訊息,非常短,但是注意了,我們不能假設任何時間,任何地點,都這麼短,當它變得很長的時候,客戶端就有可能對它進行切割,比如,我有一個字串,大小為4M,我把它分為4個1M的字串,那麼每一個1M的字串,就只能成為一個片段,每個片段獨立傳送,四個片段組合在一起形成了一條訊息,每一個片段的格式都是固定的,格式和上面的貼圖是一樣的,按照剛才說的,前面的三個片段,FIN都是0,第四個才是1,清楚了麼?So easy!!

RSV1,RSV2,RSV3

這三位是保留給擴充套件使用的,基本不會用到,反正我沒用到,所以我們可以把它們當做空氣就行,永遠設定為0,就是這麼果斷。

opcode

opcode顧名思義就是操作碼,佔用第一個位元組的低四位,所以opcode可以代表16種不同的值。你是不是想問,opcode是用來幹嘛的?
opcode是用 來解析當前片段的載荷(攜帶的資料)的,具體的後面會再次說明。

  • 0x00,表示當前片段是連續片段,這是啥意思呢?還記得上面討論FIN的時候,一條訊息被分割成多條片段?如果當前片段不是第一個,那麼opcode必須設定為0。
  • 0x01,表示當前片段所攜帶的資料是文字資料(記得最開始說的文字資料和二進位制資料的區別??),如果有多個片段的話,只需要在第一個片段設定該值,屬於同一條訊息中後面的片段,只需要設定為0即可。
  • 0x02,表示當前片段所攜帶的資料是二進位制資料,如果有多個片段的話,只需要在第一個片段設定該值,屬於同一條訊息中後面的片段,只需要設定為0即可。
  • 0x03-0x07,保留給將來使用,也就是說暫時還沒用到。
  • 0x08,表示關閉websocket連線,這個後面我會再一次講到,先放著
  • 0x09,傳送Ping片段,說白了,它主要是用來檢測遠端端點是否還存活,我想檢查我的物件是不是已經死了,但是這個片段可以攜帶資料,如果端點的一方傳送了Ping,那麼接受方,必須返回Pong片段,用中國人的話來說,就是禮尚往來嘛。
  • 0xA,傳送Pong,用以回覆Ping,是不是很簡單?
  • 0xB-F,保留給將來使用,也就是說暫時還沒用到。

MASK

表示當前片段所攜帶的資料是否經過加密,位置為第二個位元組的最高位,總共1位,它的值不是你想設定就設定的啊,RFC6455 明確規定,所有從客戶端傳送給伺服器的資料必須加密,所以mask的值必須是1。還有,所有從伺服器發往客戶端的資料,一定不能加密,所以呢,mask必須為0,就是這麼簡單粗暴。

Payload Length

這部分是用來定義負載資料的長度的,總共7位,所以最大值為127,就這麼簡單?哼哼,不會的。

  • payload_length<=125,此時資料的長度就是payload_length的大小。
  • payload_length=126,那麼緊接著payload_length的2個位元組,就用來表示資料的大小,所以當資料大小大於125,小於65535的時候,payload_length設定為126,後面分析程式碼的時候,我會再次講到。
  • payload_length=127,也就是payload_length取最大值,那麼緊接著payload_length的8個位元組,就用來表示資料的大小,此可以表示的資料可就相當大了,後面分析程式碼的時候,我會再次講到。

Mask key

它的位置緊接著資料長度的後面,大小為0或者是4個位元組。前面分析了mask的作用,如果mask為1的話,資料需要加密,此時mask key佔用4個位元組,否則長度為0,至於mask key如何用來解密資料的,後面會再次講到。

payload data

這裡就是我們從客戶端接收到的資料,不過它是經過加密的,“我是奧巴馬”,之前payload_length的長度,就是經過加密之後的資料的長度,而不是原始資料的長度。

講解完上面的內容之後,我們可以開始分析如何用php來解析Websocket訊息片段了。

解析資料包

這篇博文的開頭我就說過了,當前的websocket實現會專注於websocket最為精華,最困難的部分,所以會忽略掉一些內容,如果你理解了下面講的內容,其餘的一些細枝末節都不是問題。

計算資料的長度

//等待客戶端新傳輸的資料
    if (!socket_recv($client_socket_handle, $buffer, 1000, 0)) {
        throw new Exception(socket_strerror(socket_last_error($client_socket_handle)));
    }
    //解析訊息的長度
    $payload_length = ord($buffer[1]) & 0x7f;//第二個字元的低7位
    if ($payload_length >= 0 && $payload_length < 125) {
        $this->current_message_length = $payload_length;
        $payload_type = 1;
        echo $payload_length . "\n";
    } else if ($payload_length = 126) {
        $payload_type = 2;
        $this->current_message_length = ((ord($buffer[2]) & 0xff) << 8) | (ord($buffer[3]) & 0xff);
        echo $this->current_message_length;
    } else {
        $payload_type = 3;
        $this->current_message_length =
            (ord($buffer[2]) << 56)
            | (ord($buffer[3]) << 48)
            | (ord($buffer[4]) << 40)
            | (ord($buffer[5]) << 32)
            | (ord($buffer[6]) << 24)
            | (ord($buffer[7]) << 16)
            | (ord($buffer[8]) << 8)
            | (ord($buffer[7]) << 0);
    }

對於上面的程式碼,下面進行逐行解析

$payload_length = ord($buffer[1]) & 0x7f;//第二個字元的低7位

讀取第二個位元組的低7位,也就是之前討論的payload_length,0x7f轉換為二進位制就是01111111,ord($buffer[1]) 就是把第二個字元轉換為對應的ASCII數值,兩個進行與運算,就可以得到第二個位元組的低7位對應的數值(與運算不熟悉的朋友,請先檢視我在這篇博文前面給大家指定的連結),

if ($payload_length >= 0 && $payload_length < 125) {
        $this->current_message_length = $payload_length;
        $payload_type = 1;
        echo $payload_length . "\n";
 }

當payload_length的長度小於125的話,資料長度就等於片段長度。

if ($payload_length = 126) {
        $payload_type = 2;
        $this->current_message_length = ((ord($buffer[2]) & 0xff) << 8) | (ord($buffer[3]) & 0xff);
        echo $this->current_message_length;
  }

當payload_length的長度等於126的時候,就有些麻煩了,此時第3和第4個位元組組合為一個無符號16位整數,還記得我們之前說的,網路位元組序嗎?高位位元組在前,低位位元組在後面,所以當我們讀的時候,第3個位元組就是高8位,第4個位元組就是低8位,所以我們首先將高8位左移8位再和低8位做或運算。

$payload_type = 3;
$this->current_message_length =
    (ord($buffer[2]) << 56)
    | (ord($buffer[3]) << 48)
    | (ord($buffer[4]) << 40)
    | (ord($buffer[5]) << 32)
    | (ord($buffer[6]) << 24)
    | (ord($buffer[7]) << 16)
    | (ord($buffer[8]) << 8)
    | (ord($buffer[9]) << 0);

當payload_length的長度等於127的時候,此時的第3到第10位組合為一個無符號64位整數,所以最高的8位需要左移56位,後面的依次類推,低8位保持不動。

解析mask key

//解析掩碼,這個必須有的,掩碼總共4個位元組
$mask_key_offset = ($payload_type == 1 ? 0 : ($payload_type == 2 ? 2 : 8)) + 2;
$this->mask_key = substr($buffer, $mask_key_offset, 4);

要找到maskey,首先必須找到它在當前片段的偏移,如果payload_length<=125,那麼偏移就是2,如果payload_length==126,那麼偏移就是(2+2)=4,如果payload_length>126,那麼偏移就是(2+8)=10,同時mask key的大小為4個位元組,所以找到了偏移和長度,mask key就可以獲取到了。

解密資料

//獲取加密的內容
$real_message = substr($buffer, $mask_key_offset + 4);
$i = 0;
$parsed_ret = '';
//解析加密的資料
while ($i < strlen($real_message)) {
    $parsed_ret .= chr((ord($real_message[$i]) ^ ord(($this->mask_key[$i % 4]))));
    $i++;
}

解密資料的第一步就是要找到加密資料在當前片段中的偏移,很簡單,這個值等於maskkey的偏移(上面已經求過了)+maskkey本身的長度4,那麼怎麼來解密資料呢?看上面的程式碼,就可以看出來,解密的過程其實就是遍歷加密資料的每一個字元的ASCII值和資料(當前遍歷的位置對4取模,得出的資料必定是0,1,2,3,將得出的資料找到maskkey對應位置的ASCII值)進行異或運算求得,這個演算法是RFC6455規定的,全世界都是這樣。

返回資料給客戶端

從客戶端傳送到伺服器和伺服器傳遞給客戶端的資料格式都遵循著同樣的資料包格式,所以在我的實現中,程式碼如下:

function echoContentToClient($client_socket, $content)
{
    $len = strlen($content);
    //第一個位元組
    $char_seq = chr(0x80 | 1);

    $b_2 = 0;
    //fill length
    if ($len > 0 && $len <= 125) {
        $char_seq .= chr(($b_2 | $len));
    } else if ($len <= 65535) {
        $char_seq .= chr(($b_2 | 126));
        $char_seq .= (chr($len >> 8) . chr($len & 0xff));
    } else {
        $char_seq .= chr(($b_2 | 127));
        $char_seq .=
            (chr($len >> 56)
                . chr($len >> 48)
                . chr($len >> 40)
                . chr($len >> 32)
                . chr($len >> 24)
                . chr($len >> 16)
                . chr($len >> 8)
                . chr($len >> 0));
    }
    $char_seq .= $content;
    $this->writeToSocket($client_socket, $char_seq);
}

為了簡便起見,第一個位元組中FIN=1,opcode設定為1,接下來檢查資料的長度,這部分內容和解析資料長度的步驟剛好相反,就不再分析了,如果你把之前的都看懂了,這裡也應該沒有問題,但是特別注意了,之前我們就已經提到過,伺服器返回給客戶端的資料,不能加密,所以mask必須設定為0,mask key的長度為0。

執行例項

就和本篇博文開篇所提到的,我寫了一個簡單的websocket實現,請一定要下載自己執行起來,光看是沒有用的:php-websocket-base-implemention

老司機帶你實現Websocket協議
如何執行websocket伺服器

老司機帶你實現Websocket協議

為了你可以看到實際執行的結果,請開啟websocket.html檔案,頁面上出現這個就表示執行成功了。

老司機帶你實現Websocket協議

執行之前,請檢查埠8080是否被佔用,當然你可以修改websocket.html,改為其他的都可以,確保不被佔用就可以了,如果你仍然無法執行,請聯絡我,如果你想看到其他的內容,也請修改websocket.html檔案,然後重啟伺服器。

提示

本篇博文的目的僅僅是為了向大家簡要的介紹websocket最為核心的內容,還有一些內容沒有講到(剩下的不難,感興趣的自己可以去實現),出於讓大家更為直觀的看清楚websocket的目的,程式碼中去掉了錯誤檢查等內容,因此並不嚴謹,祝你學習愉快。

相關文章