簡介
Server-sent events(SSE),也即服務器推送事件,是一種基于 HTTP 協議實現的單向實時數據推送技術。它允許服務器在不需要客戶端輪詢的情況下,主動向客戶端發送新的數據。
特性
SSE 主要有以下幾點特性:
- 文本格式的數據傳輸:只能發送通過 utf-8 編碼的數據
- 自動重連:如果連接斷開,瀏覽器會自動發起重連(默認延遲 3 秒)
- 事件 ID 支持:可用于瀏覽器斷線重連后補發數據
- 輕量級:相比 WebSocket,SSE 基于 HTTP 協議,且瀏覽器原生支持斷線重連,相對更加簡單易用
到這里,我們很容易就會將 SSE 和 WebSocket 聯系起來,接下來我們就來看看這兩者之間的主要對比。
對比 WebSocket
特性 | SSE | WebSocket |
---|---|---|
通信模式 | 單向(服務端到客戶端) | 雙向(服務端和客戶端) |
協議 | HTTP 協議 | WebSocket 協議 |
數據類型 | 純文本 | 文本或二進制數據 |
連接保持 | 保持 HTTP 長連接,斷線后自動重連 | 需要應用層處理斷線重連邏輯 |
復雜度 | 簡單易用(類似 HTTP 請求) | 較為復雜,需要更多的管理和維護 |
安全性 | 支持 HTTPS | 支持 WSS |
服務器支持 | 大多數 HTTP 服務器無需配置即可支持 | 需要服務器支持 WebSocket 協議 |
瀏覽器支持 | 除 IE 外,其它瀏覽器普遍支持 | 現代瀏覽器普遍支持 |
從以上主要對比來看,在服務端到客戶端單向通信的場景下,SSE 看起來的確更加的簡單易用點。但只要 SSE 能實現的場景,WebSocket 也都能實現。結合這些,我們再來看看 SSE 的一些常見使用場景。
常見使用場景
- 服務器日志推送
- 服務器狀態監控
- 天氣、股票行情等實時更新
- 新聞推送或社交媒體動態更新
- 大語言模型流式輸出響應內容
當然 SSE 的使用場景絕不僅僅局限于以上幾種,你可以根據業務場景靈活使用。了解完使用場景,下面我們就來看看如何在業務中使用它!
規范及應用
這一節我們將分別從服務端和客戶端來了解 SSE 的一些基本規范及應用。
服務端應用
定義響應頭
要使用 SSE,服務端首先需要將相應接口的響應內容類型設置為 "text/event-stream"
。類似這樣:
// 響應內容類型必須是 text/event-stream
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Connection', 'keep-alive')
res.setHeader('Cache-Control', 'no-store')
為方便說明,本節所有服務端代碼都將使用 node.js 來示例,其它語言自行調整即可。
定義響應內容
每一次連接的響應內容都由若干條消息組成,消息之間以一對換行符分隔。每條消息又由若干行組成,每一行都是這種格式:[field]: value\n
。其中 field
字段的取值只能是 data
、event
、id
和 retry
,其它任何字段都將被客戶端視為無效字段而忽略,以下是具體說明:
data
用于定義每條響應消息的具體內容。內容格式只能是文本格式,類似這樣:
res.write('data: first message.\n\n')
res.write('data: second message.\n\n')
這樣客戶端就會連續收到兩條消息,分別是 first message.
和 second message.
。如圖示:
而如果你這樣定義:
res.write('data: first message.\n')
res.write('data: second message.\n\n')
客戶端收到的將是一條消息,如圖示:
可以看到,客戶端會將一對換行符之前的多個 data
字段定義的消息內容合并起來作為一條消息的完整內容,直到遇見上一對換行符。每條消息都必須通過一個或多個 data
字段定義具體的消息內容,否則將被客戶端視為無效消息而忽略。
event
用于定義每條消息的事件類型。默認是 message
,如上圖示。你可以根據業務需要指定任何其它類型,類似這樣:
res.write('data: running\n')
res.write('event: serverStatus\n\n')
這樣客戶端將會收到一條類型是 serverStatus
,內容是 running
的消息,如圖示:
id
用于定義每條消息的事件 id。如下示例,當我們這樣發送消息時:
let number = 0;
const intervalId = setInterval(() => {
number++;
res.write(`id: ${number}\n`);
res.write(`data: ${number}\n\n`);
}, 1000);
客戶端收到的消息將會是這樣的:
可以看到每條消息都有一個自己的 id 值。指定事件 id 后,當客戶端斷線重連時,http 請求頭中的 Last-Event-Id
的值便是上次連接發送的最后一條消息的事件 id,服務端可以此來補發數據等:
retry
定義 SSE 連接斷開后的重連時間。當 SSE 連接斷開后,客戶端默認 3 秒后重新連接。服務端可結合業務邏輯使用 retry
字段自定義重連時間,它的值必須是一個以毫秒為單位的整數值。
res.write('data: the server is still restarting.\n')
res.write('retry: 60000\n\n')
res.end()
一般來講,當服務端明確知道客戶端請求的數據還未準備好,且需等待一定時間時,便可定義好重連時間,并發消息告訴客戶端,之后關閉連接即可??蛻舳嗽谶B接斷掉后,等待時間達到重連時間時,便會再次發起連接。
以上便是如何定義響應內容的主要說明了。當然除此之外,服務端還可以定時發送以冒號開頭的注釋消息,以保持連接不斷:
res.write(': this is a test stream \n\n')
客戶端在收到這種非規范格式的消息后,會選擇忽略它。
到這里 SSE 在服務端的規范及應用基本就介紹完了。需要額外再啰嗦一下的是,以上代碼示例使用的都是 node.js 原生的寫法,實際業務中用起來肯定是沒有像 nestjs 已經集成好的 sse 裝飾器或者是借助于諸如 better-sse 等封裝好的 npm 包那樣簡潔方便。其它語言肯定也都有相應的框架或者工具庫封裝了 SSE 的常用能力,它們會讓你的實現看起來更加優雅!
okkk.. 下一節我們就來看看服務端定義的 SSE 接口如何在客戶端使用。
客戶端應用
實例化 EventSource
在客戶端使用 SSE 的話你需要先實例化 EventSource,實例化時 EventSource 會根據你傳入的 url 發起一個到服務端的持久化連接:
const sse = new EventSource(url);
當這里傳入的 url 是跨域 url 時,你可以通過傳入第二個參數來決定是否帶上當前域的 cookie 來發起請求。默認不帶,需要帶上的話可以這樣傳參:
const sse = new EventSource(url, { withCredentials: true });
默認事件監聽
實例化 EventSource 后,便可以通過 EventSource 實例的 onmessage
方法來監聽默認事件(message
)的消息,如下示例:
const sse = new EventSource("/api/v1/sse");
sse.onmessage = (e) => {
// 來自服務端的消息
console.log(e.data)
}
除了可以監聽 onmessage
外,你還可以通過 onopen
和 onerror
這兩個方法來分別監聽連接的建立和異常:
sse.onopen = () => {
// sse 連接已建立
}
sse.onerror = (error) => {
// sse 連接異常
console.error(error)
}
自定義事件監聽
除了默認的 message
事件,我們的業務中很可能還會用到其它的自定義事件,比如上面在介紹 event
字段時提到的 serverStatus
事件。通過 EventSource 實例的 addEventListener
方法即可監聽這些自定義事件:
function onServerStatusChange(e) {
const serverStatus = e.data;
}
sse.addEventListener("serverStatus", onServerStatusChange);
通過 addEventListener
添加的事件監聽當然就可以通過 removeEventListener
移除了:
sse.removeEventListener("serverStatus", onServerStatusChange);
如果你愿意,你當然也可以通過這種方式來監聽前面提到的 message
、open
及 error
事件了:
// 同 sse.onmessage = onMessage
sse.addEventListener("message", onMessage);
sse.addEventListener("open", onOpen);
sse.addEventListener("error", onError);
查看連接狀態
你可以方便的通過 EventSource 實例的 readyState
屬性來獲取當前連接的狀態。連接的三種狀態的值也可以通過 EventSource 實例的 CONNECTING
、OPEN
和 CLOSED
這三個屬性分別獲取。如下示例:
const sse = new EventSource("/api/v1/sse")
// 連接中
sse.readyState === sse.CONNECTING
// 連接開啟
sse.readyState === sse.OPEN
// 連接關閉
sse.readyState === sse.CLOSED
中斷連接
實例化后的 EventSource 實例,在 SSE 連接建立成功后,將一直保持連接開啟。無論中途是因為網絡原因還是服務端主動關閉而導致連接臨時中斷,之后 EventSource 實例都會主動發起重連。如果要永久中斷連接,就需要調用 EventSource 實例的 close
方法來關閉連接。
const sse = new EventSource("/api/v1/sse")
// 中斷連接
sse.close()
以上基本就是客戶端建立 SSE 連接并接收服務端推送消息的所有內容了。相比 WebSocket 的話,少了心跳檢測及斷線重連相關的邏輯,使用起來確實簡易一些。了解完基本規范及使用方法,下面我們再來看下使用 SSE 時需要注意的一些點。
注意事項
瀏覽器對于同源請求的并發數限制
如果你們的 Web 服務器配置使用的 http 協議仍是 http/1.1,由于瀏覽器對于同源請求的并發數有限制,大都為 6,而 SSE 的 http 連接又都是長連接,這就導致一旦一個瀏覽器下已經存在了 6 個同源的 SSE 連接,其它的同源請求將不能被正常受理。這是一個非常嚴重的問題,這個問題不解決,我覺得就不要使用 SSE,否則得不償失!常見的解決方案如下:
- 將 http 協議從 http/1.1 切換到 http/2。有了多路復用的特性支持,http/2 的同源最大并發請求數將達到 100(默認值)。你可以根據業務需要繼續調整這個值,但基本是不會再有上面的同源請求瓶頸限制了。
- 部署一個或多個非同源的 SSE 服務,客戶端直接跨域請求相應服務。
- 當頁面不可見時,及時中斷該頁面下的所有 SSE 連接。
- 使用其它方案比如 WebSocket 代替。
針對解決方案1,雖然現在主流瀏覽器都已支持 http/2,但仍有一些老版本瀏覽器并不完全支持。你如果要兼容這些老版本瀏覽器,在做好協議回退的同時,還得結合其它解決方案一起使用。
EventSource 僅支持 get 請求
原生的 EventSource 僅支持 get 請求,而實際業務場景中,我們很可能需要發起 post 請求。此時你可以使用已經封裝好的類似 @microsoft/fetch-event-source 這樣的 npm 包,或者你也可以參考這篇文章來原生實現。
Nginx 配置
使用 SSE 時,你可能需要根據情況做以下 nginx 配置:
- 禁用代理緩存:
proxy_cache off
。否則你可能會在后續的連接中拿到之前緩存的數據。 - 禁用代理緩沖:
proxy_buffering off
。這樣 nginx 將不再對 SSE 響應進行緩沖,直接透傳給客戶端。(這里需注意,如果你的請求會經過多個 nginx 網關,那么每一個 nginx 最好都加上這個配置。因為即使服務端可能在接口的響應 header 中設置了X-Accel-Buffering: no
,但響應數據經過的第一個 nginx 便會消耗掉這個 header,后面如果還會經過其它 nginx,那它們還是會默認緩沖數據,此時你同樣需要為這些 nginx 配置proxy_buffering off
或者add_header X-Accel-Buffering no
) - 調整連接超時:
proxy_read_timeout 120s
。這一步通常也不是必須的,nginx 默認會在兩次讀取超過 60s 時關閉連接,但 SSE 客戶端往往也會在連接斷開后 3s 發起重連, 所以影響倒也不大。何況服務端還可以通過定時發送注釋消息以保持連接不斷,所以這個配置完全可以視業務場景決定是否使用。
總結
拋開使用 http/1.1 時的同源請求并發數限制,SSE 還是挺適合這種從服務端到客戶端的單向文本消息推送場景。尤其像新聞、社交媒體動態的實時更新以及監控系統的狀態更新等。
在這些場景中,SSE 能通過簡單的單向通信和自動重連機制提供穩定可靠的服務!
參考資料
How to Use Server-sent Events in Node.js
SSE (Server-Sent Events) Using A POST Request Without EventSource