前言
現今網路世界主要都是用 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,負擔與成本就會瞬間拉高,所以如何取捨就非常考驗使用上的潛在需求。