ASP.NET Core で Server-Sent-Events を用いたリアルタイム処理を実装してみる

## はじめに
先日 Qiitaで以下の記事を拝見しました。

https://qiita.com/naoki_hayashida/items/5f5e9dd6d785a4de3326

あらためてそれぞれの通信の特徴や違いが分かる素晴らしい記事なので、これらの理解が曖昧で言語化できない人はぜひ目をとおすことをお勧めします。正直自分も何となくの理解をしていたんだということを痛感したところです。。。

フワフワ～な理解になっていたそもそもの原因は、**聞いたことがあるだけで実際にその技術を利用して動くモノを作ったことがない** からですね。せっかく知識をあらためてインプットしたので、ここはあらためてアウトプットしてみようと思いました。（作るモノは大したことのないしょうもないデモページですが、自分の手で作ることが意味のあることだと思っておりやす。）

とりあえず今回は、 **Server-Sent Events（SSE）** を用いたデモを、自分が一番利用することの多い ASP.NET Core による実装で作成してみます。また、実装についてはChatGPT先生にもご助言いただきました。

## Server-Sent Events (SSE) 
### Server-Sent Events (SSE) とは？
SSEは、サーバー → クライアントの一方向的なメッセージストリームを送るための仕組みです。
クライアント（ブラウザ）は EventSource オブジェクトを用いてサーバーからのイベントを受け取ります。
SSEはHTTPをベースとしており、サーバーから「text/event-stream」というコンテントタイプでメッセージをプッシュし続けます。
### 特徴
* 一方向通信（サーバー → クライアント）
* ブラウザ標準でサポートされている (EventSourceを使うだけ)
* 実装が比較的シンプル
### どんなケースで利用するの？
クライアント側でサーバー情報をほぼリアルタイムで反映したいが、特にクライアントからの送信が不要な場合に有効です。商品在庫数の定期的なアップデートだったり、システムステータス通知だったりとかですかね。

### 実装
#### サーバーサイド（コントローラークラス）
```c#:SseController.cs
public class SseController : Controller {

    [HttpGet("/see")]
    public IActionResult Index() {

        return View();
    }

    [HttpGet("/sse/stream")]
    public async Task Stream() {
        // ContentTypeをSSE用に設定
        Response.ContentType = "text/event-stream";
        Response.Headers.Add("Cache-Control", "no-cache");
        Response.Headers.Add("X-Accel-Buffering", "no"); // 必要に応じてバッファ無効化（NGINXなど使用時）

        // 明示的にHTTPレスポンスを非同期で継続送信するため、HTTPレスポンスを閉じないようにする
        // （Action本体終了時にも接続が維持されるようにするためにTaskで無限ループを実行）
        // 実運用ではキャンセルトークンなどで安全に終了可能とすることを推奨
        Response.StatusCode = 200;

        // 5秒おきにメッセージを送信する
        var counter = 0;
        while (!HttpContext.RequestAborted.IsCancellationRequested) {
            counter++;
            // SSEのメッセージフォーマット: "data: メッセージ本体\n\n"
            var message = $"data: 現在は {counter} 回目です ({DateTime.Now:HH:mm:ss})\n\n";
            await Response.WriteAsync(message);
            await Response.Body.FlushAsync();

            // 5秒待機
            await Task.Delay(5000, HttpContext.RequestAborted);
        }
    }
}
```
最初のリクエストを受信後、接続を閉じずに定期的にデータを返却します。例では ５秒毎に文字列を返却しています。
実装のポイントとしては以下のとおりです。
* Response.ContentType = "text/event-stream" でSSE用コンテンツタイプを設定する
* レスポンスメッセージのフォーマットは「`data:`で始まり、改行で終わる」形式とする
※ `data:`以外にも`id:`、`event:`、`retry:`といったフィールドが定義されています
* RequestAborted で接続中断を検知する

