分不清輪詢、長輪詢?不知道什麼時候該用websocket還是SSE,看這篇就夠了。
所謂的“實時推送”,從表面意思上來看是,客戶端訂閱的內容在發生改變時,伺服器能夠實時地通知客戶端,進而客戶端進行相應地反應。客戶端不需要主觀地傳送請求去獲取自己關心的內容,而是由伺服器端進行“推送”。
注意上面的推送二字打了引號,這就意味著在現有的幾種實現方式中,並不是伺服器端主動地推送,而是通過一定的手段營造了一種實時的假象。就目前現有的幾種技術而言,主要有以下幾類:
- 客戶端輪詢:傳統意義上的輪詢(Short Polling)
- 伺服器端輪詢:長輪詢(Long Polling)
- 全雙工通訊:Websocket
- 單向伺服器推送:Server-Sent Events(SSE)
文中會以一個簡易聊天室的例子來分別通過上述的四種方式實現,程式碼地址mini-chatroom(存在些許bug,主要是為了做演示用)
輪詢(Short Polling)
輪詢的實現原理:客戶端向伺服器端傳送一個請求,伺服器返回資料,然後客戶端根據伺服器端返回的資料進行處理;然後客戶端繼續向伺服器端傳送請求,繼續重複以上的步驟,如果不想給伺服器端太大的壓力,一般情況下會設定一個請求的時間間隔。
使用輪詢明顯的優點是基礎不需要額外的開發成本,請求資料,解析資料,作出響應,僅此而已,然後不斷重複。缺點也顯而易見:
- 不斷的傳送和關閉請求,對伺服器的壓力會比較大,因為本身開啟Http連線就是一件比較耗資源的事情
- 輪詢的時間間隔不好控制。如果要求的實時性比較高,顯然使用短輪詢會有明顯的短板,如果設定interval的間隔過長,會導致訊息延遲,而如果太短,會對伺服器產生壓力
程式碼實現
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);
}
}
下面是對應的請求,注意左下角的請求數量一直在變化
在上圖中,每隔1s就會傳送一個請求,看起來效果還不錯,但是如果將timeout的值設定成5s,效果將大打折扣,如圖:
長輪詢(Long Polling)
長輪詢的基本原理:客戶端傳送一個請求,伺服器會hold住這個請求,直到監聽的內容有改變,才會返回資料,斷開連線,客戶端繼續傳送請求,重複以上步驟。或者在一定的時間內,請求還得不到返回,就會因為超時自動斷開連線。
長輪詢是基於輪詢上的改進版本,主要是減少了客戶端發起Http連線的開銷,改成了在伺服器端主動地去判斷所關心的內容是否變化,所以其實輪詢的本質並沒有多大變化,變化的點在於:
- 對於內容變化的輪詢由客戶端改成了伺服器端(客戶端會在連線中斷之後,會再次傳送請求,對比短輪詢來說,大大減少了發起連線的次數)
- 客戶端只會在資料改變時去作相應的改變,對比短輪詢來說,並不是全盤接收
程式碼實現
// 客戶端
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,就會斷開連線
- 重複以上步驟
// 伺服器端
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 的實現片段
// 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,注意觀察下面的截圖:
可以看到,斷開連線的兩種方式,要麼是超時,要麼是請求有資料返回。
基於iframe的長輪詢模式
這種模式的具體的原理為:
- 在頁面中嵌入一個iframe,地址指向輪詢的伺服器地址,然後在父頁面中放置一個執行函式,比如
execute(data)
- 當伺服器有內容改變時,會向iframe傳送一個指令碼
<script>parent.execute(JSON.stringify(data))</script>
- 通過傳送的指令碼,主動執行父頁面中的方法,達到推送的效果
具體可以參看這裡
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 API方面的知識,這裡不再作講解,可以自己查閱Websocket API MDN
相容性
websocket相容性良好,基本支援所有現代瀏覽器
程式碼實現
筆者這裡採用的是socket.io,是基於websocket的封裝,提供了客戶端以及伺服器端的支援
// 客戶端
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 });
});
});
響應格式如下:
Server-Sent Events(SSE)
傳統意義上伺服器端不會主動推送給客戶端訊息,一般都是客戶端主動去請求伺服器端獲取最新的資料。SSE就是一種可以主動從服務端推送訊息的技術。
SSE的本質其實就是一個HTTP的長連線,只不過它給客戶端傳送的不是一次性的資料包,而是一個stream流,格式為text/event-stream,所以客戶端不會關閉連線,會一直等著伺服器發過來的新的資料流,視訊播放就是這樣的例子。
- SSE 使用 HTTP 協議,現有的伺服器軟體都支援。WebSocket 是一個獨立協議。
- SSE 屬於輕量級,使用簡單;WebSocket 協議相對複雜。
- SSE 預設支援斷線重連,WebSocket 需要自己實現。
- SSE 一般只用來傳送文字,二進位制資料需要編碼後傳送,WebSocket 預設支援傳送二進位制資料。
- SSE 支援自定義傳送的訊息型別。
基本的使用方法,參看SSE API
相容性
目前除了IE以及低版本的瀏覽器不支援,基本支援絕大多數的現代瀏覽器。
程式碼實現
// 客戶端
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);
});
下面是控制檯的情況,注意觀察響應型別
詳情中注意檢視請求型別,以及EventStream訊息型別
總結
- 短輪詢、長輪詢實現成本相對比較簡單,適用於一些實時性要求不高的訊息推送,在實時性要求高的場景下,會存在延遲以及會給伺服器帶來更大的壓力
- websocket目前而言實現成本相對較低,適合於雙工通訊,對於多人線上,要求實時性較高的專案比較實用
- SSE只能是伺服器端推送訊息,因此對於不需要雙向通訊的專案比較適用