【嚴選-高質量文章】開發者必知必會的 WebSocket 協議

文章介紹

關於 WebSocket,我以前也寫過了兩篇文章進行介紹:《WebSocket 從入門到寫出開源庫》和《Python如何爬取實時變化的WebSocket數據》。今天這篇文章,大致上與以前的文章內容結構類似。但質量更進一步,適合想要徹底掌握 WebSocket 協議的朋友,所以特來掘金分享給你們。html

WebSocket 是一種在單個 TCP 鏈接上進行全雙工通訊的協議,它的出現使客戶端和服務器之間的數據交換變得更加簡單。WebSocket 一般被應用在實時性要求較高的場景,例如賽事數據、股票證券、網頁聊天和在線繪圖等。前端

WebSocekt 與 HTTP 協議徹底不一樣,但一樣被普遍應用。不管是後端開發者、前端開發者、爬蟲工程師或者信息安全工做者,都應該掌握 WebSocekt 協議的知識。git

在本篇文章中,你將收穫以下知識:github

  • 讀懂 WebSocket 協議規範文檔 RFC6455
  • WebSocket 與 HTTP 的關係
  • 數據幀格式及字段含義
  • 客戶端與服務端交互流程
  • 客戶端與服務端如何保持鏈接
  • 什麼時候斷開鏈接

本篇文章適用於互聯網領域的開發者和產品經理web


開始

WebSocket 是一種在單個 TCP 鏈接上進行全雙工通訊的協議。WebSocket 通訊協議於 2011 年被 IETF 定爲標準 RFC6455,並由 RFC7936 補充規範。看到這裏,不少讀者會有疑問:什麼是 RFC?算法

RFC 是一系列以編號排定的文件,它由一系列草案和標準組成。幾乎全部互聯網通訊協議均記錄在 RFC 中,例如 HTTP 協議標準、本篇介紹的 WebSocket 協議標準、Base64 編碼規範等。除此以外,RFC 還加入了許多論題。在本篇 Chat 中,咱們對 WebSocekt 的學習和討論將基於 RFC6455數據庫

WebSocket 協議的來源

在 WebSocket 協議出現之前,網站一般使用輪詢來實現相似「數據實時更新」這樣的效果。要注意的是,這裏的「數據實時更新」是帶有引號的,這表示並非真正意義上的數據實時更新。輪詢指的是在特定的時間間隔內,由客戶端主動向服務端發起 HTTP 請求,以確認是否有新數據的行爲。下圖描述了輪詢的過程:後端

首先,客戶端會向服務端發出一個 HTTP 請求,這個請求的意圖就是向服務器詢問「大哥,有新數據嗎?」。服務器在接收到請求後,根據實際狀況(有數據或無數據)作出響應:瀏覽器

  • 有數據,我發給你;
  • 無數據,你待會再問;

這種一問一答的方式有着明顯的缺點,即瀏覽器須要不斷的向服務器發出請求。因爲 HTTP 請求包含較長的頭部信息(例如 User-Agent、Referer 和 Host 等),其中真正有效的數據可能只是很小的一部分,因此這樣會浪費不少的帶寬資源。緩存

比輪詢更好的「數據實時更新」手段是 Comet。這種技術能夠實現雙向通訊,但依然須要反覆發出請求。並且在 Comet 中,採用的是 HTTP 長鏈接,這一樣會消耗服務器資源。在這種狀況下,HTML5 定義了更節省資源,且可以讓雙端穩定實時通訊的 WebSocket 協議。在 WebSocket 協議下,客戶端和服務端只須要完成一次握手,就直接能夠建立持久性的鏈接,並進行雙向數據傳輸。下圖描述了 WebSocket 協議中,雙端通訊的過程:

WebSocket 的優勢

相對於 HTTP 協議來講,WebSocket 具備開銷少、實時性高、支持二進制消息傳輸、支持擴展和更好的壓縮等優勢。這些優勢以下所述:

較少的開銷

WebSocket 只須要一次握手,在每次傳輸數據時只傳輸數據幀便可。而 HTTP 協議下,每次請求都須要攜帶完整的請求頭信息,例如 User-Agent、Referer 和 Host 等。因此 WebSocket 的開銷相對於 HTTP 來講會少不少。

更強的實時性

因爲協議是全雙工的,因此服務器能夠隨時主動給客戶端下發數據。相對於一問一答的 HTTP 來講,WebSocket 協議下的數據傳輸的延遲明顯更少。

支持二進制消息傳輸

