前言

現今網路世界主要都是用 HTTP request-response 的 model 來做為溝通的模型,client 發送 request 給 server,server 再回傳 response 給 client,但是隨著 Web 的需求改變與技術的演進,漸漸需要另一種溝通方式,來讓 server 無須被 client 請求時就能主動傳送資料給 client,最常見的例子就是股票網站的股價更新、新聞網站的文章更新…等等,或是需要前後端雙方即時溝通的需求,衍生出 WebSockets 的誕生。技術上區分為 Client Pull 和 Server Push 兩種,本篇文章就稍微總結這兩種方式的差異,以及各自有哪些方法

實時更新的幾種方式

主要分成以下幾種方式:

  • Client Pull
    • Short Polling
    • Long Polling
  • Server Push
    • WebSockets
    • SSE (Server Send Event)

Short Polling vs Long Polling

早期更新方式是透過 JavaScript 的 Polling(輪詢)來獲得 server 最新的資料

Short polling

Short polling 是一個 AJAX-based 的 timer,讓 client 每隔一段時間去向 server 獲取資源,但效能不好,因為有可能 client 每隔 N 秒去問 server 時這 N 秒內資料都沒改動,就會浪費網路資源

Long polling

與 short polling 的方式雷同,唯一差別是有較長的 timeout,在 long polling 以前是用 Comet 作為解法,Comet 中譯是彗星,代表這筆拖很長的 request 就像彗星一樣會有長長的尾巴,他將一筆 request 拖的很長以保持 client 跟 server 的連線,完成持續接收 server 資料的需求。但這樣的作法會把傳統的 Web Server 如 Apache 的連線給佔住,所以必須要配合 non-Blocking IO 的 Web Server 才能運作

long polling 也是目前 Facebook、Plurk 實現動態更新的方法。當 server 收到 client 的請求後,如果有資料就立即回傳 response 給 client 然後立即斷線。如果沒有資料則不會立即關閉連線,而是會等待一個較長的時間,時間內如果有資料就傳回。沒資料就繼續等待直到 timeout。接下來 client 再重複相同的動作來和 server 取資料,效能上會比 short polling 好,因為連線會在取得新資料或 timeout 時斷掉,避免浪費過多的網路資源

WebSockets

WebSockets 是一個為了加速 Web 通訊傳遞而創建的網路溝通協定,提供 full-duplex(全雙工)的 channel 讓前後端在單一個 TCP 連線中能夠實時的(real-time)雙向(bidirectional)溝通,常用於如聊天室、多人線上遊戲 MMO(Massively Multiplayer Online)這類前後端須即時溝通的應用情境。

第一次連線時,client 會以 HTTP 協定握手(handshake)來向 server 發送一組帶有 websocket 相關標頭(ex: UPGRADE)的 request,server 收到後會判斷是否支援 WebSockets 的溝通協定,是的話就會回傳101 Switching Protocols的 status code 來告訴 client,接下來的溝通將會從 TCP 連線轉換到 WebSockets 的溝通協定

由此可知,在 OSI 七層中 WebSockets 和 HTTP 都是 Layer7 應用層,兩者都是仰賴於 TCP 的連線(Layer 4)

SSE (Server Send Event)

SSE (Server Send Event)是另一種 server push 的方式,他是仰賴於 HTTP 協定,最大特色就是不需要先讓 client 去 request,而是讓 server 能夠主動傳送資料給 client。與 WebSockets 最大的差異就是他只能單向,而非雙向的溝通,也就是只允許 server 發送資料給 client,技術上可視為是 Pub/Sub 模型的一種方式

實作上 client 需要先透過 JavaScript API - EventSource 來註冊事件來源,爾後 server 就能主動發送資料給 client,除此之外還有以下特色:

  • 當 client 因故丟失與事件來源的連線,每隔一段時間後將會自動重新連線(約 2~3 秒)
  • 每個傳遞的事件都能被指派一個獨一無二的 identifier(Event IDs)
  • 只能傳遞 Text 類型的資料,不像 WebSockets 能夠傳遞 binary 的資料
  • 由於 SSE 的 HTTP 連線是同時的(simultaneous),受限於 Web 瀏覽器對同一 server 的 simultaneous active HTTP/1 連接的上限,所以 SSE 最多只能在同個 browser+domain 下有 6 個連線,但如果使用 HTTP/2 的話就不會受限

React + Node.js 實作 SSE

