實現一個websocket伺服器-理論篇

瀟湘待雨發表於2017-11-12

本文是Writing WebSocket servers的中文文件,翻譯自MDNWriting WebSocket servers。篇幅略長,個人能力有限難免有所錯誤,拋磚引玉共同進步。

websocket伺服器的本質

WebSocket 伺服器簡單來說就是一個遵循特殊協議監聽伺服器任意埠的tcp應用。搭建一個定製伺服器的任務通常會讓讓人們感到害怕。然而基於實現一個簡單的Websocket伺服器沒有那麼麻煩。

一個WebSocket server可以使用任意的服務端程式語言來實現,只要該語言能實現基本的Berkeley sockets(伯克利套接字)。例如c(++)、Python、PHP、服務端JavaScript(node.js)。下面不是關於特定語言的教程,而是一個促進我們搭建自己伺服器的指南。

我們需要明白http如何工作並且有中等程式設計經驗。基於特定語言的支援,瞭解TCP sockets 同樣也是必要的。該篇教程的範圍是介紹開發一個WebSocket server需要的最少知識。

該文章將會從很底層的觀點來解釋一個 WebSocket server。WebSocket servers 通常是獨立的專門的servers(因為負載均衡和其他一些原因),因此通常使用一個反向代理(例如一個標準的HTTP server)來發現 WebSocket握手協議,預處理他們然後將客戶端資訊傳送給真正的WebSocket server。這意味著WebSocket server不必充斥這cookie和簽名的處理方法。完全可以放在代理中處理。

websocket 握手規則

首先,伺服器必須使用標準的TCPsocket來監聽即將到來的socket連線。基於我們的平臺,這些很可能被我們處理了(成熟的服務端語言提供了這些介面,使我們不必從頭做起)。例如,假設我們的伺服器監聽example.com的8000埠,socket server響應/chat的GET請求。

警告:伺服器可以選擇監聽任意埠,但是如果在80或443之外,可能會遇到防火牆或者代理的問題。443埠大多數情況下是可以的,當然需要一個安全連線(TLS/SSL)。此外,注意這一點,大多數瀏覽器不允許從安全的頁面連線到不安全的Websocket伺服器。
在WebSockets中握手是web,是HTTP想WS轉化的橋樑。通過握手,連線的詳情會被判斷,並且在完成之前每一個部分都可以終端如果條件不滿足。伺服器必須謹慎解析客戶端請求的所有資訊,否則安全問題將會發生。

客戶端握手請求

儘管我們在開發一個伺服器,客戶端仍然需要發起一個Websocket握手過程。因此我們必須知道如何解析客戶端的請求。客戶端將會傳送一個標準的HTTP請求,大概像下面的例子(HTTP版本必須1.1及以上,請求方式為GET)。

    GET /chat HTTP/1.1
    Host: example.com:8000
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    Sec-WebSocket-Version: 13複製程式碼

此處客戶端可以發起擴充套件或者子協議,在Miscellaneous檢視更多細節。同樣,公共的headers像User-Agent, Referer, Cookie, or authentication等同樣可以包括,一句話做你想做的。這些並不直接和WebSocket相關,忽略掉他們也是安全的,在很多公共的設定中,會有一個代理伺服器來處理這些資訊。

如果有的header不被識別或者有非法值,伺服器應該傳送'400 Bad Request'並立刻關閉socket,通常也會在HTTP返回體中給出握手失敗的原因,不過這些資訊可能不會被展示(因為瀏覽器不會展示他們)。如果伺服器不識別WebSockets的版本,應該返回一個Sec-WebSocket-Version 訊息頭,指明可以接受的版本(最好是V13,及最新)。下面一起看一下最神祕的訊息頭Sec-WebSocket-Key。

提示:

  • 所有的瀏覽器將會傳送一個Origin header,我們可以使用這個header來做安全限制(檢查是否相同的origin)如果並不是期望的origin返回一個403 Forbidden。然後注意下那些非瀏覽器的客戶端可以傳送一個偽造的origin,很多應用將會拒絕沒有該訊息頭的請求。
  • 請求資源定位符(這裡的/chat)在規範中沒有明確的定義,所以很多人巧妙的使用它,讓一個伺服器處理多個WebSocket 應用。例如,example.com/chat可以指向一個多使用者聊天app,而相同伺服器上的/game指向多使用者的遊戲。即相同域名下的路徑可以指向不同應用
  • 規範的HTTP code只可以在握手之前使用,當握手成功之後,應該使用不同的code集合。請檢視規範第7.4節

伺服器握手返回

當伺服器接受到請求時,應該傳送一個相當奇怪的響應,看起來大概這個樣子,不過仍然遵循HTTP規範。 請注意每一個header以\r\n結尾並且在最後一個後面加入額外的\r\n。

    HTTP/1.1 101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=複製程式碼

