Html5實踐之EventSource
服務端推
服務端推,指的是由伺服器主動的向客戶端傳送訊息(響應)。在應用層的HTTP協議實現中,“請求-響應”是一個round trip,它的起點來自客戶端,因此在應用層之上無法實現簡易的服務端推功能。當前解決服務端推送的方案有這幾個:
- 客戶端長輪詢
- websocket雙向連線
- iframe永久幀
長輪訓雖然可以避免短輪訓造成的服務端過載,但在服務端返回資料後仍需要客戶端主動發起下一個長輪訓請求,等待服務端響應,這樣仍需要底層的連線建立而且服務端處理邏輯需要相應處理,不符合邏輯上的流程簡單的服務端推送;
websocket連線相對而言功能最強大,但是它對伺服器的版本有要求,在可以使用websocket協議的伺服器上儘量採用此種方式;
iframe永久幀則是在在頁面嵌入一個專用來接受資料的iframe頁面,該頁面由伺服器輸出相關資訊,如,伺服器不停的向iframe中寫入類似的script標籤和資料,實現另一種形式的服務端推送。不過永久幀的技術會導致主頁面的載入條始終處於“loading”狀態,體驗很差。
HTML5規範中提供了服務端事件EventSource,瀏覽器在實現了該規範的前提下建立一個EventSource連線後,便可收到服務端的傳送的訊息,這些訊息需要遵循一定的格式,對於前端開發人員而言,只需在瀏覽器中偵聽對應的事件皆可。
相比較上文中提到的3中實現方式,EventSource流的實現方式對客戶端開發人員而言非常簡單,相容性上出了IE系的瀏覽器(IE、Edge)外其他都良好;對於服務端,它可以相容老的瀏覽器,無需upgrade為其他協議,在簡單的服務端推送的場景下可以滿足需求。在瀏覽器與服務端需要強互動的場景下,websocket仍是不二的選擇。
EventSource規範簡析
瀏覽器端
瀏覽器端,需要建立一個EventSource物件,並且傳入一個服務端的介面URI作為引數。
var evtSource = new EventSource('http://localhost:9111/es');
其中,'http://localhost:9111/es'為服務端吐出資料的介面。目前,EventSource在大多數瀏覽器端不支援
跨域,因此它不是一種跨域的解決方案。
預設EventSource物件通過偵聽“message”事件獲取服務端傳來的訊息,“open”事件則在http連線建立後觸發,”error“事件會在通訊錯誤(連線中斷、服務端返回資料失敗)的情況下觸發。同時,EventSource規範允許服務端指定自定義事件,客戶端偵聽該事件即可。
evtSource.addEventListener('message',function(e){
console.log(e.data);
});
evtSource.addEventListener('error',function(e){
console.log(e);
})
服務端
事件流的對應MIME格式為text/event-stream,而且其基於HTTP長連線。針對HTTP1.1規範預設採用長連線,針對HTTP1.0的伺服器需要特殊設定。
服務端返回資料需要特殊的格式,它分為四種訊息型別:
event, data, id, retry
其中,event指定自定義訊息的名稱,如event: customMessage\n;
data指定具體的訊息體,可以是物件或者字串,如data: JSON.stringify(jsonObj)\n\n
,在訊息體後面有兩個換行符\n,代表當前訊息體傳送完畢,一個換行符標識當前訊息並未結束,瀏覽器需要等待後面資料的到來後再觸發事件;
id為當前訊息的識別符號,可以不設定。一旦設定則在瀏覽器端的eventSource物件中就會有體現(假設服務端返回id: 369\n),eventSource.lastEventId == 369
。該欄位使用場景不大;
retry設定當前http連線失敗後,重新連線的間隔。EventSource規範規定,客戶端在http連線失敗後預設進行重新連線,重連間隔為3s,通過設定retry欄位可指定重連間隔;
每個欄位都有名稱,緊接著有個”:“。當出現一個沒有名稱的欄位而只有”:“時,這就會被服務端理解為”註釋“,並不會被髮送至瀏覽器端,如: commision。
由於EventSource是基於HTTP連線之上的,因此在一段沒有資料的時期會出現超時問題。伺服器預設HTTP超時時間為2分鐘,在node端可以通過response.connection.setTimeou(0)設定為預設的2min超時, 因此需要服務端做心跳保活,否則客戶端在連線超時的情況下出現net::ERR_INCOMPLETE_CHUNKED_ENCODING錯誤。通過閱讀相關規範,發現註釋行可以用來防止連線超時,伺服器可以定期傳送一條訊息註釋行,以保持連線不斷。
下面提供koa的服務端程式碼:
var fs = require('fs');
var path = require('path');
var PassThrough = require('stream').PassThrough;
var Readable = require('stream').Readable;
var koa = require('koa');
var Router = require('koa-router');
var app = new koa();
var router = new Router();
function RR(){
Readable.call(this,arguments);
}
RR.prototype = new Readable();
RR.prototype._read = function(data){
}
router.get('/',function(ctx,next){
ctx.set('content-type','text/html');
ctx.body = fs.readFileSync(path.join(process.cwd(),'eventServer.html'));
});
const sse = (stream,event, data) => {
return stream.push(`event:${ event }\ndata: ${ JSON.stringify(data) }\n\n`)
// return stream.write(`event:${ event }\ndata: ${ JSON.stringify(data) }\n\n`);
}
router.get('/es',function(ctx,next){
var stream = new RR()//PassThrough();
ctx.set({
'Content-Type':'text/event-stream',
'Cache-Control':'no-cache',
Connection: 'keep-alive'
});
sse(stream,'test',{a: "yango",b: "tango"});
ctx.body = stream;
setInterval(()=>{
sse(stream,'test',{a: "yango",b: Date.now()});
},3000);
});
app.use(router.routes());
app.listen(9111,function(){
console.log('listening port 9111');
});
此處需要注意的是koa-router的返回值必須是一個Stream(Readable),這是由於koa的特殊性造成的。如果context.body不是Stream是一個字串或者Buffer例項,會直接在node原生中呼叫res.end(buffer),結束了HTTP響應:
koa lib/application.js
// responses
if (Buffer.isBuffer(body)) return res.end(body);
if ('string' == typeof body) return res.end(body);
if (body instanceof Stream) return body.pipe(res);
因此造成了服務端事件流無法正確響應。而返回Stream型別的方式有幾種,如通過擴充套件stream模組的Readable可讀流返回或者直接採用PassThrough流返回,亦可通過through2模組或者Transform物件實現,歸根到底保證可以從該stream物件中pipe出資料至http.ServerResponse物件中。
附頁面程式碼
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<div>
hello world
</div>
<p id="info"></p>
<script>
var infoShow = document.querySelector('#info');
var se = new EventSource('http://localhost:9111/es');
se.addEventListener('test',function(e){
infoShow.textContent += e.data+'\n';
});
se.addEventListener('error',function(e){
console.log(e);
})
</script>
</body>
</html>