使用 SSE 而不是 WebSockets 實現 HTTP/2 上的單向數據流

已發表: 2022-03-10
快速總結↬每當我們使用實時數據設計 Web 應用程序時,我們都需要考慮如何將數據從服務器傳送到客戶端。 默認答案通常是“WebSockets”。 但是有更好的方法嗎? 讓我們比較一下三種不同的方法:Long polling、WebSockets 和 Server-Sent Events; 了解它們在現實世界中的局限性。 答案可能會讓你大吃一驚。

在構建 Web 應用程序時,必須考慮他們將使用哪種交付機制。 假設我們有一個處理實時數據的跨平台應用程序; 一種股票市場應用程序,提供實時買賣股票的能力。 該應用程序由為不同用戶帶來不同價值的小部件組成。

當涉及到從服務器到客戶端的數據傳遞時,我們僅限於兩種通用方法:客戶端拉取服務器推送。 作為任何 Web 應用程序的簡單示例,客戶端是 Web 瀏覽器。 當瀏覽器中的網站向服務器請求數據時,這稱為客戶端拉取。 相反,當服務器主動向您的網站推送更新時,稱為服務器推送

如今,有幾種方法可以實現這些:

  • 長/短輪詢(客戶端拉取)
  • WebSockets(服務器推送)
  • 服務器發送事件(服務器推送)。

在我們為我們的業務案例設定要求之後,我們將深入研究這三個備選方案。

商業案例

為了能夠在不重新部署整個平台的情況下快速為我們的股票市場應用程序提供新的小部件並即插即用,我們需要這些小部件是獨立的並管理它們自己的數據 I/O。 小部件不以任何方式相互耦合。 在理想情況下,他們都將訂閱某個 API 端點並開始從中獲取數據。 除了加快新功能的上市時間之外,這種方法還使我們能夠將內容導出到第三方網站,而我們的小部件則可以自行帶來他們需要的一切。

跳躍後更多! 繼續往下看↓

這裡的主要缺陷是連接數量將隨著我們擁有的小部件數量線性增長,並且我們將達到瀏覽器對一次處理的 HTTP 請求數量的限制。

我們的小部件將要接收的數據主要由數字及其數字更新組成:初始響應包含十隻股票,其中包含一些市場價值。 這包括添加/刪除股票的更新以及當前提供的股票的市場價值的更新。 我們盡可能快地為每次更新傳輸少量 JSON 字符串。

HTTP/2 提供了來自同一域的請求的多路復用,這意味著我們只能為多個響應獲得一個連接。 這聽起來可以解決我們的問題。 我們首先探索不同的選項來獲取數據,看看我們能從中得到什麼。

  • 我們將使用 NGINX 進行負載平衡和代理,以將我們所有的端點隱藏在同一個域後面。 這將使我們能夠開箱即用地使用 HTTP/2 多路復用。
  • 我們希望有效地使用移動設備的網絡和電池。

替代方案

長輪詢

長輪詢

客戶端拉動是軟件實現,相當於坐在汽車後座上不斷問“我們到了嗎?”的煩人孩子。 簡而言之,客戶端向服務器請求數據。 服務器沒有數據並在發送響應之前等待一段時間:

  • 如果在等待期間彈出某些東西,服務器會發送它並關閉請求;
  • 如果沒有什麼要發送,並且達到了最大等待時間,則服務器發送沒有數據的響應;
  • 在這兩種情況下,客戶端都會打開下一個數據請求;
  • 起泡,沖洗,重複。