#### クライアントサイド（cshtml）
※_Layout.cshtml は省略
```html:Index.cshtml
@{
    ViewData["Title"] = "Server-Sent Events";
}
<h1>Server-Sent Eventsデモ</h1>
<div class="controls">
    <button id="startBtn">接続開始</button>
    <button id="stopBtn">接続停止</button>
</div>

<div id="status">ステータス: 未接続</div>
<div id="messages"></div>

@section Css{
    <style>
        body {
          font-family: Arial, sans-serif;
          margin: 20px;
        }
        h1 {
          font-size: 1.5em;
          margin-bottom: 1em;
        }
        #messages {
          border: 1px solid #ccc;
          padding: 10px;
          width: 300px;
          height: 200px;
          overflow-y: scroll;
          background: #f9f9f9;
        }
        .msg {
          border-bottom: 1px solid #ddd;
          padding: 5px 0;
          font-size: 0.9em;
        }
    </style>
}

@section Scripts {
    <script>
        let eventSource = null; // SSE接続用のEventSourceインスタンス。初期はnull。
        const statusDiv = document.getElementById("status");   // 接続状況を表示するエリア
        const messagesDiv = document.getElementById("messages"); // 受信したメッセージを表示するエリア

        // 接続開始用の関数
        function startConnection() {
            // すでに接続中の場合は何もしない（二重接続防止）
            if (eventSource && eventSource.readyState === EventSource.OPEN) {
                return;
            }

            // SSEエンドポイント "/sse/stream" に接続を開始
            eventSource = new EventSource("/sse/stream");

            // 接続が確立したときに呼ばれる処理
            eventSource.onopen = function (e) {
                statusDiv.textContent = "ステータス: 接続成功";
            };

            // サーバーからメッセージを受信したときに呼ばれる処理
            eventSource.onmessage = function (e) {
                // 受信メッセージ用のdiv要素を作成
                const msg = document.createElement("div");
                msg.textContent = e.data;   // 受信データをテキストとして表示
                msg.className = "msg";                
                messagesDiv.appendChild(msg);
                messagesDiv.scrollTop = messagesDiv.scrollHeight; // 最新メッセージが見えるようにスクロール
            };

            // 接続中に何らかのエラーが発生した場合や接続が切れたときに呼ばれる処理
            eventSource.onerror = function (e) {
                // EventSourceの状態がCLOSEDの場合、明示的なクローズまたはサーバー側で接続終了を検知
                if (eventSource.readyState === EventSource.CLOSED) {
                    statusDiv.textContent = "ステータス: 接続切断";
                } else {
                    // エラー状態のため、次回接続試行やエラーハンドリングを検討
                    statusDiv.textContent = "ステータス: エラー";
                }
            };

            // 接続開始直後は「接続中」と表示しておく
            statusDiv.textContent = "ステータス: 接続中...";
        }

        // 接続停止用の関数
        function stopConnection() {
            if (eventSource) {
                // EventSourceを明示的にクローズして接続停止
                eventSource.close();
                eventSource = null;
                statusDiv.textContent = "ステータス: 未接続";
            }
        }

        // 接続開始・停止ボタンにイベントハンドラを登録
        document.getElementById("startBtn").addEventListener("click", startConnection);
        document.getElementById("stopBtn").addEventListener("click", stopConnection);
    </script>
}
```
サーバーからメッセージがプッシュされる度に、メッセージを表示する要素を追加しています。
ポイントは以下のとおりです。
* new EventSource("/sse/stream")でSSEに接続する
* onmessageでサーバーからのメッセージを受信して操作する

実際の動きは以下のとおりです。開発者ツールで確認してもリクエスト自体は最初の一回のみで、サーバーからメッセージを受信しているのが分かります。

![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/55588/0af125d7-6bd2-7298-ac1f-1cffbca0dbc5.gif)

## まとめ
今までふわっとしか理解していなかった Server-Sent-Events について、ASP.NET Core で実装してみることで理解が深まりました。
よくあるポーリング処理に比べて、リクエスト数が大幅に減らせることで、サーバーリソースを節約できるのが最大の利点なのかと思います。ただし、大規模なtoCシステムやマルチテナントシステム等で、サーバーに何万という同時接続が発生する場合は、スケールアウトやスケールアップ、WebSocket等の別手法を選択肢することも必要なのかなと思いましたってところで今回は終わりにしまっす！
ありがとうございました！
## 参考
[Webのリアルタイム通信、双方向通信を学ぶ（SSE、WebSocket、WebRTC、WebTransport） - Qiita](https://qiita.com/naoki_hayashida/items/5f5e9dd6d785a4de3326 "Webのリアルタイム通信、双方向通信を学ぶ（SSE、WebSocket、WebRTC、WebTransport） #websocket - Qiita")
[サーバー送信イベント - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/Server-sent_events "サーバー送信イベント - Web API | MDN")
[HTML Standard](https://html.spec.whatwg.org/multipage/server-sent-events.html "HTML Standard")