mcpField
D
dotnet-realtime-communication

dotnet-realtime-communication

github.com/wshaddix開発8674 回インストール35 週間前に更新
リポジトリ

dotnet-realtime-communication について

.NET 開発者向けのリアルタイム通信ガイドです。SignalR、SSE、JSON-RPC 2.0、gRPC ストリーミングを比較し、それぞれの実装方法と選定の考え方を説明します。

主な機能

  1. 01SignalR のリアルタイム通信とトランスポート交渉
  2. 02ASP.NET Core の SSE サーバープッシュ
  3. 03JSON-RPC 2.0 のリクエストとレスポンス
  4. 04gRPC ストリーミングパターン
  5. 05プロトコル比較と選定指針

活用シーン

  1. 01ブラウザーアプリ向けリアルタイムプロトコルの選定
  2. 02サーバーからの状態更新の実装
  3. 03WebSocket または stdio 上での JSON-RPC 構築
  4. 04サービス間ストリーミング方式の比較

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-realtime-communication

インストール後、Claude Code で/dotnet-realtime-communicationを入力して読み込まれたか確認します。

詳細

dotnet-realtime-communication

.NET アプリケーション向けのリアルタイム通信パターンです。SignalR(自動フォールバックに対応した WebSocket 経由の全二重通信)、Server-Sent Events(SSE、.NET 10 の ASP.NET Core に組み込み済み)、JSON-RPC 2.0(あらゆるトランスポートを介した構造化されたリクエスト/レスポンス)、gRPC ストリーミング(高性能なバイナリストリーミング)を比較します。要件に基づいて適切なプロトコルを選ぶための指針を示します。

対象外: HTTP クライアントファクトリのパターンとレジリエンスパイプラインについては、[skill:dotnet-http-client] と [skill:dotnet-resilience] を参照してください。ネイティブ AOT アーキテクチャとトリミング戦略については、AOT コンパイルの [skill:dotnet-native-aot]、AOT ファースト設計パターンの [skill:dotnet-aot-architecture]、トリミングに安全な開発の [skill:dotnet-trimming] を参照してください。Blazor 固有の SignalR の使用(コンポーネント統合、Blazor Server のサーキット管理、レンダーモードとの連携)については、Blazor のホスティングモデルとサーキットのパターンを解説する [skill:dotnet-blazor-patterns] を参照してください。

相互参照:gRPC ストリーミングの実装の詳細と 4 つすべてのストリーミングパターンについては、[skill:dotnet-grpc] を参照してください。リアルタイム通信エンドポイントのテストについては、[skill:dotnet-integration-testing] を参照してください。Blazor 固有の SignalR サーキット管理とレンダーモードとの連携については、[skill:dotnet-blazor-patterns] を参照してください。


プロトコルの比較

プロトコル通信方向トランスポート形式ブラウザーのサポート最適な用途
SignalR全二重WebSocket、SSE、Long Polling(自動ネゴシエーション)JSON または MessagePackあり(JS/TS クライアント)インタラクティブアプリ、チャット、ダッシュボード、共同編集
SSE(.NET 10)サーバーからクライアントへのみHTTP/1.1+テキスト(通常は JSON 行)あり(ネイティブの EventSource API)通知、ライブフィード、ステータス更新
JSON-RPC 2.0リクエスト/レスポンス任意(HTTP、WebSocket、stdio)JSONトランスポートによるツール用プロトコル(LSP)、シンプルなトランスポートを介した構造化 RPC
gRPC ストリーミング4 つすべてのパターンHTTP/2Protobuf(バイナリ)限定的(gRPC-Web)サービス間通信、高スループット、低レイテンシのストリーミング
どれを選ぶか
  • SignalR:ブラウザークライアントとの双方向リアルタイム通信が必要な場合に適しています。SignalR はトランスポートのネゴシエーションを自動的に処理します(WebSocket を優先し、利用できない場合は SSE、続いて Long Polling にフォールバックします)。クライアントがリアルタイムで送受信する必要がある場合に使用してください。
  • SSE(.NET 10 に組み込み済み):サーバーからクライアントへのプッシュのみが必要な場合に適しています。双方向通信が不要な場合は、SignalR よりシンプルです。.NET 10 の ASP.NET Core に組み込まれているため、追加のパッケージは不要です。ブラウザーのネイティブな EventSource API で動作します。
  • JSON-RPC 2.0:シンプルなトランスポートを介した、構造化されたリクエスト/レスポンスのセマンティクスが必要な場合に適しています。Language Server Protocol(LSP)や一部の .NET ツールで使用されます。ストリーミングプロトコルではありません。WebSocket または stdio を介して、型付きパラメーターを持つ名前付きメソッドが必要な場合に使用してください。
  • gRPC ストリーミング:最高のパフォーマンスが必要なサービス間ストリーミングに適しています。4 つすべてのストリーミングパターン(単項、サーバーストリーミング、クライアントストリーミング、双方向)に対応しています。両方のエンドポイントが .NET サービスまたは gRPC 互換の場合に最適です。実装の詳細については、[skill:dotnet-grpc] を参照してください。

