深入淺出 Server-sent events 技術

qinghuanI發表於2023-02-07

前言

實時獲取服務端的資料,大家第一時間想到的是輪詢和 WebSocket 兩種方案,其實還有一種新方案 Server-sent events下文簡稱(SSE)。SSE 中的資料只能由服務端推向客戶端

SSE 是基於 http 協議的伺服器推送技術,資料只能從服務端到客戶端。服務端把序列化後的資料傳送給客戶端,整個過程持續不斷直至連線關閉

WebSocket vs 輪詢 vs SSE

下面是 WebSocket、輪詢和 SSE 的功能對比

  • SSE 和輪詢使用 HTTP 協議,現有的伺服器軟體都支援。WebSocket 是一個獨立協議
  • SSE 屬於輕量級的 WebSocket,使用簡單;WebSocket 使用相對複雜,輪詢使用簡單
  • SSE 預設支援斷線重連,WebSocket 需要自己實現斷線重連
  • SSE 一般只用來傳送文字,二進位制資料需要編碼後傳送,WebSocket 預設支援傳送二進位制資料
  • SSE 支援自定義傳送的訊息型別
  • WebSocket 支援雙向推送訊息,SSE 是單向的
  • 輪詢效能開銷大、輪詢時間久導致客戶端及時更新資料

使用場景

基於服務端單向的向客戶端推送資訊的特性,SSE 使用場景主要有

  • Sass 平臺的訊息通知
  • 資訊流網站實時更新資料

使用方式

下面講解如何在客戶端使用 SSE

  1. 建立一個 EventSource 例項,向伺服器發起連線
const evtSource = new EventSource();
  1. 自定義事件

對於自定義事件,服務端和客戶端一定要保持事件名一致。服務端透過自定義事件傳送資料,
就會觸發自定義事件。SSE 預設支援 message 事件,下面以 message 事件為例

evtSource.addEventListener("message", (event) => {
  let payload;

  try {
    payload = JSON.parse(event.data); // <--- event.data 需要反序列化
    console.log("receiving data...", payload);
  } catch (error) {
    console.error("failed to parse payload from server", error);
  }
});

自定義事件的回撥函式接收 event 物件,event.data 存著服務端發給客戶端的資料但是需要反序列化

可以透過 Chrome Devtool 工具檢視 eventsource 通訊情況,如圖所示

EventSource

  • 1 - 自定義事件名,服務端和客戶端需要保持一致
  • 2 - EventStream Tab,資料都在這裡
  • 3 - 服務端推送給客戶端的資料
  1. 錯誤處理

如果連線發生錯誤,就會觸發 error 事件

evtSource.addEventListener("error", (err) => {
  console.error("EventSource failed:", err);
});
  1. 關閉連線

SSE 提供 close 方法,用來關閉 SSE 連線

evtSource.close();

瀏覽器相容性

透過 caniuse 檢視 SSE 瀏覽器相容性,如圖所示

Server-sent events

除了 IE 瀏覽器不支援,其它現代瀏覽器都支援,所以放心大膽在專案中使用 SSE

簡單封裝

在平常的工作中,每次寫 SSE 的事件監聽和錯誤處理會很麻煩。多個業務場景需要使用 SSE 時,就需要對 SSE 進行封裝。接下來我們嘗試封裝一個簡單的 SSE SDK,方便在專案中使用

當我們決定寫 SSE 的 SDK 時,首先想到使用物件導向(OOP)進行封裝。根據 SSE 的特性,那麼庫需要實現 subscribeunsubscribe兩個方法。透過確定 SSE 庫使用方式,根據使用方式確定 SDK 的實現。我們可以在程式碼中這樣使用,如下所示

// SSESdk 例項化
const SSE = new SSESdk(url, options);

// 訂閱來自服務端的訊息
SSE.subscribe("message", (data) => {
  console.log("receive message from server", data);
});

// 取消訂閱
SSE.unsuscribe();

我們要封裝的庫對外僅僅提供 subscribeunsubscribe 兩個 Api,非常方便開發人員使用。subscribe 用來訂閱來自服務端的訊息, unsubscribe 用來取消訂閱,關閉 SSE 連線,透過使用形式可以看出,使用 ES6 中的類語法。接下來我們先確定 SSE SDK 的大體結構