AJAX 調用在 HTTP 協議上工作,這意味著對同一域的請求默認情況下應該被多路復用。 但是,我們在嘗試使其按要求工作時遇到了多個問題。 我們在小部件方法中發現的一些缺陷:

  • 標頭開銷
    每個輪詢請求和響應都是一個完整的 HTTP 消息,並且在消息幀中包含一整套 HTTP 標頭。 在我們有少量頻繁消息的情況下,標頭實際上代表了傳輸數據的較大百分比。 實際有用的有效載荷遠小於傳輸的總字節數(例如,5 KB 數據的 15 KB 報頭)。

  • 最大延遲
    服務器響應後,不能再向客戶端發送數據,直到客戶端發出下一個請求。 雖然長輪詢的平均延遲接近一次網絡傳輸,但最大延遲超過三個網絡傳輸:響應、請求、響應。 但是,由於數據包丟失和重傳,任何 TCP/IP 協議的最大延遲將超過三個網絡傳輸(通過 HTTP 流水線可以避免)。 雖然在直接 LAN 連接中這不是一個大問題,但當一個人在移動和切換網絡單元時,它就會變成一個問題。 在某種程度上,這在 SSE 和 WebSockets 中可以觀察到,但在輪詢時效果最大。

  • 連接建立
    儘管可以通過使用可重用於許多輪詢請求的持久 HTTP 連接來避免這種情況,但是要相應地安排所有組件在短時間內輪詢以保持連接處於活動狀態是很棘手的。 最終,根據服務器的響應,您的投票將不同步。

  • 性能下降
    負載不足的長輪詢客戶端(或服務器)具有以消息延遲為代價降低性能的自然趨勢。 發生這種情況時,推送到客戶端的事件將排隊。 這真的取決於實施; 在我們的例子中,我們需要在向我們的小部件發送添加/刪除/更新事件時聚合所有數據。

  • 超時
    長輪詢請求需要保持掛起狀態,直到服務器有東西要發送給客戶端。 如果代理服務器閒置時間過長,這可能會導致連接被關閉。

  • 多路復用
    如果響應同時通過持久的 HTTP/2 連接發生,則可能會發生這種情況。 這可能很棘手,因為輪詢響應不能真正同步。

有關長輪詢可能遇到的實際問題的更多信息,請參見此處

網絡套接字

網絡套接字

作為服務器推送方法的第一個示例,我們將研究 WebSockets。

通過 MDN:

WebSockets 是一種先進的技術,可以在用戶的瀏覽器和服務器之間打開交互式通信會話。 使用此 API,您可以向服務器發送消息並接收事件驅動的響應,而無需輪詢服務器以獲取回复。

這是一種通過單個 TCP 連接提供全雙工通信通道的通信協議。

HTTP 和 WebSocket 都位於 OSI 模型的應用層,因此依賴於第 4 層的 TCP。

  1. 應用
  2. 介紹
  3. 會議
  4. 運輸
  5. 網絡
  6. 數據鏈接
  7. 身體的

RFC 6455 聲明 WebSocket“設計用於在 HTTP 端口 80 和 443 上工作,並支持 HTTP 代理和中介”,從而使其與 HTTP 協議兼容。 為了實現兼容性,WebSocket 握手使用 HTTP Upgrade 標頭從 HTTP 協議更改為 WebSocket 協議。

還有一篇非常好的文章,解釋了您需要了解的關於 Wikipedia 上的 WebSockets 的所有信息。 我鼓勵你閱讀它。

