WebSocket 淺析

前言

在WebSocket API尚未被衆多瀏覽器實現和發佈的時期,開發者在開發需要接收來自服務器的實時通知應用程序時,不得不求助於一些“hacks”來模擬實時連接以實現實時通信,最流行的一種方式是長輪詢 。 長輪詢主要是發出一個HTTP請求到服務器,然後保持連接打開以允許服務器在稍後的時間響應(由服務器確定)。爲了這個連接有效地工作,許多技術需要被用於確保消息不錯過,如需要在服務器端緩存和記錄多個的連接信息(每個客戶)。雖然長輪詢是可以解決這一問題的,但它會耗費更多的資源,如CPU、內存和帶寬等,要想很好的解決實時通信問題就需要設計和發佈一種新的協議。

WebSocket 是伴隨HTML5發佈的一種新協議。它實現了瀏覽器與服務器全雙工通信(full-duplex),可以傳輸基於消息的文本和二進制數據。WebSocket 是瀏覽器中最靠近套接字的API,除最初建立連接時需要藉助於現有的HTTP協議,其他時候直接基於TCP完成通信。它是瀏覽器中最通用、最靈活的一個傳輸機制,其極簡的API 可以讓我們在客戶端和服務器之間以數據流的形式實現各種應用數據交換(包括JSON 及自定義的二進制消息格式),而且兩端都可以隨時向另一端發送數據。在這個簡單的API 之後隱藏了很多的複雜性,而且還提供了更多服務,如:

  • 連接協商和同源策略;

  • 與既有 HTTP 基礎設施的互操作;

  • 基於消息的通信和高效消息分幀;

  • 子協議協商及可擴展能力。

所幸,瀏覽器替我們完成了上述工作,我們只需要簡單的調用即可。任何事物都不是完美的,設計限制和性能權衡始終會有,利用WebSocket 也不例外,在提供自定義數據交換協議同時,也不再享有在一些本由瀏覽器提供的服務和優化,如狀態管理、壓縮、緩存等。

隨着HTML5的發佈,越來越多的瀏覽器開始支持WebSocket,如果你的應用還在使用長輪詢,那就可以考慮切換了。下面的圖表顯示了在一種常見的使用案例下,WebSocket和長輪詢之間的帶寬消耗差異:

1.WebSocket API

WebSocket 對象提供了一組 API,用於創建和管理 WebSocket 連接,以及通過連接發送和接收數據。瀏覽器提供的WebSocket API很簡潔,調用示例如下:

var ws = new WebSocket('wss://example.com/socket'); 
// 創建安全WebSocket 連接(wss)
ws.onerror = function (error) { ... }
// 錯誤處理
ws.onclose = function () { ... }
// 關閉時調用
ws.onopen = function () {
// 連接建立時調用  ws.send("Connection established. Hello server!");
// 向服務端發送消息
} ws.onmessage = function(msg) {
// 接收服務端發送的消息  if(msg.data instanceof Blob) {
// 處理二進制信息    processBlob(msg.data);  } else {    processText(msg.data);
// 處理文本信息  } }

1.1.接收和發送數據

WebSocket提供了極簡的API,開發者可以輕鬆的調用,瀏覽器會爲我們完成緩衝、解析、重建接收到的數據等工作。應用只需監聽onmessage事件,用回調處理返回數據即可。 WebSocket支持文本和二進制數據傳輸,瀏覽器如果接收到文本數據,會將其轉換爲DOMString 對象,如果是二進制數據或Blob 對象,可直接將其轉交給應用或將其轉化爲ArrayBuffer,由應用對其進行進一步處理。從內部看,協議只關注消息的兩個信息:淨荷長度和類型(前者是一個可變長度字段),據以區別UTF-8 數據和二進制數據。示例如下:

var wss = new WebSocket('wss://example.com/socket');
ws.binaryType = "arraybuffer"; 

// 接收數據
wss.onmessage = function(msg) {
 if(msg.data instanceof ArrayBuffer) {    processArrayBuffer(msg.data);  } else {    processText(msg.data);  } }
 
// 發送數據
ws.onopen = function () {  socket.send("Hello server!");  socket.send(JSON.stringify({'msg': 'payload'}));  var buffer = new ArrayBuffer(128);  socket.send(buffer);  var intview = new Uint32Array(buffer);  socket.send(intview);  var blob = new Blob([buffer]);  socket.send(blob); }

Blob 對象是包含有隻讀原始數據的類文件對象,可存儲二進制數據,它會被寫入磁盤;ArrayBuffer (緩衝數組)是一種用於呈現通用、固定長度的二進制數據的類型,作爲內存區域可以存放多種類型的數據。

對於將要傳輸的二進制數據,開發者可以決定以何種方式處理,可以更好的處理數據流,Blob 對象一般用來表示一個不可變文件對象或原始數據,如果你不需要修改它或者不需要把它切分成更小的塊,那這種格式是理想的;如果你還需要再處理接收到的二進制數據,那麼選擇ArrayBuffer 應該更合適。

WebSocket 提供的信道是全雙工的,在同一個TCP 連接上,可以雙向傳輸文本信息和二進制數據,通過數據幀中的一位(bit)來區分二進制或者文本。WebSocket 只提供了最基礎的文本和二進制數據傳輸功能,如果需要傳輸其他類型的數據,就需要通過額外的機制進行協商。WebSocket 中的send( ) 方法是異步的:提供的數據會在客戶端排隊,而函數則立即返回。在傳輸大文件時,不要因爲回調已經執行,就錯誤地以爲數據已經發送出去了,數據很可能還在排隊。要監控在瀏覽器中排隊的數據量,可以查詢套接字的bufferedAmount 屬性:

var ws = new WebSocket('wss://example.com/socket');

ws.onopen = function () {
  subscribeToApplicationUpdates(function(evt) { 
    if (ws.bufferedAmount == 0) 
    ws.send(evt.data); 
  });
};

前面的例子是向服務器發送應用數據,所有WebSocket 消息都會按照它們在客戶端排隊的次序逐個發送。因此,大量排隊的消息,甚至一個大消息,都可能導致排在它後面的消息延遲——隊首阻塞!爲解決這個問題,應用可以將大消息切分成小塊,通過監控bufferedAmount 的值來避免隊首阻塞。甚至還可以實現自己的優先隊列,而不是盲目都把它們送到套接字上排隊。要實現最優化傳輸,應用必須關心任意時刻在套接字上排隊的是什麼消息!

1.2.子協議協商

在以往使用HTTP 或XHR 協議來傳輸數據時,它們可以通過每次請求和響應的HTTP 首部來溝通元數據,以進一步確定傳輸的數據格式,而WebSocket 並沒有提供等價的機制。上文已經提到WebSocket只提供最基礎的文本和二進制數據傳輸,對消息的具體內容格式是未知的。因此,如果WebSocket需要溝通關於消息的元數據,客戶端和服務器必須達成溝通這一數據的子協議,進而間接地實現其他格式數據的傳輸。下面是一些可能策略的介紹:

  • 客戶端和服務器可以提前確定一種固定的消息格式,比如所有通信都通過 JSON編碼的消息或者某種自定義的二進制格式進行,而必要的元數據作爲這種數據結構的一個部分;

  • 如果客戶端和服務器要發送不同的數據類型,那它們可以確定一個雙方都知道的消息首部,利用它來溝通說明信息或有關淨荷的其他解碼信息;

  • 混合使用文本和二進制消息可以溝通淨荷和元數據,比如用文本消息實現 HTTP首部的功能,後跟包含應用淨荷的二進制消息。

上面介紹了一些可能的策略來實現其他格式數據的傳輸,確定了消息的串行格式化,但怎麼確保客戶端和服務端是按照約定發送和處理數據,這個約定客戶端和服務端是如何協商的呢?這就需要WebSocket 提供一個機制來協商,這時WebSocket構造器方法的第二個可選參數就派上用場了,通過這個參數客戶端和服務端就可以根據約定好的方式處理髮送及接收到的數據。

WebSocket構造器方法如下所示:

WebSocket WebSocket(
in DOMString url, 
// 表示要連接的URL。這個URL應該爲響應WebSocket的地址。
in optional DOMString protocols
// 可以是一個單個的協議名字字符串或者包含多個協議名字字符串的數組。默認設爲一個空字符串。
)
;

通過上述WebSocket構造器方法的第二個參數,客戶端可以在初次連接握手時,可以告知服務器自己支持哪種協議。如下所示:

var ws = new WebSocket('wss://example.com/socket',['appProtocol', 'appProtocol-v2']);

ws.onopen = function () {  if (ws.protocol == 'appProtocol-v2') { 
    ...
  } else {
    ...
  }
}

如上所示,WebSocket 構造函數接受了一個可選的子協議名字的數組,通過這個數組,客戶端可以向服務器通告自己能夠理解或希望服務器接受的協議。當服務器接收到該請求後,會根據自身的支持情況,返回相應信息。

  • 有支持的協議,則子協議協商成功,觸發客戶端的onopen回調,應用可以查詢WebSocket 對象上的protocol 屬性,從而得知服務器選定的協議;

  • 沒有支持的協議,則協商失敗,觸發onerror 回調,連接斷開。

1.3.WS與WSS

WebSocket 資源URI採用了自定義模式:ws 表示純文本通信( 如ws://example.com/socket),wss 表示使用加密信道通信(TCP+TLS)。爲什麼不使用http而要自定義呢?

WebSocket 的主要目的,是在瀏覽器中的應用與服務器之間提供優化的、雙向通信機制。可是,WebSocket 的連接協議也可以用於瀏覽器之外的場景,可以通過非HTTP協商機制交換數據。考慮到這一點,HyBi Working Group 就選擇採用了自定義的URI模式:

  • ws協議:普通請求,佔用與http相同的80端口;

  • wss協議:基於SSL的安全傳輸,佔用與tls相同的443端口。

各自的URI如下:

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

很多現有的HTTP 中間設備可能不理解新的WebSocket 協議,而這可能導致各種問題:盲目的連接升級、意外緩衝WebSocket 幀、不明就裏地修改內容、把WebSocket 流量誤當作不完整的HTTP 通信,等等。這時WSS就提供了一種不錯的解決方案,它建立一條端到端的安全通道,這個端到端的加密隧道對中間設備模糊了數據,因此中間設備就不能再感知到數據內容,也就無法再對請求做特殊處理

2. WebSocket協議

HyBi Working Group 制定的WebSocket 通信協議(RFC 6455)包含兩個高層組件:開放性HTTP 握手用於協商連接參數,二進制消息分幀機制用於支持低開銷的基於消息的文本和二進制數據傳輸。WebSocket 協議嘗試在既有HTTP 基礎設施中實現雙向HTTP 通信,因此也使用HTTP 的80 和443 端口。不過,這個設計不限於通過HTTP 實現WebSocket 通信,未來的實現可以在某個專用端口上使用更簡單的握手,而不必重新定義一個協議。WebSocket 協議是一個獨立完善的協議,可以在瀏覽器之外實現。不過,它的主要應用目標還是實現瀏覽器應用的雙向通信。

2.1.數據成幀

WebSocket 使用了自定義的二進制分幀格式,把每個應用消息切分成一或多個幀,發送到目的地之後再組裝起來,等到接收到完整的消息後再通知接收端。基本的成幀協議定義了幀類型有操作碼、有效載荷的長度,指定位置的Extension data和Application data,統稱爲Payload data,保留了一些特殊位和操作碼供後期擴展。在打開握手完成後,終端發送一個關閉幀之前的任何時間裏,數據幀可能由客戶端或服務器的任何一方發送。具體的幀格式如下所示:

  • FIN: 1 bit 。表示此幀是否是消息的最後幀,第一幀也可能是最後幀。

  • RSV1,RSV2,RSV3: 各1 bit 。必須是0,除非協商了擴展定義了非0的意義。

  • opcode:4 bit。表示被傳輸幀的類型:x0 表示一個後續幀;x1 表示一個文本幀;x2 表示一個二進制幀;x3-7 爲以後的非控制幀保留;x8 表示一個連接關閉;x9 表示一個ping;xA 表示一個pong;xB-F 爲以後的控制幀保留。

  • Mask: 1 bit。表示淨荷是否有掩碼(只適用於客戶端發送給服務器的消息)。

  • Payload length: 7 bit, 7 + 16 bit, 7 + 64 bit。 淨荷長度由可變長度字段表示: 如果是 0~125,就是淨荷長度;如果是 126,則接下來 2 字節表示的 16 位無符號整數纔是這一幀的長度; 如果是 127,則接下來 8 字節表示的 64 位無符號整數纔是這一幀的長度。

  • Masking-key:0或4 Byte。 用於給淨荷加掩護,客戶端到服務器標記。

  • Extension data: x Byte。默認爲0 Byte,除非協商了擴展。

  • Application data: y Byte。 在”Extension data”之後,佔據了幀的剩餘部分。

  • Payload data: (x + y) Byte。”extension data” 後接 “application data”。

• :最小的通信單位,包含可變長度的幀首部和淨荷部分,淨荷可能包含完整或部分應用消息。
• 消息:一系列幀,與應用消息對等。

是否把消息分幀由客戶端和服務器實現決定,應用並不需要關注WebSocket幀和如何分幀,因爲客戶端(如瀏覽器)和服務端爲完成該工作。那麼客戶端和服務端是按照什麼規則進行分幀的呢?RFC 6455規定的分幀規則如下:

  1. 一個未分幀的消息包含單個幀,FIN設置爲1,opcode非0。

  2. 一個分幀了的消息包含:開始於:單個幀,FIN設爲0,opcode非0;後接 :0個或多個幀,FIN設爲0,opcode設爲0;終結於:單個幀,FIN設爲1,opcode設爲0。一個分幀了消息在概念上等價於一個未分幀的大消息,它的有效載荷長度等於所有幀的有效載荷長度的累加;然而,有擴展時,這可能不成立,因爲擴展定義了出現的Extension data的解釋。例如,Extension data可能只出現在第一幀,並用於後續的所有幀,或者Extension data出現於所有幀,且只應用於特定的那個幀。在缺少Extension data時,下面的示例示範了分幀如何工作。舉例:如一個文本消息作爲三個幀發送,第一幀的opcode是0x1,FIN是0,第二幀的opcode是0x0,FIN是0,第三幀的opcode是0x0,FIN是1。   

  3. 控制幀可能被插入到分幀了消息中,控制幀必須不能被分幀。如果控制幀不能插入,例如,如果是在一個大消息後面,ping的延遲將會很長。因此要求處理消息幀中間的控制幀。

  4. 消息的幀必須以發送者發送的順序傳遞給接受者。

  5. 一個消息的幀必須不能交叉在其他幀的消息中,除非有擴展能夠解釋交叉。

  6. 一個終端必須能夠處理消息幀中間的控制幀。

  7. 一個發送者可能對任意大小的非控制消息分幀。

  8. 客戶端和服務器必須支持接收分幀和未分幀的消息。

  9. 由於控制幀不能分幀,中間設施必須不嘗試改變控制幀。

  10. 中間設施必須不修改消息的幀,如果保留位的值已經被使用,且中間設施不明白這些值的含義。

在遵循了上述分幀規則之後,一個消息的所有幀屬於同樣的類型,由第一個幀的opcdoe指定。由於控制幀不能分幀,消息的所有幀的類型要麼是文本、二進制數據或保留的操作碼中的一個。

雖然客戶端和服務端都遵循同樣的分幀規則,但也是有些差異的。在客戶端往服務端發送數據時,爲防止客戶端中運行的惡意腳本對不支持WebSocket 的中間設備進行緩存投毒攻擊(cache poisoning attack),發送幀的淨荷都要使用幀首部中指定的值加掩碼。被標記的幀必須設置MASK域爲1,Masking-key必須完整包含在幀裏,它用於標記Payload data。Masking-key是由客戶端隨機選擇的32位值,標記鍵應該是不可預測的,給定幀的Masking-key必須不能簡單到服務器或代理可以預測Masking-key是用於一序列幀的,不可預測的Masking-key是阻止惡意應用的作者從wire上獲取數據的關鍵。由於客戶端發送到服務端的信息需要進行掩碼處理,所以客戶端發送數據的分幀開銷要大於服務端發送數據的開銷,服務端的分幀開銷是2~10 Byte,客戶端是則是6~14 Byte。

控制幀

控制幀由操作碼標識,操作碼的最高位是1。當前爲控制幀定義的操作碼有0x8(關閉)、0x9(Ping)和0xA(Pong),操作碼0xB-0xF是保留的,未定義。控制幀用來交流WebSocket的狀態,能夠插入到消息的多個幀的中間。所有的控制幀必須有一個小於等於125字節的有效載荷長度,必須不能被分幀。

  • 關閉:操作碼爲0x8。關閉幀可能包含一個主體(幀的應用數據部分)指明關閉的原因,如終端關閉,終端接收到的幀太大,或終端接收到的幀不符合終端的預期格式。從客戶端發送到服務器的關閉幀必須標記,在發送關閉幀後,應用程序必須不再發送任何數據。如果終端接收到一個關閉幀,且先前沒有發送關閉幀,終端必須發送一個關閉幀作爲響應。終端可能延遲發送關閉幀,直到它的當前消息發送完成。在發送和接收到關閉消息後,終端認爲WebSocket連接已關閉,必須關閉底層的TCP連接。服務器必須立即關閉底層的TCP連接;客戶端應該等待服務器關閉連接,但並非必須等到接收關閉消息後才關閉,如果它在合理的時間間隔內沒有收到反饋,也可以將TCP關閉。如果客戶端和服務器同時發送關閉消息,兩端都已發送和接收到關閉消息,應該認爲WebSocket連接已關閉,並關閉底層TCP連接。

  • Ping:操作碼爲0x9。一個Ping幀可能包含應用程序數據。當接收到Ping幀,終端必須發送一個Pong幀響應,除非它已經接收到一個關閉幀。它應該儘快返回Pong幀作爲響應。終端可能在連接建立後、關閉前的任意時間內發送Ping幀。注意:Ping幀可作爲keepalive或作爲驗證遠程終端是否可響應的手段。

  • Pong:操作碼爲0xA。Pong 幀必須包含與被響應Ping幀的應用程序數據完全相同的數據。如果終端接收到一個Ping 幀,且還沒有對之前的Ping幀發送Pong 響應,終端可能選擇發送一個Pong 幀給最近處理的Ping幀。一個Pong 幀可能被主動發送,這作爲單向心跳。對主動發送的Pong 幀的響應是不希望的。

數據幀

數據幀攜帶需要發送的目標數據,由操作碼標識,操作碼的最高位是0。當前爲數據幀定義的(文本),0x2(二進制),操作碼0x3-0x7爲以後的非控制幀保留,未定義。

操作碼決定了數據的解釋:

  • 文本:操作碼爲0x1。有效載荷數據是UTF-8編碼的文本數據。特定的文本幀可能包含部分的UTF-8 序列,然而,整個消息必須包含有效的UTF-8,當終端以UTF-8解釋字節流時發現字節流不是一個合法的UTF-8流,那麼終端將關閉連接。

  • 二進制:操作碼爲0x2。有效載荷數據是任意的二進制數據,它的解釋由應用程序層唯一決定。

2.2.協議擴展

從上述的數據分幀格式可以知道,有很多擴展位預留,WebSocket 規範允許對協議進行擴展,可以使用這些預留位在基本的WebSocket 分幀層之上實現更多的功能。

下面是負責制定WebSocket 規範的HyBi Working Group進行的兩項擴展:

  • 多路複用擴展(A Multiplexing Extension for WebSockets):這個擴展可以將WebSocket 的邏輯連接獨立出來,實現共享底層的TCP 連接。每個WebSocket 連接都需要一個專門的TCP 連接,這樣效率很低。多路複用擴展解決了這個問題。它使用“信道ID”擴展每個WebSocket 幀,從而實現多個虛擬的WebSocket 信道共享一個TCP 連接。

  • 壓縮擴展(Compression Extensions for WebSocket):給WebSocket 協議增加了壓縮功能。基本的WebSocket 規範沒有壓縮數據的機制或建議,每個幀中的淨荷就是應用提供的淨荷。雖然這對優化的二進制數據結構不是問題,但除非應用實現自己的壓縮和解壓縮邏輯,否則很多情況下都會造成傳輸載荷過大的問題。實際上,壓縮擴展就相當於HTTP 的傳輸編碼協商。

要使用擴展,客戶端必須在第一次的Upgrade 握手中通知服務器,服務器必須選擇並確認要在商定連接中使用的擴展。下面就是對升級協商的介紹。

2.3.升級協商

從上面的介紹可知,WebSocket具有很大的靈活性,提供了很多強大的特性:基於消息的通信、自定義的二進制分幀層、子協議協商、可選的協議擴展等等。上面也講到,客戶端和服務端需先通過HTTP方式協商適當的參數後纔可建立連接,完成協商之後,所有信息的發送和接收不再和HTTP相關,全由WebSocket自身的機制處理。當然,完成最初的連接參數協商並非必須使用HTTP協議,它只是一種實現方案,可以有其他選擇。但使用HTTP協議完成最初的協商,有以下好處:讓WebSockets 與現有HTTP 基礎設施兼容:WebSocket 服務器可以運行在80 和443 端口上,這通常是對客戶端唯一開放的端口;可以重用並擴展HTTP 的Upgrade 流,爲其添加自定義的WebSocket 首部,以完成協商。

在協商過程中,用到的一些頭域如下:

  • Sec-WebSocket-Version:客戶端發送,表示它想使用的WebSocket 協議版本(13表示RFC 6455)。如果服務器不支持這個版本,必須迴應自己支持的版本。

  • Sec-WebSocket-Key:客戶端發送,自動生成的一個鍵,作爲一個對服務器的“挑戰”,以驗證服務器支持請求的協議版本;

  • Sec-WebSocket-Accept:服務器響應,包含Sec-WebSocket-Key 的簽名值,證明它支持請求的協議版本;

  • Sec-WebSocket-Protocol:用於協商應用子協議:客戶端發送支持的協議列表,服務器必須只回應一個協議名;

  • Sec-WebSocket-Extensions:用於協商本次連接要使用的WebSocket 擴展:客戶端發送支持的擴展,服務器通過返回相同的首部確認自己支持一或多個擴展。

在進行HTTP Upgrade之前,客戶端會根據給定的URI、子協議、擴展和在瀏覽器情況下的origin,先打開一個TCP連接,隨後再發起升級協商。升級協商具體如下:

GET /socket HTTP/1.1 
// 請求的方法必須是GET,HTTP版本必須至少是1.1

Host: thirdparty.comOrigin: http://example.com

Connection: Upgrade Upgrade: websocket
// 請求升級到WebSocket 協議

Sec-WebSocket-Version: 13
// 客戶端使用的WebSocket 協議版本

Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
// 自動生成的鍵,以驗證服務器對協議的支持,其值必須是nonce組成的隨機選擇的16字節的被base64編碼後的值

Sec-WebSocket-Protocol: appProtocol, appProtocol-v2
// 可選的應用指定的子協議列表

Sec-WebSocket-Extensions: x-webkit-deflate-message, x-custom-extension
// 可選的客戶端支持的協議擴展列表,指示了客戶端希望使用的協議級別的擴展

在安全工程中,Nonce是一個在加密通信只能使用一次的數字。在認證協議中,它往往是一個隨機或僞隨機數,以避免重放攻擊。Nonce也用於流密碼以確保安全。如果需要使用相同的密鑰加密一個以上的消息,就需要Nonce來確保不同的消息與該密鑰加密的密�

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章