此外,伺服器可以在這裡決定擴充套件或者子協議請求。更多詳情請檢視Miscellaneous。Sec-WebSocket-Accept 部分很有趣,伺服器必須基於客戶端請求的Sec-WebSocket-Key 中得到它,具體做法如下:將Sec-WebSocket-Key 和"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"連結,通過SHA-1 hash獲得結果,然後返回該結果的base64編碼。

###提示
因為這個看似複雜的過程存在,所以客戶端不用關心伺服器是否支援websocket。另外,該過程的重要性還是在於安全性,如果一個伺服器將一個Websocket連線作為http請求解析的話,將會有不小的問題。

因此,如果key是"dGhlIHNhbXBsZSBub25jZQ==",Accept將會是"s3pPLMBiTxaQ9kYGzzhZRbK+xOo=",一旦伺服器傳送這些訊息頭,握手協議就完成了。

伺服器在回覆握手之前,可以傳送其他的header像Set-Cookie、要求籤名、重定向等。

跟蹤客戶端

雖然並不直接與Websocket協議相關,但值得我們注意。伺服器將會跟蹤客戶端的sockets,因此我們不必和已經完成握手協議的客戶端再次進行握手。相同客戶端的IP地址可以嘗試多次連線(但是伺服器可以選擇拒絕,如果他們嘗試多次連線以達到儲存自己Denial-of-Service 蹤跡的目的)

FramesEdit 資料交換

客戶端和伺服器都可以在任意時間傳送訊息、這正是websocket的魔力所在。然而從資料幀中提取資訊的過程就不那麼充滿魔力了。儘管所有的幀遵循相同的特定格式,從客戶端發到伺服器的資料通過X異或加密 (使用32位的金鑰)進行處理,該規範的第五章詳細描述了相關內容。

格式

每個從客戶端傳送到伺服器的資料幀遵循下面的格式:

    幀格式:  
​​
      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 ...                |
     +---------------------------------------------------------------+複製程式碼

MASK (掩碼:一串二進位制程式碼對目標欄位進行位與運算,遮蔽當前的輸入位。)位只表明資訊是否已進行掩碼處理。來自客戶端的訊息必須經過處理,因此我們應該將其置為1(事實上5.1節表明,如果客戶端傳送未掩碼處理的訊息,伺服器必須斷開連線)當傳送一個幀至客戶端時,不要處理資料並且不設定mask位。下面將會闡述原因。注意:我們必須處理訊息即使用一個安全的socket。RSV1-3可以被忽略,這是待擴充套件位。

opcode欄位定義如何解析有效的資料:

  • 0x0 繼續處理
  • 0x1 text(必須是UTF-8編碼)
  • 0x2 二進位制 和其他叫做控制程式碼的資料。
  • 0x3-0x7 0xB-0xF 該版本的WebSockets無意義

FIN 表明是否是資料集合的最後一段訊息,如果為0,伺服器繼續監聽訊息,以待訊息剩餘的部分。否則伺服器認為訊息已經完全傳送。

有效編碼資料長度

為了解析有效編碼資料,我們必須知道何時結束。這是知道有效資料長度的重要所在。不幸的是,有一些複雜。讓我們分步驟來看。

  1. 閱讀9-15位並且作為無符號整數解釋,如果是小於等於125,這就是資料的長度。如果是126,請繼續步驟2,如果是127請閱讀,步驟3
  2. 閱讀後面16位並且作為無符號整數解讀,結束
  3. 閱讀後面64位並且作為無符號整數解讀,結束

讀取並反掩碼資料

如果MASK位被設定(當然它應該被設定,對於一個從客戶端到伺服器的訊息),讀取後4位元組(即32位),即加密的key。一旦資料長度和加密key被解碼,我們可以直接從socket中讀取成批的位元組。獲取編碼的資料和掩碼key,將其解碼,迴圈遍歷加密的位元組(octets,text資料的單位)並且將其與第(i%4)位掩碼位元組(即i除以4取餘)進行異或運算,如果用js就如下所示(該規則就是加密解密的規則而已,沒必要深究,大家知道如何使用就好)。

var DECODED = "";
    for (var i = 0; i < ENCODED.length; i++) {
        DECODED[i] = ENCODED[i] ^ MASK[i % 4];
    }複製程式碼

現在我們可以知道我們應用上解碼之後的資料具體含義了。

訊息分割

FIN和opcode欄位共同工作來講一個訊息分解為單獨的幀,該過程叫做訊息分割,只有在opcodes為0x0-0x2時才可用(前面也提到,當前版本其他數值無意義)。

回想一下,opcode指明瞭一個幀的將要做什麼,如果是0x1,資料是text。如果是0x2,詩句是二進位制資料。然而當其為0x0時,該幀是一個繼續幀,表示伺服器應該將該幀的有效資料和伺服器收到的最後一幀連結起來。這是一個草圖,指明瞭當客戶端傳送text訊息時,第一個訊息在一個單獨的幀裡傳送,然而第二個訊息卻包括三個幀,伺服器如何反應。FIN和opcode細節僅僅對客戶端展示。看一下下面的例子應該會更容易理解。

Client: FIN=1, opcode=0x1, msg="hello"
Server: (訊息傳輸過程完成) Hi.
Client: FIN=0, opcode=0x1, msg="and a"
Server: (監聽,新的訊息包含開始的文字)
Client: FIN=0, opcode=0x0, msg="happy new"
Server: (監聽,有效資料與上面的訊息拼接)
Client: FIN=1, opcode=0x0, msg="year!"
Server: (訊息傳輸完成) Happy new year to you too!複製程式碼

注意:第一幀包括一個完全的訊息(FIN=1並且opcode!=0x0),因此當伺服器發現結束時可以返回。第二幀有效資料為text(opcode=0x1),但是完整的訊息沒有到達(FIN=0)。該訊息所有剩下的部分通過繼續幀傳送(opcode=0x0),並且最後以幀通過FIN=1表明身份。

WebSockets 的心跳:ping和pong

在握手接受之後的任意點,不論是客戶端還是伺服器都可以選擇傳送ping給另一部分。當ping被接收時,接收方必須儘可能的返回一個pong。我們可以用該方式來確保連線依然有效。

一個ping或者pong只是一個規則的幀,但是是控制幀,Pings的opcode為0x9,pong是0xA。當我們得到ping時,返回具有完全相同有效資料的pong。(對ping和pong而言,最大有效資料長度是125)我們可能在沒有傳送ping的情況下,得到一個pong。這種情況請忽略。

在傳送pong之前,如果我們接收到不止一個ping,只需回應一個pong即可。

關閉連線

要關閉客戶端和伺服器之間的連線,我們可以傳送一個包含特定控制佇列的資料的控制幀來開始關閉的握手協議。當接收到該幀時,另一方傳送一個關閉幀作為回應。然後前者會關閉連線。關閉連線之後接收到的資料都會被丟棄。

更多

WebSocket 擴充套件和子協議在握手過程中通過headers進行約定。有時擴充套件和子協議太近似了以致於難以分別。最基本的區別是,擴充套件控制websocket 幀並且修改有效資料。然而子協議構成websocket有效資料並且從不修改任何事物。擴充套件是可選的廣義的,子協議是必須的侷限性的。

擴充套件

將擴充套件看作壓縮一個檔案在傳送之前,無論你如何做,你將傳送相同的資料只不過幀不同而已。收件人最終將會受到與你本地拷貝相同的資料,不過以不同方式傳送。這就是擴充套件做的事情。websockets定義了一個協議和基本的方式去傳送資料,然而擴充套件例如壓縮可以以更短的幀來阿鬆相同的資料。

子協議

將子協議看作定做的xml表或者文件型別說明。你在使用XML和它的語法,但是你被限制於你同意的結構。WebSocket子協議就是如此。他們不介紹其他一些華麗的東西,僅僅建立結構,像一個文件型別和表一樣,兩個部分(client & server)都同意該協議,和文件型別和表不同,子協議由伺服器實現並且客戶端不能對外引用。
一個客戶端必須請求特定的子協議,為了達到目的,將會傳送一些像下面的內容作為原始握手的一部分。

GET /chat HTTP/1.1
...
Sec-WebSocket-Protocol: soap, wamp複製程式碼

或者等價的寫法

...
Sec-WebSocket-Protocol: soap
Sec-WebSocket-Protocol: wamp複製程式碼

現在,伺服器必須選擇客戶端建議並且支援的一種協議。如果多餘一個,傳送客戶端傳送過來的第一個。想象我們的伺服器可以使用soap和wamp中的一個,然後,返回的握手中將會傳送如下形式。

Sec-WebSocket-Protocol: soap複製程式碼

伺服器不能傳送超過一個的Sec-Websocket-Protocol訊息頭,如果伺服器不想使用任一個子協議,應該不傳送Sec-WebSocket-Protocol 訊息頭。傳送一個空白的訊息頭是錯誤的。客戶端可能會關閉連線如果不能獲得期望的子協議。

如果我們希望我們的伺服器遵守一定的子協議,自然地在我們的伺服器需要額外的程式碼。想象我們使用一個子協議json,基於該子協議,所有的資料將會作為JSON傳遞,如果一個客戶端徵求子協議並且伺服器想使用它,服務你需要有一個JSON解析。實話實說,將會有一個工具庫,但是伺服器也要需要傳遞資料。

為了避免名稱衝突,推薦選用domain的一部分作為子協議的名稱。如果我們開發一個使用特定格式的聊天app,我們可能使用這樣的名字:Sec-WebSocket-Protocol: chat.example.com 注意,這不是必須的。僅僅是一個可選的慣例,我們可以使用我們想用的任意字元。

結束語

翻譯這篇文件的初衷是看到關於websocket的中文大部分都是客戶端相關的內容,自己又對伺服器端的實現感興趣,沒有找到合適的資料,就只好自己閱讀下英文,本著提高自己的目的將其翻譯下來,希望對其他同學有所幫助,原文檢視 。後面請期待node實現websocket伺服器的實踐篇。

源文件出處

翻譯自MDNWriting WebSocket servers

相關文章