HTML5 - Server-Send Event (SSE)

在網頁前端如果想要被動接收到 Server-Side 的訊息,最早可以透過 long-polling 的方法。而隨著 HTML5 出來後,WebSocket 協助我們解決了 Client-Side 和 Server-Side 的即時雙向溝通,ASP.NET SignalR 也出現了,然而當我們只需要前端被動接收 Server-Side 的資料這種單向溝通的情況下,我們會思考到真的需要用到雙向溝通的 WebSocket 嗎?這時限量的回答會是NO!因為其實有另外一個方式,Server-Sent Events (SSE),這就是限量今天要講的主題。


SSE 的概念簡單來說就是單向的WebSocket,所謂的單向是 Server-Side 到 Client-Side。除了與 WebSocket 差異在單向之外,最不同的是相較於 WebSocket 走獨立的協定,SSE 走的是純 HTTP 協定,所以不用擔心一般的 Web Server 無法處理。

在教大家如何使用之前,我們先來了解核心的內部資料結構:
SSE 主要的技術核心就是透過 Javascript API ,EventSource APIEventSource 定義了 onopen, onerror, onmessage 三個基本事件。onopen 為當連線開啟時觸發的事件;onerror 為當連線發生錯誤時觸發的事件;onmessage 為當 Client-Side 接收到 Server-Side 傳送的訊息時觸發的事件。另外可以透過 EventSource 的 readyState 得知目前的連線狀態下表為EventSource所定義的連線狀態:


連線狀態(readyState)
狀態
備註
CONNECTING
0
連線中
OPEN
1
開啟
CLOSED
2
關閉

再來看看我們接收到的資料格式:
Server-Side 送出的資料 MIME TYPE 為 text/event-stream,資料結構有三個欄位:

  • event:事件類別,意思是要傳送給哪個事件,如果不填的話,預設是onmessage。
  • data:資料,如果是物件或陣列的話需要序列化,瀏覽器接收到後可用 event.data 取得資料。
  • retry:傳送時間間隔,預設為3000毫秒,如果要修改必須從 Server-Side 傳送該欄位。
  • id:事件ID,該欄位如果有設定的話,瀏覽器在接收到 Server-Side 資料後會在 Buffer 紀錄 last event id 的欄位,如果斷線的話,瀏覽器會在 Request 加入 last event id,告知 Server-Side 從這個事件開始重送。

這裡要特別注意的是,設置的每個欄位後面都要加入\n,data 欄位則是要加入 \n\n,所以最後瀏覽器接收到的應該是類似下列格式:


event: update
retry: 5000
data: [{Title: 'Test', Date: '20161213'}]

data: 1000

event: info
data: {Name: 'Limited'}

上面格式表示三次的傳送,可以看到 data 都是最後一個,而且 data 後面的\n\n區隔了各次傳送的資料。

好了,最後限量用一個 Log 即時檢視的範例 DEMO SSE 實際的運作情況:

Server-Side
[RoutePrefix("log")]
public class RandomValueController : ApiController
{
    /// <summary>
    /// 即時取得LOG資料
    /// </summary>
    [Route("info")]
    [HttpGet]
    public IHttpActionResult GetInfoLevelLog()
    {
        try
        {
            var rsp = Request.CreateResponse();

            var infoList = GenerateRandomLogList(LogLevel.Info, new Random().Next(0, 11));

            // 傳送至update事件,改為每5秒傳送
            var json = $"event: update\nretry: 5000\ndata: {JsonConvert.SerializeObject(infoList)}\n\n";

            rsp.Content = new StringContent(json, Encoding.UTF8, "text/event-stream");

            return ResponseMessage(rsp);
        }
        catch (Exception e)
        {
            return InternalServerError(e);
        }
    }

    /// <summary>
    /// 隨機產生LOG資料
    /// </summary>
    private IList<LogRecord> GenerateRandomLogList(LogLevel level, int count)
    {
        var logList = new List<LogRecord>();
        for (var i = 0; i < count; i++)
        {
            var log = new LogRecord
            {
                Title = level.ToString() + i,
                Level = level,
                Message = $"logging {level.ToString()} at {DateTimeOffset.Now.ToString()}"
            };
            logList.Add(log);
        }
        return logList;
    }
}

/// <summary>
/// LOG資料結構
/// </summary>
internal class LogRecord
{
    public string Title { get; set; }

    public string ID { get; } = Guid.NewGuid().ToString();