WebSocket 定義了二進制幀,能夠更輕鬆地處理二進制內容。

支持擴展

開發者能夠擴展協議,或者實現部分自定義的子協議。

更好的壓縮

Websocket 在適當的擴展支持下,能夠沿用以前內容的上下文。這樣在傳遞相似結構的數據時,能夠顯著地提升壓縮率。

WebSocket 協議規範

WebSocket 是一個通訊協議,該協議的規範與標準均記錄在 RFC6455 中。協議共有 14 個部分,但與協議規範相關的只有 11 個部分:

  1. 介紹
  2. 術語和其餘約定
  3. WebSocket URI
  4. 握手規範
  5. 數據幀
  6. 發送和接收數據
  7. 關閉鏈接
  8. 錯誤處理
  9. 擴展
  10. 通訊安全
  11. 注意事項

而與本篇 Chat 相關的爲 四、五、六、7 部分的內容,這些也是 WebSocket 中較爲重要的內容。接下來,咱們就來學習這些知識。

雙端交互流程

客戶端與服務端鏈接成功以前,使用的通訊協議是 HTTP。鏈接成功後,使用的纔是 WebSocket 協議。下圖描述了雙端交互的流程:

首先,客戶端向服務端發出一個 HTTP 請求,請求中攜帶了服務端規定的信息,並在信息中代表但願將協議升級爲 WebSocket。這個請求被稱爲升級請求,雙端升級協議的整個過程叫作握手。而後服務端驗證客戶端發送的信息,若是符合規範則將協議替換成 WebSocket,並將升級成功的信息響應給客戶端。最後,雙方就能夠基於 WebSocket 協議互相推送信息了。如今,咱們須要學習的第一個知識點就是握手。

雙端握手

咱們先來看看 RFC6455 對客戶端握手的規定,原文錨點連接爲 Opening Handshak。此段原文以下:

The opening handshake is intended to be compatible with HTTP-based server-side software and intermediaries, so that a single port can be used by both HTTP clients talking to that server and WebSocket clients talking to that server.  To this end, the WebSocket client's handshake is an HTTP Upgrade request: 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 In compliance with [RFC2616], header fields in the handshake may be sent by the client in any order, so the order in which different header fields are received is not significant. 複製代碼

原文代表,握手時使用的並非 WebSocekt 協議,而是 HTTP 協議,握手時發出的請求叫作升級請求。客戶端在握手階段經過 ConnectionUpgrade 頭域及對應的值告知服務端,要求將當前通訊協議升級爲指定協議,此處指定的是 WebSocket 協議。其餘頭域名及值的做用以下:

  • GET /chat HTTP/1.1 代表本次請求基於 HTTP/1.1,請求方式爲 GET
  • Sec-WebSocket-Protocol 用於指定子協議;
  • Sec-WebSocket-Version 代表協議版本,要求雙端版本一致。當前 WebSocekt 協議版本默認爲 13
  • Origin 代表請求來自於哪一個站點;
  • Host 代表目標主機;
  • Sec-WebSocket-Key 用於防止攻擊者惡意欺騙服務端;

也就是說,握手時客戶端只須要按照上述規定向服務端發出一個 HTTP 請求便可。

服務端收到客戶端發起的請求後,按照 RFC6455 的約定驗證請求信息。驗證經過就表明握手成功,此時服務端應當按照約定將如下內容響應給客戶端:

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

服務端會給出表明鏈接結果的響應狀態碼,101 狀態碼錶示表示本次請求成功且獲得服務端的正確處理。ConnectionUpgrade 表示已經切換成 websocket 協議。Sec-WebSocket-Accept 則是通過服務器確認,而且加密事後的 Sec-WebSocket-Key,這個值根據客戶端發送的 Sec-WebSocket-Key 生成。Sec-WebSocket-Protocol 代表雙端約定的子協議。

這樣,客戶端與服務端就完成了握手操做。雙端達成一致,通訊協議將由 HTTP 協議切換成 WebSocket 協議。

發送和接收數據

雙方握手成功,並肯定協議後,就能夠互相發送信息了。客戶端和服務端互發消息與咱們平時在社交應用中互發消息相似,例如:

client: Hello, Server boy.

server: Hello, Client Man.
複製代碼

固然,這裏的 Hello, Server boyHello, Client Man 是有助於咱們理解的比喻。實際上,WebSocket 協議的中的數據傳輸格式並非這樣直接呈現的。

數據幀

WebSocket 雙端傳輸的是一個個數據幀,數據幀的約定原文以下:

