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 目錄放進去應該就可以玩了。

補充一些應用考量:

  1. Server-Sent Events 會固定佔用一條 TCP 連線,在伺服端耗用一條 Thread,在規劃時記得估算同時上線人線,評估伺服器是否能承載。
  2. 瀏覽器對同一台網域名稱的連線數上限只有六條,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

Post a comment