在 ASP.NET 用 Server-Sent Events 實現即時廣播
0 |
Server-Sent Events 算是蠻古老的技術,可實現伺服器端對瀏覽器的單向串流傳輸,目前除了 IE,所有瀏覽器都支援。
但提到網站串流傳輸,不是已經有 WebSocket、SignalR 了,Server-Sent Events 還有實用價值嗎?有!
如果是伺服器對瀏覽器的單向串流傳輸,Server-Sent Events 最大好處是 IIS 不需特別開啟功能、不依賴第三方程式庫,即便是 ASP.NET 2.0/3.5 Web Site 網站,用一支 .aspx 就能實現,面對這種極簡風格的解決方案,我向來無法抵抗。
關於 Server-Sent Events 的基本用法,MDN 有篇很棒的中文教學,以該教學為基礎,我試著在 ASP.NET 用 Server-Sent Events (以下簡稱 SSE) 實現即時廣播。
先看最終成果:
在以上展示中,index.html 放了四個 iframe,透過按鈕控制載入或離開測試網頁 client.html。client.html 載入時將建立 EventSource 物件連上 sse-service.aspx 接收訊息。ASPX 端則用一個 ConcurrentDictionary 掌握目前連線的 client.html,即時將目前線上人員廣播給各網頁,所以當有網頁加入或退出時,線上人數會即時更新。
偵測離開網頁是靠 HttpResponse.IsClientConnected,它能感測瀏覽器切換網址或關閉,在展示後段還示範重新整理網頁,可看到線上人員歸零。
測試過程我遇到一個問題,ASP.NET Request 預設有 90 秒的執行時限,若讓 sse-service.aspx 不斷迴圈等著送廣播,一到 90 秒會出現逾時錯誤:
(註:SSE 在遇到錯誤或連線中斷時會自動重連,客戶端可透過 .close() 停止自動重連,伺服器端則可透過傳回 HTTP 301 或 307 導向正常網頁或傳回 204 停止重連。參考)
面對這個狀況,我採取的策略不是延長 executionTimeout,而是限定 sse-service.aspx 每次執行最多一分鐘就結束,在 client.html 則在 onerror 事件偵測伺服器端結束進行重連(或交給 SSE 的自動重連機制亦可)。如此的好處是,若 HttpResponse.IsClientConnected 遇到瀏覽器或網路異常未正確偵測到客戶端離線,最多也只會存活一分鐘,不致等到海枯石爛虛耗資源,縮短 sse-service.aspx Thread 壽命有利提升系統穩定性。所以大家可留意展示中每個 iframe 右上角有個數字,它是 SSE 連線的持續秒數,到 60 秒時會歸零重新開始。
來看程式碼,client.html 如下:
<!DOCTYPE html>
<html>
<head>
<style>
html, body { font-size: 9pt; }
#dura { position: absolute; top: 2px; right: 2px; opacity: 0.5; }
</style>
</head>
<body>
<div id="stat"></div><div id="dura"></div>
<ul id="msgs"></ul>
<script>
const sseKey = Math.random().toString().substr(2, 4);
var evtSource;
var dura = 0;
function updateDura() {
document.getElementById('dura').innerText = (dura++) + "s";
}
setInterval(updateDura, 1000);
var debounce;
function connect() {
dura = 0;
updateDura();
evtSource = new EventSource('sse-service.aspx?k=' + sseKey);
evtSource.onmessage = function (e) {
const li = document.createElement('li');
li.innerText = e.data;
document.getElementById('msgs').prepend(li);
}
evtSource.addEventListener('stat', function (e) {
clearTimeout(debounce);
debounce = setTimeout(function() {
document.getElementById('stat').innerText = e.data;
}, 200);
});
evtSource.onerror = function (err) {
evtSource.close();
connect();
}
}
connect();
</script>
</body>
</html>
程式同時示範用 onmessage 接收未指定 EventId 的資料,用 addEventListener 接收 EventId: 'stat' 資訊,由於 60 秒重連時會出現人數先減一再馬上加一的閃動(由此可見 SSE 的即時性),我用了 clearTimeout()、setTimeout() 的 Debounce 手法避免重連時的人數跳動(如果想看閃動效果可把 clearTimeout(debounce) 註解掉)。
再來看 ASPX 端,sse-service.aspx 如下:
<%@ Page Language="C#" %>
<%@ Import Namespace="System.Collections.Concurrent" %>
<script runat="server">
public class SseProcessor
{
static ConcurrentDictionary<string, SseProcessor> pool = new ConcurrentDictionary<string, SseProcessor>();
string sseKey;
HttpResponse response;
public SseProcessor(string sseKey, HttpResponse response)
{
this.sseKey = sseKey;
this.response = response;
}
public static void Broadcast(string evtId, string message)
{
Broadcast(evtId + "\t" + message);
}
public static void Broadcast(string message)
{
foreach(var p in pool.Values)
try
{
lock(p) { p.Messages.Enqueue(message); }
}
catch {
//ignore
}
}
public Queue<string> Messages = new Queue<string>();
public void Run()
{
response.ContentType = "text/event-stream";
pool.TryAdd(this.sseKey, this);
Broadcast("stat", pool.Count() + " users online");
try
{
var timeout = DateTime.Now.AddSeconds(60);
while (response.IsClientConnected && DateTime.Now.CompareTo(timeout) < 0)
{
if (Messages.Any())
{
var msg = Messages.Dequeue();
var p = msg.Split('\t');
if (p.Length == 2)
{
response.Write("event: " + p[0] + "\n");
response.Write("data: " + p[1] + "\n\n");
}
else
response.Write("data: " + msg + "\n\n");
response.Flush();
}
System.Threading.Thread.Sleep(100);
}
}
finally
{
SseProcessor dummy;
pool.TryRemove(this.sseKey, out dummy);
}
Broadcast("stat", pool.Count() + " users online");
}
}
void Page_Load(object sender, EventArgs e)
{
if (Request["m"] == "broadcast")
SseProcessor.Broadcast(Request["t"] ?? "nothing");
else {
var sseKey = Request["k"] ?? Guid.NewGuid().ToString().Substring(0, 4);
var proc = new SseProcessor(sseKey, Response);
proc.Run();
}
}
</script>
等待廣播訊息傳送到客戶端的邏輯被包成一個 SseProcessor 類別,在其中用 ConcurrentDictionary 掌握 SSE 連線,提供 static void Broadcast() 對所有 SSE 連線廣播,Run() 則是一個 while 迴圈每 0.1 秒跑一次,若有訊息就往伺服器端送,迴圈會在客戶端離線或滿一分鐘時結束。由於擔心程式出錯會讓 SseProcessor 殘留,我加了一些 try/catch/finally 避免。
完整程式我放上 Github 了,有需要的同學可自行 Clone,在 IIS 開個 Web Application,把 sse 目錄放進去應該就可以玩了。
補充一些應用考量:
- Server-Sent Events 會固定佔用一條 TCP 連線,在伺服端耗用一條 Thread,在規劃時記得估算同時上線人線,評估伺服器是否能承載。
- 瀏覽器對同一台網域名稱的連線數上限只有六條,Server-Sent Events 固定佔用會影響瀏覽器從網站下載其他內容,若把六條連線用光,此時下載 js、css、呼叫 AJAX 的請求全部都會被擱置。(將範例程式的 iframe 數增加到六個即可體驗)
未來伺服器若改用 HTTP/2 協定(同時連線上限 100 條),此限制可望改善。
A simple ASP.NET example to broadcast via server-sent events with single .aspx.
Comments
Be the first to post a comment