In the WebSocket Protocol, data is transmitted using a sequence of frames. To avoid confusing network intermediaries (such as intercepting proxies) and for security reasons that are further discussed in Section 10.3, a client MUST mask all frames that it sends to the server (see Section 5.3 for further details). (Note that masking is done whether or not the WebSocket Protocol is running over TLS.)  The server MUST close the connection upon receiving a frame that is not masked.  In this case, a server MAY send a Close frame with a status code of 1002 (protocol error) as defined in Section 7.4.1.  A server MUST NOT mask any frames that it sends to the client.  A client MUST close a connection if it detects a masked frame.  In this case, it MAY use the status code 1002 (protocol error) as defined in Section 7.4.1.  (These rules might be relaxed in a future specification.)
The base framing protocol defines a frame type with an opcode, a payload length, and designated locations for "Extension data" and "Application data", which together define the "Payload data". Certain bits and opcodes are reserved for future expansion of the
protocol.
A data frame MAY be transmitted by either the client or the server at any time after opening handshake completion and before that endpoint has sent a Close frame (Section 5.5.1).
複製代碼

原文代表,協議中約定數據傳輸時並非使用 Unicode 編碼,而是使用數據幀(Frame)。下圖描述了數據幀的組成:

數據幀由幾個部分組成:FIN、RSV一、RSV二、RSV三、opcode、MASK、Payload length、Payload Data、和 Masking-key。下面,咱們來了解一下數據幀組件的大致含義或做用。

FIN

佔 1 bit,其值爲 01,值對應的含義以下:

0:不是消息的最後一個分片;

1:是消息的最後一個分片;
複製代碼

RSV1 RSV2 RSV3

均佔 1 bit,通常狀況下值爲 0。當客戶端、服務端協商採用 WebSocket 擴展時,這三個標誌位能夠非 0,且值的含義由擴展進行定義。若是出現非零的值,但並無採用 WebSocket 擴展,則鏈接出錯。

Opcode

佔 4 bit,其值能夠是 %x0%x1%x2%x3~7%x8%x9%xA%xB~F 中的任何一個。值對應的含義以下:

%x0:表示一個延續幀。當 Opcode 爲 0 時,表示本次數據傳輸採用了數據分片,當前收到的數據幀爲其中一個數據分片;

%x1:表示這是一個文本幀(text frame);

%x2:表示這是一個二進制幀(binary frame);

%x3-7:保留的操做代碼,用於後續定義的非控制幀;

%x8:表示鏈接斷開,是一個控制幀;

%x9:表示這是一個心跳請求(ping);

%xA:表示這是一個心跳響應(pong);

%xB-F:保留的操做代碼,用於後續定義的控制幀;
複製代碼

Mask

佔 1 bit,其值爲 01。值 0 表示要對數據進行掩碼異或操做,反之亦然。

Payload length

佔 7 bit 或 7+16 bit 或 7+64 bit,表示數據的長度,其值能夠是0~127 中的任何一個數。值對應的含義以下:

0~126:數據的長度等於該值;

126:後續 2 個字節表明一個 16 位的無符號整數,該無符號整數的值爲數據的長度;

127:後續 8 個字節表明一個 64 位的無符號整數(最高位爲 0),該無符號整數的值爲數據的長度。
複製代碼

掩碼

掩碼的做用並非爲了防止數據泄密,而是爲了防止早期版本的協議中存在的代理緩存污染攻擊(proxy cache poisoning attacks)問題。這裏要注意的是從客戶端向服務端發送數據時,須要對數據進行掩碼操做;從服務端向客戶端發送數據時,不須要對數據進行掩碼操做。

若是服務端接收到的數據沒有進行過掩碼操做,服務端須要斷開鏈接。若是Mask是1,那麼在Masking-key中會定義一個掩碼鍵(masking key),並用這個掩碼鍵來對數據載荷進行反掩碼。

全部客戶端發送到服務端的數據幀,Mask都是1。

掩碼算法:按位作循環異或運算,先對該位的索引取模來得到 Masking-key 中對應的值 x,而後對該位與 x 作異或,從而獲得真實的 byte 數據。

Making-key

佔 0 或 4 bytes,其值爲 01。值對應的含義以下:

0:沒有 Masking-key;
1:有 Masking-key;
複製代碼

Payload Data

雙端接收到數據幀以後,能夠根據上述幾個數據幀組件的值對 Payload Data 進行處理或直接提取數據。

數據收發流程