    public DateTimeOffset Timestamp { get; } = DateTimeOffset.Now;

    public string Message { get; set; }

    public LogLevel Level { get; set; }
}

/// <summary>
/// LOG分類
/// </summary>
[Flags]
internal enum LogLevel
{
    Debug = 0,
    Info,
    Warn,
    Error,
    Fatal,
    All = Debug | Info | Warn | Error | Fatal
}

Client-Side
$(function () {
    // 檢查瀏覽器是否支援SSE
    if (typeof (EventSource) === 'undefined') {
        alert('Browser does not support SSE');
    } else {
        var source = new EventSource('/log/info');

        // 連線開啟事件
        source.onopen = function () {
            switch (event.target.readyState) {
                // 1
                case EventSource.OPEN:
                    console.log('onopen open ' + new Date().toString());
                    break;
                // 0
                case EventSource.CONNECTING:
                    console.log('onopen connecting ' + new Date().toString());
                    break;
                // 2
                case EventSource.CLOSED:
                    console.log('onopen closed ' + new Date().toString());
                    break;
                default:
                    break;
            }
        };

        // 預設接收訊息事件
        source.onmessage = function (event) {
            console.log('onmessage ' + new Date().toString());
        };

        // 連線錯誤事件
        source.onerror = function (event) {
            switch (event.target.readyState) {
                    // 0
                case EventSource.CONNECTING:
                    console.log('onerr connecting ' + new Date().toString());
                    break;
                    // 2
                case EventSource.CLOSED:
                    console.log('onerr closed ' + new Date().toString());
                    break;
                default:
                    break;
            }
        };

        // 自訂事件
        source.addEventListener('update', function (event) {
            var logList = JSON.parse(event.data);
            for (var i = 0; i < logList.length; i++) {
                var log = logList[i];
                var $tr = $('<tr></tr>');
                $tr.append('<td>' + log.ID + '</td>')
                    .append('<td>' + log.Title + '</td>')
                    .append('<td>' + log.Timestamp + '</td>')
                    .append('<td>' + log.Level + '</td>')
                    .append('<td>' + log.Message + '</td>');
                if ($('#msg_table > tbody > tr:first').length == 0) {
                    $('#msg_table > tbody').append($tr);
                } else {
                    $('#msg_table > tbody > tr:first').before($tr)
                }
            }
        });
    }

    window.stopSSE = function () {
        source.close();
    };
});

簡單來說,這個範例就是在 Server-Side 隨機產生LOG資料來模擬一直有LOG產生,再來 Server-Side 每隔5秒會將資料傳送至 Client-Side。Client-Side 接收到資料後,會將資料插入表格中,較新的資料會在表格最上方。 

前端實作方式很簡單,就產生一個 EventSource 的實體並丟入 Server-Side 的 URL,然後再定義一些事件實作就OK了。對了,如果要關掉連線可以透過 EventSource 本身的 close 方法


執行結果:





執行結果可以看到 Client-Side 接收到資料會進 onerror 等待重新連線,過5秒(範例裡設定的)後才會在連線接收到資料。

下圖提供從開發人員工具擷取的 SSE 資訊






了解了 SSE 之後就多了一種可以用的 Server Polling 的方法,這樣就不用每次都只有 WebSocket 了。在雙向溝通的情況下,如果 Server-Side Polling 比重比較大時也可以用 SSE,只要把 Client-Side Polling 的部分改成用一般的 AJAX 可以省下一些 Effort。最後要提醒的是不管你用哪一種技術都要注意各瀏覽器是否有支援喔


瀏覽器
Chrome
IE
FireFox
Safari
Opera
SSE
9
X
6
5
11

最後在附上一張比較表(排名):



Long-Polling
Server-Sent Event
WebSocket
瀏覽器支援程度
1
3(IE不支援)
2
Server負載程度
3
2
1
Client負載程度
3
1
1
時間
3
2
1
實作複雜程度
1
1
3(Web Server要有支援)


站內相關文章:

WebSocket - 新一代網路傳輸技術WebSocket簡介
WebSocket - WebSocket Server Console實作
SignalR - .Net Real Time Web傳輸技術

參考來源:

MDN - Server-sent events
W3C - Server-Sent Events
WebSockets vs Server-Sent Events vs Long-polling
HTML Living Standard - Last Updated 12 December 2016
GTW - HTML5 的 Server-Sent Events 串流使用教學





留言