WebSocket 協議 1~4 節

hsy0發表於2019-01-17

此文僅作為 RFC6455 的學習筆記。篇幅太長超過了簡書的單篇最大長度,故分為兩篇,此篇記錄 1~4 節,其餘見 WebSocket 協議 5~10 節;

1.1 背景知識

由於歷史原因,在建立一個具有雙向通訊機制的 web 應用程式時,需要利用到 HTTP 輪詢的方式。圍繞輪詢產生了 “短輪詢” 和 “長輪詢”。

短輪詢

瀏覽器賦予了指令碼網路通訊的程式設計介面 XMLHttpRequest,以及定時器介面 setTimeout。因此,客戶端指令碼可以每隔一段時間就主動的向伺服器發起請求,詢問是否有新的資訊產生:

  1. 客戶端向伺服器發起一個請求,詢問 “有新資訊了嗎”
  2. 服務端接收到客戶端的請求,但是此時沒有新的資訊產生,於是直接回復 “沒有”,並關閉連結
  3. 客戶端知道了沒有新的資訊產生,那麼就暫時什麼都不做
  4. 間隔 5 秒鐘之後,再次從步驟 1 開始迴圈執行

長輪詢

使用短輪詢的方式有一個缺點,由於客戶端並不知道伺服器端何時會產生新的訊息,因此它只有每隔一段時間不停的向伺服器詢問 “有新資訊了嗎”。而長輪詢的工作方式可以是這樣:

  1. 客戶端向伺服器發起一個請求,詢問 “有新資訊了嗎”
  2. 伺服器接收到客戶端的請求,此時並沒有新的資訊產生,不過伺服器保持這個連結,像是告訴客戶端 “稍等”。於是直到有了新的資訊產生,服務端將新的資訊返回給客戶端。
  3. 客戶端接收到訊息之後顯示出來,並再次由步驟 1 開始迴圈執行

可以看到 “長輪詢” 相較於 “短輪詢” 可以減少大量無用的請求,並且客戶端接收到新訊息的時機將會有可能提前。

繼續改進

我們知道 HTTP 協議在開發的時候,並不是為了雙向通訊程式準備的,起初的 web 的工作方式只是 “請求-返回” 就夠了。

但是由於人們需要提高 web 應用程式的使用者體驗,以及 web 技術本身的便捷性 - 不需要另外的安裝軟體,使得瀏覽器也需要為指令碼提供一個雙向通訊的功能,比如在瀏覽器中做一個 IM(Instant Message)應用或者遊戲。

通過 “長、短輪詢” 模擬的雙向通訊,有幾個顯而易見的缺點:

  1. 每次的請求,都有大量的重複資訊,比如大量重複的 HTTP 頭。
  2. 即使 “長輪詢” 相較 “短輪詢” 而言使得新資訊到達客戶端的及時性可能會有所提高,但是仍有很大的延遲,因為一條長連線結束之後,伺服器端積累的新資訊要等到下一次客戶端和其建立連結時才能傳遞出去。
  3. 對於開發人員而言,這種模擬的方式是難於除錯的

於是,需要一種可以在 “瀏覽器-伺服器” 模型中,提供簡單易用的雙向通訊機制的技術,而肩負這個任務的,就是 WebSocket

1.2 協議概覽

協議分為兩部分:“握手” 和 “資料傳輸”。

客戶端發出的握手資訊類似:

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
複製程式碼

客戶端的握手請求由 請求行(Request-Line) 開始。客戶端的迴應由 狀態行(Status-Line) 開始。請求行和狀態行的生產式見 RFC2616

首行之後的部分,都是沒有順序要求的 HTTP Headers。其中的一些 HTTP頭 的意思稍後將會介紹,不過也可包括例子中沒有提及的頭資訊,比如 Cookies 資訊,見 RFC6265。HTTP頭的格式以及解析方式見 RFC2616

一旦客戶端和服務端都傳送了它們的握手資訊,握手過程就完成了,隨後就開始資料傳輸部分。因為這是一個雙向的通訊,所以客戶端和服務端都可以首先發出資訊。

在資料傳輸時,客戶端和伺服器都使用 “訊息 Message” 的概念去表示一個個資料單元,而訊息又由一個個 “幀 frame” 組成。這裡的幀並不是對應到具體的網路層上的幀。

一個幀有一個與之相關的型別。屬於同一個訊息的每個幀都有相同的資料型別。粗略的說,有文字型別(以 UTF-8 編碼 RFC3629)和二進位制型別(可以表示圖片或者其他應用程式所需的型別),控制幀(不是傳遞具體的應用程式資料,而是表示一個協議級別的指令或者訊號)。協議中定義了 6 中幀型別,並且保留了 10 種型別為了以後的使用。

1.3 開始握手

