前言
伺服器推(Server Push)是一類特定技術的總稱。一般情況,客戶端與伺服器的互動方式是:客戶端發起請求,伺服器收到請求返回響應結果,客戶端接收響應結果進行處理。從上述的互動過程中可以看出,客戶端想要獲取資料,需要自主地向服務端發起請求,獲取相關資料。
在大多數場景下,客戶端的“主動式”行為已經可以滿足需求了。然而,在一些場景下,需要伺服器“主動”向客戶端推送資料。例如:
- 聊天室或者對話類應用
- 實時的資料監控與統計
- 股票財經類看板等等
這類應用有幾個重要特點:要求較高的實時性,同時客戶端無法預期資料更新週期,在服務端獲取最新資料時,需要將資訊同步給客戶端。這類應用場景被稱為“伺服器推”(Server Push)。
“伺服器推”技術由來已久,從最初的簡單輪詢,到後來基於長輪詢的COMET,到HTML5規範的SSE,以及實現全雙工的WebSocket協議,“伺服器推”的技術不斷髮展。本文會介紹這些技術的基本原理以及實現方式,來幫助大家迅速瞭解與掌握“伺服器推”各類技術的基本原理。文末會附上完整的demo地址。
1. 簡易輪詢
簡易輪詢是“解決”該問題最簡陋的一個技術方式。
簡易輪詢本質上就是在前端建立一個定時器,每隔一定的時間去查詢後端服務,如果有資料則進行相應的處理。
function polling() {
fetch(url).then(data => {
process(data);
return;
}).catch(err => {
return;
}).then(() => {
setTimeout(polling, 5000);
});
}
polling();
複製程式碼
輪詢開始時,向後端傳送請求,待響應結束後,間隔一定時間再去請求資料,如此迴圈往復。效果如下:
這種做法的優點就是非常簡單,幾乎不需要進行任何額外的配置或開發。
而與此同時,缺點也十分明顯。首先,這種相當於定時輪詢的方式在獲取資料上存在顯而易見的延遲,要想降低延遲,只能縮短輪詢間隔;而另一方面,每次輪詢都會進行一次完整的HTTP請求,如果沒有資料更新,相當於是一次“浪費”的請求,對服務端資源也是一種浪費。
因此,輪詢的時間間隔需要進行仔細考慮。輪詢的間隔過長,會導致使用者不能及時接收到更新的資料;輪詢的間隔過短,會導致查詢請求過多,增加伺服器端的負擔。
2. COMET
隨著web應用的發展,尤其是基於ajax的web2.0時代中web應用需求與技術的發展,基於純瀏覽器的“伺服器推”技術開始受到較多關注,Alex Russell(Dojo Toolkit 的專案 Lead)稱這種基於HTTP長連線、無須在瀏覽器端安裝外掛的“伺服器推”技術為“Comet”。
常用的COMET分為兩種:基於HTTP的長輪詢(long-polling)技術,以及基於iframe的長連線流(stream)模式。
2.1 基於HTTP的長輪詢(long-polling)
在簡單輪詢中,我們會每隔一定的時間向後端請求。這種方式最大的問題之一就是,資料的獲取延遲受限於輪詢間隔,無法第一時間獲取服務想要推送資料。
長輪詢是再此基礎上的一種改進。客戶端發起請求後,服務端會保持住該連線,直到後端有資料更新後,才會將資料返回給客戶端;客戶端在收到響應結果後再次傳送請求,如此迴圈往復。關於簡單輪詢與長輪詢的區別,一圖勝千言:
這樣,服務端一旦有資料想要推送,可以及時送達到客戶端。
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);
});
});
複製程式碼
效果如下:
2.2 基於iframe的長連線流(stream)模式
當我們在頁面中嵌入一個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流這兩種COMMET技術,具有了不錯的實用價值,其特點在於相容性非常強,不需要客戶端或服務端支援某些新的特性。不過,為了便於處理COMET使用時的一些問題,還是推薦在生產環境中考慮一些成熟的第三方庫。值得一提的是,Socket.io在不相容WebSocket(我們後面會提到)的瀏覽器中也會回退到長輪詢模式。
然而,COMET技術並不是HTML5標準的一部分,從相容標準的角度出發的話,並不推薦使用。(尤其在我們有了一些其他技術之後)
3. SSE (Server-Sent Events)
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")來分隔。每個事件的資料可能由多行組成。
- 型別為空白,表示該行是註釋,會在處理時被忽略。
- 型別為 data,表示該行包含的是資料。以 data 開頭的行可以出現多次。所有這些行都是該事件的資料。
- 型別為 event,表示該行用來宣告事件的型別。瀏覽器在收到資料時,會產生對應型別的事件。例如我在上面自定義的
my_msg
事件。 - 型別為 id,表示該行用來宣告事件的識別符號。
- 型別為 retry,表示該行用來宣告瀏覽器在連線斷開之後進行再次連線之前的等待時間。
可以看到,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。
4. WebSocket
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裡,感興趣的朋友可以在這裡下載,並在本地執行檢視。