class SSEClient {
  constructor() {}

  subscribe(type, handler) {}

  unsunscribe() {}
}

SSEClient 類中有三個方法需要實現,透過 constructor 接受可配置的引數,比如 SSE 建立連線失敗後的重試次數和重試時間。subscribe 接收一個與後端保持一致的事件名和一個回撥函式。unsunscribe 不需要傳遞任何引數,呼叫 unsunscribe 方法關閉SSE 連線

// SSE-client.js

class SSEClient {
  constructor(url) {
    this.url = url;
    this.es = null;
  }

  subscribe(type, handler) {
    this.es = new EventSource(url);

    this.es.addEventListener("open", () => {
      console.log("server sent event connect created");
    });

    this.es.addEventListener(type, (event) => {
      let payload;

      try {
        payload = JSON.parse(event.data);
        console.log("receiving data...", payload);
      } catch (error) {
        console.error("failed to parse payload from server", error);
      }

      if (typeof handler === "function") {
        handler(payload);
      }
    });

    this.es.addEventListener("error", () => {
      console.error("EventSource connection failed for subscribe.Retry");
    });
  }

  unsunscribe() {
    if (this.es) {
      this.es.close();
    }
  }
}

就這樣實現了一個簡單的 SSE SDK。首先根據 url 引數建立一個 SSEClient 例項,當呼叫 subscribe 方法時,才會根據傳入的 url 建立 SSE 連線,然後監聽對應的事件,一旦連線建立成功,後端向客戶端傳送資料,就可以從 handler 方法中拿到資料

這個庫僅僅實現了非常基本的功能,程式碼封裝上存在很多問題。比如 es 的事件全部雜糅在 subscribe 方法中、缺少 SSE 連線建立失敗的重試等等功能。接下來我們對剛剛實現的 SSEClient SDK 進行最佳化

const defaultOptions = {
  retry: 5,
  interval: 3 * 1000,
};

class SSEClient {
  constructor(url, options = defaultOptions) {
    this.url = url;
    this.es = null;
    this.options = options;
    this.retry = options.retry;
    this.timer = null;
  }

  _onOpen() {
    console.log("server sent event connect created");
  }

  _onMessage(handler) {
    return (event) => {
      this.retry = options.retry;
      let payload;

      try {
        payload = JSON.parse(event.data);
        console.log("receiving data...", payload);
      } catch (error) {
        console.error("failed to parse payload from server", error);
      }

      if (typeof handler === "function") {
        handler(payload);
      }
    };
  }

  _onError(type, handler) {
    return () => {
      console.error("EventSource connection failed for subscribe.Retry");
      if (this.es) {
        this._removeAllEvent(type, handler);
        this.unsunscribe();
      }

      if (this.retry > 0) {
        this.timer = setTimeout(() => {
          this.subscribe(type, handler);
        }, this.options.interval);
      } else {
        this.retry--;
      }
    };
  }

  _removeAllEvent(type, handler) {
    this.es.removeEventListener("open", this._onOpen);
    this.es.removeEventListener(type, this._onMessage(handler));
    this.es.removeEventListener("error", this._onError(type, handler));
  }

  subscribe(type, handler) {
    this.es = new EventSource(url);

    this.es.addEventListener("open", this._onOpen);
    this.es.addEventListener(type, this._onMessage(handler));
    this.es.addEventListener("error", this._onError(type, handler));
  }

  unsunscribe() {
    if (this.es) {
      this.es.close();
      this.es = null;
    }
    if (this.timer) {
      clearTimeout(this.timer);
    }
  }
}

我們將 SSEClient 中的三個事件方法分別提取為三個私有方法,_onOpen 方法在 event 觸發 open 時呼叫,向控制檯輸出連結已經建立。
_onMessage 方法在後端向前端傳送資料時觸發,負責解析資料,並呼叫 handler 方法。_onError 方法在 SSE 發生錯誤時觸發,
會在控制檯輸出錯誤的提示,根據開發者傳入的重試次數,先關閉上一次的 SSE 連結,取消所有的事件監聽,關閉定時器,
再開啟遞迴呼叫 subscribe 方法進行重連, 一旦重連成功,重試次數恢復為設定的重試次數,如果超過重試次數依舊沒有連線成功,那麼 SSE 會徹底終止。需要開發人員排查具體原因

