服務器推(Server Push)是一類特定技術的總稱。通常狀況,客戶端與服務器的交互方式是:客戶端發起請求,服務器收到請求返回響應結果,客戶端接收響應結果進行處理。從上述的交互過程當中能夠看出,客戶端想要獲取數據,須要自主地向服務端發起請求,獲取相關數據。javascript
在大多數場景下,客戶端的「主動式」行爲已經能夠知足需求了。然而,在一些場景下,須要服務器「主動」向客戶端推送數據。例如:html
這類應用有幾個重要特色:要求較高的實時性,同時客戶端沒法預期數據更新週期,在服務端獲取最新數據時,須要將信息同步給客戶端。這類應用場景被稱爲「服務器推」(Server Push)。前端
「服務器推」技術由來已久,從最初的簡單輪詢,到後來基於長輪詢的COMET,到HTML5規範的SSE,以及實現全雙工的WebSocket協議,「服務器推」的技術不斷髮展。本文會介紹這些技術的基本原理以及實現方式,來幫助你們迅速瞭解與掌握「服務器推」各種技術的基本原理。文末會附上完整的demo地址。java
簡易輪詢是「解決」該問題最簡陋的一個技術方式。node
簡易輪詢本質上就是在前端建立一個定時器,每隔必定的時間去查詢後端服務,若是有數據則進行相應的處理。git
function polling() {
fetch(url).then(data => {
process(data);
return;
}).catch(err => {
return;
}).then(() => {
setTimeout(polling, 5000);
});
}
polling();
複製代碼
輪詢開始時,向後端發送請求,待響應結束後,間隔必定時間再去請求數據,如此循環往復。效果以下:github
這種作法的優勢就是很是簡單,幾乎不須要進行任何額外的配置或開發。web
而與此同時,缺點也十分明顯。首先,這種至關於定時輪詢的方式在獲取數據上存在顯而易見的延遲,要想下降延遲,只能縮短輪詢間隔;而另外一方面,每次輪詢都會進行一次完整的HTTP請求,若是沒有數據更新,至關因而一次「浪費」的請求,對服務端資源也是一種浪費。ajax
所以,輪詢的時間間隔須要進行仔細考慮。輪詢的間隔過長,會致使用戶不能及時接收到更新的數據;輪詢的間隔太短,會致使查詢請求過多,增長服務器端的負擔。算法
隨着web應用的發展,尤爲是基於ajax的web2.0時代中web應用需求與技術的發展,基於純瀏覽器的「服務器推」技術開始受到較多關注,Alex Russell(Dojo Toolkit 的項目 Lead)稱這種基於HTTP長鏈接、無須在瀏覽器端安裝插件的「服務器推」技術爲「Comet」。
經常使用的COMET分爲兩種:基於HTTP的長輪詢(long-polling)技術,以及基於iframe的長鏈接流(stream)模式。
在簡單輪詢中,咱們會每隔必定的時間向後端請求。這種方式最大的問題之一就是,數據的獲取延遲受限於輪詢間隔,沒法第一時間獲取服務想要推送數據。
長輪詢是再此基礎上的一種改進。客戶端發起請求後,服務端會保持住該鏈接,直到後端有數據更新後,纔會將數據返回給客戶端;客戶端在收到響應結果後再次發送請求,如此循環往復。關於簡單輪詢與長輪詢的區別,一圖勝千言:
這樣,服務端一旦有數據想要推送,能夠及時送達到客戶端。
function query() {
fetchMsg('/longpolling')
.then(function(data) {
// 請求結束,觸發事件通知eventbus
eventbus.trigger('fetch-end', {data, status: 0});
});
}
eventbus.on('fetch-end', function (result) {
// 處理服務端返回的數據
process(result);
// 再次發起請求
query();
});
複製代碼
以上是一段簡略版的前端代碼,經過eventbus來通知請求結束,收到結束消息後,process(result)
處理所需數據,同時再次調用query()
發起請求。
而在服務端,以node爲例,服務端只須要在監聽到有消息/數據更新時,再進行返回便可。
const app = http.createServer((req, res) => {
// 返回數據的方法
const longPollingSend = data => {
res.end(data);
};
// 當有數據更新時,服務端「推送」數據給客戶端
EVENT.addListener(MSG_POST, longPollingSend);
req.socket.on('close', () => {
console.log('long polling socket close');
// 注意在鏈接關閉時移除監聽,避免內存泄露
EVENT.removeListener(MSG_POST, longPollingSend);
});
});
複製代碼
效果以下:
當咱們在頁面中嵌入一個iframe並設置其src時,服務端就能夠經過長鏈接「源源不斷」地向客戶端輸出內容。
例如,咱們能夠向客戶端返回一段script標籤包裹的javascript代碼,該代碼就會在iframe中執行。所以,若是咱們預先在iframe的父頁面中定義一個處理函數process()
,而在每次有新數據須要推送時,在該鏈接響應中寫入<script>parent.process(${your_data})</script>
。那麼iframe中的這段代碼就會調用父頁面中預先定義的process()
函數。(是否是有點像JSONP傳輸數據的方式?)
// 在父頁面中定義的數據處理方法
function process(data) {
// do something
}
// 建立不可見的iframe
var iframe = document.createElement('iframe');
iframe.style = 'display: none';
// src指向後端接口
iframe.src = '/long_iframe';
document.body.appendChild(iframe);
複製代碼
後端仍是以node爲例
const app = http.createServer((req, res) => {
// 返回數據的方法,將數據拼裝成script腳本返回給iframe
const iframeSend = data => {
let script = `<script type="text/javascript"> parent.process(${JSON.stringify(data)}) </script>`;
res.write(script);
};
res.setHeader('connection', 'keep-alive');
// 注意設置相應頭的content-type
res.setHeader('content-type', 'text/html; charset=utf-8');
// 當有數據更新時,服務端「推送」數據給客戶端
EVENT.addListener(MSG_POST, iframeSend);
req.socket.on('close', () => {
console.log('iframe socket close');
// 注意在鏈接關閉時移除監聽,避免內存泄露
EVENT.removeListener(MSG_POST, iframeSend);
});
});
複製代碼
效果以下:
不過使用iframe有個小瑕疵,所以這個iframe至關於永遠也不會加載完成,因此瀏覽器上會一直有一個loading標誌。
總得來講,長輪詢和iframe流這兩種COMET技術,具備了不錯的實用價值,其特色在於兼容性很是強,不須要客戶端或服務端支持某些新的特性。不過,爲了便於處理COMET使用時的一些問題,仍是推薦在生產環境中考慮一些成熟的第三方庫。值得一提的是,Socket.io在不兼容WebSocket(咱們後面會提到)的瀏覽器中也會回退到長輪詢模式。
然而,COMET技術並非HTML5標準的一部分,從兼容標準的角度出發的話,並不推薦使用。(尤爲在咱們有了一些其餘技術以後)
SSE (Server-Sent Events) 是HTML5標準中的一部分。其實現原理相似於咱們在上一節中提到的基於iframe的長鏈接模式。
HTTP響應內容有一種特殊的content-type —— text/event-stream,該響應頭標識了響應內容爲事件流,客戶端不會關閉鏈接,而是等待服務端不斷得發送響應結果。
SSE規範比較簡單,主要分爲兩個部分:瀏覽器中的EventSource
對象,以及服務器端與瀏覽器端之間的通信協議。
在瀏覽器中能夠經過EventSource
構造函數來建立該對象
var source = new EventSource('/sse');
複製代碼
而SSE的響應內容能夠當作是一個事件流,由不一樣的事件所組成。這些事件會觸發前端EventSource
對象上的方法。
// 默認的事件
source.addEventListener('message', function (e) {
console.log(e.data);
}, false);
// 用戶自定義的事件名
source.addEventListener('my_msg', function (e) {
process(e.data);
}, false);
// 監聽鏈接打開
source.addEventListener('open', function (e) {
console.log('open sse');
}, false);
// 監聽錯誤
source.addEventListener('error', function (e) {
console.log('error');
});
複製代碼
EventSource
經過事件監聽的方式來工做。注意上面的代碼監聽了my_msg
事件,SSE支持自定義事件,默認事件經過監聽message
來獲取數據。
SSE中,每一個事件由類型和數據兩部分組成,同時每一個事件能夠有一個可選的標識符。不一樣事件的內容之間經過僅包含回車符和換行符的空行("\r\n")來分隔。每一個事件的數據可能由多行組成。
my_msg
事件。能夠看到,SSE確實是一個比較簡單的協議規範,服務端實現也比較簡單:
const app = http.createServer((req, res) => {
const sseSend = data => {
res.write('retry:10000\n');
res.write('event:my_msg\n');
// 注意文本數據傳輸
res.write(`data:${JSON.stringify(data)}\n\n`);
};
// 注意設置響應頭的content-type
res.setHeader('content-type', 'text/event-stream');
// 通常不會緩存SSE數據
res.setHeader('cache-control', 'no-cache');
res.setHeader('connection', 'keep-alive');
res.statusCode = 200;
res.write('retry:10000\n');
res.write('event:my_msg\n\n');
EVENT.addListener(MSG_POST, sseSend);
req.socket.on('close', () => {
console.log('sse socket close');
EVENT.removeListener(MSG_POST, sseSend);
});
});
複製代碼
效果以下:
此外,咱們還能夠考慮結合HTTP/2的優點來使用SSE。然而,一個可能不太好的消息是,IE/Edge並不兼容。
固然,你能夠經過一些手段來寫一個兼容IE的polyfill。不過,因爲IE上的XMLHttpRequest對象並不支持獲取部分的響應內容,所以只能使用XDomainRequest來替代,固然,這也致使了一些小問題。若是你們對具體的實現細節感興趣,能夠看一下這個polyfill庫Yaffle/EventSource。
WebSocket與http協議同樣都是基於TCP的。WebSocket其實不只僅限於「服務器推」了,它是一個全雙工的協議,適用於須要進行復雜雙向數據通信的場景。所以也有着更復雜的規範。
當客戶端要和服務端創建WebSocket鏈接時,在客戶端和服務器的握手過程當中,客戶端首先會向服務端發送一個HTTP請求,包含一個Upgrade
請求頭來告知服務端客戶端想要創建一個WebSocket鏈接。
在客戶端創建一個WebSocket鏈接很是簡單:
var ws = new WebSocket('ws://127.0.0.1:8080');
複製代碼
固然,相似於HTTP
和HTTPS
,ws
相對應的也有wss
用以創建安全鏈接。
這時的請求頭以下:(注意其中的Upgrade字段)
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cache-Control: no-cache
Connection: Upgrade
Cookie: Hm_lvt_4e63388c959125038aabaceb227cea91=1527001174
Host: 127.0.0.1:8080
Origin: http://127.0.0.1:8080
Pragma: no-cache
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: 0lUPSzKT2YoUlxtmXvdp+w==
Sec-WebSocket-Version: 13
Upgrade: websocket
複製代碼
而服務器在收到請求後進行處理,響應頭以下
Connection: Upgrade
Origin: http://127.0.0.1:8080
Sec-WebSocket-Accept: 3NOOJEzyscVfEf0q14gkMrpV20Q=
Upgrade: websocket
複製代碼
表示升級到了WebSocket協議。
注意,上面的請求頭中有一個Sec-WebSocket-Key
,這其實和加密、安全性關係不大,最主要的做用是來驗證服務器是否真的正確「理解」了WebSocket、該WebSocket鏈接是否有效。服務器會使用Sec-WebSocket-Key
,並根據一個固定的算法
mask = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; // 一個規定的字符串
accept = base64(sha1(key + mask));
複製代碼
生成Sec-WebSocket-Accept
響應頭字段,交由瀏覽器驗證。
接下來,瀏覽器與服務器之間就能夠愉快地進行雙向通訊了。
鑑於篇幅,關於WebSocket協議的具體規範與細節(例如數據幀格式、心跳檢查等)就不在這裏深刻了,網絡上也有不少這類的不錯的文章能夠閱讀,感興趣的讀者也能夠閱讀本文最後的參考資料。
下面簡單介紹一下WebSocket的使用。
在瀏覽器端,創建WebSocket鏈接後,能夠經過onmessage
來監聽數據信息。
var ws = new WebSocket('ws://127.0.0.1:8080');
ws.onopen = function () {
console.log('open websocket');
};
ws.onmessage = function (e) {
var data = JSON.parse(e.data);
process(data);
};
複製代碼
在服務器端,因爲WebSocket協議具備較多的規範與細節須要處理,所以建議使用一些封裝較完備的第三方庫。例如node中的websocket-node和著名的socket.io。固然,其餘語言也有許多開源實現。node部分代碼以下:
const http = require('http');
const WebSocketServer = require('websocket').server;
const app = http.createServer((req, res) => {
// ...
});
app.listen(process.env.PORT || 8080);
const ws = new WebSocketServer({
httpServer: app
});
ws.on('request', req => {
let connection = req.accept(null, req.origin);
let wsSend = data => {
connection.send(JSON.stringify(data));
};
// 接收客戶端發送的數據
connection.on('message', msg => {
console.log(msg);
});
connection.on('close', con => {
console.log('websocket close');
EVENT.removeListener(MSG_POST, wsSend);
});
// 當有數據更新時,使用WebSocket鏈接來向客戶端發送數據
EVENT.addListener(MSG_POST, wsSend);
});
複製代碼
效果以下:
服務器推(Server Push)做爲一類特定的技術,在一些業務場景中起到了重要的做用,瞭解各種技術實現的原理與特色,有利於在實際的業務場景中幫助咱們作出必定的選擇與判斷。
爲了便於理解文中的內容,我把全部代碼整理在了一個demo裏,感興趣的朋友能夠在這裏下載,並在本地運行查看。