在瞭解到 WebSocket 傳輸的數據幀格式後,咱們再來學習數據收發的流程。在雙端創建 WebSocket 鏈接後,任何一端均可以給另外一端發送消息,這裏的消息指的就是數據幀。但平時咱們輸入或輸出的信息都是「明文」,因此在消息發送前須要將「明文」經過必定的方法轉換成數據幀。而在接收端,拿到數據幀後須要按照必定的規則將數據幀轉換爲」明文「。下圖描述了雙端收發 Hello, world 的主要流程:

保持鏈接和關閉鏈接

WebSocket 雙端的鏈接能夠保持長期不斷開,但實際應用中卻不會這麼作。若是保持全部鏈接不斷開,但鏈接中有不少不活躍的成員,那麼就會形成嚴重的資源浪費。

服務端如何判斷客戶端是否活躍呢?

服務端會按期給全部的客戶端發送一個 opcode 爲 %x9 的數據幀,這個數據幀被稱爲 Ping 幀。客戶端在收到 Ping 幀時,必須回覆一個 opcode 爲 %xA 的數據幀(又稱爲 Pong 幀),不然服務端就能夠主動斷開鏈接。反之,若是服務端在發送 Ping 幀後可以獲得客戶端 Pong 幀的迴應,就表明這個客戶端是活躍的,不要斷開鏈接。

若是須要關閉鏈接,那麼一端向另外一端發送 opcode 爲 %x8 的數據幀便可,這個數據幀被稱爲關閉幀。

插個廣告

若是以爲本篇文章對你有幫助,但願你能到 GitChat 上訂閱我發表的 Chat,支持我繼續分享高質量文章。

GitChat 《開發者必知必會的 WebSocket 協議》

GitChat《MongoDB 實戰教程:數據庫與集合的 CRUD 操做篇》


實際代碼解讀-Python

上面所述均爲 RFC6455 中約定的 WebSocket 協議規範。在學習完理論知識後,咱們能夠經過一些示例(代碼僞代碼)來加深對上述知識的理解。

Echo Test 是 websocket.org 提供的一個測試平臺,開發者能夠用它測試與 WebSocket 相關的鏈接、消息發送和消息接收等功能。下面的代碼演示也將基於 Echo Test

客戶端握手

上面提到過,客戶端向服務端發出升級請求時,請求頭以下:

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
複製代碼

對應的 Python 代碼以下:

import requests

url = 'http://echo.websocket.org/?encoding=text'
header = {
    "Host": "echo.websocket.org",
    "Upgrade": "websocket",
    "Connection": "Upgrade",
    "Sec-WebSocket-Key": "9GxOnSwEuBNbLeBwiltymg==",
    "Origin": "http://www.websocket.or",
    "Sec-WebSocket-Protocol": "chat, superchat",
    "Sec-WebSocket-Version": "13"
}

resp = requests.get(url, headers=header)
print(resp.status_code)
複製代碼

代碼運行後返回的結果爲 101,這說明上方代碼完成了升級請求的工做。

數據轉換爲數據幀

數據轉換爲數據幀涉及到不少知識,同時須要運行完整的 WebSocket 客戶端。本篇 Chat 不演示完整的代碼結構,僅講解對應的代碼邏輯。完整的 WebSocket 客戶端可在 Github 上克隆我編寫的開源的庫:aiowebsocekt

克隆到本地後打開 freams.py ,這就是負責數據幀的轉換處理的主要文件。

首先看 write() 方法,發送端發送數據時,數據會通過該方法。write() 方法的完整代碼以下:

async def write(self, fin, code, message, mask=True, rsv1=0, rsv2=0, rsv3=0):
        """Converting messages to data frames and sending them. Client data frames must be masked,so mask is True. """
        head1, head2 = self.pack_message(fin, code, mask, rsv1, rsv2, rsv3)
        output = io.BytesIO()
        length = len(message)
        if length < 126:
            output.write(pack('!BB', head1, head2 | length))
        elif length < 2**16:
            output.write(pack('!BBH', head1, head2 | 126, length))
        elif length < 2**64:
            output.write(pack('!BBQ', head1, head2 | 127, length))
        else:
            raise ValueError('Message is too long')

        if mask:
            # pack mask
            mask_bits = pack('!I', random.getrandbits(32))
            output.write(mask_bits)
            message = self.message_mask(message, mask_bits)

        output.write(message)
        self.writer.write(output.getvalue())
複製代碼

