5

在 ASP.NET 用 Server-Sent Events 實現即時廣播

 2 years ago
source link: https://blog.darkthread.net/blog/server-sent-events-aspx/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
在 ASP.NET 用 Server-Sent Events 實現即時廣播-黑暗執行緒

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 條),此限制可望改善。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK