編寫 WebSocket 伺服器
WebSocket 伺服器不過是一個在 TCP 伺服器的任何埠上偵聽並遵循特定協議的應用程式。如果您以前從未做過,建立自定義伺服器可能會顯得令人生畏。不過,在您選擇的平臺上實現基本的 WebSocket 伺服器實際上可以非常簡單。
WebSocket 伺服器可以使用任何能夠實現Berkeley 套接字的伺服器端程式語言編寫,例如 C(++)、Python、PHP 或伺服器端 JavaScript。這並非針對任何特定語言的教程,而是作為指導,以方便您編寫自己的伺服器。
本文假設您已經熟悉 HTTP 的工作原理,並且您具有中等水平的程式設計經驗。根據語言支援情況,可能需要 TCP 套接字知識。本指南的範圍是提供編寫 WebSocket 伺服器所需的最少知識。
注意:閱讀最新的官方 WebSockets 規範 RFC 6455。第 1 節和第 4-7 節對伺服器實現者特別有意義。第 10 節討論了安全性,在暴露您的伺服器之前您務必仔細閱讀。
這裡從非常低的層面解釋了 WebSocket 伺服器。WebSocket 伺服器通常是獨立且專用的伺服器(出於負載平衡或其他實際原因),因此您通常會使用反向代理(例如常規的 HTTP 伺服器)來檢測 WebSocket 握手,對其進行預處理,並將這些客戶端傳送到真正的 WebSocket 伺服器。這意味著您無需在伺服器程式碼中包含 cookie 和身份驗證處理程式(例如)。
WebSocket 握手
首先,伺服器必須使用標準 TCP 套接字偵聽傳入的套接字連線。根據您的平臺,這可能會自動為您處理。例如,假設您的伺服器正在 example.com 的 8000 埠上偵聽,並且您的套接字伺服器響應 example.com/chat 上的 GET 請求。
警告:伺服器可以選擇任何埠進行偵聽,但如果選擇 80 或 443 以外的任何埠,可能會遇到防火牆和/或代理問題。瀏覽器通常要求 WebSocket 使用安全連線,儘管它們可能會為本地裝置提供例外。
握手是 WebSockets 中的“Web”。它是從 HTTP 到 WebSockets 的橋樑。在握手中,連線的詳細資訊會進行協商,如果條款不利,任何一方都可以在完成前退出。伺服器必須仔細理解客戶端的所有請求,否則可能會出現安全問題。
注意:請求 URI(此處為 /chat)在規範中沒有明確定義含義。因此,許多人使用它來讓一個伺服器處理多個 WebSocket 應用程式。例如,example.com/chat 可以呼叫一個多使用者聊天應用程式,而同一伺服器上的 /game 可能呼叫一個多人遊戲。
客戶端握手請求
即使您正在構建伺服器,客戶端仍然必須透過聯絡伺服器並請求 WebSocket 連線來啟動 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
客戶端可以在此處請求擴充套件和/或子協議;有關詳細資訊,請參閱雜項。此外,諸如 User-Agent、Referer、Cookie 等常見標頭或身份驗證標頭也可能存在。您可以隨意處理它們;它們與 WebSocket 沒有直接關係。忽略它們也是安全的。在許多常見的設定中,反向代理已經處理了它們。
注意:所有瀏覽器都會發送一個Origin 標頭。您可以使用此標頭進行安全檢查(檢查同源、自動允許或拒絕等),如果您不喜歡看到的內容,則傳送 403 Forbidden。這對於跨站點 WebSocket 劫持 (CSWH) 是有效的。但是,請注意非瀏覽器代理可以傳送偽造的 Origin。大多數應用程式會拒絕沒有此標頭的請求。
如果任何標頭無法理解或值不正確,伺服器應傳送 400(“錯誤請求”)響應並立即關閉套接字。通常,它還可以在 HTTP 響應正文中給出握手失敗的原因,但該訊息可能永遠不會顯示(瀏覽器不顯示它)。如果伺服器不理解該版本的 WebSockets,它應該返回一個 Sec-WebSocket-Version 標頭,其中包含它理解的版本。在上面的示例中,它表示 WebSocket 協議的第 13 版。
這裡最有趣的標頭是 Sec-WebSocket-Key。接下來我們來看它。
注意:常規 HTTP 狀態碼只能在握手之前使用。握手成功後,您必須使用一組不同的程式碼(在規範的 7.4 節中定義)。
伺服器握手響應
當伺服器收到握手請求時,它應該發回一個特殊響應,表明協議將從 HTTP 更改為 WebSocket。該標頭看起來像以下內容(請記住每行標頭都以 \r\n 結尾,並在最後一個標頭後新增一個額外的 \r\n 以表示標頭結束)
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
此外,伺服器可以在此處決定擴充套件/子協議請求;有關詳細資訊,請參閱雜項。Sec-WebSocket-Accept 標頭非常重要,因為伺服器必須從客戶端傳送的 Sec-WebSocket-Key 中派生它。要獲取它,將客戶端的 Sec-WebSocket-Key 與字串 "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 串聯(這是一個“魔術字串”),對結果進行 SHA-1 雜湊,並返回該雜湊的 base64 編碼。
注意:這種看似過於複雜的流程是為了讓客戶端清楚地知道伺服器是否支援 WebSockets。這很重要,因為如果伺服器接受 WebSockets 連線但將資料解釋為 HTTP 請求,則可能會出現安全問題。
因此,如果 Key 是 "dGhlIHNhbXBsZSBub25jZQ==",則 Sec-WebSocket-Accept 標頭的值為 "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="。一旦伺服器傳送這些標頭,握手就完成了,您可以開始交換資料!
注意:在傳送回覆握手之前,伺服器可以傳送其他標頭,例如 Set-Cookie,或者透過其他狀態碼請求身份驗證或重定向。
跟蹤客戶端
這與 WebSocket 協議沒有直接關係,但值得一提的是:您的伺服器必須跟蹤客戶端的套接字,這樣您就不用與已經完成握手的客戶端再次握手。同一個客戶端 IP 地址可能會嘗試多次連線。但是,如果它們嘗試的連線次數過多,伺服器可以拒絕它們,以防止拒絕服務攻擊。
例如,您可以維護一個使用者名稱或 ID 號表,以及相應的 WebSocket 和您需要與該連線關聯的其他資料。
交換資料幀
客戶端或伺服器都可以在任何時候選擇傳送訊息——這就是 WebSockets 的魔力。然而,從這些所謂的“資料幀”中提取資訊並不是一個神奇的體驗。雖然所有幀都遵循相同的特定格式,但從客戶端到伺服器的資料使用 XOR 加密(使用 32 位金鑰)進行遮蔽。規範的第 5 節詳細描述了這一點。
格式
每個資料幀(從客戶端到伺服器或反之亦然)都遵循相同的格式
Data frame from the client to server (message length 0–125): 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 | Masking-key | |I|S|S|S| (4) |A| (7) | (32) | |N|V|V|V| |S| | | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+ Data frame from the client to server (16-bit message length): 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) | |N|V|V|V| |S| (== 126) | | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+-------------------------------+ | Masking-key | +---------------------------------------------------------------+ : Payload Data : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+ Data frame from the server to client (64-bit payload length): 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) | (64) | |N|V|V|V| |S| (== 127) | | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued | + - - - - - - - - - - - - - - - +-------------------------------+ | | Masking-key | +-------------------------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+
這意味著一個幀包含以下位元組
- 第一個位元組
- Bit 0 FIN:指示這是否是系列中的最後一條訊息。如果為 0,則伺服器繼續監聽訊息的更多部分;否則,伺服器應認為訊息已送達。稍後會詳細介紹。
- 位 1–3 RSV1、RSV2、RSV3:可以忽略,它們用於擴充套件。
- 位 4-7 OPCODE:定義如何解釋有效載荷資料:
0x0表示繼續,0x1表示文字(總是以 UTF-8 編碼),0x2表示二進位制,以及其他稍後將討論的所謂“控制程式碼”。在此版本的 WebSockets 中,0x3到0x7和0xB到0xF沒有意義。
- 位 8 MASK:指示訊息是否已編碼。來自客戶端的訊息必須被掩碼,因此您的伺服器必須期望此位為 1。(實際上,規範的 5.1 節指出,如果客戶端傳送未掩碼的訊息,您的伺服器必須與該客戶端斷開連線。)伺服器到客戶端的訊息未被掩碼,此位設定為 0。我們稍後將在讀取和取消掩碼資料中解釋掩碼。注意:即使使用安全套接字,也必須掩碼訊息。
- 位 9-15:有效載荷長度。也可能包括接下來的 2 位元組或 8 位元組;請參閱解碼有效載荷長度。
- 如果使用掩碼(對於客戶端到伺服器的訊息總是如此),接下來的 4 個位元組包含掩碼鍵;請參閱讀取和取消掩碼資料。
- 所有後續位元組都是有效載荷。
解碼有效載荷長度
要讀取有效載荷資料,您必須知道何時停止讀取。這就是為什麼有效載荷長度很重要。不幸的是,這有些複雜。要讀取它,請遵循以下步驟
- 讀取位 9-15(包含)並將其解釋為無符號整數。如果它小於或等於 125,那麼這就是長度;您已完成。如果它是 126,轉到步驟 2。如果它是 127,轉到步驟 3。
- 讀取接下來的 16 位,並將其解釋為無符號整數。您已完成。
- 讀取接下來的 64 位,並將其解釋為無符號整數。(最高有效位必須為 0。)您已完成。
讀取並解除資料掩碼
如果 MASK 位已設定(並且對於客戶端到伺服器的訊息,它應該已設定),請讀取接下來的 4 個八位位元組(32 位);這是掩碼金鑰。解碼有效載荷長度和掩碼金鑰後,您可以從套接字讀取該數量的位元組。我們將資料稱為 ENCODED,金鑰稱為 MASK。要獲取 DECODED,遍歷 ENCODED 的八位位元組,並用 MASK 的第 (i modulo 4) 個八位位元組對八位位元組進行 XOR 運算。以 JavaScript 為例
// The function receives the frame as a Uint8Array.
// firstIndexAfterPayloadLength is the index of the first byte
// after the payload length, so it can be 2, 4, or 10.
function getPayloadDecoded(frame, firstIndexAfterPayloadLength) {
const mask = frame.slice(
firstIndexAfterPayloadLength,
firstIndexAfterPayloadLength + 4,
);
const encodedPayload = frame.slice(firstIndexAfterPayloadLength + 4);
// XOR each 4-byte sequence in the payload with the bitmask
const decodedPayload = encodedPayload.map((byte, i) => byte ^ mask[i % 4]);
return decodedPayload;
}
const frame = Uint8Array.from([
// FIN=1, RSV1-3=0, opcode=0x1 (text)
0b10000001,
// MASK=1, payload length=5
0b10000101,
// 4-byte mask
1, 2, 3, 4,
// 5-byte payload
105, 103, 111, 104, 110,
]);
// Assume you got the number 2 from properly decoding the payload length
const decoded = getPayloadDecoded(frame, 2);
現在,您可以根據您的應用程式弄清楚 decoded 的含義。例如,如果它是文字訊息,您可以將其解碼為 UTF-8。
console.log(new TextDecoder().decode(decoded)); // "hello"
掩碼是一種安全措施,可避免惡意方預測傳送到伺服器的資料。客戶端將為每條訊息生成一個加密隨機掩碼金鑰。
訊息分片
FIN 和 opcode 欄位協同工作,將訊息分割成獨立的幀傳送。這稱為訊息分片。分片僅適用於操作碼 0x0 到 0x2。
回想一下,操作碼說明了幀的用途。如果它是 0x1,則有效載荷是文字。如果它是 0x2,則有效載荷是二進位制資料。然而,如果它是 0x0,則該幀是繼續幀;這意味著伺服器應將該幀的有效載荷連線到它從該客戶端收到的上一個幀。這是一個粗略的草圖,其中伺服器響應客戶端傳送文字訊息。第一條訊息在一個幀中傳送,而第二條訊息透過三個幀傳送。FIN 和操作碼詳細資訊僅顯示給客戶端
Client: FIN=1, opcode=0x1, msg="hello" Server: (process complete message immediately) Hi. Client: FIN=0, opcode=0x1, msg="and a" Server: (listening, new message containing text started) Client: FIN=0, opcode=0x0, msg="happy new" Server: (listening, payload concatenated to previous message) Client: FIN=1, opcode=0x0, msg="year!" Server: (process complete message) Happy new year to you too!
請注意,第一個幀包含一條完整的訊息(FIN=1 且 opcode!=0x0),因此伺服器可以根據需要進行處理或響應。客戶端傳送的第二個幀包含文字有效載荷(opcode=0x1),但整個訊息尚未到達(FIN=0)。該訊息的所有剩餘部分都透過繼續幀(opcode=0x0)傳送,訊息的最終幀由 FIN=1 標記。規範的 5.4 節描述了訊息分片。
Pings 和 Pongs:WebSocket 的心跳
在握手之後的任何時候,客戶端或伺服器都可以選擇向對方傳送 ping。收到 ping 後,接收方必須儘快傳送 pong。例如,您可以使用此功能來確保客戶端仍然連線。
ping 或 pong 只是一個常規幀,但它是一個控制幀。ping 的操作碼為 0x9,pong 的操作碼為 0xA。當您收到 ping 時,傳送一個與 ping 具有完全相同有效載荷資料的 pong(對於 ping 和 pong,最大有效載荷長度為 125)。您也可能在從未傳送 ping 的情況下收到 pong;如果發生這種情況,請忽略它。
注意:如果您在有機會發送 pong 之前收到了不止一個 ping,您只需傳送一個 pong。
關閉連線
要關閉連線,客戶端或伺服器都可以傳送一個控制幀,其中包含指定控制序列的資料,以開始關閉握手(詳細資訊請參見第 5.5.1 節)。收到這樣的幀後,另一個對等體傳送一個關閉幀作為響應。然後第一個對等體關閉連線。連線關閉後收到的任何進一步資料都將被丟棄。
雜項
注意:WebSocket 程式碼、擴充套件、子協議等都在 IANA WebSocket 協議登錄檔中註冊。
WebSocket 擴充套件和子協議在握手期間透過標頭進行協商。有時擴充套件和子協議非常相似,但有明顯的區別。擴充套件控制 WebSocket 幀並修改有效載荷,而子協議則構建 WebSocket 有效載荷並從不修改任何內容。擴充套件是可選和通用化的(如壓縮);子協議是強制性和區域性化的(如用於聊天和 MMORPG 遊戲的子協議)。
擴充套件
將擴充套件想象成在傳送電子郵件給某人之前壓縮檔案。無論您做什麼,您都是以不同形式傳送相同的資料。收件人最終將能夠獲得與您的本地副本相同的資料,但它以不同的方式傳送。這就是擴充套件的作用。WebSockets 定義了一個協議和一種傳送資料的簡單方法,但像壓縮這樣的擴充套件可以允許傳送相同的資料,但以更短的格式。
注意:擴充套件在規範的 5.8、9、11.3.2 和 11.4 節中解釋。
子協議
將子協議想象成自定義的 XML 模式或 文件型別定義。您仍然使用 XML 及其語法,但您還受到您商定的結構的限制。WebSocket 子協議就是這樣。它們不引入任何花哨的東西,它們只是建立結構。像文件型別或模式一樣,雙方必須就子協議達成一致;與文件型別或模式不同,子協議在伺服器上實現,不能由客戶端外部引用。
注意:子協議在規範的 1.9、4.2、11.3.4 和 11.5 節中解釋。
客戶端必須請求一個特定的子協議。為此,它將傳送類似這樣的內容作為原始握手的一部分
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 解析器。實際上,這將是庫的一部分,但伺服器需要傳遞資料。
注意:為了避免名稱衝突,建議將您的子協議名稱作為域字串的一部分。如果您正在構建一個使用 Example Inc. 專有格式的自定義聊天應用程式,那麼您可以使用:Sec-WebSocket-Protocol: chat.example.com。請注意,這不是必需的,它只是一個可選約定,您可以使用任何您喜歡的字串。