🌟

.NET 10 で Server Sent Events 対応の Web API を作って呼ぶ方法

に公開

はじめに

最近の AI 関係の API は Server Sent Events (SSE) とかでリアルタイムにぱらぱらと結果を返してくれる API が増えてきました。.NET でも .NET 10 (2025/04/26 時点ではプレビュー) でそういう API を簡単に作れるようになっているので、ここではサクッと .NET で作って、サクッと .NET から呼んでみたいと思います。.NET 9 まででも SSE の API は作れましたが、自分でレスポンスに SSE のレスポンスを書き込むような実装をしなければいけませんでした。.NET 10 では、非常に簡単にストリーム対応の API を作れるようになっています。

Web API の作成

まずは WebAPI を作ります。Visual Studio 2022 (Preview) で .NET 10 の ASP.NET Core Web API プロジェクトを作成します。
プロジェクト名は任意ですが、ここでは StreamWebApiLab として作成した前提で進めていきます。作成の際のオプションは以下のようにしました。

では実装していきましょう。StreamWebApiLab プロジェクトの Program.cs を開いて以下のように修正します。

Program.cs
var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.UseHttpsRedirection();

// SSE 対応の /chat/messages エンドポイントを定義
app.MapGet("/chat/messages", (int length) =>
{
    // IAsyncEnumerable を返すメソッドを定義
    static async IAsyncEnumerable<ChatMessage> GetMessages(int length)
    {
        var random = Random.Shared;
        var users = new[] { "Alice", "Bob", "Charlie", "Diana" };
        var messages = new[]
        {
            "Hello, world!",
            "How are you?",
            "Goodbye!",
            "See you later!",
            "Have a great day!"
        };
        for (int i = 0; i < length; i++)
        {
            await Task.Delay(1000);
            yield return new ChatMessage(
                DateTimeOffset.UtcNow, 
                users[random.Next(users.Length)], 
                messages[random.Next(messages.Length)]);
        }
    }

    // ServerSentEvents を使って SSE 対応のレスポンスを返す。
    // コンテンツは GetMessages メソッドが返す内容になる。
    return TypedResults.ServerSentEvents(GetMessages(length));
});

app.Run();

// チャットメッセージのレスポンスの型定義
record ChatMessage(DateTimeOffset Timestamp, string User, string Message);

このコードでは、/chat/messages エンドポイントを定義しています。クエリパラメータ length で指定された数だけ、1 秒ごとにランダムなチャットメッセージを返すようになっています。TypedResults.ServerSentEvents メソッドを使うことで、SSE 対応のレスポンスを簡単に返すことができます。

では実際に呼んでみましょう。プロジェクトにある StreamWebApiLab.http ファイルを開いて以下のように変更してエンドポイントを叩きます。

StreamWebApiLab.http
@StreamWebApiLab_HostAddress = http://localhost:5025

GET {{StreamWebApiLab_HostAddress}}/chat/messages?length=5
Accept: text/event-stream

正しくコードが書けているなら、以下のようにレスポンスが返ってきます。

5 つのメッセージを要求しているので大体5秒でレスポンスが返ってきて、ボディの内容も SSE の形式の data: で始まっていることがわかります。

Web API の呼び出し

次に、.NET からこの API を呼び出してみます。StreamWebApiLab.Client という名前でコンソール アプリケーションのプロジェクトを作成します。そして Program.cs を以下のように修正します。

Program.cs
using System.Text.Json;

var client = new HttpClient
{
    BaseAddress = new("http://localhost:5025")
};

// 5 件のメッセージを取得する
await using var stream = await client.GetStreamAsync("/chat/messages?length=5");
using var reader = new StreamReader(stream);

var jsonSerializerOptions = new JsonSerializerOptions
{
    PropertyNameCaseInsensitive = true,
    Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};

const string prefix = "data: ";
string? line;
// 終わるまで読み込み続ける
while ((line = await reader.ReadLineAsync()) != null)
{
    var span = line.AsSpan();
    // 先頭に "data: " が付いている場合は、JSON としてデシリアライズする
    if (span.StartsWith(prefix))
    {
        var json = span[prefix.Length..];
        var message = JsonSerializer.Deserialize<ChatMessage>(json, jsonSerializerOptions);
        Console.WriteLine($"Client timestamp: {TimeProvider.System.GetUtcNow()}, Server timestamp {message?.Timestamp}: {message?.User} - {message?.Message}");
    }
}

// すぐ終わらないようにするためにキー入力を待つ
Console.ReadKey();

// チャットメッセージのレスポンスの型定義
record ChatMessage(DateTimeOffset Timestamp, string User, string Message);

HttpClient を使って /chat/messages エンドポイントを叩いて、ストリームを取得しています。ストリームから 1 行ずつ読み込んで、先頭に data: が付いている行だけを JSON としてデシリアライズして表示しています。サーバーを起動したあとにクライアントを起動すると、以下のようにメッセージが表示されます。