SignalR

SignalR は、自動接続管理とトランスポートネゴシエーションに対応したリアルタイムの Web 機能を提供します。

サーバーのセットアップ
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR(options =>
{
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();
    options.MaximumReceiveMessageSize = 64 * 1024; // 64 KB
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);
});

var app = builder.Build();

app.MapHub<NotificationHub>("/hubs/notifications");
Hub の実装
public sealed class NotificationHub(
    ILogger<NotificationHub> logger) : Hub
{
    public override async Task OnConnectedAsync()
    {
        var userId = Context.UserIdentifier;
        if (userId is not null)
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}");
        }

        await base.OnConnectedAsync();
    }

    // Client-to-server method
    public async Task SendMessage(string channel, string message)
    {
        // Broadcast to all clients in the channel group
        await Clients.Group(channel).SendAsync("ReceiveMessage",
            Context.UserIdentifier, message);
    }

    // Server-to-client streaming
    public async IAsyncEnumerable<StockPrice> StreamPrices(
        string symbol,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            yield return await GetLatestPrice(symbol, cancellationToken);
            await Task.Delay(1000, cancellationToken);
        }
    }
}
型付き Hub

インターフェイスを使用して、クライアントメソッドの呼び出し時にコンパイル時の安全性を確保します。

public interface INotificationClient
{
    Task ReceiveMessage(string user, string message);
    Task OrderStatusChanged(int orderId, string status);
}

public sealed class NotificationHub(
    ILogger<NotificationHub> logger) : Hub<INotificationClient>
{
    public async Task SendMessage(string channel, string message)
    {
        // Compile-time checked -- no magic strings
        await Clients.Group(channel).ReceiveMessage(
            Context.UserIdentifier!, message);
    }
}
Hub の外部からの送信

IHubContext をインジェクションすると、バックグラウンドサービスやコントローラーからメッセージを送信できます。

public sealed class OrderService(
    IHubContext<NotificationHub, INotificationClient> hubContext)
{
    public async Task UpdateOrderStatus(int orderId, string userId, string status)
    {
        // Send to specific user group
        await hubContext.Clients.Group($"user:{userId}")
            .OrderStatusChanged(orderId, status);
    }
}
トランスポートネゴシエーション

SignalR は最適なトランスポートを自動的にネゴシエーションします。

  1. WebSocket(優先)-- 全二重、最小レイテンシ
  2. Server-Sent Events -- サーバーからクライアントへのみ。WebSocket が利用できない場合にフォールバックします。
  3. Long Polling -- あらゆる環境で使えるフォールバック。レイテンシが最も高くなります。

必要に応じて、特定のトランスポートを指定します。

// Server: disable specific transports
app.MapHub<NotificationHub>("/hubs/notifications", options =>
{
    options.Transports = HttpTransportType.WebSockets |
                         HttpTransportType.ServerSentEvents;
    // Disables Long Polling
});
MessagePack プロトコル

ペイロードを小さくし、シリアル化を高速化するには MessagePack を使用します。

// Server
builder.Services.AddSignalR()
    .AddMessagePackProtocol();

// Client (JavaScript)
// new signalR.HubConnectionBuilder()
//     .withUrl("/hubs/notifications")
//     .withHubProtocol(new signalR.protocols.msgpack.MessagePackHubProtocol())
//     .build();
接続のライフサイクル

OnConnectedAsync と OnDisconnectedAsync をオーバーライドして、接続状態を管理します。

public sealed class NotificationHub(
    ILogger<NotificationHub> logger,
    IConnectionTracker tracker) : Hub<INotificationClient>
{
    public override async Task OnConnectedAsync()
    {
        var userId = Context.UserIdentifier;
        var connectionId = Context.ConnectionId;

        logger.LogInformation("Client {ConnectionId} connected (user: {UserId})",
            connectionId, userId);

        // Track connection for presence features
        if (userId is not null)
        {
            await tracker.AddConnectionAsync(userId, connectionId);
            await Groups.AddToGroupAsync(connectionId, $"user:{userId}");
        }

        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        var userId = Context.UserIdentifier;
        var connectionId = Context.ConnectionId;

        if (exception is not null)
        {
            logger.LogWarning(exception,
                "Client {ConnectionId} disconnected with error", connectionId);
        }

        if (userId is not null)
        {
            await tracker.RemoveConnectionAsync(userId, connectionId);
        }

        await base.OnDisconnectedAsync(exception);
    }
}
グループ管理

グループは軽量な Pub/Sub の仕組みを提供します。接続は複数のグループに所属でき、グループへの所属は接続ごとに管理されます。

public sealed class ChatHub : Hub<IChatClient>
{
    // Join a room (called by clients)
    public async Task JoinRoom(string roomName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
        await Clients.Group(roomName).UserJoined(Context.UserIdentifier!, roomName);
    }

    // Leave a room
    public async Task LeaveRoom(string roomName)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName);
        await Clients.Group(roomName).UserLeft(Context.UserIdentifier!, roomName);
    }

    // Send to specific group
    public async Task SendToRoom(string roomName, string message)
    {
        await Clients.Group(roomName).ReceiveMessage(
            Context.UserIdentifier!, message);
    }

    // Send to all except caller
    public async Task BroadcastExceptSelf(string message)
    {
        await Clients.Others.ReceiveMessage(
            Context.UserIdentifier!, message);
    }
}

グループは永続化されません。接続が切断されるとクリアされます。必要に応じて(データベースやキャッシュから復元するなど)、OnConnectedAsync で接続をグループに再追加してください。

クライアントからサーバーへのストリーミング

クライアントは IAsyncEnumerable<T> または ChannelReader<T> を使って、ハブにデータをストリーミングできます。

public sealed class UploadHub : Hub
{
    // Accept a stream of items from the client
    public async Task UploadData(
        IAsyncEnumerable<SensorReading> stream,
        CancellationToken cancellationToken)
    {
        await foreach (var reading in stream.WithCancellation(cancellationToken))
        {
            await ProcessReading(reading);
        }
    }
}
認証

SignalR は ASP.NET Core ホストと同じ認証を使用します。WebSocket 接続では、WebSocket がカスタムヘッダーをサポートしていないため、アクセストークンはクエリー文字列で送信されます。

// Server: configure JWT for SignalR
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://identity.example.com";
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                // Read token from query string for WebSocket requests
                var accessToken = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;
                if (!string.IsNullOrEmpty(accessToken) &&
                    path.StartsWithSegments("/hubs"))
                {
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapHub<NotificationHub>("/hubs/notifications")
    .RequireAuthorization();

ハブ内で Context.UserIdentifier にアクセスして、認証済みユーザーを識別します。デフォルトでは、この値は ClaimTypes.NameIdentifier クレームに対応します。IUserIdProvider を使ってカスタマイズできます。

public sealed class EmailUserIdProvider : IUserIdProvider
{
    public string? GetUserId(HubConnectionContext connection)
    {
        return connection.User?.FindFirst(ClaimTypes.Email)?.Value;
    }
}

// Register
builder.Services.AddSingleton<IUserIdProvider, EmailUserIdProvider>();
バックプレーンによるスケーリング

複数サーバーでデプロイする場合は、バックプレーンを使用してインスタンス間のメッセージを同期します。バックプレーンがないと、あるサーバーから送信されたメッセージは、別のサーバー上の接続からは見えません。

Redis バックプレーン:

builder.Services.AddSignalR()
    .AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis")!,
        options =>
        {
            options.Configuration.ChannelPrefix =
                RedisChannel.Literal("MyApp:");
        });

