快速搞懂 | WebSocket協議的詳解與實踐

概述

對WebSocket協議只停留在長鏈接上?想了解WebSokcet協議具體是如何實現的嗎?瀏覽器揹着咱們又偷偷作了哪些工做?html

因爲實踐是檢驗協議的惟一標準,所以本文將以客戶端和服務端一次數據傳輸來學習WebSocket協議的設計。java

本文基於WebSocket協議的文檔RFC6455總結而成(已經大佬翻譯了中文版)git

提示,若是你對TCP協議還不夠了解,建議閱讀TCP協議小冊github

本文預設你已經知道如何使用WebSocketweb

測試環境搭建

  • 操做系統 Bug10
  • 軟件 WireShark 用於抓包
  • 編寫好的WebSocket服務端和客戶端用於本地測試

WireShark 配置

衆所周知WireShark能夠用來抓取網絡封包,被普遍的應用到安全以及測試領域中,所以咱們能夠用WireShark來分析使用WebSocket協議的過程當中服務端和客戶端都偷偷說了些什麼。算法

第一步,選擇要監聽的網卡express

因爲咱們使用的是本地的測試環境,也就是說咱們訪問的地址localhost,所以咱們須要監聽本地迴環網絡,也就是圖中紅框所標註的網卡。segmentfault

第二步,過濾無關數據數組

以下圖所示,設置tcp.port == 8080 表示咱們只監聽8080端口的數據,下文中還會再次說明瀏覽器

WebSocket協議詳解

握手協議-Opening Handshake

客戶端若是想與服務端創建WebSocket鏈接,則必須經歷如下流程

0x01 客戶端發出握手請求

在客戶端與服務端創建TCP鏈接以後,客戶端以HTTP報文的形式發送握手請求到服務端,該HTTP報文必須符合如下要求

  • HTTP報文必須合法,且請求的方式爲GET

  • HTTP報文的必須包含如下消息頭以標誌這是一個WebSocket握手請求

Upgrade: websocket
Connection: Upgrade
複製代碼
  • HTTP報文的消息頭中必須包含Sec-WebSocket-Key字段,此字段主要用於WebSocket協議的校驗,以防止濫用,此字段只能出現一次, 其值的算法在後文會詳細說明

  • 若是此請求來自瀏覽器,則HTTP報文的消息頭必須包含Origin字段,其餘方式的請求也能夠包含此字段。

  • HTTP報文的消息頭中必須包含Sec-WebSocket-Version,以代表WebSocket的版本,且其值必須爲13

  • HTTP報文消息頭中能夠包含Sec-WebSocket-Protocol,以代表客戶端所但願執行的子協議

  • HTTP報文消息頭中能夠包含Sec-WebSocket-Extensions,以代表客戶端所但願執行的擴展(如消息壓縮插件)

  • HTTP報文能夠包含其餘消息頭

在客戶端發出握手的HTTP請求以後,在服務端返回響應以前,客戶端不能發送任何數據給服務端

0x02 服務端響應

若是服務端決定接收來自客戶端的握手請求與客戶端創建WebSocket鏈接,那麼服務端必須完成如下操做。

假設來自客戶端的握手請求是合法的, 即客戶端的握手請求符合RFC6455的定義

一、 服務端必須向客戶端證實本身處理WebSocket協議。證實的方式以下

在客戶端隨機生成一個16字節大小的字節數組,並使用Base64加密該字節數組,將加密後所獲得得字符串填入Sec-WebSocket-Key字段, 如如下所示

Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
複製代碼

服務端要證實本身能夠處理WebSocket協議,則必須對Sec-WebSocket-Key作如下操做,取出其值也就是dGhlIHNhbXBsZSBub25jZQ==,將該字符串與魔法字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11鏈接,得出如下字符串

dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11

對以上字符串執行SHA-1算法得出其散列值(一個字節數組),以下所示

0xb3 0x7a 0x4f 0x2c 0xc0 0x62 0x4f 0x16 0x90 0xf6 0x46 0x06 0xcf 0x38 0x59 0x45 0xb2 0xbe 0xc4 0xea
複製代碼