握手部分的設計目的就是相容現有的基於 HTTP 的服務端元件(web 伺服器軟體)或者中介軟體(代理伺服器軟體)。這樣一個埠就可以同時接受普通的 HTTP 請求或則 WebSocket 請求了。為了這個目的,WebSocket 客戶端的握手是一個 HTTP 升級版的請求(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
複製程式碼

為了遵循協議 RFC2616,握手中的頭欄位是沒有順序要求的。

跟在 GET 方法後面的 “請求識別符號 Request-URI” 是用於區別 WebSocket 連結到的不同終節點。一個 IP 可以對應服務於多個域名,這樣一臺機器上就可以跑多個站點,然後通過 “請求識別符號”,單個站點中又可以含有多個 WebSocket 終節點。

Host 頭中的伺服器名稱可以讓客戶端標識出哪個站點是其需要訪問的,也使得伺服器得知哪個站點是客戶端需要請求的。

其餘的頭資訊是用於配置 WebSocket 協議的選項。典型的一些選項就是,子協議選項 Sec-WebSocket-Protocol、列出客戶端支出的擴充套件 Sec-WebSocket-Extensions、源標識 Origin 等。Sec-WebSocket-Protocol 子協議選項,是用於標識客戶端想和服務端使用哪一種子協議(都是應用層的協議,比如 chat 表示採用 “聊天” 這個應用層協議)。客戶端可以在 Sec-WebSocket-Protocol 提供幾個供服務端選擇的子協議,這樣服務端從中選取一個(或者一個都不選),並在返回的握手資訊中指明,比如:

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

Origin可以預防在瀏覽器中執行的指令碼,在未經 WebSocket 伺服器允許的情況下,對其傳送跨域的請求。瀏覽器指令碼在使用瀏覽器提供的 WebSocket 介面對一個 WebSocket 服務發起連線請求時,瀏覽器會在請求的 Origin 中標識出發出請求的指令碼所屬的,然後 WebSocket 在接受到瀏覽器的連線請求之後,就可以根據其中的源去選擇是否接受當前的請求。

比如我們有一個 WebSocket 服務執行在 http://websocket.example.com,然後你開啟一個網頁 http://another.example.com,在個 another 的頁面中,有一段指令碼試圖向我們的 WebSocket 服務發起連結,那麼瀏覽器在其請求的頭中,就會標註請求的源為 http://another.example.com,這樣我們就可以在自己的服務中選擇接收或者拒絕該請求。

服務端為了告知客戶端它已經接收到了客戶端的握手請求,服務端需要返回一個握手響應。在服務端的握手響應中,需要包含兩部分的資訊。第一部分的資訊來自於客戶端的握手請求中的 Sec-WebSocket-Key 頭欄位:

Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
複製程式碼

客戶端握手請求中的 Sec-WebSocket-Key 頭欄位中的內容是採用的 base64 編碼 RFC4648 的。服務端並不需要將這個值進行反編碼,只需要將客戶端傳來的這個值首先去除首尾的空白,然後和一段固定的 GUID RFC4122 字串進行連線,固定的 GUID 字串為 258EAFA5-E914-47DA-95CA-C5AB0DC85B11。連線後的結果使用 SHA-1(160數位)FIPS.180-3 進行一個雜湊操作,對雜湊操作的結果,採用 base64 進行編碼,然後作為服務端響應握手的一部分返回給瀏覽器。

比如一個具體的例子:

  1. 客戶端握手請求中的 Sec-WebSocket-Key 頭欄位的值為 dGhlIHNhbXBsZSBub25jZQ==
  2. 服務端在解析了握手請求的頭欄位之後,得到 Sec-WebSocket-Key 欄位的內容為 dGhlIHNhbXBsZSBub25jZQ==,注意前後沒有空白
  3. dGhlIHNhbXBsZSBub25jZQ== 和一段固定的 GUID 字串進行連線,新的字串為 dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
  4. 使用 SHA-1 雜湊演算法對上一步中新的字串進行雜湊。得到雜湊後的內容為(使用 16 進位制的數表示每一個位元組中內容):0xb3 0x7a 0x4f 0x2c 0xc0 0x62 0x4f 0x16 0x90 0xf6 0x46 0x06 0xcf 0x38 0x59 0x45 0xb2 0xbe 0xc4 0xea
  5. 對上一步得到的雜湊後的位元組,使用 base64 編碼,得到最後的字串s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
  6. 最後得到的字串,需要放到服務端響應客戶端握手的頭欄位 Sec-WebSocket-Accept 中。

服務端的握手響應和客戶端的握手請求非常的類似。第一行是 HTTP狀態行,狀態碼是 101

HTTP/1.1 101 Switching Protocols
複製程式碼

任何其他的非 101 表示 WebSocket 握手還沒有結束,客戶端需要使用原有的 HTTP 的方式去響應那些狀態碼。狀態行之後,就是頭欄位。

ConnectionUpgrade 頭欄位完成了對 HTTP 的升級。Sec-WebSocket-Accept 中的值表示了服務端是否接受了客戶端的請求。如果它不為空,那麼它的值包含了客戶端在其握手請求中 Sec-WebSocket-Key 頭欄位所帶的值、以及一段預定義的 GUID 字串(上面已經介紹過怎麼由二者合成新字串的)。任何其他的值都被認為伺服器拒絕了請求。服務端的握手響應類似:

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

這些字元需要被 WebSocket 的客戶端(一般就是瀏覽器)檢查核對之後,才能決定是否繼續執行相應的客戶端指令碼,或者其他接下來的動作。

可選的頭欄位也可以被包含在服務端的握手響應中。在這個版本的協議中,主要的可選頭欄位就是 Sec-WebSocket-Protocol,它可以指出服務端選擇哪一個子協議。客戶端需要驗證服務端選擇的子協議,是否是其當初的握手請求中的 Sec-WebSocket-Protocol 中的一個。作為服務端,必須確保選的是客戶端握手請求中的幾個子協議中的一個:

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

服務端也可以設定 cookie 見RFC6265,但是這不是必須的。

1.4 關閉握手

關閉握手的操作也很簡單。

任意一端都可以選擇關閉握手過程。需要關閉握手的一方通過傳送一個特定的控制序列(第 5 節會描述)去開始一個關閉握手的過程。一端一旦接受到了來自另一端的請求關閉控制幀後,接收到關閉請求的一端如果還沒有返回一個作為響應的關閉幀的話,那麼它需要先傳送一個關閉幀。在接受到了對方響應的關閉幀之後,發起關閉請求的那一端就可以關閉連線了。

在傳送了請求關閉控制序列之後,傳送請求的一端將不可以再傳送其他的資料內容;同樣的,一但接收到了一端的請求關閉控制序列之後,來自那一端的其他資料內容將被忽略。注意這裡的說的是資料內容,控制幀還是可以響應的。否則就下面一句就沒有意義了。

兩邊同時發起關閉請求也是可以的。

之所以需要這樣做,是因為客戶端和伺服器之間可能還存在其他的中介軟體。一段關閉之後,也需要通知另一端也和中介軟體斷開連線。

1.5 設計理念

WebSocket 協議的設計理念就是提供極小的幀結構(幀結構存在的目的就是使得協議是基於幀的,而不是基於流的,同時幀可以區分 Unicode 文字和二進位制的資料)。它期望可以在應用層中使得後設資料可以被放置到 WebSocket 層上,也就是說,給應用層提供一個將資料直接放在 TCP 層上的機會,再簡單的說就可以給瀏覽器指令碼提供一個使用受限的 Raw TCP 的機會。

從概念上來說,WebSocket 只是一個建立於 TCP 之上的層,它提供了下面的功能:

  • 給瀏覽器提供了一個基於源的安全模型(origin-based security model)
  • 給協議提供了一個選址的機制,使得在同一個埠上可以創立多個服務,並且將多個域名關聯到同一個 IP
  • 在 TCP 層之上提供了一個類似 TCP 中的幀的機制,但是沒有長度的限制
  • 提供了關閉握手的方式,以適應存在中介軟體的情況

從概念上將,就只有上述的幾個用處。不過 WebSocket 可以很好的和 HTTP 協議一同協作,並且可以充分的利用現有的 web 基礎設施,比如代理。WebSocket 的目的就是讓簡單的事情變得更加的簡單。

協議被設計成可擴充套件的,將來的版本中將很可能會新增關於多路複用的概念。

1.6 安全模型

WebSocket 協議使用源模型(origin model),這樣瀏覽器中的一個頁面中的指令碼需要訪問其他源的資源時將會有所限制。如果是在一個 WebSocket 客戶端中直接使用了 WebSocet(而不是在瀏覽器中),源模型就沒有什麼作用,因為客戶端可以設定其為任意的值。

並且協議的設計目的也是不希望干擾到其他協議的工作,因為只有通過特定的握手步驟才能建立 WebSocket 連線。另外由於握手的步驟,其他已經存在的協議也不會干擾到 WebSocket 協議的工作。比如在一個 HTTP 表單中,如果表單的地址是一個 WebSocket 服務的話,將不會建立連線,因為到目前本文成文為止,在瀏覽器中是不可以通過 HTML 和 Javascript APIs 去設定 Sec- 頭的。

1.7 和 TCP 以及 HTTP 之間的關係

WebSocket 是一個獨立的基於 TCP 的協議,它與 HTTP 之間的唯一關係就是它的握手請求可以作為一個升級請求(Upgrade request)經由 HTTP 伺服器解釋(也就是可以使用 Nginx 反向代理一個 WebSocket)。

預設情況下,WebSocket 協議使用 80 埠作為一般請求的埠,埠 443 作為基於傳輸加密層連(TLS)RFC2818 接的埠

1.8 建立一個連線

因為 WebSocket 服務通常使用 80 和 443 埠,而 HTTP 服務通常也是這兩個埠,那麼為了將 WebSocket 服務和 HTTP 服務部署到同一個 IP 上,可以限定流量從同一個入口處進入,然後在入口處對流量進行管理,概況的說就是使用反向代理或者是負載均衡。

1.9 WebSocket 協議的子協議

在使用 WebSocket 協議連線到一個 WebSocket 伺服器時,客戶端可以指定其 Sec-WebSocket-Protocol 為其所期望採用的子協議集合,而服務端則可以在此集合中選取一個並返回給客戶端。

這個子協議的名稱應該遵循第 11 節中的內容。為了防止潛在的衝突問題,應該在域名的基礎上加上服務組織者的名稱(或者服務名稱)以及協議的版本。比如 v2.bookings.example.net 對應的就是 版本號-服務組織(或服務名)-域名

2 一致性的要求

見原文 section-2

3 WebSocket URIs

在這份技術說明中,定義了兩種 URI 方案,使用 ABNF 語法 RFC 5234,以及 URI 技術說明 RFC3986 中的生產式。

ws-URI = "ws:" "//" host [ ":" port ] path [ "?" query ]
wss-URI = "wss:" "//" host [ ":" port ] path [ "?" query ]

host = <host, defined in [RFC3986], Section 3.2.2>
port = <port, defined in [RFC3986], Section 3.2.3>
path = <path-abempty, defined in [RFC3986], Section 3.3>
query = <query, defined in [RFC3986], Section 3.4>
複製程式碼

埠部分是可選的;“ws” 預設使用的埠是 80,“wss” 預設使用的埠是 443。

如果資源識別符號(URI)的方案(scheme)部分使用的是大小寫不敏感的 “wss” 的話,那麼就說這個 URI 是 “可靠的 secure”,並且說明 “可靠標記(secure flag)已經被設定”。

“資源名稱 resource-name” 也就是 4.1 節中的 /resource name/,可以按下面的部分(順序)連線:

  • 如果不用路徑不為空,加上 “/”
  • 緊接著就是路徑部分
  • 如果查詢元件不為空 ,加上 “?“
  • 緊接著就是查詢部分

片段識別符號(fragment identifier) “#” 在 WebSocket URIs 的上下文是沒有意義的,不能出現在 URIs 中。在 WebSocket 的 URI 中,如果出現了字元 “#” 需要使用 %23 進行轉義。

4.1 客戶端要求

為了建立一個 WebSocket 連線,由客戶端開啟一個連線然後傳送這一節中定義的握手資訊。連線初始的初始狀態被定義為 “連線中 CONNECTING”。客戶端需要提供 /host/,/port/,/resource name/ 和 /secure/ 標記,這些都是上一節中的 WebSocket URI 中的元件,如果有的話,還需要加上使用的 /protocols/ 和 /extensions/。另外,如果客戶端是瀏覽器,它還需要提供 /origin/。

連線開始前需要的設定資訊為(/host/, /port/, /resource name/ 和 /secure/)以及需要使用的 /protocols/ 和 /extensions/,如果在瀏覽器下還有 /origin/。這些設定資訊選定好了之後,就必須開啟一個網路連線,傳送握手資訊,然後讀取服務端返回的握手資訊。具體的網路連線應該如何被開啟,如何傳送握手資訊,如何解釋服務端的握手響應,這些將在接下來的部分討論。我們接下來的文字中,將使用第 3 節中定義的專案名稱,比如 “/host/” 和 “/secure/”。

  1. 在解析 WebSocket URI 的時候,需要使用第 3 節中提到的技術說明去驗證其中的元件。如果包含了任何無效的 URI 元件,客戶端必須將連線操作標記為失敗,並停止接下來的步驟
  2. 可以通過 /host/ 和 /port/ 這一對 URI 元件去標識一個 WebSocket 連線。這一部分的意思就是,如果可以確定服務端的 IP,那麼就使用 “服務端 IP + port” 去標識一個連線。這樣的話,如果已經存在一個連線是 “連線中 CONNECTING” 的狀態,那麼其他具有相同標識的連線必須等待那個正在連線中的連線完成握手後,或是握手失敗後關閉了連線後,才可以嘗試和伺服器建立連線。任何時候只能有一個具有相同的標識的連線是 “正在連線中” 的狀態。

但是如果客戶端無法知道伺服器的IP(比如,所有的連線都是通過代理伺服器完成的,而 DNS 解析部分是交由代理伺服器去完成),那麼客戶端就必須假設每一個主機名稱對應到了一個獨立伺服器,並且客戶端必須對同時等待連線的的連線數進行控制(比如,在無法獲知伺服器 IP 的情況下,可以認為 a.example.comb.example.com 是兩臺不同的伺服器,但是如果每臺伺服器都有三十個需要同時發生的連線的話,可能就應該不被允許)

注意:這就使得指令碼想要執行 “拒絕服務攻擊 denial-of-service attack” 變得困難,不然的話指令碼只需要簡單的對一個 WebSocket 伺服器開啟很多的連線就可以了。服務端也可以進一步的有一個佇列的概念,這樣將暫時無法處理的連線放到佇列中暫停,而不是將它們立刻關閉,這樣就可以減少客戶端重連的比率。

注意:對於客戶端和伺服器之間的連線數是沒有限制的。在一個客戶端請數目(根據 IP)達到了服務端的限定值或者服務端資源緊缺的時候,服務端可以拒絕或者關閉客戶端連線。

  1. 使用代理:如果客戶端希望在使用 WebSocket 的時候使用代理的話,客戶端需要連線到代理伺服器並要求代理伺服器根據其指定的 /host/,/port/ 對遠端伺服器開啟一個 TCP 連線,有興趣的可以看 Tunneling TCP based protocols through Web proxy servers

如果可能的話,客戶端可以首選適用於 HTTPS 的代理設定。

如果希望使用 PAC 指令碼的話,WebSocket URIs 必須根據第 3 節說的規則。

注意:在使用 PAC 的時候,WebSocket 協議是可以特別標註出來的,使用 “ws” 和 “wss”。

  1. 如果網路連線無法開啟,無論是因為代理的原因還是直連的網路問題,客戶端必須將連線動作標記為失敗,並終止接下來的行為。

  2. 如果設定了 /secure/,那麼客戶端在和服務端建立了連線之後,必須要先進行 TLS 握手,TLS 握手成功後,才可以進行 WebSocket 握手。如果 TLS 握手失敗(比如服務端證照不能通過驗證),那麼客戶端必須關閉連線,終止其後的 WebSocket 握手。在 TLS 握手成功後,所有和服務的資料交換(包括 WebSocket 握手),都必須建立在 TLS 的加密隧道上。

客戶端在使用 TLS 時必須使用 “伺服器名稱標記擴充套件 Server Name Indication extension” RFC6066

一旦客戶端和服務端的連線建立好(包括經由代理或者通過 TLS 加密隧道),客戶端必須向服務端傳送 WebSocket 握手資訊。握手內容包括了 HTTP 升級請求和一些必選以及可選的頭欄位。握手的細節如下:

  1. 握手必須是一個有效的 HTTP 請求,有效的 HTTP 請求的定義見 RFC2616

  2. 請求的方法必須是 GET,並且 HTTP 的版本必須至少是 1.1

    比如,如果 WebSocket 的 URI 是 ws://example.com/chat,那麼請求的第一行必須是 GET /chat HTTP/1.1

  3. 請求的 Request-URI 部分必須遵循第 3 節中定義的 /resource name/ 的定義。可以使相對路徑或者絕對路徑,比如:

相對路徑:GET /chat HTTP/1.1 中間的 /chat 就是請求的 Request-URI,也是 /resource name/ 絕對路徑:GET http://www.w3.org/pub/WWW/TheProject.html HTTP/1.1,其中的 /resource name/ 就是 /pub/WWW/TheProject.html 感謝 @forl 的指正

絕對路徑解析之後會有 /resource name/,/host/ 或者可能會有 /port/。/resource name/ 可能會有查詢引數的,只不過例子中沒有。

  1. 請求必須有一個 |Host| 頭欄位,它的值是 /host/ 主機名稱加上 /port/ 埠名稱(當不是使用的預設埠時必須顯式的指明)

  2. 請求必須有一個 |Upgrade| 頭欄位,它的值必須是 websocket 這個關鍵字(keyword)

  3. 請求必須有一個 |Connection| 頭欄位,它的值必須是 Upgrade 這個標記(token)

  4. 請求必須有一個 |Sec-WebSocket-Key| 頭欄位,它的值必須是一個噪音值,由 16 個位元組的隨機數經過 base64 編碼而成。每個連線的噪音必須是不同且隨機的。

注意:作為一個例子,如果選擇的隨機 16 個位元組的值是 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0a 0x0b 0x0c 0x0d 0x0e 0x0f 0x10,那麼頭欄位中的值將是 AQIDBAUGBwgJCgsMDQ4PEC==

  1. 如果連線來自瀏覽器客戶端,那麼 |Origin| RFC6454 就是必須的。如果連線不是來自於一個瀏覽器客戶端,那麼這個值就是可選的。這個值表示的是發起連線的程式碼在執行時所屬的源。關於源是由哪些部分組成的,見 RFC6454

作為一個例子,如果程式碼是從 http://cdn.jquery.com 下載的,但是執行時所屬的源是 http://example.com,如果程式碼向 ww2.example.com 發起連線,那麼請求中 |Origin| 的值將是 http://example.com

  1. 請求必須有一個 |Sec-WebSocket-Version| 頭欄位,它的值必須是 13

  2. 請求可以有一個可選的頭欄位 |Sec-WebSocket-Protocol|。如果包含了這個頭欄位,它的值表示的是客戶端希望使用的子協議,按子協議的名稱使用逗號分隔。組成這個值的元素必須是非空的字串,並且取值範圍在 U+0021 到 U+007E 之間,不可以包含定義在 RFC2616 的分隔字元(separator character),並且每個以逗號分隔的元素之間必須相互不重複。

  3. 請求可以有一個可選的頭欄位 |Sec-WebSocket-Extensions|。如果包含了這個欄位,它的值表示的是客戶端希望使用的協議級別的擴充套件,具體的介紹以及它的格式在第 9 節

  4. 請求可以包含其他可選的頭欄位,比如 cookies RFC6265,或者認證相關的頭欄位,比如 |Authorization| 定義在 RFC2616,它們的處理方式就參照定義它們的技術說明中的描述。

一旦客戶端的握手請求傳送完成後,客戶端必須等待服務端的握手響應,在此期間不可以向伺服器傳輸任何資料。客戶端必須按照下面的描述去驗證服務端的握手響應:

  1. 如果服務端傳來的狀態碼不是 101,那麼客戶端可以按照一般的 HTTP 請求處理狀態碼的方式去處理。比如服務端傳來 401 狀態碼,客戶端可以執行一個授權驗證;或者服務端回傳的是 3xx 的狀態碼,那麼客戶端可以進行重定向(但是客戶端不是非得這麼做)。如果是 101 的話,就接著下面的步驟。

  2. 如果服務端回傳的握手中沒有 |Upgrade| 頭欄位或者 |Upgrade| 都欄位的值不是 ASCII 大小寫不敏感的 websocket 的話,客戶端必須標記 WebSocket 連線為失敗。

  3. 如果服務端回傳的握手中沒有 |Connection| 頭欄位或者 |Connection| 的頭欄位內容不是大小寫敏感的 Upgrade 的話,客戶端必須表示 WebSocket 連線為失敗。

  4. 如果服務端的回傳握手中沒有 |Sec-WebSocket-Accept| 頭欄位或者 |Sec-WebSocket-Accept| 頭欄位的內容不是 |Sec-WebSocket-Key| 的內容(字串,不是 base64 解碼後的)聯結上字串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 的字串進行 SHA-1 得出的位元組再 base64 編碼得到的字串的話,客戶端必須標記 WebSocket 連線為失敗。

簡單的說就是客戶端也必須按照服務端生成 |Sec-WebSocket-Accept| 頭欄位值的方式也生成一個字串,與服務端回傳的進行對比,如果不同就標記連線為失敗的。

  1. 如果服務端回傳的 |Sec-WebSocket-Extensions| 頭欄位的內容不是客戶端握手請求中的擴充套件集合中的元素或者 null 的話,客戶端必須標記連線為失敗。這個頭欄位的解析規則在第 9 節中進行了描述。

比如客戶端的握手請求中的期望使用的擴充套件集合為:

Sec-WebSocket-Extensions: bar; baz=2
複製程式碼

那麼服務端可以選擇使用其中的某個(些)擴充套件,通過在回傳的 |Sec-WebSocket-Extensions| 頭欄位中表明:

Sec-WebSocket-Extensions: bar; baz=2
複製程式碼

上面的服務端返回表示都使用。也可以使用其中的一個:

Sec-WebSocket-Extensions: bar
複製程式碼

如果服務端希望表示一個都不使用,即表示 null,那麼服務端回傳的資訊中將不可以包含 |Sec-WebSocket-Extensions|。

失敗的界定就是,如果客戶端握手請求中有 |Sec-WebSocket-Extensions|,但是服務端返回的 |Sec-WebSocket-Extensions| 中包含了客戶端請求中沒有包含的值,那麼必須標記連線為失敗。服務端的返回中不包含 |Sec-WebSocket-Extensions| 是可以的,表示客戶端和服務端之間將不使用任何擴充套件。

  1. 如果客戶端在握手請求中包含了子協議頭欄位 |Sec-WebSocket-Protocol|,其中的值表示客戶端希望使用的子協議的集合。如果服務端回傳資訊的 |Sec-WebSocket-Protocol| 值不屬於客戶端握手請求中的子協議集合的話,那麼客戶端必須標記連線為失敗。

如果服務端的握手響應不符合 4.2.2 小節中的服務端握手定義的話,客戶端必須標記連線為失敗。

請注意,根據 RFC2616 技術說明,請求和響應的中所有頭欄位的名稱都是大小寫不敏感的(不區分大小寫)。

如果服務端的響應符合上述的描述的話,那麼就說明 WebSocket 的連線已經建立了,並且連線的狀態變為 “OPEN 狀態”。另外,服務端的握手響應中也可以包含 cookie 資訊,cookie 資訊被稱為是 “服務端開始握手的 cookie 設定”。

4.2 服務端要求

WebSocket 伺服器可能會卸下一些對連線的管理操作,而將這些管理操作交由網路中的其他代理,比如負載均衡伺服器或者反向代理伺服器。對於這種情況,在這個技術說明中,將組成服務端的基礎設施的所有部分合起來視為一個整體。

比如,在一個資料中心,會有一個伺服器專門使用者響應客戶端的握手請求,在握手成功之後將連線轉交給實際處理任務的伺服器。在這份技術說明中,服務端指代的就是這裡的兩臺機器的組成的整體。

4.2.1 讀取客戶端的握手請求

當客戶端發起一個 WebSocket 請求時,它會傳送握手過程種屬於它那一部分的內容。服務端必須解析客戶端提交的握手請求,以從中獲得生成服務端響應內容的必要的資訊。

客戶端的握手請求有接下來的幾部分構成。服務端在讀取客戶端請求時,發現握手的內容和下面的描述不相符(注意 RFC2616,頭欄位的順序是不重要的),包括但不限於那些不符合相關 ABNF 語法描述的內容時,必須停止對請求的解析並返回一個具有適當的狀態碼 HTTP 響應(比如 400 Bad Request)。

  1. 必須是 HTTP/1.1 或者以上的 GET 請求,包含一個 “請求資源識別符號 Request-URI”,請求資源識別符號遵循第 3 節中定義的 /resource name/。

  2. 一個 |Host| 頭欄位,向伺服器指明需要訪問的服務名稱(域名)

  3. 一個 |Upgrade| 頭欄位,值為大小寫不敏感的 websocket 字串

  4. 一個 |Connection| 頭欄位,它的值是大小寫不敏感的字串 Upgrade

  5. 一個 |Sec-WebSocket-Key| 頭欄位,它的值是一段使用 base64 編碼Section 4 of [RFC4648] 後的字串,解碼之後是 16 個位元組的長度。

  6. 一個 |Sec-WebSocket-Version| 頭欄位,它的值是 13.

  7. 可選的,一個 |Origin| 頭欄位。這個是所有瀏覽器客戶度必須傳送的。如果服務端限定只能由瀏覽器作為其客戶端的話,在缺少這個欄位的情況下,可以認定這個握手請求不是由瀏覽器發起的,反之則不行。

  8. 可選的,一個 |Sec-WebSocket-Protocol| 頭欄位。由一些值組成的列表,這些值是客戶端希望使用的子協議,按照優先順序從左往右排序。

  9. 可選的,一個 |Sec-WebSocket-Extensions| 頭欄位。有一些值組成的列表,這些值是客戶端希望使用的擴充套件。具體的表示在第 9 節。

  10. 可選的,其他頭欄位,比如那些用於向服務端傳送 cookie 或則認證資訊。未知的頭欄位將被忽略 RFC2616

4.2.2 傳送服務端的握手響應

當客戶端對服務端建立了一個 WebSocket 連線之後,服務端必須完成接下來的步驟,以此去接受客戶端的連線,並回應客戶端的握手。

  1. 如果連線發生在 HTTPS(基於 TLS 的 HTTP)埠上,那麼要執行一個 TLS 握手。如果 TLS 握手失敗,就必須關閉連線;否則的話之後的所有通訊都必須建立在加密隧道上。

  2. 服務端可以對客戶端執行另外的授權認證,比如通過返回 401 狀態碼和 對應的 |WWW-Authenticate|,相關描述在 RFC2616

  3. 服務端也可以對客戶端進行重定向,使用 3xx 狀態碼 RFC2616。注意這一步也可以發生在上一步之前。

  4. 確認下面的資訊:

  • /origin/

    客戶端握手請求中的 |origin| 頭欄位表明了指令碼在發起請求時所處的源。源被序列化成 ASCII 並且被轉換成了小寫。服務端可以選擇性地使用這個資訊去決定是否接受這個連線請求。如果服務端不驗證源的話,那麼它將接收來自任何地方的請求。如果服務端不想接收這個連線的話,它必須返回適當的 HTTP 錯誤狀態碼(比如 403 Forbidden)並且終止接下來的 WebSocket 握手過程。更詳細的內容,見第 10 節

  • /key/

    客戶端握手請求中的 |Sec-WebSocket-Key| 頭欄位包含了一個使用 base64 編碼後的值,如果解碼的話,這個值是 16 位元組長的。這個編碼後的值用於服務端生成表示其接收客戶端請求的內容。服務端沒有必要去將這個值進行解碼。

  • /version/

    客戶端握手請求中的 |Sec-WebSocket-Version| 頭欄位包含了客戶端希望進行通訊的 WebSocket 協議的版本號。如果服務端不能理解這個版本號的話,那麼它必須終止接下來的握手過程,並給客戶端返回一個適當的 HTTP 錯誤狀態碼(比如 426 Upgrade Required),同時在返回的資訊中包含一個 |Sec-WebSocket-Version| 頭欄位,通過其值指明服務端能夠理解的協議版本號。

  • /subprotocol/

    服務端可以選擇接受其中一個子協議,或者 null。子協議的選取必須來自客戶端的握手資訊中的 |Sec-WebSocket-Protocol| 頭欄位的元素集合。如果客戶端沒有傳送 |Sec-WebSocket-Protocol| 頭欄位,或者客戶端傳送的 |Sec-WebSocket-Protocol| 頭欄位中沒有一個可以被當前服務端接受的話,服務端唯一可以返回值就是 null。不傳送這個頭欄位就表示其值是 null。注意,空字串並不表示這裡的 null 並且根據 RFC2616 中的 ABNF 定義,空字串也是不合法的。根據協議中的描述,客戶端握手請求中的 |Sec-WebSocket-Protocol| 是一個可選的頭欄位,所以如果服務端必須使用這個頭欄位的話,可以選擇性的拒絕客戶端的連線請求。

  • /extensions/

    一個可以為空的列表,表示客戶端希望使用的協議級別的擴充套件。如果服務端支援多個擴充套件,那麼必須從客戶端握手請求中的 |Sec-WebSocket-Extensions| 按需選擇多個其支援的擴充套件。如果客戶端沒有傳送次頭欄位,則表示這個欄位的值是 null,空字元並不表示 null。返回的 |Sec-WebSocket-Extensions| 值中不可以包含客戶端不支援的擴充套件。這個欄位值的選擇和解釋將在第 9 節中討論

  1. 如果服務端選擇接受來自客戶端的連線,它必須回答一個有效的 HTTP 響應:

  2. 一個狀態行,包含了響應碼 101。比如 HTTP/1.1 101 Switching Protocols

  3. 一個 |Upgrade| 頭欄位,值為 websocket

  4. 一個 |Connection| 頭欄位,值為 Upgrade

  5. 一個 |Sec-WebSocket-Accept| 頭欄位。這個值通過連線定義在 4.2.2 節中的第 4 步的 /key/ 和字串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,連線後的字串運用 SHA-1 得到一個 20 位元組的值,最後使用 base64 將這 20 個位元組的內容編碼,得到最後的用於返回的字串。

相應的 ABNF 定義如下:

```
Sec-WebSocket-Accept     = base64-value-non-empty
base64-value-non-empty = (1*base64-data [ base64-padding ]) |
                        base64-padding
base64-data      = 4base64-character
base64-padding   = (2base64-character "==") |
                  (3base64-character "=")
base64-character = ALPHA | DIGIT | "+" | "/"
```

    注意:作為一個例子,如果來自客戶端握手請求中的 |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| 的欄位值。
複製程式碼
  1. 可選的,一個 |Sec-WebSocket-Protocol| 頭欄位,它的值已經在第 4.2.2 節中的第 4 步定義了

  2. 可選的,一個 |Sec-WebSocket-Extensions| 頭欄位,它的值已經在第4.2.2 節中的第 4 步定義了。如果有服務端選擇了多個擴充套件,可以將它們分別放在 |Sec-WebSocket-Extensions| 頭欄位中,或者合併到一起放到一個 |Sec-WebSocket-Extensions| 頭欄位中。

這樣就完成了服務端的握手。如果服務端沒有發生終止的完成了所有的握手步驟,那麼服務端就可以認為連線已經建立了,並且 WebSocket 連線的狀態變為 OPEN。在這時,客戶端和服務端就可以開始傳送(或者接收)資料了。

4.3 握手中使用的新的頭欄位的 ABNF

這一節中將使用定義在 Section 2.1 of [RFC2616] ABNF 語法/規則,包括隱含的 LWS 規則(implied *LWS rule)。為了便於閱讀,這裡給出 LWS 的簡單定義:任意數量的空格,水平 tab 或者換行(換行指的是 CR(carriage return) 後面跟著 LF(linefeed),使用轉義字元表示就是 \r\n)。

注意,接下來的一些 ABNF 約定將運用於這一節。一些規則的名稱與與之對應的頭欄位相關。這些規則表示相應的頭欄位的值的語法,比如 Sec-WebSocket-Key ABNF 規則,它描述了 |Sec-WebSocket-Key| 頭欄位的值的語法。名字中具有 -Client 字尾的 ABNF 規則,表示的是客戶端向服務端傳送請求時的欄位值語法;名字中具有 -Server 字尾的 ABNF 規則,表示的是服務端向客戶端傳送請求時的欄位值語法。比如 ABNF 規則 Sec-WebSocket-Protocol-Client 描述了 |Sec-WebSocket-Protocol| 存在與由客戶端傳送到服務端的請求中的語法。

接下來新頭欄位可以在握手期間由客戶端發往服務端:

Sec-WebSocket-Key = base64-value-non-empty
Sec-WebSocket-Extensions = extension-list
Sec-WebSocket-Protocol-Client = 1#token
Sec-WebSocket-Version-Client = version

base64-value-non-empty = (1*base64-data [ base64-padding ]) |
                        base64-padding
base64-data      = 4base64-character
base64-padding   = (2base64-character "==") |
                 (3base64-character "=")
base64-character = ALPHA | DIGIT | "+" | "/"
extension-list = 1#extension
extension = extension-token *( ";" extension-param )
extension-token = registered-token
registered-token = token

extension-param = token [ "=" (token | quoted-string) ]
       ; When using the quoted-string syntax variant, the value
       ; after quoted-string unescaping MUST conform to the
       ; 'token' ABNF.
  NZDIGIT       =  "1" | "2" | "3" | "4" | "5" | "6" |
                   "7" | "8" | "9"
  version = DIGIT | (NZDIGIT DIGIT) |
            ("1" DIGIT DIGIT) | ("2" DIGIT DIGIT)
            ; Limited to 0-255 range, with no leading zeros
複製程式碼

下面的新欄位可以在握手期間由服務端發往客戶端:

Sec-WebSocket-Extensions = extension-list
Sec-WebSocket-Accept     = base64-value-non-empty
Sec-WebSocket-Protocol-Server = token
Sec-WebSocket-Version-Server = 1#version
複製程式碼

4.4 支援多個版本的 WebSocket 協議

這一節對在客戶端和服務端之間提供多個版本的 WebSocket 協議提供了一些指導意見。

使用 WebSocket 的版本公告能力(|Sec-WebSocket-Version| 頭欄位),客戶端可以指明它期望的採用的協議版本(不一定就是客戶端已經支援的最新版本)。如果服務端支援相應的請求版本號的話,則握手可以繼續,如果服務端不支援請求的版本號,它必須迴應一個(或多個) |Sec-WebSocket-Version| 頭欄位,包含所有它支援的版本。這時,如果客戶端也支援服務端的其中一個協議的話,它就可以使用新的版本號去重複客戶端握手的步驟。

下面的例子可以作為上文提到的版本協商的演示:

客戶端傳送:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
...
Sec-WebSocket-Version: 25
複製程式碼

服務端的返回看起來類似:

HTTP/1.1 400 Bad Request
...
Sec-WebSocket-Version: 13, 8, 7
複製程式碼

注意,伺服器也可以返回下面的內容:

HTTP/1.1 400 Bad Request
...
Sec-WebSocket-Version: 13
Sec-WebSocket-Version: 8, 7
複製程式碼

客戶端現在就可以重新採用版本 13 (如果客戶端也支援的話)進行握手請求了:

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

相關文章