Azure SignalR Service(マネージドバックプレーン):

builder.Services.AddSignalR()
    .AddAzureSignalR(builder.Configuration["Azure:SignalR:ConnectionString"]);

Azure SignalR Service は接続管理を全面的に引き受けます。ASP.NET Core サーバーはハブのロジックを処理し、Azure は WebSocket 接続、スケーリング、メッセージルーティングを管理します。


Server-Sent Events(SSE)— .NET 10

.NET 10 では ASP.NET Core に SSE のサポートが組み込まれ、追加パッケージなしでサーバーからクライアントへのストリーミングを簡単に実装できます。

Minimal API エンドポイント
app.MapGet("/events/orders", async (
    OrderEventService eventService,
    CancellationToken cancellationToken) =>
{
    // TypedResults.ServerSentEvents returns an SSE response
    return TypedResults.ServerSentEvents(
        eventService.GetOrderEventsAsync(cancellationToken));
});
イベントソースの実装
public sealed class OrderEventService
{
    public async IAsyncEnumerable<SseItem<OrderEvent>> GetOrderEventsAsync(
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            var evt = await WaitForNextEvent(cancellationToken);
            yield return new SseItem<OrderEvent>(evt, "order-update");
        }
    }
}
ブラウザクライアント
const source = new EventSource('/events/orders');

source.addEventListener('order-update', (event) => {
    const order = JSON.parse(event.data);
    updateDashboard(order);
});

source.onerror = () => {
    // EventSource automatically reconnects
    console.log('SSE connection lost, reconnecting...');
};
SignalR より SSE を選ぶ場合
  • 一方向のプッシュのみ — クライアントからサーバーへのメッセージが不要な場合、SSE のほうがシンプルです
  • ブラウザ標準 — JavaScript ライブラリは不要です(EventSource API を使用します)
  • 自動再接続 — ブラウザは Last-Event-ID を使って自動的に再接続します
  • HTTP/1.1 互換 — WebSocket アップグレードをサポートしないプロキシ経由でも動作します

JSON-RPC 2.0

JSON-RPC 2.0 は、JSON でエンコードされた、ステートレスでトランスポートに依存しないリモートプロシージャコールプロトコルです。Language Server Protocol(LSP)の基盤であり、一部の .NET ツールのシナリオで使用されています。

プロトコルの構造
// Request
{"jsonrpc": "2.0", "method": "textDocument/completion", "params": {...}, "id": 1}

// Response
{"jsonrpc": "2.0", "result": {...}, "id": 1}

// Notification (no response expected)
{"jsonrpc": "2.0", "method": "textDocument/didChange", "params": {...}}

// Error
{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": 1}
StreamJsonRpc(.NET ライブラリ)

StreamJsonRpc は、JSON-RPC 2.0 の主要な .NET ライブラリです。

<PackageReference Include="StreamJsonRpc" Version="2.*" />
// Server: expose methods via JSON-RPC over a stream
using StreamJsonRpc;

public sealed class CalculatorService
{
    public int Add(int a, int b) => a + b;
    public Task<double> DivideAsync(double a, double b) =>
        b == 0 ? throw new ArgumentException("Division by zero")
               : Task.FromResult(a / b);
}

// Wire up over a WebSocket -- UseWebSockets() is required for upgrade handling
app.UseWebSockets();
app.Map("/jsonrpc", async (HttpContext context) =>
{
    if (!context.WebSockets.IsWebSocketRequest)
    {
        context.Response.StatusCode = 400;
        return;
    }

    var ws = await context.WebSockets.AcceptWebSocketAsync();
    using var rpc = new JsonRpc(new WebSocketMessageHandler(ws));
    rpc.AddLocalRpcTarget(new CalculatorService());
    rpc.StartListening();
    await rpc.Completion;
});
// Client
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri("ws://localhost:5000/jsonrpc"),
    CancellationToken.None);

using var rpc = new JsonRpc(new WebSocketMessageHandler(ws));
rpc.StartListening();

var result = await rpc.InvokeAsync<int>("Add", 2, 3);
// result == 5
JSON-RPC 2.0 を選ぶ場合
  • Language Server Protocol(LSP)の実装を構築または統合する場合
  • gRPC では大がかりすぎる場合に、WebSocket または stdio 経由でシンプルな RPC を行う場合
  • JSON-RPC を使用する .NET 以外のシステムと相互運用する場合
  • ツールやエディターとの統合