對以上字節數組執行Base64加密算法得出字符串s3pPLMBiTxaQ9kYGzzhZRbK+xOo=,而後將該值寫入握手響應的Sec-WebSocket-Accept字段中, 也就是說你會在響應中看到如下字段

Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
複製代碼

以上來自RFC6455文檔。 若是你曾經調用過第三方接口(如某信的公衆號),是否是有種似曾相識的感受呢?

客戶端會對此值進行校驗,以檢查服務端是否可以正確的處理WebSocket協議。

咱們能夠看看Web應用容器/框架們是如何處理此字段的

一號選手 Jetty

處理握手協議的關鍵類org.eclipse.jetty.websocket.server.HandshakeRFC6455

public class HandshakeRFC6455 implements WebSocketHandshake {
    /** * RFC 6455 - Sec-WebSocket-Version * 驗證了上文的說法,即版本號必須爲13 * 沒辦法規矩是人定的,(ˉ▽ˉ;)... */
    public static final int VERSION = 13;

    @Override
    public void doHandshakeResponse(ServletUpgradeRequest request, ServletUpgradeResponse response) throws IOException {
        String key = request.getHeader("Sec-WebSocket-Key");
        if (key == null)
            throw new BadMessageException("Missing request header 'Sec-WebSocket-Key'");

        // build response
        response.setHeader("Upgrade", "WebSocket");
        response.addHeader("Connection", "Upgrade");
        //沒錯,關鍵類就在這兒 AcceptHash.hashKey(key)
        response.addHeader("Sec-WebSocket-Accept", AcceptHash.hashKey(key));

        request.complete();

        response.setStatusCode(HttpServletResponse.SC_SWITCHING_PROTOCOLS);
        response.complete();
    }
}
複製代碼

AcceptHash.hashKey(key)

public class AcceptHash {
    //魔法值,注意其編碼的方式
    private static final byte[] MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.ISO_8859_1);
    
    public static String hashKey(String key) {
        try
        {
            //使用SHA1求對應字符串的散列值
            MessageDigest md = MessageDigest.getInstance("SHA1");
            //獲取Sec-WebSocket-Key
            md.update(key.getBytes(StandardCharsets.UTF_8));
            //此法至關於將兩個字符串鏈接在一塊兒
            md.update(MAGIC);
            //digest()會獲取通過SHA1算法計算以後的字節數組
            return Base64.getEncoder().encodeToString(md.digest());
        }
        catch (Exception e)
        {
            throw new RuntimeException(e);
        }
    }
}

複製代碼

二號選手 Express-ws

express-ws 依賴 ws, 上圖代碼位於websocket-server.js

留個小問題,對比JS的代碼,你知道爲啥Java要特別指定字符串的編碼值嗎?

二、 在處理WebSocket協議以後, 服務端須要處理來自客戶端握手請求中的擴展請求,即處理Sec-WebSocket-Extensions字段。該字段代表了客戶端但願服務端加載的擴展插件, 但事實上插件是否加載是以服務端爲準的,服務端會在返回的消息頭中代表它所支持的插件。

例如,客戶端發送瞭如下的插件擴展請求,但願服務端加載permessage-deflate以及client_max_window_bits插件

Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
複製代碼

若是服務端僅支持permessage-deflate插件的,那麼服務端會返回

Sec-WebSocket-Extensions: permessage-deflate
複製代碼

在以後的通訊過程當中,服務端和客戶端都只會加載permessage-deflate插件(關於此插件,實踐過程當中會說起,困擾了我很久)

三、 告知客戶端服務端所支持的子協議,也就是處理Sec-WebSocket-Protocol字段並從中選擇一個所支持協議並返回給客戶端。處理方式以下

var protocol = req.headers['sec-websocket-protocol'];
    //若是存在sec-websocket-protocol字段
    if (protocol) {
      //客戶端傳過來的全部的協議
      protocol = protocol.trim().split(/ *, */);

      //
      // Optionally call external protocol selection handler.
      // handleProtocols 爲鉤子函數,即若是定義了此函數則將協議的選擇交給此函數處理
      if (this.options.handleProtocols) {
        protocol = this.options.handleProtocols(protocol, req);
      } else {
        //不然,就默認唄,還能咋樣
        protocol = protocol[0];
      }
      //若是選擇到了協議,就返回Sec-WebSocket-Protocol
      if (protocol) {
        headers.push(`Sec-WebSocket-Protocol: ${protocol}`);
        ws.protocol = protocol;
      }
    }
