20 行代碼寫一個數據推送服務

由於 HTTP/1.1 本身不支持伺服器主動向客戶端推送消息,在例如即時通訊、消息提醒等應用場景中就會很不方便。解決的方法有很多,WebSocket 就很不錯,但是如果想要快速實現的話就不推薦使用,本文要介紹的是一種輕量的解決方案:SSE。

SSE 是基於 HTTP 協議來完成伺服器推送的。不要誤會,這裡並不是說 HTTP/2,而是一種取巧的方式:當伺服器向客戶端聲明接下來要發送流信息時,客戶端就會保持連接打開,SSE 使用的就是這種原理。

SSE 能做什麼

理論上,SSE 和 WebSocket 做的是同一件事情。當你需要用新數據局部更新網路應用時,SSE 可以做到不需要用戶執行任何操作,便可以完成。

舉例我們要做一個統計系統的管理後台,我們想知道統計數據的實時情況。類似這種更新頻繁、低延遲的場景,SSE 可以完全滿足。

其他一些應用場景:例如郵箱服務的新郵件提醒,微博的新消息推送、管理後台的一些操作實時同步等,SSE 都是不錯的選擇。

SSE vs. WebSocket

SSE 是單向通道,只能伺服器向客戶端發送消息,如果客戶端需要向伺服器發送消息,則需要一個新的 HTTP 請求。這對比 WebSocket 的雙工通道來說,會有更大的開銷。這麼一來的話就會存在一個「什麼時候才需要關心這個差異?」的問題,如果平均每秒會向伺服器發送一次消息的話,那應該選擇 WebSocket。如果一分鐘僅 5 - 6 次的話,其實這個差異並不大。

在瀏覽器兼容方面,兩者差不多。在較早之前,每當需要建立雙向 Socket 時就會使用 Flash,在移動瀏覽器不支持 Flash 的情況下,WebSocket 的兼容是比較難做的。

SSE 我認為最大的優勢是便利:

  • 實現一個完整的服務僅需要少量的代碼;
  • 可以在現有的服務中使用,不需要啟動一個新的服務;
  • 可以用任何一種服務端語言中使用;
  • 基於 HTTP/HTTPS 協議,可以直接運行於現有的代理伺服器和認證技術。

有了這些優勢,在選擇使用SSE時就已經為自己的項目節約了不少成本。

簡單示例

下面是一個簡單的示例,實現一個 SSE 服務。

伺服器

