本文引用自“ 豆米博客”的《JS實時通信三把斧》系列文章,有優化和改動。
1、引言
有關Web端即時通訊技術的文章我已整理過很多篇,閱讀過的讀者可能都很熟悉,早期的Web端即時通訊方案,受限于Web客戶端的技術限制,想實現真正的“即時”通信,難度相當大。
傳統的Web端即時通訊技術從短輪詢到長連詢,再到Comet技術,在如此原始的HTML標準之下,為了實現所謂的“即時”通信,技術上可謂絞盡腦汁,極盡所能。
自從HTML5標準發布之后,WebSocket這類技術橫空出世,實現Web端即時通訊技術的便利性大大提前,以往想都不敢想的真正全雙工實時通信,如此早已成為可能。
本文將專門介紹WebSocket、socket.io、SSE這幾種現代的Web端即時通訊技術,從適用場景到技術原理,通俗又不失深度的文字,特別適合對Web端即時通訊技術有一定了解,且想深入學習WebSocket等現代Web端“實時”通信技術,卻又不想花時間去深讀枯燥的IETF技術手冊的讀者。
(本文同步發布于:http://www.52im.net/thread-3695-1-1.html)
2、本文作者
“豆米”:現居杭州,熱愛前端,熱愛互聯網,豆米是“洋芋(土豆-豆)”和“米喳(米)”的簡稱。
作者博客:https://blog.5udou.cn/
作者Github:https://github.com/linxiaowu66/
3、知識預備
如果你對Web端即時通訊技術的前世今生不曾了解,建議先讀以下文章:
- 《新手入門貼:史上最全Web端即時通訊技術原理詳解》
- 《Web端即時通訊技術盤點:短輪詢、Comet、Websocket、SSE》
- 《詳解Web端通信方式的演進:從Ajax、JSONP 到 SSE、Websocket》
- 《網頁端IM通信技術快速入門:短輪詢、長輪詢、SSE、WebSocket》
如果你對本文將要介紹的技術已有了解,建議進行專項學習,以便深入掌握:
- 《Comet技術詳解:基于HTTP長連接的Web端實時通信技術》
- 《SSE技術詳解:一種全新的HTML5服務器推送事件技術》
- 《WebSocket詳解(三):深入WebSocket通信協議細節》
- 《理論聯系實際:從零理解WebSocket的通信原理、協議格式、安全性》
- 《WebSocket從入門到精通,半小時就夠!》
4、WebSocket

在這里不打算詳細介紹整個WebSocket協議的內容,根據我本人以前協議的學習思路,我挑重點使用問答方式來介紹該協議,這樣讀起來就不那么枯燥。
4.1 基本情況
協議運行在OSI的哪層?
應用層,WebSocket協議是一個獨立的基于TCP的協議。 它與HTTP唯一的關系是它的握手是由HTTP服務器解釋為一個Upgrade請求。
協議運行的標準端口號是多少?
默認情況下,WebSocket協議使用端口80用于常規的WebSocket連接、端口443用于WebSocket連接的在傳輸層安全(TLS)RFC2818之上的隧道化口。
4.2 協議是如何工作的?
協議的工作流程可以參考下圖:
其中幀的一些重要字段需要解釋一下:
- 1)Upgrade:`upgrade`是HTTP1.1中用于定義轉換協議的`header`域。它表示,如果服務器支持的話,客戶端希望使用現有的「網絡層」已經建立好的這個「連接(此處是 TCP 連接)」,切換到另外一個「應用層」(此處是 WebSocket)協議;
- 2)Connection:`Upgrade`固定字段。Connection還有其他字段,可以自己給自己科普一下;
- 3)Sec-WebSocket-Key:用來發送給服務器使用(服務器會使用此字段組裝成另一個key值放在握手返回信息里發送客戶端);
- 4)Sec-WebSocket-Protocol:標識了客戶端支持的子協議的列表;
- 5)Sec-WebSocket-Version:標識了客戶端支持的WS協議的版本列表,如果服務器不支持這個版本,必須回應自己支持的版本;
- 6)Origin:作安全使用,防止跨站攻擊,瀏覽器一般會使用這個來標識原始域;
- 7)Sec-WebSocket-Accept:服務器響應,包含Sec-WebSocket-Key 的簽名值,證明它支持請求的協議版本。
關于Sec-WebSocket-Key和Sec-WebSocket-Accept的計算是這樣的:
所有兼容RFC 6455 的WebSocket 服務器都使用相同的算法計算客戶端挑戰的答案:將Sec-WebSocket-Key 的內容與標準定義的唯一GUID字符(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)串拼接起來,計算出SHA1散列值,結果是一個base-64編碼的字符串,把這個字符串發給客戶端即可。
用代碼就是實現如下:
const key = crypto.createHash('sha1')
.update(req.headers['sec-websocket-key'] + constants.GUID, 'binary')
.digest('base64')
至于為什么需要這么一個步驟,可以參考《理論聯系實際:從零理解WebSocket的通信原理、協議格式、安全性》一文。
引用如下:
Sec-WebSocket-Key/Sec-WebSocket-Accept在主要作用在于提供基礎的防護,減少惡意連接、意外連接。
作用大致歸納如下:
- 1)避免服務端收到非法的websocket連接(比如http客戶端不小心請求連接websocket服務,此時服務端可以直接拒絕連接);
- 2)確保服務端理解websocket連接。因為ws握手階段采用的是http協議,因此可能ws連接是被一個http服務器處理并返回的,此時客戶端可以通過Sec-WebSocket-Key來確保服務端認識ws協議。(并非百分百保險,比如總是存在那么些無聊的http服務器,光處理Sec-WebSocket-Key,但并沒有實現ws協議。。。);
- 3)用瀏覽器里發起ajax請求,設置header時,Sec-WebSocket-Key以及其他相關的header是被禁止的。這樣可以避免客戶端發送ajax請求時,意外請求協議升級(websocket upgrade);
- 4)可以防止反向代理(不理解ws協議)返回錯誤的數據。比如反向代理前后收到兩次ws連接的升級請求,反向代理把第一次請求的返回給cache住,然后第二次請求到來時直接把cache住的請求給返回(無意義的返回);
- 5)Sec-WebSocket-Key主要目的并不是確保數據的安全性,因為Sec-WebSocket-Key、Sec-WebSocket-Accept的轉換計算公式是公開的,而且非常簡單,最主要的作用是預防一些常見的意外情況(非故意的)。
強調:Sec-WebSocket-Key/Sec-WebSocket-Accept 的換算,只能帶來基本的保障,但連接是否安全、數據是否安全、客戶端/服務端是否合法的 ws客戶端、ws服務端,其實并沒有實際性的保證。
4.3 協議傳輸的幀格式是什么?
幀格式定義的格式如下:
各個字段的解釋如下:
- 1)FIN: 1bit,用來表明這是一個消息的最后的消息片斷,當然第一個消息片斷也可能是最后的一個消息片斷;
- 2)RSV1,RSV2,RSV3: 分別都是1位,如果雙方之間沒有約定自定義協議,那么這幾位的值都必須為0,否則必須斷掉WebSocket連接。在ws中就用到了RSV1來表示是否消息壓縮了的;
- 3)opcode:4 bit,表示被傳輸幀的類型:
- - %x0 表示連續消息片斷;
- - %x1 表示文本消息片斷;
- - %x2 表未二進制消息片斷;
- - %x3-7 為將來的非控制消息片斷保留的操作碼;
- - %x8 表示連接關閉;
- - %x9 表示心跳檢查的ping;
- - %xA 表示心跳檢查的pong;
- - %xB-F 為將來的控制消息片斷的保留操作碼。
- 4)Mask: 1 bit。定義傳輸的數據是否有加掩碼,如果設置為1,掩碼鍵必須放在masking-key區域,客戶端發送給服務端的所有消息,此位都是1;
- 5)Payload length:傳輸數據的長度,以字節的形式表示:7位、7+16位、或者7+64位。如果這個值以字節表示是0-125這個范圍,那這個值就表示傳輸數據的長度;如果這個值是126,則隨后的兩個字節表示的是一個16進制無符號數,用來表示傳輸數據的長度;如果這個值是127,則隨后的是8個字節表示的一個64位無符合數,這個數用來表示傳輸數據的長度。多字節長度的數量是以網絡字節的順序表示。負載數據的長度為擴展數據及應用數據之和,擴展數據的長度可能為0,因而此時負載數據的長度就為應用數據的長度;
- 6)Masking-key:0或4個字節,客戶端發送給服務端的數據,都是通過內嵌的一個32位值作為掩碼的;掩碼鍵只有在掩碼位設置為1的時候存在;
- 7)Extension data: x位,如果客戶端與服務端之間沒有特殊約定,那么擴展數據的長度始終為0,任何的擴展都必須指定擴展數據的長度,或者長度的計算方式,以及在握手時如何確定正確的握手方式。如果存在擴展數據,則擴展數據就會包括在負載數據的長度之內;
- 8)Application data: y位,任意的應用數據,放在擴展數據之后,應用數據的長度=負載數據的長度-擴展數據的長度;
- 9)Payload data: (x+y)位,負載數據為擴展數據及應用數據長度之和;
更多細節請參考RFC6455-數據幀,這里不作贅述。
針對上面的各個字段的介紹,有一個Mask的需要說一下。
掩碼鍵(Masking-key)是由客戶端挑選出來的32位的隨機數。掩碼操作不會影響數據載荷的長度。
掩碼、反掩碼操作都采用如下算法。
首先,假設:
- 1)original-octet-i:為原始數據的第i字節;
- 2)transformed-octet-i:為轉換后的數據的第i字節;
- 3)j:為i mod 4的結果;
- 4)masking-key-octet-j:為mask key第j字節。
算法描述為: original-octet-i 與 masking-key-octet-j 異或后,得到 transformed-octet-i。
即: j = i MOD 4 transformed-octet-i = original-octet-i XOR masking-key-octet-j
用代碼實現:
const mask = (source, mask, output, offset, length) => {
for(vari = 0; i < length; i++) {
output[offset + i] = source[i ] ^ mask[i & 3];
}
};
解掩碼是反過來的操作:
const unmask = (buffer, mask) => {
// Required until [url=https://github.com/nodejs/node/issues/9006]https://github.com/nodejs/node/issues/9006[/url] is resolved.
const length = buffer.length;
for(vari = 0; i < length; i++) {
buffer[i ] ^= mask[i & 3];
}
};
同樣的為什么需要掩碼操作,也可以參考之前的那篇文章:《理論聯系實際:從零理解WebSocket的通信原理、協議格式、安全性》,完整的我就不列舉了。
需要注意的重點,我引用一下:
WebSocket協議中,數據掩碼的作用是增強協議的安全性。但數據掩碼并不是為了保護數據本身,因為算法本身是公開的,運算也不復雜。除了加密通道本身,似乎沒有太多有效的保護通信安全的辦法。
那么為什么還要引入掩碼計算呢,除了增加計算機器的運算量外似乎并沒有太多的收益(這也是不少同學疑惑的點)。
答案還是兩個字: 安全。但并不是為了防止數據泄密,而是為了防止早期版本的協議中存在的代理緩存污染攻擊(proxy cache poisoning attacks)等問題。
5、socket.io
5.1 本節引言