複製代碼

此字段至關於爲開發者預留更多的操做空間。更多資料

除此以外,服務端響應還必須包含如下字段和值以代表成功創建WebSocket鏈接

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: WebSocket
複製代碼

實踐

  • 寫一個WebSocket服務端和客戶端。
  • 打開WireShark,若是服務端在本地則監聽迴環網絡,若是不在本機則監聽對應網卡
  • 設置過濾規則,值監聽對應端口,本次測試服務端的端口是8080
tcp.port == 8080
複製代碼

以下圖所示,爲WireShark捕獲的WebSocket協議握手以及通訊包。事實上握手協議能夠視爲一個協商過程,即服務端和客戶端互相告知本身能夠什麼樣的方式來處理數據過程的。

1.客戶端發起握手請求

2.服務端響應握手請求

3.協議切換,在本例中服務端在與客戶端完成握手以後,會當即發送一條數據給客戶端,以下圖所示,注意紅框所標準的內容,此時協議已經轉換成了WebSocket協議

相信你也注意到了第一個黃框所標註的內容,裏面的內容是WebSocket協議傳輸數據的關鍵。而這也是咱們要用WireShark抓包的緣由,由於Chrome並不會呈現底層的細節給咱們,它只會告訴咱們服務端返回了什麼數據。

數據傳輸

想要真正理解WebSocket的數據傳輸協議爲何要這樣子設計你必須知道如下概念。

  • WebSocket協議是基於TCP協議的應用層協議
  • TCP是基於字節流的傳輸協議
  • 流是沒有邊界的

以上概念意味着咱們必須知道數據的邊界是什麼,換種說法就是咱們必須知道以什麼形式去劃分字節流,以便得到一個完整的數據。

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

在開始以前有必要對payload解釋一下,payload即負載能夠理解咱們要傳輸的數據。

下表爲對WebSocket種各字段的解釋.

字段名 SIZE 做用
FIN 1位 標誌此數據封包是否爲這次消息的最後一個封包
RSV1, RSV2, RSV2 每一個標誌各一位,總計三位 這三個標誌位主要與擴展相關,下文的實踐會給出詳細說明
opcode 4位 標誌此數據封包中負載(即Payload,能夠視爲實際傳輸的內容)的類型,因爲其有4位數,所以支持16種類型的內容,經常使用的有0001即文本,0002二進制數據
MASK 1位 表示此數據封包種的負載(Payload)是否執行了掩碼操做,若是此位爲1則Masking-key會起做用,掩碼具體如何起做用會在下文的實踐種說明
Payload len 7位 表示負載的長度,因爲此字段爲7位所以,此字段最大支持傳輸127個字節數
Extended Payload Length 16 位或 64位 若是Payload len字段的值爲126,那麼整個Payload的長度爲 7 + 16 位,若是Payload len 字段的值爲127那麼整個payload的長度爲 7 + 64
Masking-key 0或4字節 若是Mask位爲1,則此字段起做用,此字段主要用來對數據執行掩碼操做
Payload Data N字節 這次數據封包要傳輸的內容,長度由Payload len與Extend payload len 定義

如今,你知道WebSocket協議是如何定義流的邊界了嗎?

實踐

學習的最好辦法就是實踐,加油,奧裏給! - 巨魔

測試環境: Bug 10 + Chrome + Jetty

發送數據

首先讓咱們發條消息juejin,kesan(長度爲12)給服務端

注意到如下信息

  • FIN = 1 由於咱們發送的消息一個封包徹底足夠傳輸,因此此數據封包也是最後一個/一幀, 所以 FIN = 1 沒毛病
  • RSV1 = 1, RSV2 = 0, RSV3 = 0, 在上文咱們說過這三個標誌位與擴展相關的,而此時WireShark也提示咱們這次傳輸啓用了Pre-Message Compressed插件
  • OPCODE = 0001, 這次的負載/傳輸的是文本消息
  • Payload length = 14 這次負載內容的字節數是14,你發現問題了嗎?
  • Masking-key = 7eca6052 這次負載的掩碼爲7eca6052,掩碼通常出如今客戶端發送數據到服務端的過程當中