gRPC ストリーミング

4 種類すべてのストリーミングパターン(単項、サーバーストリーミング、クライアントストリーミング、双方向ストリーミング)、認証、ロードバランシング、ヘルスチェックなど、gRPC の実装に関する詳細は [skill:dotnet-grpc] を参照してください。

簡易判断:gRPC ストリーミング、SignalR、SSE の比較
要件選択肢
サービス間通信、両方が .NETgRPC ストリーミング
ブラウザクライアントで双方向通信が必要SignalR
ブラウザクライアントでサーバープッシュのみ必要SSE
最大スループット、バイナリペイロードgRPC ストリーミング
ブラウザクライアントで自動再接続が必要SSE(標準機能)または SignalR(組み込み機能)
複数のクライアントプラットフォーム(JS、モバイル、.NET)SignalR

主な原則

  • ブラウザー向けのリアルタイム通信には、既定で SignalR を使用します -- トランスポートのネゴシエーション、再接続、グループ化が標準で処理されます
  • シンプルなサーバープッシュには SSE を使用します -- .NET 10 の組み込みサポートにより、一方向の通知では最も軽量な選択肢です
  • サービス間通信には gRPC ストリーミングを使用します -- 最高のパフォーマンス、強く型付けされたコントラクト、4 種類すべてのストリーミングパターンに対応しています
  • ツーリングプロトコルには JSON-RPC 2.0 を使用します -- シンプルなトランスポート(WebSocket、stdio)を介した構造化 RPC が必要な場合に適しています
  • 強く型付けされたハブを使用します -- Hub<T> を使うと、メソッド名のタイプミスを実行時ではなくコンパイル時に検出できます
  • バックプレーンを使って SignalR をスケーリングします -- 複数サーバーのデプロイでは、Redis または Azure SignalR Service を使用します

AOT コンパイルパイプラインについては [skill:dotnet-native-aot] を、AOT 互換のリアルタイム通信パターンについては [skill:dotnet-aot-architecture] を参照してください。


エージェントが注意すべき点

  1. SSE で十分な場合に SignalR を使用しないでください -- 双方向通信を必要とせず、サーバーからクライアントへのプッシュだけが必要な場合は、SSE のほうがシンプルで軽量です。
  2. クライアントが MessagePack を使用する場合、サーバーで AddMessagePackProtocol() を呼び出し忘れないでください -- プロトコルが一致しないと、接続が失敗しても通知されないことがあります。
  3. 必要がない限り、SignalR で Long Polling トランスポートを使用しないでください -- WebSocket と比べて、レイテンシーとサーバーリソースの使用量が大幅に増加します。
  4. 接続 ID を長期間保存しないでください -- SignalR の接続 ID は再接続時に変わります。アドレス指定にはユーザー識別子またはグループを使用してください。
  5. gRPC ストリーミングをブラウザーに直接使用しないでください -- ブラウザーは HTTP/2 トレーラーをネイティブでサポートしていません。プロキシを介して gRPC-Web を使用するか、SignalR/SSE を選択してください。
  6. SSE と WebSocket を混同しないでください -- SSE は一方向(サーバーからクライアントのみ)です。クライアントからサーバーへのメッセージが必要な場合は、SignalR または WebSocket を直接使用してください。
  7. SignalR で JWT を使用する場合、OnMessageReceived を設定し忘れないでください -- WebSocket 接続では、初回のハンドシェイク後にカスタム HTTP ヘッダーを送信できません。アクセストークンは JwtBearerEvents.OnMessageReceived でクエリ文字列から読み取る必要があります。
  8. グループへの所属が再接続後も維持されると思い込まないでください -- グループは接続 ID に紐づいており、再接続時に接続 ID は変わります。OnConnectedAsync で接続をグループに再追加してください。
  9. バックプレーンなしで複数サーバーに SignalR をデプロイしないでください -- Redis または Azure SignalR Service がないと、あるサーバーインスタンスで送信されたメッセージは、ほかのインスタンス上の接続からは見えません。

クレジット

Aaronontheweb/dotnet-skills(MIT ライセンス)をもとにしています。


参考資料

コメント