在確定套接字實際上可以為我們工作之後,我們開始在我們的業務案例中探索它們的功能,並一堵又一堵地碰壁。

  • 代理服務器:一般來說,WebSockets 和代理有幾個不同的問題:

    • 第一個與互聯網服務提供商及其處理網絡的方式有關。 半徑代理問題阻塞端口等等。
    • 第二類問題與代理配置為處理不安全的 HTTP 流量和長期連接的方式有關(使用 HTTPS 會減少影響)。
    • 第三個問題“使用 WebSockets,你被迫運行 TCP 代理而不是 HTTP 代理。 TCP 代理不能注入標頭、重寫 URL 或執行我們傳統上由 HTTP 代理處理的許多角色。”
  • 連接數:圍繞數字 6 的 HTTP 請求的著名連接限制不適用於 WebSockets。 50 個套接字 = 50 個連接。 10 個瀏覽器選項卡乘以 50 個套接字 = 500 個連接等等。 由於 WebSocket 是用於傳遞數據的不同協議,因此它不會自動通過 HTTP/2 連接進行多路復用(它根本沒有真正運行在 HTTP 之上)。 在服務器和客戶端上實現自定義多路復用太複雜,無法使套接字在指定的業務案例中有用。 此外,這將我們的小部件耦合到我們的平台,因為它們需要客戶端上的某種 API 來訂閱,沒有它我們無法分發它們。

  • 負載均衡(無多路復用) :如果每個用戶打開n個套接字,則適當的負載均衡非常複雜。 當您的服務器超載時,您需要根據軟件的實施創建新實例並終止舊實例,在“重新連接”時採取的操作可能會觸發大量刷新鍊和對數據的新請求,這將使您的系統超載. WebSockets 需要在服務器和客戶端上維護。 如果當前的服務器負載很高,則無法將套接字連接移動到另一台服務器。 它們必須關閉並重新打開。

  • DoS :這通常由前端 HTTP 代理處理,而 WebSocket 所需的 TCP 代理無法處理這些代理。 可以連接到套接字並開始用數據淹沒您的服務器。 WebSocket 讓您容易受到此類攻擊。

  • 重新發明輪子:使用 WebSockets,人們必須自己處理許多在 HTTP 中處理的問題。

可以在此處閱讀有關 WebSockets 實際問題的更多信息。

WebSockets 的一些很好的用例是聊天和多人遊戲,其中的好處超過了實現問題。 由於它們的主要好處是雙向通信,而我們並不真正需要它,我們需要繼續前進。

影響

我們在開發、測試和擴展方面增加了運營開銷; 軟件及其 IT 基礎設施:輪詢和 WebSockets。

我們在移動設備和網絡上遇到了同樣的問題。 這些設備的硬件設計通過保持天線和與蜂窩網絡的連接保持活躍來保持開放連接。 這會導致電池壽命縮短、發熱,在某些情況下還會導致額外的數據費用。

但為什麼我們仍然有移動設備的問題?

讓我們考慮一下默認移動設備是如何連接到互聯網的:

移動設備在能夠連接到 Internet 之前需要經過幾個環節。

對移動網絡如何工作的簡單解釋:通常,移動設備具有可以從小區接收數據的低功率天線。 這樣,一旦設備從來電中接收到數據,它就會啟動全雙工天線以建立呼叫。 每當想撥打電話或訪問 Internet(如果沒有 WiFi 可用)時,都會使用相同的天線。 全雙工天線需要與蜂窩網絡建立連接並進行一些身份驗證。 建立連接後,您的設備和手機之間會進行一些通信,以執行我們的網絡請求。 我們被重定向到處理 Internet 請求的移動服務提供商的內部代理。 從那時起,該過程就為人所知:它向 DNS 詢問www.domainname.ext的實際位置,接收資源的 URI,並最終重定向到它。

正如您可以想像的那樣,此過程會消耗大量電池電量。 這就是為什麼手機廠商給出的待機時間為幾天,通話時間為幾個小時的原因。

如果沒有 WiFi,WebSockets 和輪詢都需要全雙工天線幾乎持續工作。 因此,我們也面臨著數據消耗增加和功耗增加(取決於設備)的熱量。

當事情看起來很黯淡時,看起來我們將不得不重新考慮我們的應用程序的業務需求。 我們錯過了什麼嗎?

上證所

服務器發送的事件

通過 MDN:

“EventSource 接口用於接收服務器發送的事件。 它通過 HTTP 連接到服務器,並以文本/事件流格式接收事件,而無需關閉連接。”

輪詢的主要區別在於我們只獲得一個連接並保持事件流通過它。 長輪詢為每次拉取創建一個新的連接——因此我們在那裡遇到的標題開銷和其他問題。