Client timestamp: 2025/04/26 6:28:04 +00:00, Server timestamp 2025/04/26 6:28:04 +00:00: Charlie - Hello, world!
Client timestamp: 2025/04/26 6:28:05 +00:00, Server timestamp 2025/04/26 6:28:05 +00:00: Alice - Hello, world!
Client timestamp: 2025/04/26 6:28:06 +00:00, Server timestamp 2025/04/26 6:28:06 +00:00: Diana - Hello, world!
Client timestamp: 2025/04/26 6:28:07 +00:00, Server timestamp 2025/04/26 6:28:07 +00:00: Bob - See you later!
Client timestamp: 2025/04/26 6:28:08 +00:00, Server timestamp 2025/04/26 6:28:08 +00:00: Alice - See you later!

ちゃんとクライアント タイムスタンプとサーバー タイムスタンプが同期してることがわかります。つまり、サーバーで生成したメッセージを即座にクライアントに返すことができていることがわかります。

余談: SSE じゃないストリーム対応の API

SSE を使わなくても純粋に JSON をちょっとずつ返す API で SSE のようなことも出来ます。これは現行の .NET で簡単にできます。詳細は省きますが単に IAsyncEnumerable<T> を返す API を作って、クライアント側は GetFromJsonAsAsyncEnumerable メソッドを使うだけです。以下のように書きます。

サーバー側の実装は以下のようになります。シンプルに IAsyncEnumerable<T> を返すだけです。

Program.cs
var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.UseHttpsRedirection();

// ストリーム対応の /chat/messages エンドポイントを定義
app.MapGet("/chat/messages", (int length) =>
{
    // IAsyncEnumerable を返すメソッドを定義
    static async IAsyncEnumerable<ChatMessage> GetMessages(int length)
    {
        var random = Random.Shared;
        var users = new[] { "Alice", "Bob", "Charlie", "Diana" };
        var messages = new[]
        {
            "Hello, world!",
            "How are you?",
            "Goodbye!",
            "See you later!",
            "Have a great day!"
        };
        for (int i = 0; i < length; i++)
        {
            await Task.Delay(1000);
            yield return new ChatMessage(
                DateTimeOffset.UtcNow, 
                users[random.Next(users.Length)], 
                messages[random.Next(messages.Length)]);
        }
    }

    // IAsyncEnumerable を返す
    return TypedResults.Ok(GetMessages(length));
});

app.Run();

// チャットメッセージのレスポンスの型定義
record ChatMessage(DateTimeOffset Timestamp, string User, string Message);

クライアント側は以下のように書きます。GetFromJsonAsAsyncEnumerable<T> メソッドを使うことで、ストリームを非同期に取得することができます。

Program.cs
using System.Net.Http.Json;

var client = new HttpClient
{
    BaseAddress = new("http://localhost:5025")
};

// GetFromJsonAsAsyncEnumerable<T> メソッドを使用して、/chat/messages?length=5 エンドポイントからチャットメッセージを非同期に取得
await foreach (var message in client.GetFromJsonAsAsyncEnumerable<ChatMessage>("/chat/messages?length=5"))
{
    if (message == null) continue;

    // 受信したメッセージを表示
    Console.WriteLine($"Client timestamp: {TimeProvider.System.GetUtcNow()}, Server timestamp: {message.Timestamp:yyyy/MM/dd HH:mm:ss} {message.User}: {message.Message}");
}

// すぐ終わらないようにするためにキー入力を待つ
Console.ReadKey();

// チャットメッセージのレスポンスの型定義
record ChatMessage(DateTimeOffset Timestamp, string User, string Message);

実行すると以下のようになります。ちゃんとストリームで受信していることがわかります。

Client timestamp: 2025/04/26 6:45:23 +00:00, Server timestamp: 2025/04/26 06:45:23 Alice: Hello, world!
Client timestamp: 2025/04/26 6:45:24 +00:00, Server timestamp: 2025/04/26 06:45:24 Bob: How are you?
Client timestamp: 2025/04/26 6:45:25 +00:00, Server timestamp: 2025/04/26 06:45:25 Diana: Hello, world!
Client timestamp: 2025/04/26 6:45:26 +00:00, Server timestamp: 2025/04/26 06:45:26 Charlie: Hello, world!
Client timestamp: 2025/04/26 6:45:27 +00:00, Server timestamp: 2025/04/26 06:45:27 Charlie: See you later!

JavaScript の EventSource を使うのではなく .NET から呼ぶだけの Web API の場合は、こっちの方が実装が楽なのでいいですね。JavaScript や他の言語の SSE のクライアントに対応する必要がある場合は SSE の方がいいと思います。

まとめ

ということで .NET 10 で SSE 対応の Web API を簡単に作れる機能が追加されるので、それを試してみました。今までも data: で始まるレスポンスをレスポンスのボディに自分で書けば行けたのですが、ちょっとめんどくさいので .NET 10 で簡単にできるようになったのは嬉しいですね。ただ、クライアント側の機能は無さそうなので、個人的には欲しいなぁと思いました。

Microsoft (有志)

Discussion