【騰訊Bugly乾貨分享】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規定的分幀規則如下:
一個未分幀的訊息包含單個幀,FIN設定為1,opcode非0。
一個分幀了的訊息包含:開始於:單個幀,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。
控制幀可能被插入到分幀了訊息中,控制幀必須不能被分幀。如果控制幀不能插入,例如,如果是在一個大訊息後面,ping的延遲將會很長。因此要求處理訊息幀中間的控制幀。
訊息的幀必須以傳送者傳送的順序傳遞給接受者。
一個訊息的幀必須不能交叉在其他幀的訊息中,除非有擴充套件能夠解釋交叉。
一個終端必須能夠處理訊息幀中間的控制幀。
一個傳送者可能對任意大小的非控制訊息分幀。
客戶端和伺服器必須支援接收分幀和未分幀的訊息。
由於控制幀不能分幀,中間設施必須不嘗試改變控制幀。
中間設施必須不修改訊息的幀,如果保留位的值已經被使用,且中間設施不明白這些值的含義。
在遵循了上述分幀規則之後,一個訊息的所有幀屬於同樣的型別,由第一個幀的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.com
Origin: 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來確保不同的訊息與該金鑰加密的金鑰流不同。
與瀏覽器中客戶端發起的任何連線一樣,WebSocket 請求也必須遵守同源策略:瀏覽器會自動在升級握手請求中追加Origin 首部,遠端伺服器可能使用CORS 判斷接受或拒絕跨源請求。要完成握手,伺服器必須返回一個成功的“Switching Protocols”(切換協議)響應,具體如下:
HTTP/1.1 101 Switching Protocols // 101 響應碼確認升級到WebSocket 協議
Upgrade: websocket
Connection: Upgrade
Access-Control-Allow-Origin: http://example.com // CORS 首部表示選擇同意跨源連線
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= // 簽名的鍵值驗證協議支援
Sec-WebSocket-Protocol: appProtocol-v2 // 伺服器選擇的應用子協議
Sec-WebSocket-Extensions: x-custom-extension // 伺服器選擇的WebSocket 擴充套件
所有相容RFC 6455 的WebSocket 伺服器都使用相同的演算法計算客戶端挑戰的答案:將Sec-WebSocket-Key 的內容與標準定義的唯一GUID 字串拼接起來,計算出SHA1 雜湊值,結果是一個base-64 編碼的字串,把這個字串發給客戶端即可。Sec-WebSocket-Accept 這個頭域的 ABNF [RFC2616]定義如下:
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 | "+" | "/"
如果客戶端傳送的key值為:”dGhlIHNhbXBsZSBub25jZQ==”,服務端將把”258EAFA5-E914-47DA-95CA-C5AB0DC85B11” 這個唯一的GUID與它拼接起來,就是”dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CAC5AB0DC85B11”,然後對其進行SHA-1雜湊,結果為”0xb3 0x7a 0x4f 0x2c 0xc0 0x62 0x4f 0x16 0x90 0xf6 0x46 0x06 0xcf 0x38 0x59 0x45 0xb2 0xbe 0xc4 0xea”,再進行base64-encoded即可得”s3pPLMBiTxaQ9kYGzzhZRbK+xOo=”。
成功的WebSocket 握手必須是客戶端傳送協議版本和自動生成的挑戰值,伺服器返回101 HTTP 響應碼(Switching Protocols)和雜湊形式的挑戰答案,確認選擇的協議版本。
一旦客戶端開啟握手傳送出去,在傳送任何資料之前,客戶端必須等待伺服器的響應。客戶端必須按如下步驟驗證響應:
如果從伺服器接收到的狀態碼不是101,按HTTP【RFC2616】程式處理響應。在特殊情況下,如果客戶端接收到401狀態碼,可能執行認證;伺服器可能用3xx狀態碼重定向客戶端(但不要求客戶端遵循他們)。否則按下面處理。
如果響應缺失Upgrade頭域或Upgrade頭域的值沒有包含大小寫不敏感的ASCII 值”websocket”,客戶端必須使WebSocket連線失敗。
如果響應缺失Connection頭域或其值不包含大小寫不敏感的ASCII值”Upgrade”,客戶端必須使WebSocket連線失敗。
如果響應缺失Sec-WebSocket-Accept頭域或其值不包含 [Sec-WebSocket-Key] (作為字串,非base64解碼的)+ “258EAFA5-E914-47DA-95CA-C5AB0DC85B11” 的base64編碼 SHA-1值,客戶端必須使WebSocket連線失敗。
如果響應包含Sec-WebSocket-Extensions頭域,且其值指示使用的擴充套件不出現在客戶端傳送的握手(伺服器指示的擴充套件不是客戶端要求的),客戶端必須使WebSocket連線失敗。
如果響應包含Sec-WebSocket-Protocol頭域,且這個頭域指示使用的子協議不包含在客戶端的握手(伺服器指示的子協議不是客戶端要求的),客戶端必須使WebSocket連線失敗。
如果客戶端完成了對服務端響應的升級協商驗證,該連線就可以用作雙向通訊通道交換WebSocket 訊息。從此以後,客戶端與伺服器之間不會再發生HTTP 通訊,一切由WebSocket 協議接管。
3.使用場景及效能
Websocket協議具有極簡的API,開發者可以很簡便的呼叫,而且提供了二進位制分幀、可擴充套件性以及子協議協商等強大特性,使得WebSocket 成為在瀏覽器中採用自定義應用協議的最佳選擇。但,在計算機世界裡,任何技術和理論一般都是為解決特定問題而生的,並不是普世化的解決方案,WebSocket亦是如此。WebSocket 不能取代XHR 或SSE,何時以及如何使用,毋庸置疑會對效能產生巨大影響,要獲得最佳效能,我們必須善於利用它的長處!下面將對現有的一些協議與WebSocket 對比進行一個大致介紹。
請求和響應流
XHR 是專門為“事務型”請求/ 響應通訊而優化的:客戶端向伺服器傳送完整的、格式良好的HTTP 請求,伺服器返回完整的響應。這裡不支援請求流,在Streams API 可用之前,沒有可靠的跨瀏覽器響應流API。 SSE 可以實現伺服器到客戶端的高效、低延遲的文字資料流:客戶端發起 SSE 連線,伺服器使用事件源協議將更新流式傳送給客戶端。客戶端在初次握手後,不能向伺服器傳送任何資料。 WebSocket 是唯一一個能通過同一個TCP 連線實現雙向通訊的機制,客戶端和伺服器隨時可以交換資料。因此,WebSocket 在兩個方向上都能保證文字和二進位制應用資料的低延遲交付。
客戶端到服務端傳遞訊息的總時延由以下四個部分構成:
- 傳播延遲:訊息從傳送端到接收端需要的時間,是訊號傳播距離和速度的函式,傳播時間取決於距離和訊號通過的媒介,播速度通常不超過光速;
- 傳輸延遲:把訊息中的所有位元轉移到鏈路中需要的時間,是訊息長度和鏈路速率的函式,由傳輸鏈路的速率決
定,與客戶端到伺服器的距離無關; - 處理延遲:處理分組首部、檢查位錯誤及確定分組目標所需的時間,常由硬體完成,因此相應的延遲一般非常短;
- 排隊延遲:如果分組到達的速度超過了路由器的處理能力,那麼分組就要在入站緩衝區排隊,到來的分組排隊等待處理的時間就是排隊延遲。
無論是什麼樣的傳輸機制,都不會減少客戶端與伺服器間的往返次數,資料包的傳播延遲都一樣。但,採用不同的傳輸機制可以有不同的排隊延遲。對XHR 輪詢而言,排隊延遲就是客戶端輪詢間隔:伺服器上的訊息可用之後,必須等到下一次客戶端XHR 請求才能傳送。相對來說,SSE 和WebSocket 使用持久連線,這樣伺服器(和客戶端——如果是WebSocket)就可以在訊息可用時立即傳送它,消除了訊息的排隊延遲,也就使得總的傳輸延遲更小。
訊息開銷
在完成最初的升級協商之後,客戶端和伺服器即可通過WebSocket 協議雙向交換資料,訊息分幀之後每幀會新增2~14 位元組的開銷;SSE 會給每個 訊息新增 5 位元組,但僅限於 UTF-8 內容(SSE 不是為傳輸二進位制載荷而設計的!如果有必要,可以把二進位制物件編碼為base64 形式,然後再使用SSE); HTTP 1.x 請求(XHR 及其他常規請求)會攜帶 500~800 位元組的 HTTP 元資料,加上cookie; HTTP 2.0 壓縮 HTTP 元資料,可以顯著減少開銷,如果請求都不修改首部,那麼開銷可以低至8 位元組。WebSocket專門為雙向通訊而設計,開銷很小,在實時通知應用開發中是不錯的選擇。
上述開銷不包括IP、TCP 和TLS 分幀的開銷,後者一共會給每個訊息增加60~100 位元組,無論使用的是什麼應用協議。
效率及壓縮
在使用HTTP協議傳輸資料時,每個請求都可以協商最優的傳輸編碼格式(如對文字資料採用gzip 壓縮);SSE 只能傳輸UTF-8 格式資料,事件流資料可以在整個會話期間使用gzip 壓縮;WebSocket 可以傳輸文字和二進位制資料,壓縮整個會話行不通,二進位制的淨荷也可能已經壓縮過了!
鑑於WebSocket的特殊性,它需要實現自己的壓縮機制,並針對每個訊息選擇應用。HyBi 工作組正在為WebSocket 協議制定以訊息為單位的壓縮擴充套件,但這個擴充套件尚未得到任何瀏覽器支援。目前來說,除非應用通過細緻優化自己的二進位制淨荷實現自己的壓縮邏輯,同時也針對文字訊息實現自己的壓縮邏輯,否則傳輸資料過程中一定會產生很大的位元組開銷!
自定義應用協議
HTTP已經誕生了數十年,具有廣泛的應用,各種優化專門的優化機制也已經被瀏覽器及伺服器等裝置實施,XHR 請求自然而然就繼承了所有這些功能。然而,對於只使用HTTP協議完成升級協商的WebSocket來說,流式資料處理可以讓我們在客戶端和伺服器間自定義協議,但也會錯過瀏覽器提供的很多服務,應用可能必須實現自已的邏輯來填充某些功能空白,比如快取、狀態管理、元資料交付等等。
部署WebSocket
HTTP 是專為短時突發性傳輸設計的,很多伺服器、代理和其他中間裝置的HTTP 連線空閒超時設定都很激進。這就與WebSocket的長時連線、實時雙向通訊相悖,部署時需要關注下面的三個方面:
- 位於各自網路中的路由器、負載均衡器和代理;
- 外部網路中透明、確定的代理伺服器(如 ISP 和運營商的代理);
- 客戶網路中的路由器、防火牆和代理。
鑑於使用者所處的網路環境是各不相同的,不受開發者所控制。某些網路甚至會完全遮蔽WebSocket通訊,有些裝置也不支援WebSocket協議,這時就需要採用備用機制,使用其他技術來實現類似與WebSocket的通訊(如socket.io等)。雖然,我們無法處理網路中的中間裝置,但對於處在我們自己掌控下的基礎設施還是可以做一些工作的,可以對通訊路徑上的每一臺負載均衡器、路由器和Web 伺服器針對長時連線進行調優。然而,長時連線和空閒會話會佔用所有中間裝置及伺服器的記憶體和套接字資源,開銷很大,部署WebSocket、SSE及HTTP 2.0等賴於長時會話的協議都會對運維提出新的挑戰。在使用WebSocket的過程中,也需要做到優化二進位制淨荷和壓縮 UTF-8 內容以最小化傳輸資料、監控客戶端緩衝資料的量、切分應用訊息避免隊首阻塞、合用的情況下利用其他傳輸機制等。
總結
WebSocket 協議為實時雙向通訊而設計,提供高效、靈活的文字和二進位制資料傳輸,同時也錯過了瀏覽器為HTTP提供的一些服務,在使用時需要應用自己實現。在進行應用資料傳輸時,需要根據不同的場景選擇恰當的協議,WebSocket 並不能取代HTTP、XHR 或SSE,關鍵還是要利用這些機制的長處以求得最佳效能。
Socket.IO
鑑於現在不同的平臺及瀏覽器版本對WebSocket支援的不同,有開發者做了一個叫做socket.io 的為實時應用提供跨平臺實時通訊的庫,我們可以使用它完成向WebSocket的切換。socket.io 旨在使實時應用在每個瀏覽器和移動裝置上成為可能,模糊不同的傳輸機制之間的差異。socket.io 的名字源於它使用了瀏覽器支援並採用的 HTML5 WebSocket 標準,因為並不是所有的瀏覽器都支援 WebSocket ,所以該庫支援一系列降級功能:
- Websocket
- Adobe:registered: Flash:registered: Socket
- AJAX long polling
- AJAX multipart streaming
- Forever Iframe
- JSONP Polling
在大部分情境下,你都能通過這些功能選擇與瀏覽器保持類似長連線的功能。具體細節請看Socket.io。
參考資料
更多精彩內容歡迎關注騰訊 Bugly的微信公眾賬號:
騰訊 Bugly是一款專為移動開發者打造的質量監控工具,幫助開發者快速,便捷的定位線上應用崩潰的情況以及解決方案。智慧合併功能幫助開發同學把每天上報的數千條 Crash 根據根因合併分類,每日日報會列出影響使用者數最多的崩潰,精準定位功能幫助開發同學定位到出問題的程式碼行,實時上報可以在釋出後快速的瞭解應用的質量情況,適配最新的 iOS, Android 官方作業系統,鵝廠的工程師都在使用,快來加入我們吧!