通過 html5doctor.com:

服務器發送事件是由服務器發出並由瀏覽器接收的實時事件。 它們與 WebSocket 的相似之處在於它們是實時發生的,但它們在很大程度上是一種來自服務器的單向通信方法。

這看起來有點奇怪,但經過考慮——我們的主要數據流是從服務器到客戶端,而從客戶端到服務器的情況要少得多。

看起來我們可以將其用於交付數據的主要業務案例。 我們可以通過發送新請求來解決客戶購買問題,因為協議是單向的,客戶端無法通過它向服務器發送消息。 這最終將導致全雙工天線在移動設備上啟動的時間延遲。 然而,我們可以忍受它不時發生——畢竟這種延遲是以毫秒為單位的。

獨特的功能

  • 連接流來自服務器並且是只讀的。
  • 他們使用常規 HTTP 請求進行持久連接,而不是特殊協議。 開箱即用地通過 HTTP/2 進行多路復用。
  • 如果連接斷開,EventSource 會觸發錯誤事件並自動嘗試重新連接。 服務器還可以在客戶端嘗試重新連接之前控制超時(稍後會詳細說明)。
  • 客戶端可以發送帶有消息的唯一 ID。 當客戶端在斷開連接後嘗試重新連接時,它將發送最後一個已知 ID。 然後,服務器可以看到客戶端錯過了n條消息,並在重新連接時發送積壓的錯過消息。

示例客戶端實施

這些事件類似於瀏覽器中發生的普通 JavaScript 事件——比如點擊事件——除了我們可以控制事件的名稱和與之關聯的數據。

讓我們看看客戶端的簡單代碼預覽:

 // subscribe for messages var source = new EventSource('URL'); // handle messages source.onmessage = function(event) { // Do something with the data: event.data; };

我們從示例中看到的是客戶端相當簡單。 它連接到我們的源並等待接收消息。

為了使服務器能夠通過 HTTP 或使用專用的服務器推送協議將數據推送到網頁,規範在客戶端引入了“EventSource”接口。 使用這個 API 包括創建一個 `EventSource` 對象和註冊一個事件監聽器。

WebSockets 的客戶端實現看起來與此非常相似。 套接字的複雜性在於 IT 基礎設施和服務器實施。

事件源

每個EventSource對像都有以下成員:

  • URL:施工時設置。
  • 請求:最初為空。
  • 重新連接時間:以 ms 為單位的值(用戶代理定義的值)。
  • 最後一個事件 ID:最初是一個空字符串。
  • 就緒狀態:連接狀態。
    • 連接 (0)
    • 打開 (1)
    • 已關閉 (2)

除了 URL,所有的都被視為私有的,不能從外部訪問。

內置事件:

  1. 打開
  2. 信息
  3. 錯誤

處理連接中斷

如果連接斷開,瀏覽器會自動重新建立連接。 服務器可能會發送超時以重試或永久關閉連接。 在這種情況下,瀏覽器要么在超時後嘗試重新連接,要么在連接收到終止消息時根本不嘗試。 看起來相當簡單——事實上也是如此。

示例服務器實現

那麼如果客戶端那麼簡單,也許服務器實現很複雜?