首先,調用 pack_message() 方法構造數據幀中的 FIN、Opcode、RSV一、RSV二、RSV3。而後根據消息的長度構造數據幀中的 Payload length。接着根據發送端是客戶端或服務端對數據進行掩碼。最後將數據放到數據幀中,並將數據幀發送給接收端。這裏用到的 pack_message() 方法代碼以下:

@staticmethod
    def pack_message(fin, code, mask, rsv1=0, rsv2=0, rsv3=0):
        """Converting message into data frames conversion rule reference document: https://tools.ietf.org/html/rfc6455#section-5.2 """
        head1 = (
                (0b10000000 if fin else 0)
                | (0b01000000 if rsv1 else 0)
                | (0b00100000 if rsv2 else 0)
                | (0b00010000 if rsv3 else 0)
                | code
        )
        head2 = 0b10000000 if mask else 0  # Whether to mask or not
        return head1, head2
複製代碼

用於執行掩碼操做的 message_mask() 方法代碼以下:

@staticmethod
    def message_mask(message: bytes, mask):
        if len(mask) != 4:
            raise FrameError("The 'mask' must contain 4 bytes")
        return bytes(b ^ m for b, m in zip(message, cycle(mask)))
複製代碼

以上就是數據轉換爲數據幀併發送給接收端的主要代碼。

數據幀轉換爲數據

一樣是 freams.py 文件,此次咱們來看 read() 方法。接收端接收數據後,數據會通過該方法。read() 方法的完整代碼以下:

async def read(self, text=False, mask=False, maxsize=None):
        """return information about message """
        fin, code, rsv1, rsv2, rsv3, message = await self.unpack_frame(mask, maxsize)
        await self.extra_operation(code, message)  # 根據操做碼決定後續操做
        if any([rsv1, rsv2, rsv3]):
            logging.warning('RSV not 0')
        if not fin:
            logging.warning('Fragmented control frame:Not FIN')
        if code is DataFrames.binary.value and text:
            if isinstance(message, bytes):
                message = message.decode()
        if code is DataFrames.text.value and not text:
            if isinstance(message, str):
                message = message.encode()
        return message
複製代碼

首先,調用 unpack_frame() 方法從數據幀中提取出 FIN、Opcode、RSV一、RSV二、RSV3 和 Payload Data(代碼中是 message)。而後根據 Opcode 決定後續的操做,例如提取數據、關閉鏈接、發送 Ping 幀或 Pong 幀等。

unpack_frame() 方法的完整代碼以下:

async def unpack_frame(self, mask=False, maxsize=None):
        reader = self.reader.readexactly
        frame_header = await reader(2)
        head1, head2 = unpack('!BB', frame_header)

        fin = True if head1 & 0b10000000 else False
        rsv1 = True if head1 & 0b01000000 else False
        rsv2 = True if head1 & 0b00100000 else False
        rsv3 = True if head1 & 0b00010000 else False
        code = head1 & 0b00001111

        if (True if head2 & 0b10000000 else False) != mask:
            raise FrameError("Incorrect masking")

        length = head2 & 0b01111111
        if length == 126:
            message = await reader(2)
            length, = unpack('!H', message)
        elif length == 127:
            message = await reader(8)
            length, = unpack('!Q', message)
        if maxsize and length > maxsize:
            raise FrameError("Message length is too long)".format(length, maxsize))
        if mask:
            mask_bits = await reader(4)
        message = self.message_mask(message, mask_bits) if mask else await reader(length)
        return fin, code, rsv1, rsv2, rsv3, message
複製代碼

從數據幀中提取 FIN、RSV一、Opcode 和 Payload Data(代碼中是 message) 等組件時,使用的是按位與運算。對位運算不太瞭解的朋友能夠查閱我以前在微信公衆號發表的《七分鐘全面瞭解位運算》文章。接着根據是否掩碼調用 message_mask() 方法,最後將獲得的組件返回給調用方。

總結

本篇 Chat 咱們瞭解了 WebSocekt 協議的來源,並討論了它的優勢。而後解讀 RFC6455 中對 WebSocket 的約定,瞭解到雙端交互流程、保持鏈接和關閉鏈接方面的知識。最後學習到如何將 WebSocket 協議轉換爲具體的代碼。

WebSocket 有幾個關鍵點:握手、數據與數據幀的轉換、保持鏈接的 Ping 幀和 Pong 幀、主動關閉鏈接的關閉幀。但願你們在看過本篇 Chat 後,可以對 WebSocket 協議有一個全新的認識。

相關文章
相關標籤/搜索