Chapter 07

SignalR 实时通信

掌握 SignalR Hub 设计、强类型接口、组管理与 Redis Backplane 横向扩展,实现多人聊天室。

SignalR 原理

SignalR 是 ASP.NET Core 的实时通信库,它会自动选择最优传输协议:优先 WebSocket,降级为 Server-Sent Events(SSE),最后降级为 Long Polling。开发者无需关心底层协议细节。

WebSocket
TCP 全双工长连接,服务端和客户端都能主动发送消息,延迟最低(毫秒级)。是 SignalR 的首选传输方式。需要浏览器/代理支持(现代环境几乎全部支持)。
Server-Sent Events(SSE)
单向长连接,只有服务端可以主动推送数据给客户端(客户端发消息仍通过普通 HTTP)。实现简单,基于 HTTP/1.1,不需要特殊代理配置。适合只需要服务端推送的场景。
Long Polling
客户端发起 HTTP 请求,服务端保持连接直到有消息可推送或超时。降级方案,兼容性最好但效率最低。现代应用几乎不需要回退到此模式。
Hub
SignalR 的核心抽象,类似 Controller 但面向长连接。客户端连接到 Hub 后,可以调用 Hub 上的方法,Hub 也可以调用客户端上的方法。Hub 是临时性的——每次调用都会实例化一个新的 Hub 对象,不适合存储状态。

定义 Hub

// 强类型 Hub:通过接口定义客户端方法(推荐)
public interface IChatClient
{
    Task ReceiveMessage(ChatMessage message);
    Task UserJoined(string userName);
    Task UserLeft(string userName);
}

public record ChatMessage(string Sender, string Content, DateTimeOffset SentAt);

[Authorize]
public class ChatHub(IUserService userService) : Hub<IChatClient>
{
    // 客户端调用服务端方法
    public async Task SendMessage(string roomId, string content)
    {
        var userName = Context.User!.FindFirstValue(ClaimTypes.Name)!;
        var msg = new ChatMessage(userName, content, DateTimeOffset.UtcNow);

        // 广播给房间所有成员(包括发送者)
        await Clients.Group(roomId).ReceiveMessage(msg);
    }

    public async Task JoinRoom(string roomId)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, roomId);
        var userName = Context.User!.FindFirstValue(ClaimTypes.Name)!;

        // 通知房间其他成员(排除发送者)
        await Clients.OthersInGroup(roomId).UserJoined(userName);
    }

    public async Task LeaveRoom(string roomId)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomId);
        var userName = Context.User!.FindFirstValue(ClaimTypes.Name)!;
        await Clients.Group(roomId).UserLeft(userName);
    }

    // 连接/断开回调
    public override async Task OnConnectedAsync()
    {
        await userService.SetOnlineAsync(Context.UserIdentifier!);
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? ex)
    {
        await userService.SetOfflineAsync(Context.UserIdentifier!);
        await base.OnDisconnectedAsync(ex);
    }
}

注册与配置

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

// 映射 Hub 端点
app.MapHub<ChatHub>("/hubs/chat");
app.MapHub<NotificationHub>("/hubs/notifications");

// JWT 认证在 WebSocket 中需要特殊处理(Token 在 Query String)
builder.Services.AddAuthentication().AddJwtBearer(options =>
{
    options.Events = new JwtBearerEvents
    {
        OnMessageReceived = ctx =>
        {
            var token = ctx.Request.Query["access_token"];
            var path  = ctx.HttpContext.Request.Path;
            if (!string.IsNullOrEmpty(token) && path.StartsWithSegments("/hubs"))
                ctx.Token = token;
            return Task.CompletedTask;
        }
    };
});

服务端主动推送(IHubContext)

// 在任意服务中向客户端推送消息
public class OrderNotificationService(IHubContext<ChatHub, IChatClient> hubContext)
{
    public async Task NotifyOrderCompletedAsync(string userId, Order order)
    {
        var msg = new ChatMessage(
            "System",
            $"您的订单 #{order.Id} 已完成,共 {order.Total:C}",
            DateTimeOffset.UtcNow
        );

        // 向特定用户推送(可能有多个连接)
        await hubContext.Clients.User(userId).ReceiveMessage(msg);
    }

    public async Task BroadcastSystemAlertAsync(string alert)
    {
        // 广播给所有连接的客户端
        await hubContext.Clients.All.ReceiveMessage(
            new ChatMessage("System", alert, DateTimeOffset.UtcNow));
    }
}

Redis Backplane:横向扩展

单实例 SignalR 的连接信息(哪个用户在哪个连接上)只存在于该进程内存中。当你有多个服务器实例时,用户 A 连接到 Server 1,用户 B 连接到 Server 2,Server 1 无法直接向 Server 2 上的用户 B 推送消息。Redis Backplane 通过 Redis 的发布订阅机制解决这个问题:所有服务器实例订阅同一 Redis 频道,广播消息时发布到 Redis,所有实例都会收到并转发给本地连接的客户端。

// 安装:dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis

builder.Services.AddSignalR()
    .AddStackExchangeRedis(
        builder.Configuration.GetConnectionString("Redis")!,
        options => options.Configuration.ChannelPrefix = RedisChannel.Literal("chat-app")
    );

JavaScript 客户端

// 安装: npm install @microsoft/signalr
import * as signalR from "@microsoft/signalr";

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/chat", {
        accessTokenFactory: () => getAuthToken()  // JWT 令牌
    })
    .withAutomaticReconnect([0, 2000, 10000, 30000])  // 自动重连间隔
    .configureLogging(signalR.LogLevel.Information)
    .build();

// 监听服务端事件
connection.on("ReceiveMessage", (msg) => {
    appendMessage(msg.sender, msg.content, msg.sentAt);
});

connection.on("UserJoined", (userName) => {
    showNotification(`${userName} 加入了房间`);
});

// 启动连接
await connection.start();

// 调用服务端方法
await connection.invoke("JoinRoom", roomId);
await connection.invoke("SendMessage", roomId, messageText);

// 断开时清理
await connection.stop();
本章小结 SignalR 自动处理 WebSocket/SSE/Long Polling 降级,让开发者专注于业务逻辑。强类型 Hub(Hub<TClient>)比字符串方法名更安全,重构时不会遗漏。IHubContext<THub, TClient> 让任意服务都可以向客户端推送消息,打破了 Hub 只在连接生命周期内使用的限制。多实例部署时,Redis Backplane 是首选的横向扩展方案,配置极简。