讓咱們康康這次傳輸的內容,你發現問題了嗎?

數據去哪了呀?等等,你還記得掩碼讓咱們看一下去掉掩碼以後得數據是咋樣的

仍是不對,去掉掩碼以後仍是不對,並且長度也不對,看到旁邊的 Decompressed payload了嗎?讓咱們進去看看

終於對了!!!如今你知道這次傳輸發生了什麼嗎?先考慮一會

RSV1 = 1 說明Chrome啓用了插件而且Jetty也支持此插件,所以這次消息傳輸啓用了插件,那麼到底啓用了什麼插件呢?其實在上文的握手協議已經說明了,在客戶端和服務端執行握手協議的時候會協商兩者要共同啓用的插件並經過Sec-WebSocket-Extensions來講明。而這次啓用的插件是permessage-deflate插件,用來壓縮消息。

但尷尬的是,消息壓縮彷佛不起做用而且還變長了ԾㅂԾ.

咱們換個方法測試一下,發一大堆1給服務端,看看發生了什麼

哦吼,起做用了,能夠看出壓縮前的數據長度爲41字節,而壓縮後的數據只有5字節,確實起做用了呀。(此插件的實現不在本文的討論範圍內)

那麼,我想康康沒有啓用壓縮數據的WebSocket包該咋辦?

有請Bug 10的御用Bug多PDF閱讀器EPUB閱讀器Edge瀏覽器選手登場

沒錯,Edge不支持permessage-deflate數據壓縮插件

Edge這孩子比較慘,他微軟爸爸一個插件都沒給他,太慘了,難怪要拐走隔壁谷歌的chromium當本身的兒子。

好吧,看看原汁原味的數據封包長什麼樣,能夠看出只要去掉掩碼就能夠直接獲取數據,而不須要再解壓縮數據

去掉掩碼只須要將4個字節的數據與掩碼異或便可 見如下代碼,分爲4字節和不足4字節的狀況

if (remaining >= 4 && (offset & 3) == 0)
    {
        payload.putInt(start, payload.getInt(start) ^ maskInt);
        start += 4;
        offset += 4;
    }
    else
    {
        payload.put(start, (byte)(payload.get(start) ^ maskBytes[offset & 3]));
        ++start;
        ++offset;
    }
複製代碼
接收數據

以下圖所示爲服務端發送給客戶端的消息沒有掩碼也沒有啓用插件,所以其RSV1,RSV2, RSV3 = 0

固然,你在測試過程當中會發現瀏覽器和服務端一直在偷偷玩pongpongpong的遊戲,以下圖所示

事實上,這是服務端和客戶端在玩心跳鏈接,若是你不知道心跳鏈接時啥子玩意,建議閱讀

總結

關於WebSocket協議還有不少細節沒有講到,只講了我學到的(●ˇ∀ˇ●),若是真正的要理解協議的設計的各個細節建議仍是閱讀RFC6455文檔,畢竟信息通過傳播都會存在必定程度失真。

學到了什麼?

  • 一句話來講就是流是沒有邊界(出自張師傅的小冊),一旦理解了這個概念理解其它基於TCP傳輸協議的應用層協議時候就很快了。

  • 全部適用於Socket的優化手段基本上也都適用於WebSocket,如NIO和AIO等

  • 啓用數據壓縮插件真的好嗎?不見得, 仍是得分狀況,畢竟不管是數據壓縮仍是解壓縮都是須要時間得,得問值不值得,具體就得看業務場景了

  • 基於散列函數的身份校驗方法,簡單來講就是將一堆參數和特定的字符串(密鑰、魔數)以約定形式組合起來得出其散列值以供校驗方校驗請求方的身份信息。

有人看的話再談談其餘方面的吧,看需求去了(^_^)

相關文章
相關標籤/搜索