介紹完上一節WebSocket協議,我們把視線轉移到現代Web端即時通訊技術的第二個利器:socket.io。
估計有讀者就會問,WebSocket和socket.io有啥區別啊?
在了解socket.io之前,我們先聊聊傳統Web端即時通訊“長連接”技術的實現背景。
5.2 傳統Web長連接的技術實現背景
在現實的Web端產品中,并不是所有的Web客戶端都支持長連接的,或者換句話說,在WebSocket協議出來之前,是三種方式去實現WebSocket類似的功能的。
這三種方式是:
- 1)Flash:使用Flash是一種簡單的方法。不過很明顯的缺點就是Flash并不會安裝在所有客戶端上,比如iPhone/iPad。
- 2)Long-Polling:也就是眾所周之的“長輪詢”,在過去,這是一種有效的技術,但并沒有對消息發送進行優化。雖然我不會把AJAX長輪詢當做一種hack技術,但它確實不是一個最優方法;
- 3)Comet:在過去,這被稱為Web端的“服務器推”技術,相對于傳統的 Web 應用, 開發 Comet 應用具有一定的挑戰性,具體請見《Comet技術詳解:基于HTTP長連接的Web端實時通信技術》。
那么如果單純地使用WebSocket的話,那些不支持的客戶端怎么辦呢?難道直接放棄掉?
當然不是。Guillermo Rauch大神寫了socket.io這個庫,對WebSocket進行封裝,從而讓長連接滿足所有的場景,不過當然得配合使用對應的客戶端代碼。
socket.io將會使用特性檢測的方式來決定以websocket/ajax長輪詢/flash等方式建立連接。
那么socket.io是如何做到這些的呢?
我們帶著以下幾個問題去學習:
- 1)socket.io到底有什么新特性?
- 2)socket.io是怎么實現特性檢測的?
- 3)socket.io有哪些坑呢?
- 4)socket.io的實際應用是怎樣的,需要注意些什么?
如果有童鞋對上述問題已經清楚,想必就沒有往下讀的必要了。
5.3 socket.io的介紹
通過前面章節,讀者們都知道了WebSocket的功能,那么socket.io相對于WebSocket,在此基礎上封裝了一些什么新東西呢?
socket.io其實是有一套封裝了websocket的協議,叫做engine.io協議,在此協議上實現了一套底層雙向通信的引擎Engine.io。
而socket.io則是建立在engine.io上的一個應用層框架而已。所以我們研究的重點便是engine.io協議。
在socket.io的README中提到了其實現的一些新特性(回答了問題一):
- 1)可靠性:連接依然可以建立即使應用環境存在: 代理或者負載均衡器 個人防火墻或者反病毒軟件;
- 2)支持自動連接: 除非特別指定,否則一個斷開的客戶端會一直重連服務器直到服務器恢復可用狀態;
- 3)斷開連接檢測:在Engine.io層實現了一個心跳機制,這樣允許客戶端和服務器知道什么時候其中的一方不能響應。該功能是通過設置在服務端和客戶端的定時器實現的,在連接握手的時候,服務器會主動告知客戶端心跳的間隔時間以及超時時間;
- 4)二進制的支持:任何序列化的數據結構都可以用來發送;
- 5)跨瀏覽器的支持:該庫甚至支持到IE8;
- 6)支持復用:為了在應用程序中將創建的關注點隔離開來,Socket.io允許你創建多個namespace,這些namespace擁有單獨的通信通道,但將共享相同的底層連接;
- 7)支持Room:在每一個namespace下,你可以定義任意數量的通道,我們稱之為"房間",你可以加入或者離開房間,甚至廣播消息到指定的房間。
注意:Socket.IO不是WebSocket的實現,雖然 Socket.IO確實在可能的情況下會去使用WebSocket作為一個transport,但是它添加了很多元數據到每一個報文中:報文的類型以及namespace和ack Id。這也是為什么標準WebSocket客戶端不能夠成功連接上 Socket.IO 服務器,同樣一個 Socket.IO 客戶端也連接不上標準WebSocket服務器的原因。
5.4 engine.io協議介紹
完整的engine.io協議的握手過程如下圖:
當前engine.io協議的版本是3,我們根據上圖來大致介紹一下engine.io協議。
5.4.1)engine.io協議請求字段:
我們看到的是請求的url和WebSocket不大一樣,解釋一下:
- 1)EIO=3: 表示的是使用的是Engine.io協議版本3;
- 2)transport=polling/websocket: 表示使用的長連接方式是輪詢還是WebSocket;
- 3)t=xxxxx: 代碼中使用yeast根據時間戳生成一個唯一的字符串;
- 4)sid=xxxx: 客戶端和服務器建立連接之后獲取到的session id,客戶端拿到之后必須在每次請求中追加這個字段。
除了上述的3個字段,協議還描述了下面幾個字段:
- 1)j: 如果transport是polling,但是要求有一個JSONP的響應,那么j就應該設置為JSONP響應的索引值;
- 2)b64: 如果客戶端不支持XHR,那么客戶端應該設置b64=1傳給服務器,告知服務器所有的二進制數據應該以base64編碼后再發送。
另外engine.io默認的path是 /engine.io,socket.io在初始化的時候設置為了 /socket.io,所以大家看到的path就都是 /socket.io 了:
function Server(srv, opts){
if(!(this instanceof Server)) return new Server(srv, opts);
if('object'== typeof srv && srv instanceof Object && !srv.listen) {
opts = srv;
srv = null;
}
opts = opts || {};
this.nsps = {};
this.parentNsps = new Map();
this.path(opts.path || '/socket.io');
5.4.2)數據包編碼要求:
engine.io協議的數據包編碼有自己的一套格式,在協議介紹上engine.io-protocol,定義了兩種編碼類型: packet和payload。
一個編碼過的packet是下面這種格式:
<packettype id>[<data>]
然后協議定義了下面幾種packet type(采用數字進行標識):
- 1)0(open): 當開始一個新的transport的時候,服務端會發送該類型的packet;
- 2)1(close): 請求關閉這個transport但是不要自己關閉關閉連接;
- 3)2(ping): 由客戶端發送的ping包,服務端必須回應一個包含相同數據的pong包;
- 4)3(pong): 響應ping包,服務端發送;
- 5)4(message): 實際消息,在客戶端和服務端都可以監聽message事件獲取消息內容;
- 6)5(upgrade): 在engine.io切換transport之前,它會用來測試服務端和客戶端是否在該transport上通信。如果測試成功,客戶端會發送一個upgrade包去讓服務器刷新它的緩存并切換到新的transport;
- 7)6(noop): 主要用來強制一個輪詢循環當收到一個WebSocket連接的時候。
那payload也有對應的格式要求:
- 1)如果當只有發送string并且不支持XHR的時候,其編碼格式是::[:[...]];
- 2)當不支持XHR2并且發送二進制數據,但是使用base64編碼字符串的時候,其編碼格式是::b[...];
- 3)當支持XHR2的時候,所有的數據都被編碼成二進制,格式是:<0 for string data, 1 for binary data>[...];
- 4)如果發送的內容混雜著UTF-8的字符和二進制數據,字符串的每個字符被寫成一個字符編碼,用1個字節表示。
注意:payload的編碼要求不適用于WebSocket的通信。
針對上面的編碼要求,我們隨便舉個例子.
之前在第一條polling請求的時候,服務端編碼發送了這個數據:
97:0{"sid":"Peed250dk55pprwgAAAA","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":60000}2:40
根據上面的知識,我們知道第一次服務端會發送一個open的數據包。
所以組裝出來的packet是:
0
然后服務端會告知客戶端去嘗試升級到websocket,并且告知對應的sid。
于是整合后便是:
0{"sid":"Peed250dk55pprwgAAAA","upgrades":"websocket","pingInterval":25000,"pingTimeout":60000}
接著根據payload的編碼格式,因為是string,且長度是97個字節。
所以是:
97:0{"sid":"Peed250dk55pprwgAAAA","upgrades":"websocket","pingInterval":25000,"pingTimeout":60000}
接著第二部分數據是message包類型,并且數據是0,所以是40,長度為2字節,所以是2:40,最后就拼成剛才大家看到的結果。
注意:
ping/pong的間隔時間是服務端告知客戶端的:"pingInterval":25000,"pingTimeout":60000,也就是說心跳時間默認是25秒,并且等待pong響應的時間默認是60s。
5.5 升級協議的必備過程
協議定義了transport升級到websocket需要經歷一個必須的過程。
如下圖:
WebSocket的測試開始于發送probe,如果服務器也響應probe的話,客戶端就必須發送一個upgrade包。
為了確保不會丟包,只有在當前transport的所有buffer被刷新并且transport被認為paused的時候才可以發送upgrade包。服務端收到upgrade包的時候,服務端必須假設這是一個新的通道并發送所有已存的緩存到這個通道上
在Chrome上的效果如下:
5.6 engine.io的代碼實現
熟悉了engine.io協議之后,我們看看代碼是怎么實現主流程的。
客戶端的engine.io的主要實現流程我們在上面文字介紹了。
結合代碼engine.io,畫了這么一個客戶端流程圖:
服務端的代碼和客戶端非常相似,其實現流程圖如下:
6、SSE
6.1 本節引言

