ASP.NET 10 Server Sent Event

Intro

.NET 9 中我們支持了 SseItem 我們可以藉助它來解析 ServerSentEvent 的 response,在 .NET 10 進一步增加了 SseItem 並在 ASP.NET Core 中原生支持了返回 ServerSentEvent 結果,在 server 端返回 sse 結果變得更加容易

What

什麼是 Server Sent Event?

Server-Sent Events (SSE) 是一種由服務器主動向瀏覽器單向推送數據的技術。與傳統的輪詢或 WebSocket 不同,SSE 使用 HTTP 協議 建立一個持久連接,然後由服務器不斷通過這個連接發送事件數據給客户端。

特性

SSE

WebSocket

HTTP 輪詢

通信方向

服務器 → 客户端(單向)

雙向

客户端主動請求

協議

基於 HTTP

獨立的 WebSocket 協議

HTTP

實現複雜度

簡單

相對複雜

簡單但效率低

兼容性

大多數現代瀏覽器支持

大多數現代瀏覽器支持

所有瀏覽器

重連機制

內置

需手動實現

無需(每次新連接)

如果需求只是 服務端向瀏覽器實時推送數據(例如消息通知、實時數據流),SSE 通常比 WebSocket 更輕量且實現簡單。

chatgpt 之類的流式響應也都是基於 Server Sent Event 來實現的,前端使用基本示例如下:

const evtSource = new EventSource("/sse");

evtSource.onmessage = function(event) {
    console.log("收到數據:", event.data);
};

evtSource.addEventListener("customEvent", function(event) {
    console.log("收到自定義事件:", event.data);
});

evtSource.onerror = function(err) {
    console.error("連接出錯", err);
};

// 關閉連接
evtSource.close();

我們可以註冊一個 EventSource 對象,會觸發一個 HTTP GET 請求,然後後端會逐步地流式返回對應的數據,當連接異常斷開時客户端會自動嘗試重連,當不再需要時可以通過 close() 方法關閉當前 EventSource 連接

New API

新的 API 定義如下:

public static class TypedResults 
{
+    public static ServerSentEventResult<string> ServerSentEvents<T>(
+        IAsyncEnumerable<string> value, 
+        string? eventType = null);
+
+    public static ServerSentEventResult<T> ServerSentEvents<T>(
+        IAsyncEnumerable<T> value, 
+        string? eventType = null);
+
+    public static ServerSentEventResult<T> ServerSentEvents<T>(
+        IAsyncEnumerable<SseItem<T>> value);
}

+ public sealed class ServerSentEventResult<T> : IResult, IEndpointMetadataProvider
+ {
+    static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder)
+ }

Samples

我們來看一個使用示例:

var app = WebApplication.Create();
app.MapGet("/sse", (CancellationToken cancellationToken) => 
    Results.ServerSentEvents(GetSseData(cancellationToken)));
app.MapGet("/sse1", (CancellationToken cancellationToken) => 
    Results.ServerSentEvents(GetSseTypedData(cancellationToken), "heartbeat"));
app.MapGet("/sse2", (CancellationToken cancellationToken) => 
    Results.ServerSentEvents(GetSseItem(cancellationToken), "heartbeat"));
await app.RunAsync();


static async IAsyncEnumerable<string> GetSseData(
    [EnumeratorCancellation]CancellationToken cancellationToken
    )
{
    while (!cancellationToken.IsCancellationRequested)
    {
        await Task.Delay(1000, cancellationToken);
        yield return $"date: {DateTimeOffset.Now}";
    }
}

static async IAsyncEnumerable<EchoDataModel> GetSseTypedData(
    [EnumeratorCancellation]CancellationToken cancellationToken
)
{
    while (!cancellationToken.IsCancellationRequested)
    {
        await Task.Delay(1000, cancellationToken);
        yield return new EchoDataModel("test", DateTimeOffset.Now);
    }
}

static async IAsyncEnumerable<SseItem<DateTimeOffset>> GetSseItem(
    [EnumeratorCancellation]CancellationToken cancellationToken
)
{
    while (!cancellationToken.IsCancellationRequested)
    {
        await Task.Delay(1000, cancellationToken);
        yield return new SseItem<DateTimeOffset>(DateTimeOffset.Now)
        {
            EventId = Guid.CreateVersion7().ToString("N"),
            // ReconnectionInterval = TimeSpan.FromSeconds(10)
        };
    }
}

public record EchoDataModel(string Data, DateTimeOffset Date);

這裏的示例有三種使用方式,第一種方式是直接返回了一個 IAsyncEnumerable<string> 對象,會直接返回給客户端

我們從瀏覽器的網絡請求中可以看到 Server Sent Event 的一些特點,有一些特定的 response header,示例如下,並且可以看到 response 的內容,針對 Server Sent Event 還會有一個 EventStream 的 tab 可以更友好地查看收到的消息

ASP.NET Core 10 中的 Server Sent Event_API

sse response headers

ASP.NET Core 10 中的 Server Sent Event_API_02

sse response

這裏我們沒有指定 eventType 可以使用前面示例的 onmessage 方法處理,示例如下:

evtSource.onmessage = (event) => {
    console.log(`Received message: ${event.data}`);
};

第二個示例指定了一個 eventType heartbeat,並且第二個示例返回一個強類型的數據模型 IAsyncEnumerable<EchoDataModel>,response 示例如下:

ASP.NET Core 10 中的 Server Sent Event_Server_03

sse with eventType

可以看到此時我們 response 里加了一個 event: heartbeat 這行輸出指定了收到的數據的 eventType 為 heartbeat,在 chrome network 請求的 EventStream 中對應的 type 也從前面的 message 變成了 heartbeat,我們返回的數據被序列化成了 JSON,JSON 格式的控制是和 API 的 JSON 序列化設置保持一致的

我們再來看最後一個例子,最後一個例子返回的是 IAsyncEnumerable<SseItem<DateTimeOffset>>,也指定了 eventType 為 heartbeat

ASP.NET Core 10 中的 Server Sent Event_數據_04

sse-SseItem-response

這裏可以看到 EventId 是空的,eventId 和 data 都在 data 裏被包了一層,感覺這裏是個 BUG,不應該是這樣的,正確的應該是正確指定了 id 的,並且指定 retry 如果指定了的話,在 GitHub 上提了一個 issue,感興趣的朋友可以關注下:https://github.com/dotnet/aspnetcore/issues/64183

根據規範,eventId 以及 retry 應該是一個獨立的一行,類似於 eventType 如下:

ASP.NET Core 10 中的 Server Sent Event_數據_05

然後默認的 retry 時間是 3 秒,可自定義 ReconnectionInterval ,這裏出錯是因為我們調用了錯誤的方法,我們應該在構建 SseItem 時指定 event

More

這一方法不僅可以在 Minimal API 中使用,在 controller 中也是可以使用的,使用示例如下:

public class TestController : ControllerBase
{
    public IResult Sse() => Results.ServerSentEvent(...);
}

最近基於 sse 改造了我們內部的一個 load test 的工具,原來的方式是在服務器端執行一個 k6 腳本,然後讀取 process 的 output 等待讀取完成之後才會返回給客户端這就導致前端一直卡在那裏,什麼數據都不顯示,尤其是跑的 load test 時間長了的話體驗就很差,非常明顯,於是基於 sse 做了一些改造,將進程的輸出流式地保存起來,通過 sse 返回給前端,前端通過處理 sse 返回的數據流式地顯示在前端頁面上了。

References

  • https://developer.mozilla.org/en-US/docs/Web/API/EventSource
  • https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events

• https://source.dot.net/#System.Net.ServerSentEvents/System/Net/ServerSentEvents/SseItem.cs,cf62e7e50c935179

  • https://www.encora.com/insights/real-time-communication-simplified-a-deep-dive-into-server-sent-events-sse
  • https://github.com/WeihanLi/SamplesInPractice/blob/d99879160837c8d36c3f68eadb6c1e51c507f89c/net10sample/Net10Samples/AspNetCoreSample.cs#L29