一個可以用在專案上的簡單 SSE SDK 封裝完

第三方庫

SSE 雖然很好,但是也有它先天不足,主要問題是不能透過 headers 傳遞 Authorization token。雖然可以把 token 放在 url 上解決不能傳 token 的問題,但是又會引發 token 安全隱患。所以社群裡有使用 xhrfetch 模擬原生Server-sent events 的功能,解決不能透過 headers 傳遞 Authorization token 的問題。主要有兩個第三方庫,分別是 eventsourceevent-source-polyfill,下面筆者詳細講述這兩個庫的使用

eventsource

此庫是 EventSource 客戶端的純 JavaScript 實現。使用方式很簡單。在專案中安裝依賴

yarn add eventsource
# Or npm install eventsource

然後從 eventsource 中匯出 EventSource 類,然後例項化得到 es 例項

import EventSource from "eventsource";

const eventSourceInitDict = { headers: { authorization: "Bearer token" } };
const es = new EventSource(url, eventSourceInitDict);

es.addEventListener("message", (event) => {
  console.log("receiving data from server:", JSON.parse(event.data));
});

eventsource 的實現用到了一些 node 標準庫。分別是 httpshttp
筆者將 eventsource 的部分原始碼列在下面。

// eventsource.js 原始碼如下

const https = require("https");
const http = require("http");

然而,瀏覽器環境並不支援 httpshttp 標準庫。所以當我們在瀏覽器環境中使用 eventsource 時,需要做一些額外的工作。下面以 webpack5 為例子講解解決辦法

  • 需要在 webpack 配置檔案中新增 node-polyfill-webpack-plugin 外掛
yarn add node-polyfill-webpack-plugin -D

然後在 webpack 配置檔案使用該外掛

// 專案中的 webpack 配置檔案,比如 webpack.config.js

const NodePolyfillPlugin = require("node-polyfill-webpack-plugin");

module.exports = {
  // Other rules...
  plugins: [new NodePolyfillPlugin()],
};
  • 或者在 webpackcallback 中對使用的庫進行單獨的配置
module.exports = {
  // other configuration ...
  resolve: {
    fallback: {
      https: false,
      http: false,
    },
  },
};

做完上面的步驟後,eventsource 可以在瀏覽器中正常執行

如果不想改動 webpack 的配置,那麼可以試試 event-source-polyfill 這個庫

event-source-polyfill

event-source-polyfill 的使用非常簡單,使用 EventSourcePolyfill 替換原生的 EventSource

import { EventSourcePolyfill } from "event-source-polyfill";

var es = new EventSourcePolyfill(url, {
  headers: {
    authorization: "Bearer token",
  },
});

es.addEventListener("message", (event) => {
  console.log("receiving data from server:", JSON.parse(event.data));
});

不足之處

eventsourceevent-source-polyfill 只是在一定的程度上解決了 Authorization token 的問題,但它們也存在問題。這兩個庫提供的 close 方法只能關閉處於 pending 狀態的 SSE 連線,因為 fetch 一旦從 pending 變為 resolved
reject, 其結果無法改變。當頻繁的斷開 SSE 連線和建立新 SSE 連線時,舊的 SSE 連線實際上並沒有關閉,系統裡會存在多個
SSE 連線,這樣會帶來很大的效能開銷

FAQ

  1. SSE 不能向服務端傳送資料?

可以將資料放入 url 中,斷開當前的 SSE 連線,根據新 url 重新建立 SSE 連線

總結

本篇文章講述一種服務端向客戶端推送資訊的技術、它比 WebSocket 更簡單更輕量化,比輪詢效能好。簡單介紹 Server-sent events 的技術原理和使用場景,並進行簡單的封裝,方便日常在專案中使用。推薦使用 eventsourceevent-source-polyfill 第三方庫解決不能透過 headers 傳遞 Authorization token 的問題。

參考連結

相關文章