本文前兩節分析了WebSocket和socket.io,現在我們來看看SSE。
很多人也許好奇,有了WebSocket這種實時通信,為什么還需要SSE呢?
答案其實很簡單:那就是SSE其實是單向通信,而WebSocket是雙向通信。
比如:在股票行情、新聞推送的這種只需要服務器發送消息給客戶端場景中,使用SSE可能更加合適。
另外:SSE是使用HTTP傳輸的,這意味著我們不需要一個特殊的協議或者額外的實現就可以使用。而WebSocket要求全雙工連接和一個新的WebSocket服務器去處理。加上SSE在設計的時候就有一些WebSocket沒有的特性,比如自動重連接、event IDs、以及發送隨機事件的能力,所以各有各的特長,我們需要根據實際應用場景,去選擇不同的應用方案。
6.2 SSE介紹
SSE的簡單模型是:一個客戶端去從服務器端訂閱一條“流”,之后服務端可以發送消息給客戶端直到服務端或者客戶端關閉該“流”,所以SSE全稱叫“server-sent-event”。
相比以前的輪詢,SSE可以為B2C帶來更高的效率。
有一張圖片畫出了二者的區別:
6.3 SSE數據幀的格式
SSE必須編碼成utf-8的格式,消息的每個字段使用"\n"來做分割,并且需要下面4個規范定義好的字段。
這4個字段是:
- 1)Event: 事件類型;
- 2)Data: 發送的數據;
- 3)ID: 每一條事件流的ID;
- 4)Retry: 告知瀏覽器在所有的連接丟失之后重新開啟新的連接等待的時間,在自動重新連接的過程中,之前收到的最后一個事件流ID會被發送到服務端。
下圖是通過wireshark抓包得到的數據包的原始格式:
6.4 SSE通信過程
SSE的通信過程比較簡單,底層的一些實現都被瀏覽器給封裝好了,包括數據的處理。
大致流程如下:
在瀏覽器中截圖如下:
攜帶的數據是JSON格式的,瀏覽器都幫你整合成為一個Object:
在wireshark中,其通信流程如下。
發送請求:
得到響應:
在開始推送信息流之前,服務器還會發送一個客戶端會忽略掉的包,這個具體原因不清楚:
斷開連接后的重傳:
6.5 SSE的簡單使用示例
瀏覽器端的使用:
const es = new EventSource('/sse')
服務端的使用:
const sseStream = new SseStream(req)
sseStream.pipe(res)
sseStream.write({
id: sendCount,
event: 'server-time',
retry: 20000, // 告訴客戶端,如果斷開連接后,20秒后再重試連接
data: {ts: newDate().toTimeString(), count: sendCount++}
})
更多API使用和demo介紹分別參考:SSE API、demo代碼。
6.6 兼容性及缺點
兼容性:
▲ 上圖來自 https://caniuse.com/?search=Server-Sent-Events
缺點:
- 1)因為是服務器 -> 客戶端的,所以它不能處理客戶端請求流;
- 2)因為是明確指定用于傳輸UTF-8數據的,所以對于傳輸二進制流是低效率的,即使你轉為base64的話,反而增加帶寬的負載,得不償失。
7、參考資料
[1] WebSocket API文檔
[2] SSE API文檔
[3] 新手入門貼:史上最全Web端即時通訊技術原理詳解
[4] Web端即時通訊技術盤點:短輪詢、Comet、Websocket、SSE
[5] SSE技術詳解:一種全新的HTML5服務器推送事件技術
[6] Comet技術詳解:基于HTTP長連接的Web端實時通信技術
[7] 新手快速入門:WebSocket簡明教程
[8] WebSocket詳解(三):深入WebSocket通信協議細節
[9] WebSocket詳解(四):刨根問底HTTP與WebSocket的關系(上篇)
[10] WebSocket詳解(五):刨根問底HTTP與WebSocket的關系(下篇)
[11] 使用WebSocket和SSE技術實現Web端消息推送
[12] 詳解Web端通信方式的演進:從Ajax、JSONP 到 SSE、Websocket
[13] MobileIMSDK-Web的網絡層框架為何使用的是Socket.io而不是Netty?
[14] 理論聯系實際:從零理解WebSocket的通信原理、協議格式、安全性
[15] WebSocket從入門到精通,半小時就夠!
[16] WebSocket硬核入門:200行代碼,教你徒手擼一個WebSocket服務器
[17] 網頁端IM通信技術快速入門:短輪詢、長輪詢、SSE、WebSocket
本文已同步發布于“即時通訊技術圈”公眾號。
同步發布鏈接是:http://www.52im.net/thread-3695-1-1.html