常用的服務端推送技術,包括輪詢、長輪詢、websocket、server-sent-event(SSE)
傳統的HTTP請求是由客戶端發送一個request,服務端返回對應response,所以當服務端想主動給客戶端發送消息時就遇到了問題。常見的業務場景如新消息提醒。
1、輪詢(Polling)
最簡單的方法是輪詢,即客戶端不斷的發送請求來獲取最新的消息。優點是實現簡單。缺點是請求中有大半是無用,浪費帶寬和服務器資源,同時,根據輪詢的時間間隔不同,獲取消息會有對應的延遲。
實例,新浪微博新消息提示。打開控制台可以發現 https://rm.api.weibo.com/2/remind/push_count.json
一個 jsonp
請求,這個請求每隔 30s 發送一次,每次需求 100ms 左右。
2、長輪詢(Long Polling)
長輪詢也比較容易理解,就是前端發起請求,並設置一個比較長的超時時間,后端接收到請求后,如果沒有相關數據,會hold住請求直到有結果了,或者等待一定時間超時才返回。返回后,客戶端會立即發起下一次請求。長輪詢的控制權的服務器端,出現相關數據后會立即返回,實時性較高。
實例,QQ郵箱的新消息提醒。可以看到 https://wp.mail.qq.com/poll
請求不斷發送, 沒有新消息時,請求每次都會需要 30s,上一次請求返回后立即發送下一次請求,而當服務端有新消息時會立即返回,實時性較高。
用代碼簡單實現以上兩種輪詢
服務端代碼
const express = require('express');
const port = 2333;
const app = express();
app.get('/start', start);
app.get('/getCurrentResult', getCurrentResult);
app.get('/getFinalResult', getFinalResult);
app.listen(port, () => console.log(`Server listening on port ${port}`));
// 開始一個任務
function start(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*'); // 允許跨域
_startTask();
res.json({
code: 0,
data: '開始任務'
});
}
// 返回實時結果
function getCurrentResult(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.json({
code: 0,
data: result
});
}
// 任務運行結束之后再返回運行結果
async function getFinalResult(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
let result = await _startTask();
res.json({
code: 0,
data: result
});
}
// 模擬執行一個任務
let result = null;
function _startTask() {
result = null;
return new Promise((res, rej) => {
// 任務需要10s 10s后得到result
setTimeout(() => {
result = 'hello world';
res(result);
}, 10000);
});
}
客戶端代碼
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>輪詢&長輪詢</title>
</head>
<body>
<button onclick="start(1)">開始任務 輪詢</button>
<button onclick="start(2)">開始任務 長輪詢</button>
<div id="hint"></div>
<script>
function start(type) {
console.log('===start===');
fetch('http://localhost:2333/start').then(res => {
return res.json();
}).then(function(data){
console.log(data);
setHint('任務執行中...');
type == 1 ? loop() : longPolling();
}).catch(function(err){
console.error(err);
});
}
function loop() {
fetch('http://localhost:2333/getCurrentResult').then(res => {
return res.json();
}).then(function(data){
console.log(data);
if (!data.data) {
setTimeout(loop, 1000); // 1s輪詢一次
} else {
setHint('執行成功 結果 = ' + data.data);
}
}).catch(function(err){
console.error(err);
});
}
function longPolling() {
fetch('http://localhost:2333/getFinalResult').then(res => {
return res.json();
}).then(function(data){
console.log(data);
setHint('執行成功 結果 = ' + data.data);
}).catch(function(err){
console.error(err);
});
}
function setHint(text) {
hint.innerHTML = text;
}
</script>
</body>
</html>
3、WebSocket
上面兩種方式,實際上還是客戶端單向發送消息,而 WebSocket 本質上解決了這個問題,WebSocket 是 HTML5 開始提供的一種在單個 TCP 連接上進行全雙工通訊的協議。在 WebSocket API 中,瀏覽器和服務器只需要完成一次握手,兩者之間就直接可以創建持久性的連接,並進行雙向數據傳輸。
WebSocket 握手階段采用 HTTP 協議,客戶端瀏覽器首先要向服務器發起一個 HTTP 請求,其中附加頭信息 Upgrade: WebSocket
表明這是一個申請協議升級的 HTTP 請求,服務器端解析這些附加的頭信息然后產生應答信息返回給客戶端,客戶端和服務器端的 WebSocket 連接就建立起來了,雙方就可以通過這個連接通道自由的傳遞信息,並且這個連接會持續存在直到客戶端或者服務器端的某一方主動的關閉連接。WebSocket 沒有同源限制。
實例,LeetCode-CN 的新消息提醒,猜測是通過 WebSocket 實時返回是否有新消息,再通過 XHR 請求具體信息。
簡單代碼實現,使用了 ws 包
服務端代碼
const WebSocket = require('ws');
const http = require('http');
const port = 2333;
const server = http.createServer();
const wss = new WebSocket.Server({ server, path: '/ws' });
wss.on('connection', function(ws) {
console.log('WebSocket connection established');
let progress = 0;
ws.send(`任務進度 -- ${progress}%`);
let timer = setInterval(() => {
// 推送任務完成進度
if (++progress % 10 == 0) {
ws.send(`任務進度 -- ${progress}%`);
}
if (progress == 100) {
clearInterval(timer);
ws.close();
}
}, 200);
ws.on('close', () => {
console.log('WebSocket connection closed');
clearInterval(timer);
});
});
server.listen(port, function() {
console.log(`Server listening on port ${port}`);
});
客戶端代碼
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>WebSocket</title>
</head>
<body>
<button onclick="start()">開始任務</button>
<div id="hint" style="white-space: pre-line;"></div>
<script>
let ws;
function start() {
if (ws) ws.close();
console.log('===start===');
ws = new WebSocket('ws://localhost:2333/ws');
ws.onmessage = function(ev) {
let data = ev.data;
console.log(data);
showMessage(data);
}
ws.onerror = function() {
console.log('WebSocket error');
};
ws.onopen = function() {
console.log('WebSocket connection established');
};
ws.onclose = function() {
console.log('WebSocket connection closed');
ws = null;
};
}
function showMessage(text) {
hint.innerHTML = hint.innerHTML + '\n' + text;
}
</script>
</body>
</html>
4、Sever-Sent Event(SSE)
SSE 是一種能讓瀏覽器通過 HTTP 連接自動收到服務器端推送的技術,EventSource 是 瀏覽器提供的對應 API。通過 EventSource 實例打開與 HTTP 服務器的持久連接,該服務器以文本/事件流格式發送事件,連接會保持打開狀態,直到服務端或客戶端主動關閉。
與 WebSocket 區別,SSE 基於 HTTP 協議,使用簡單,SSE 默認支持斷線重連,但是 SSE 只能由服務端向客戶端推動消息。
SSE 有四種字段,其他的字段會被忽略。字段之間用\n
分隔,每條消息要以 \n\n
結尾。
data // 數據項
event // 事件項 默認為 message 可設置任意值
id // 數據標識符,用於斷線重連
retry // 斷線后重連時間
實際應用,SSE 在股價顯示場景應用較多,如 東方財富網(感謝 @heart_ 提醒)
簡單代碼實現:
服務端代碼
const express = require('express');
const port = 2333;
const app = express();
app.get('/sse', respondSSE);
function respondSSE(req, res) {
let msg = 0;
let timer;
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*'
});
res.write(sseMsg({
data: '===start===',
// 默認 event 是 'message'
}));
timer = setInterval(() => {
res.write(sseMsg({
id: Date.now(),
event: 'custom-event',
data: msg++,
retry: 2000
}));
}, 1000);
res.on('close', function () {
clearInterval(timer);
console.log('SSE connection closed');
});
}
const sseMsg = (sseObj) => {
let fields = ['id', 'event', 'data', 'retry'];
return fields
.filter(f => sseObj[f] != null)
.map(f => f + ':' + sseObj[f]).join('\n') + '\n\n';
}
app.listen(port, () => console.log(`Server listening on port ${port}`));
客戶端代碼
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<button onclick="start()">開始</button>
<button onclick="over()">結束</button>
<div id="hint"></div>
<script>
let source;
function start() {
source = new EventSource('http://localhost:2333/sse');
source.addEventListener('open', () => {
console.log('SSE connection established');
}, false);
source.addEventListener('message', e => {
console.log(e.data);
}, false);
source.addEventListener('custom-event', e => {
console.log('custom-event data: ', e.data);
showMessage('新消息: ' + e.data + ' 條');
}, false);
}
function over() {
source.close();
}
function showMessage(text) {
hint.innerHTML = text;
}
</script>
</body>
</html>
5、HTTP/2 Server Push
文章比較少,而且和上面的推送並不一樣,看到這篇講得不錯~
Node HTTP/2 Server Push 從了解到放棄