好吧,SSE 的服務器處理程序可能如下所示:

 function handler(response) { // setup headers for the response in order to get the persistent HTTP connection response.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); // compose the message response.write('id: UniqueID\n'); response.write("data: " + data + '\n\n'); // whenever you send two new line characters the message is sent automatically }

我們定義了一個處理響應的函數:

  1. 設置標題
  2. 創建消息
  3. 發送

請注意,您看不到send()push()方法調用。 這是因為標准定義了消息將在收到兩個\n\n字符後立即發送,如示例中所示: response.write("data: " + data + '\n\n'); . 這將立即將消息推送到客戶端。 請注意, data必須是轉義字符串,並且末尾沒有換行符。

消息構造

如前所述,消息可以包含一些屬性:

  1. ID
    如果字段值不包含 U+0000 NULL,則將最後一個事件 ID 緩衝區設置為字段值。 否則,忽略該字段。
  2. 數據
    將字段值附加到數據緩衝區,然後將單個 U+000A 換行 (LF) 字符附加到數據緩衝區。
  3. 事件
    將事件類型緩衝區設置為字段值。 這會導致event.type獲取您的自定義事件名稱。
  4. 重試
    如果字段值僅包含 ASCII 數字,則將字段值解釋為以十為底的整數,並將事件流的重新連接時間設置為該整數。 否則,忽略該字段。

其他任何內容都將被忽略。 我們不能介紹我們自己的領域。

添加event的示例:

 response.write('id: UniqueID\n'); response.write('event: add\n'); response.write('retry: 10000\n'); response.write("data: " + data + '\n\n');

然後在客戶端,這是用addEventListener處理的,如下所示:

 source.addEventListener("add", function(event) { // do stuff with data event.data; });

只要您提供不同的 ID,您就可以發送多條以新行分隔的消息。

 ... id: 54 event: add data: "[{SOME JSON DATA}]" id: 55 event: remove data: JSON.stringify(some_data) id: 56 event: remove data: { data: "msg" : "JSON data"\n data: "field": "value"\n data: "field2": "value2"\n data: }\n\n ...

這極大地簡化了我們可以對數據進行的操作。

特定服務器要求

在我們的後端 POC 期間,我們發現它有一些需要解決的細節才能有效地實施 SSE。 最好的情況是您將使用基於事件循環的服務器,例如 NodeJS、Kestrel 或 Twisted。 這個想法是,使用基於線程的解決方案,每個連接都有一個線程 → 1000 個連接 = 1000 個線程。 使用事件循環解決方案,您將擁有一個用於 1000 個連接的線程。

  1. 如果 HTTP 請求表明它可以接受事件流 MIME 類型,則您只能接受 EventSource 請求;
  2. 您需要維護所有已連接用戶的列表才能發出新事件;
  3. 您應該偵聽已斷開的連接並將其從已連接用戶列表中刪除;
  4. 您應該選擇維護消息歷史記錄,以便重新連接的客戶端可以趕上錯過的消息。

它按預期工作,一開始看起來很神奇。 我們得到了我們想要的一切,讓我們的應用程序以一種有效的方式工作。 就像所有看起來好得令人難以置信的事情一樣,我們有時會面臨一些需要解決的問題。 但是,它們的實現或解決並不復雜:

  • 眾所周知,舊代理服務器在某些情況下會在短暫超時後斷開 HTTP 連接。 為了防止此類代理服務器,作者可以每 15 秒左右添加一個註釋行(以 ':' 字符開頭)。

  • 希望將事件源連接相互關聯或與先前提供的特定文檔相關聯的作者可能會發現依賴 IP 地址不起作用,因為單個客戶端可以有多個 IP 地址(由於有多個代理服務器),而單個 IP 地址可以有多個客戶端(由於共享代理服務器)。 最好在提供文檔時在文檔中包含一個唯一標識符,然後在建立連接時將該標識符作為 URL 的一部分傳遞。

  • 作者還被警告說,HTTP 分塊可能對該協議的可靠性產生意想不到的負面影響,特別是如果分塊是由不知道時序要求的不同層完成的。 如果這是一個問題,可以禁用分塊以提供事件流。

  • 如果每個頁面都有指向同一域的 EventSource,則支持 HTTP 的每服務器連接限制的客戶端在從站點打開多個頁面時可能會遇到問題。 作者可以通過使用每個連接使用唯一域名的相對複雜的機制來避免這種情況,或者允許用戶在每個頁面的基礎上啟用或禁用 EventSource 功能,或者通過使用共享工作程序共享單個 EventSource 對象。

  • 瀏覽器支持和 Polyfills:Edge 落後於這個實現,但是有一個 polyfill 可以幫助你。 然而,SSE 最重要的案例是針對 IE/Edge 沒有可行市場份額的移動設備。

一些可用的 polyfills:

  • 耶夫餅
  • amvtek
  • 雷米

無連接推送等功能

在受控環境中運行的用戶代理,例如綁定到特定運營商的移動手持設備上的瀏覽器,可以將連接管理卸載到網絡上的代理。 在這種情況下,出於一致性目的的用戶代理被認為包括手機軟件和網絡代理。

例如,移動設備上的瀏覽器在建立連接後,可能會檢測到它位於支持網絡上,並請求網絡上的代理服務器接管連接的管理。 這種情況的時間表可能如下:

  1. 瀏覽器連接到遠程 HTTP 服務器並請求作者在 EventSource 構造函數中指定的資源。
  2. 服務器偶爾發送消息。
  3. 在兩條消息之間,瀏覽器檢測到除了保持 TCP 連接處於活動狀態的網絡活動之外它處於空閒狀態,並決定切換到睡眠模式以節省電量。
  4. 瀏覽器與服務器斷開連接。
  5. 瀏覽器聯繫網絡上的服務並請求該服務(“推送代理”)來維持連接。
  6. “推送代理”服務聯繫遠程 HTTP 服務器,並請求作者在 EventSource 構造函數中指定的資源(可能包括Last-Event-ID HTTP 標頭等)。
  7. 瀏覽器允許移動設備進入睡眠狀態。
  8. 服務器發送另一條消息。
  9. “推送代理”服務使用諸如 OMA 推送之類的技術將事件傳送到移動設備,移動設備只喚醒足以處理事件,然後返回睡眠狀態。

這可以減少總數據使用量,因此可以節省大量電力。

除了實現規範定義的現有 API 和文本/事件流連線格式以及以更分佈式的方式(如上所述)之外,還可以支持其他適用規範定義的事件框架格式。

概括

經過漫長而詳盡的 POC,包括服務器和客戶端實現,看起來 SSE 是我們數據交付問題的答案。 它也有一些陷阱,但事實證明它們是微不足道的。

這就是我們的生產設置最終的樣子:

架構概述
最終架構概述。 所有 API 端點都在 nginx 後面,因此客戶端可以得到多路響應。

我們從 NGINX 得到以下信息:

  • 代理到不同地方的 API 端點;
  • HTTP/2 及其所有優點,例如連接多路復用;
  • 負載均衡;
  • SSL。

通過這種方式,我們在一個地方管理我們的數據交付和證書,而不是在每個端點上單獨進行。

我們從這種方法中獲得的主要好處是:

  • 數據高效;
  • 更簡單的實現;
  • 它通過 HTTP/2 自動多路復用;
  • 將客戶端上的數據連接數限制為一個;
  • 提供一種機制來通過將連接卸載到代理來節省電池。

SSE 不僅是提供快速更新的其他方法的可行替代方案; 在移動設備的優化方面,它看起來像是在自己的聯盟中。 與替代方案相比,它的實現沒有開銷。 在服務器端實現方面,它與輪詢沒有太大區別。 在客戶端,它比輪詢簡單得多,因為它需要初始訂閱和分配事件處理程序——與管理 WebSocket 的方式非常相似。

如果您想獲得一個簡單的客戶端-服務器實現,請查看代碼演示。

資源

  • “在雙向 HTTP 中使用長輪詢和流式傳輸的已知問題和最佳實踐”,IETF (PDF)
  • W3C 推薦,W3C
  • “WebSocket 會在 HTTP/2 中存活嗎?”Allan Denis,InfoQ
  • “使用服務器發送的事件進行流式更新”,Eric Bidelman,HTML5 Rocks
  • “使用 HTML5 SSE 的數據推送應用程序”,O'Reilly Media 的 Darren Cook