use strict;const http = require(http);http.createServer((req, res) => { // 伺服器聲明接下來發送的是事件流 res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, Access-Control-Allow-Origin: *, }); // 發送消息 setInterval(() => { res.write(event: slide
); // 事件類型 res.write(`id: ${+new Date()}
`); // 消息ID res.write(data: 7
); // 消息數據 res.write(retry: 10000
); // 重連時間 res.write(

); // 消息結束 }, 3000); // 發送注釋保持長連接 setInterval(() => { res.write(:

); }, 12000);}).listen(2000);

伺服器首先向客戶端聲明接下來發送的是事件流(text/event-stream)類型的數據,然後就可以向客戶端多次發送消息。

事件流是一個簡單的文本流,僅支持 UTF-8 格式的編碼。每條消息以一個空行作為分隔符。

在規範中為消息定義了 4 個欄位:

event 消息的事件類型。客戶端收到消息時,會在當前的 EventSource 對象上觸發一個事件,這個事件的名稱就是這個欄位的值,如果消息沒有這個欄位,客戶端的 EventSource 對象就會觸發默認的 message 事件。

id 這條消息的 ID。客戶端接收到消息後,會把這個 ID 作為內部屬性 Last-Event-ID,在斷開重連成功後,會把 Last-Event-ID 發送給伺服器。

data 消息的數據欄位。客戶端會把這個欄位解析為字元串,如果一條消息有多個 data 欄位,客戶端會自動用換行符連接成一個字元串。

retry 指定客戶端重連的時間。只接受整數,單位是毫秒。如果這個值不是整數則會被自動忽略。

一個很有意思的地方是,規範中規定以冒號開頭的消息都會被當作注釋,一條普通的注釋(:

)對於伺服器來說只佔 5 個字元,但是發送到客戶端上的時候不會觸發任何事件,這對客戶端來說是非常友好的。所以注釋一般被用於維持伺服器和客戶端的長連接。

客戶端

我們創建了一個 EventSource 對象,傳入參數:url。並且根據伺服器的狀態和發送的信息作出響應。

use strict;if (window.EventSource) { // 創建 EventSource 對象連接伺服器 const source = new EventSource(http://localhost:2000); // 連接成功後會觸發open事件 source.addEventListener(open, () => { console.log(Connected); }, false); // 伺服器發送信息到客戶端時,如果沒有event欄位,默認會觸發message事件 source.addEventListener(message, e => { console.log(`data: ${e.data}`); }, false); // 自定義EventHandler,在收到event欄位為slide的消息時觸發 source.addEventListener(slide, e => { console.log(`data: ${e.data}`); // => data: 7 }, false); // 連接異常時會觸發error事件並自動重連 source.addEventListener(error, e => { if (e.target.readyState === EventSource.CLOSED) { console.log(Disconnected); } else if (e.target.readyState === EventSource.CONNECTING) { console.log(Connecting...); } }, false);} else { console.error(Your browser doesn support SSE);}

SSE 如何保證數據完整性

客戶端在每次接收到消息時,會把消息的 id 欄位作為內部屬性 Last-Event-ID 儲存起來。

SSE 默認支持斷線重連機制,在連接斷開時會觸發 EventSource的error 事件,同時自動重連。再次連接成功時 EventSource 會把 Last-Event-ID 屬性作為請求頭髮送給伺服器,這樣伺服器就可以根據這個 Last-Event-ID 作出相應的處理。

這裡需要注意的是,id 欄位不是必須的,伺服器有可能不會在消息中帶上 id 欄位,這樣子客戶端就不會存在 Last-Event-ID 這個屬性。所以為了保證數據可靠,我們需要在每條消息上帶上 id 欄位。

減少開銷

在SSE的草案中提到,"text/event-stream" 的 MIME 類型傳輸應當在靜置 15 秒後自動斷開。在實際的項目中也會有這個機制,但是斷開的時間沒有被列入標準中。

為了減少伺服器的開銷,我們也可以有目的的斷開和重連。

簡單的辦法是伺服器發送一個關閉消息並指定一個重連的時間戳,客戶端在觸發關閉事件時關閉當前連接並創建一個計時器,在重連時把計時器銷毀。

use strict;function connectSSE() { if (window.EventSource) { const source = new EventSource(http://localhost:2000); let reconnectTimeout; source.addEventListener(open, () => { console.log(Connected); clearTimeout(reconnectTimeout); }, false); source.addEventListener(pause, e => { source.close(); const reconnectTime = +e.data; const currentTime = +new Date(); reconnectTimeout = setTimeout(() => { connectSSE(); }, reconnectTime - currentTime); }, false); } else { console.error(Your browser doesn support SSE); }}connectSSE();

瀏覽器兼容

Broswer support of EventSource from Can I Use...

向下兼容

早些時候,為了實現數據實時更新最常見的方法就是輪詢。

輪詢是以一個固定頻率向伺服器發送請求,伺服器在有數據更新時返回新的數據,以此來管理數據的更新。這種輪詢的方式不但開銷大,而且更新的效率和頻率有關,也不能達到及時更新的目的。

接著便出現了長輪詢的方式:客戶端向伺服器發送請求之後,伺服器會暫時把請求掛起,等到有數據更新時再返回最新的數據給客戶端,客戶端在接收到新的消息後再向伺服器發送請求。與常規輪詢的不同之處是:數據可以做到實時更新,可以減少不必要的開銷。

這裡有一個「選擇長輪詢還是常規輪詢?」的命題,長輪詢是不是總比常規輪詢佔有優勢?我們可以從帶寬佔用的角度分析,如果一個程序數據更新太過頻繁,假設每秒 2 次更新,如果使用長輪詢的話每分鐘要發送 120 次 HTTP 請求。如果使用常規輪詢,每 5 秒發送一次請求的話,一分鐘才 20 次,從這裡看,常規輪詢更佔有優勢。

長輪詢和SSE最關鍵的區別在於,每一次數據更新都需要一次 HTTP 請求。和 WebSocket 還有 SSE 一樣,長輪詢也會佔用一個 socket。在數據更新效率上和 SSE 差不多,一有數據更新就能檢測到。加上所有瀏覽器都支持,是一個不錯的 SSE 替代方案。

結尾

文章介紹了 SSE 的用法及使用過程中的一些技巧。對比 WebSocket,SSE 在開發時間和成本上佔有較大的優勢。做數據推送服務,除了 WebSocket,SSE 也是一個不錯的選擇,希望對大家有所幫助。

參考

Server-Sent Events

EventSource - Web APIs | MDN

Using server-sent events - Web APIs | MDN

版權聲明:自由轉載-非商用-非衍生-保持署名(創意共享3.0許可證) 轉載請註明出處


推薦閱讀:

tornado如何實現非同步websocket推送?
web AR系統-3 效率評估-websocket
對於 Socket 粘包的困惑?
一步一步教您用websocket+nodeJS搭建簡易聊天室(4)

TAG:WebSocket | 信息推送 | HTTP |