實時訊息推送整理
阿新 • • 發佈:2020-10-16
分不清輪詢、長輪詢?不知道什麼時候該用websocket還是SSE,看這篇就夠了。
所謂的“實時推送”,從表面意思上來看是,客戶端訂閱的內容在發生改變時,伺服器能夠實時地通知客戶端,進而客戶端進行相應地反應。客戶端不需要主觀地傳送請求去獲取自己關心的內容,而是由伺服器端進行“推送”。
注意上面的推送二字打了引號,這就意味著在現有的幾種實現方式中,並不是伺服器端主動地推送,而是通過一定的手段營造了一種實時的假象。就目前現有的幾種技術而言,主要有以下幾類:
- 客戶端輪詢:傳統意義上的輪詢(Short Polling)
- 伺服器端輪詢:長輪詢(Long Polling)
- 全雙工通訊:Websocket
- 單向伺服器推送:Server-Sent Events(SSE)
文中會以一個簡易聊天室的例子來分別通過上述的四種方式實現,程式碼地址[mini-chatroom](https://github.com/Rynxiao/mini-chatroom)(存在些許bug,主要是為了做演示用)
![overview](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016105757468-305314082.gif)
## 輪詢(Short Polling)
輪詢的實現原理:客戶端向伺服器端傳送一個請求,伺服器返回資料,然後客戶端根據伺服器端返回的資料進行處理;然後客戶端繼續向伺服器端傳送請求,繼續重複以上的步驟,如果不想給伺服器端太大的壓力,一般情況下會設定一個請求的時間間隔。
![shortPolling](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016105818016-1902321969.png)
使用輪詢明顯的優點是基礎不需要額外的開發成本,請求資料,解析資料,作出響應,僅此而已,然後不斷重複。缺點也顯而易見:
- 不斷的傳送和關閉請求,對伺服器的壓力會比較大,因為本身開啟Http連線就是一件比較耗資源的事情
- 輪詢的時間間隔不好控制。如果要求的實時性比較高,顯然使用短輪詢會有明顯的短板,如果設定interval的間隔過長,會導致訊息延遲,而如果太短,會對伺服器產生壓力
### 程式碼實現
```javascript
var ShortPollingNotification = {
datasInterval: null,
subscribe: function() {
this.datasInterval = setInterval(function() {
Request.getDatas().then(function(res) {
window.ChatroomDOM.renderData(res);
});
}, TIMEOUT);
return this.unsubscribe;
},
unsubscribe: function() {
this.datasInterval && clearInterval(this.datasInterval);
}
}
```
![shortPolling](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016105918505-929368309.gif)
下面是對應的請求,注意左下角的請求數量一直在變化
![shortNetwork](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016105939851-33410716.gif)
在上圖中,每隔1s就會發送一個請求,看起來效果還不錯,但是如果將timeout的值設定成5s,效果將大打折扣,如圖:
![shortPolling5s](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016110009244-1365730063.gif)
## 長輪詢(Long Polling)
長輪詢的基本原理:客戶端傳送一個請求,伺服器會hold住這個請求,直到監聽的內容有改變,才會返回資料,斷開連線,客戶端繼續傳送請求,重複以上步驟。或者在一定的時間內,請求還得不到返回,就會因為超時自動斷開連線。
![longPolling](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016110027013-94184699.png)
長輪詢是基於輪詢上的改進版本,主要是減少了客戶端發起Http連線的開銷,改成了在伺服器端主動地去判斷所關心的內容是否變化,所以其實輪詢的本質並沒有多大變化,變化的點在於:
- 對於內容變化的輪詢由客戶端改成了伺服器端(客戶端會在連線中斷之後,會再次傳送請求,對比短輪詢來說,大大減少了發起連線的次數)
- 客戶端只會在資料改變時去作相應的改變,對比短輪詢來說,並不是全盤接收
### 程式碼實現
```javascript
// 客戶端
var LongPollingNotification = {
// ....
subscribe: function() {
var that = this;
// 設定超時時間
Request.getV2Datas(this.getKey(),{ timeout: 10000 }).then(function(res) {
var data = res.data;
window.ChatroomDOM.renderData(res);
// 成功獲取資料後會再次傳送請求
that.subscribe();
}).catch(function (error) {
// timeout 之後也會再次傳送請求
that.subscribe();
});
return this.unsubscribe;
}
// ....
}
```
筆者採用的是express,預設不支援hold住請求,因此用了一個express-longpoll的庫來實現。
下面是一個原生不用庫的實現(這裡只是介紹原理),整體的思路是:如果伺服器端支援hold住請求的話,那麼在一定的時間內會自輪詢,然後期間通過比較key值,判斷是否返回新資料
- 客戶端第一次會帶一個空的key值,這次會立即返回,獲取新內容,伺服器端將計算出的contentKey返回給客戶端
- 然後客戶端傳送第二次請求,帶上第一次返回的contentKey作為key值,然後進行下一輪的比較
- 如果兩次的key值相同,就會hold請求,進行內部輪詢,如果期間有新內容或者客戶端timeout,就會斷開連線
- 重複以上步驟
```javascript
// 伺服器端
router.get('/v2/datas', function(req, res) {
const key = _.get(req.query, 'key', '');
let contentKey = chatRoom.getContentKey();
while (key === contentKey) {
sleep.sleep(5);
contentKey = chatRoom.getContentKey();
}
const connectors = chatRoom.getConnectors();
const messages = chatRoom.getMessages();
res.json({
code: 200,
data: { connectors: connectors, messages: messages, key: contentKey },
});
});
```
以下是用 [express-longpoll](https://www.npmjs.com/package/express-longpoll) 的實現片段
```javascript
// mini-chatroom/public/javascripts/server/longPolling.js
function pushDataToClient(key, longpoll) {
var contentKey = chatRoom.getContentKey();
if (key !== contentKey) {
var connectors = chatRoom.getConnectors();
var messages = chatRoom.getMessages();
longpoll.publish(
'/v2/datas',
{
code: 200,
data: {connectors: connectors, messages: messages, key: contentKey},
}
);
}
}
longpoll.create("/v2/datas", function(req, res, next) {
key = _.get(req.query, 'key', '');
pushDataToClient(key, longpoll);
next();
});
intervalId = setInterval(function() {
pushDataToClient(key, longpoll);
}, LONG_POLLING_TIMEOUT);
```
為了方便演示,我將客戶端發起請求的timeout改成了4s,注意觀察下面的截圖:
![longPollingNetwork](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016110956670-1296999533.gif)
可以看到,斷開連線的兩種方式,要麼是超時,要麼是請求有資料返回。
### 基於iframe的長輪詢模式
這種模式的具體的原理為:
- 在頁面中嵌入一個iframe,地址指向輪詢的伺服器地址,然後在父頁面中放置一個執行函式,比如`execute(data)`
- 當伺服器有內容改變時,會向iframe傳送一個指令碼``
- 通過傳送的指令碼,主動執行父頁面中的方法,達到推送的效果
具體可以參看[這裡](https://juejin.im/post/6844903955240058893#heading-4)
## Websocket
>The WebSocket Protocol enables two-way communication between a client running untrusted code in a controlled environment to a remote host that has opted-in to communications from that code.
>
>The protocol consists of an opening handshake followed by basic message framing, layered over TCP.
>
>The goal of this technology is to provide a mechanism for browser-based applications that need two-way communication with servers that does not rely on opening multiple HTTP connections (e.g., using XMLHttpRequest or iframe and long polling).
>
>
>The WebSocket Protocol attempts to address the goals of existing bidirectional HTTP technologies in the context of the existing HTTP infrastructure; as such, it is designed to work over HTTP ports 80 and 443 as well as to support HTTP proxies and intermediaries, even if this implies some complexity specific to the current environment.
>
### 特徵
- websocket是雙向通訊的,設計的目的主要是為了減少傳統輪詢時http連線數量的開銷
- 建立在TCP協議之上,握手階段採用 HTTP 協議,因此握手時不容易遮蔽,能通過各種 HTTP 代理伺服器
- 與HTTP相容性良好,同樣可以使用80和443埠
- 沒有同源限制,客戶端可以與任意伺服器通訊
- 可以傳送文字,也可以傳送二進位制資料。
- 協議識別符號是`ws`(如果加密,則為`wss`),伺服器網址就是 URL
![websocket](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016111024184-1181679663.png)
關於Websocket API方面的知識,這裡不再作講解,可以自己查閱[Websocket API MDN](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
### 相容性
websocket相容性良好,基本支援所有現代瀏覽器
[![websocket1](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016111051800-1589284363.png)](https://caniuse.com/mdn-api_websocket)
### 程式碼實現
筆者這裡採用的是[socket.io](https://socket.io/),是基於websocket的封裝,提供了客戶端以及伺服器端的支援
```javascript
// 客戶端
var WebsocketNotification = {
// ...
subscribe: function(args) {
var connector = args[1];
this.socket = io();
this.socket.emit('register', connector);
this.socket.on('register done', function() {
window.ChatroomDOM.renderAfterRegister();
});
this.socket.on('data', function(res) {
window.ChatroomDOM.renderData(res);
});
this.socket.on('disconnect', function() {
window.ChatroomDOM.renderAfterLogout();
});
}
// ...
}
// 伺服器端
var io = socketIo(httpServer);
io.on('connection', (socket) => {
socket.on('register', function(connector) {
chatRoom.onConnect(connector);
io.emit('register done');
var data = chatRoom.getDatas();
io.emit('data', { data });
});
socket.on('chat', function(message) {
chatRoom.receive(message);
var data = chatRoom.getDatas();
io.emit('data', { data });
});
});
```
響應格式如下:
![websocket-request-response](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016111114415-191156913.png)
## Server-Sent Events(SSE)
傳統意義上伺服器端不會主動推送給客戶端訊息,一般都是客戶端主動去請求伺服器端獲取最新的資料。[SSE](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)就是一種可以主動從服務端推送訊息的技術。
> SSE的本質其實就是一個HTTP的長連線,只不過它給客戶端傳送的不是一次性的資料包,而是一個stream流,格式為text/event-stream,所以客戶端不會關閉連線,會一直等著伺服器發過來的新的資料流,視訊播放就是這樣的例子。
>
> - SSE 使用 HTTP 協議,現有的伺服器軟體都支援。WebSocket 是一個獨立協議。
> - SSE 屬於輕量級,使用簡單;WebSocket 協議相對複雜。
> - SSE 預設支援斷線重連,WebSocket 需要自己實現。
> - SSE 一般只用來傳送文字,二進位制資料需要編碼後傳送,WebSocket 預設支援傳送二進位制資料。
> - SSE 支援自定義傳送的訊息型別。
基本的使用方法,參看[SSE API](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)
![sse](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016111137374-204514403.png)
### 相容性
目前除了IE以及低版本的瀏覽器不支援,基本支援絕大多數的現代瀏覽器。
[![sse2](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016111243330-56262537.png)](https://caniuse.com/?search=Server-Sent%20Events)
### 程式碼實現
```javascript
// 客戶端
var SSENotification = {
source: null,
subscribe: function() {
if ('EventSource' in window) {
this.source = new EventSource('/sse');
this.source.addEventListener('message', function(res) {
const d = res.data;
window.ChatroomDOM.renderData(JSON.parse(d));
});
}
return this.unsubscribe;
},
unsubscribe: function () {
this.source && this.source.close();
}
}
// 伺服器端
router.get('/sse', function(req, res) {
const connectors = chatRoom.getConnectors();
const messages = chatRoom.getMessages();
const response = { code: 200, data: { connectors: connectors, messages: messages } };
res.writeHead(200, {
"Content-Type":"text/event-stream",
"Cache-Control":"no-cache",
"Connection":"keep-alive",
"Access-Control-Allow-Origin": '*',
});
res.write("retry: 10000\n");
res.write("data: " + JSON.stringify(response) + "\n\n");
var unsubscribe = Event.subscribe(function() {
const connectors = chatRoom.getConnectors();
const messages = chatRoom.getMessages();
const response = { code: 200, data: { connectors: connectors, messages: messages } };
res.write("data: " + JSON.stringify(response) + "\n\n");
});
req.connection.addListener("close", function () {
unsubscribe();
}, false);
});
```
下面是控制檯的情況,注意觀察響應型別
![sse-type](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016111315189-2009829249.png)
詳情中注意檢視請求型別,以及EventStream訊息型別
![sse3](https://img2020.cnblogs.com/blog/681618/202010/681618-20201016111337249-2118037786.gif)
## 總結
- 短輪詢、長輪詢實現成本相對比較簡單,適用於一些實時性要求不高的訊息推送,在實時性要求高的場景下,會存在延遲以及會給伺服器帶來更大的壓力
- websocket目前而言實現成本相對較低,適合於雙工通訊,對於多人線上,要求實時性較高的專案比較實用
- SSE只能是伺服器端推送訊息,因此對於不需要雙向通訊的專案比較適用
## 參考連線
- [The WebSocket Protocol](https://tools.ietf.org/html/rfc6455)
- [Websocket API MDN](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
- [Server-sent events MDN](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
- [WebSocket 教程](http://www.ruanyifeng.com/blog/2017/05/websocket.html)
- [Server-Sent Events 教程](https://www.ruanyifeng.com/blog/2017/05/server-sent_events.html)
- [webSocket(二) 短輪詢、長輪詢、Websocket、sse](https://juejin.im/post/6844903955240