底下實作一個文章列表的顯示,模擬當後端收到新文章時。可以即時發送給 client 做實時更新

後端使用 express 搭建處理事件的 server

npm install express cors body-parser

提供三個 route

  • GET /status: 用來查看目前有幾個連線的 client 數量
  • GET /events: 提供給前端註冊 EventSource,負責事件的發送與接收
  • POST /article: 新增文章

需注意的是傳送 data 時,必須使用以下字串格式才能發送(最後一行要兩個換行符號):

data: ...\n
data: ...\n
data: ...\n\n

底下是 server.js

const express = require("express");
const bodyParser = require("body-parser");
const cors = require("cors");

const PORT = 3001;

const app = express();

app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

let clients = [];
let articles = [];

// Know how many clients have connected
app.get("/status", (req, res) => {
  return res.json({
    clients: clients.length,
  });
});

// Handle event processing
app.get("/events", (req, res) => {
  const headers = {
    "Content-Type": "text/event-stream",
    Connection: "keep-alive",
    "Cache-Control": "no-cache",
  };
  res.writeHead(200, headers);

  const data = `data: ${JSON.stringify(articles)}\n\n`;

  res.write(data);

  const clientId = Date.now();

  const newClient = {
    id: clientId,
    response: res,
  };

  clients.push(newClient);

  req.on("close", () => {
    console.log(`${clientId} Connection closed`);
    clients = clients.filter((client) => client.id !== clientId);
  });
});

// Add article
app.post("/article", (req, res) => {
  const newArticle = req.body;
  articles.push(newArticle);
  res.json(newArticle);

  // Send events to all clients
  clients.forEach((client) =>
    client.response.write(`data: ${JSON.stringify(newArticle)}\n\n`)
  );
});

app.listen(PORT, () => {
  console.log(`App listening at http://localhost:${PORT}`);
});

再來實作前端程式碼,使用 React 搭建一個簡易的文章列表

npx create-react-app client

底下是 App.js

import React, { useState, useEffect } from "react";
import "./App.css";

function App() {
  const [articles, setArticles] = useState([]);
  const [listening, setListening] = useState(false);

  useEffect(() => {
    if (!listening) {
      // Register event source
      const events = new EventSource("http://localhost:3001/events");

      events.onmessage = (event) => {
        const parsedData = JSON.parse(event.data); // text-based, not binary

        setArticles((articles) => articles.concat(parsedData));
      };

      setListening(true);
    }
  }, [listening, articles]);

  return (
    <table className="stats-table">
      <thead>
        <tr>
          <th>Articles</th>
          <th>Source</th>
        </tr>
      </thead>
      <tbody>
        {articles.map((article, idx) => (
          <tr key={idx}>
            <td>{article.title}</td>
            <td>{article.content}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

export default App;

再來要準備一個 fetch.http 檔,藉由 REST Client 這個 Vscode Extension 來代替 Postman 測試 API

@API_URL = http://localhost:3001

###
GET {{API_URL}}/status

###
POST {{API_URL}}/article
Content-Type: application/json

{
    "title": "Article_01",
    "content": "This is a demo app of Server-Sent Events"
}

###
POST {{API_URL}}/article
Content-Type: application/json

{
    "title": "Article_02",
    "content": "This is another demo app of Server-Sent Events"
}

萬事俱備,來測試看看實時更新的結果

啟動 server 和 client 後,我們開兩個視窗來創建兩個註冊事件的 client,先戳看看 /status 驗證連線人數為兩個,再來依序 POST 兩筆 article

成功! 兩個 client 都如預期的實時收到文章列表了!

p.s. React 開發時渲染會有兩次,為了避免收到兩次訊息,可以把 strict 模式註解掉

接著實測同一個 chrome 內開 7 個 tab,會發現第 7 個不會再收到訊息,驗證文件提到的瀏覽器對單一 server 的連線只有 6 個的限制

總結

以上就是研究後端把資料實時更新給前端的幾種做法,其中 SSE 以前沒聽過而是這次研究中才發現的,所以花了較多時間來研究.至於該如何選擇哪種方式要視情況而定,如果沒有雙向溝通的必要,可以使用 SSE 來代替 WebSockets,並且透過 HTTP/2 就可以解決 6 個連線上限的問題,但若是未來因應需求要修改成雙向溝通,那就必須把程式碼打掉重練,將 SSE 修改成 Websockets,負擔與成本就會瞬間拉高,所以如何取捨就非常考驗使用上的潛在需求。