SignalR:面向 .NET 应用程序的实时通信

ASP.NET Core SignalR 实用指南:用于为 .NET 应用程序添加聊天、实时通知、实时仪表板等实时功能的库,涵盖 Hub、传输协议、分组、横向扩展、身份验证及客户端集成。

SignalR:面向 .NET 应用程序的实时通信

引言

SignalR 是 ASP.NET Core 的实时通信库,它允许服务器代码在事件发生时立即将内容推送给已连接的客户端,而不是让客户端反复轮询某个端点询问”有没有新东西?”。它在底层传输(WebSocket、Server-Sent Events 或长轮询)之上抽象出一套简单的、RPC 风格的编程模型:服务器调用客户端上的方法,客户端调用服务器上的方法,两者都像在调用本地函数一样自然。

// 服务器:向每个已连接客户端推送消息
await hubContext.Clients.All.SendAsync("ReceiveMessage", user, message);
// 客户端:接收消息
connection.on("ReceiveMessage", (user, message) => {
    console.log(`${user}: ${message}`);
});

无需手动处理 WebSocket 帧、无需从头编写重连逻辑、无需为不支持 WebSocket 的客户端或网络设计降级方案——SignalR 全部搞定。

1. 为什么需要 SignalR

轮询的问题

在实时库普及之前,”实时”更新通常靠轮询模拟:

setInterval(async () => {
    const response = await fetch('/api/notifications/latest');
    // ... 检查有没有新内容
}, 3000);

这种方式浪费带宽和服务器资源(大部分请求都返回”没有新东西”)、增加延迟(客户端最多要等一个轮询周期才能看到更新),并且随着连接客户端数量增长难以扩展,每个客户端都在定时器上猛敲服务器,无论实际是否有变化。

SignalR 取而代之提供的能力

  • 推送式更新:服务器在数据可用的一瞬间就发送,没有轮询延迟。
  • 传输协议抽象:SignalR 会根据客户端、服务器及中间代理的支持情况,自动协商最佳可用传输协议(优先 WebSocket,降级到 Server-Sent Events,再到长轮询)。
  • 连接管理:自动重连、连接生命周期事件,以及每个客户端持久化的连接 ID。
  • 分组与定向发送:向所有客户端、指定客户端、命名分组或指定已认证用户发送消息,无需手动维护这些管道。
  • 横向扩展支持:借助 Backplane(Redis、Azure SignalR Service),负载均衡后面的多个服务器实例可以表现为一个逻辑 Hub。

典型使用场景

  • 聊天应用:SignalR 的经典示例。
  • 实时通知:”你的报告已生成”、”有人评论了你的帖子”。
  • 实时仪表板:股票行情、监控仪表板、实时更新的体育比分。
  • 协作编辑指示:”Alice 正在输入……”、共享文档中的光标位置。
  • 实时进度上报:长时间运行的服务器端任务将进度更新流式回传给发起它的客户端。

2. 传输协议与连接协商

SignalR 并不强制要求使用 WebSocket,它会协商当前可用的最佳传输协议,并优雅降级:

传输协议工作原理适用场景
WebSocket全双工、持久 TCP 连接客户端、服务器及任何代理都支持时的首选
Server-Sent Events(SSE)HTTP 上单向服务器→客户端流,客户端通过单独的 HTTP 请求发送数据WebSocket 不可用但浏览器支持 SSE 时的降级方案
长轮询(Long polling)客户端发起请求,服务器一直保持连接直到有数据返回,随后客户端立即重新轮询受限代理或老旧环境下的最后兜底方案
builder.Services.AddSignalR(options =>
{
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();
});
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/chatHub", {
        transport: signalR.HttpTransportType.WebSockets // 需要时可强制指定传输协议
    })
    .build();

在大多数现代部署中,WebSocket 会被自动协商并启用,降级链主要服务于老旧浏览器、阻止 WebSocket 升级的企业代理,或支持能力有限的特定托管环境。

3. Hub:核心抽象

Hub 是 SignalR 的核心抽象:一个既能接收客户端调用、又能向客户端推送调用的类。

一个极简聊天 Hub

public class ChatHub : Hub
{
    public async Task SendMessage(string user, string message)
    {
        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
}
  • SendMessage 是客户端调用的方法(类似一个 RPC 端点)。
  • Clients.All.SendAsync("ReceiveMessage", ...) 是服务器调用已连接客户端方法的方式——"ReceiveMessage" 只是客户端订阅的字符串名称,并不是真正的 C# 方法(第 7 节会介绍强类型替代方案)。

注册

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
var app = builder.Build();
app.MapHub<ChatHub>("/chatHub");
app.Run();

连接生命周期事件

public class ChatHub : Hub
{
    public override async Task OnConnectedAsync()
    {
        await Clients.Caller.SendAsync("ReceiveMessage", "System", "Welcome!");
        await base.OnConnectedAsync();
    }
    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        await Clients.Others.SendAsync("ReceiveMessage", "System", $"{Context.ConnectionId} left.");
        await base.OnDisconnectedAsync(exception);
    }
}

Context.ConnectionId 是 SignalR 为每条连接分配的唯一标识,可用于跟踪在线状态、将连接映射到用户,或定向指定客户端。

4. 从服务器调用客户端

Hub 上的 Clients 属性(或在 Hub 外部注入的 IHubContext<T>)提供多种定向选项:

await Clients.All.SendAsync("ReceiveMessage", user, message);           // 所有已连接客户端
await Clients.Caller.SendAsync("ReceiveMessage", "System", "Hi!");       // 仅调用者
await Clients.Others.SendAsync("ReceiveMessage", user, message);         // 除调用者外的所有客户端
await Clients.Client(connectionId).SendAsync("Notify", "You have mail"); // 指定某条连接
await Clients.Clients(connectionIds).SendAsync("Notify", "Batch update"); // 指定一组连接
await Clients.User(userId).SendAsync("Notify", "Order shipped");         // 指定已认证用户的全部连接

在 Hub 外部调用(例如从控制器或后台服务中)

通常,你需要推送的消息并非由 Hub 方法触发,而是由其他事件触发,后台任务完成、收到 webhook、定时任务等。此时注入 IHubContext<T>

public class OrderService
{
    private readonly IHubContext<OrderHub> _hubContext;
    public OrderService(IHubContext<OrderHub> hubContext) => _hubContext = hubContext;
    public async Task MarkAsShippedAsync(int orderId)
    {
        // ... 更新数据库
        await _hubContext.Clients.User(orderOwnerId).SendAsync("OrderShipped", orderId);
    }
}

这是将 SignalR 与应用其他部分连接起来的标准模式——大多数实时通知并非源于客户端调用 Hub 方法,而是源于需要在事件发生时通知客户端的服务器端业务逻辑。

5. 分组(Groups)

分组让你可以把连接组织起来,向部分客户端广播,例如,查看某个聊天室或仪表板的全部用户。

public class ChatHub : Hub
{
    public async Task JoinRoom(string roomName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
        await Clients.Group(roomName).SendAsync("ReceiveMessage", "System", $"{Context.ConnectionId} joined {roomName}");
    }
    public async Task LeaveRoom(string roomName)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName);
    }

    public async Task SendToRoom(string roomName, string user, string message)
    {
        await Clients.Group(roomName).SendAsync("ReceiveMessage", user, message);
    }
}

分组完全由服务器端管理,并且不会在重连后自动恢复,如果客户端连接断开后重连(获得新的 ConnectionId),你的应用代码需要将其重新加入相关分组,通常是在 OnConnectedAsync 中基于持久化的关联关系(例如”该用户之前在 42 号房间”)来恢复。

6. 强类型 Hub

用 Clients.All.SendAsync("ReceiveMessage", user, message) 这种字符串方法名调用是”弱类型”的,方法名拼写错误只会在运行时暴露,而不是编译时错误,客户端方法的参数也没有 IntelliSense 提示。强类型 Hub 通过共享接口解决这一问题:

public interface IChatClient
{
    Task ReceiveMessage(string user, string message);
    Task UserJoined(string user);
}
public class ChatHub : Hub<IChatClient>
{
    public async Task SendMessage(string user, string message)
    {
        await Clients.All.ReceiveMessage(user, message); // 编译期检查的方法调用
    }
}
public class NotificationService
{
    private readonly IHubContext<ChatHub, IChatClient> _hubContext;
    public NotificationService(IHubContext<ChatHub, IChatClient> hubContext) => _hubContext = hubContext;
    public Task NotifyJoinedAsync(string user) =>
        _hubContext.Clients.All.UserJoined(user);
}

对于任何非平凡的 Hub,这都是推荐做法。它能把”我重命名了方法却忘了更新字符串字面量”这类整类 bug 变成编译期错误。

7. 客户端集成

JavaScript/TypeScript 客户端

import * as signalR from "@microsoft/signalr";
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/chatHub")
    .withAutomaticReconnect()
    .build();

connection.on("ReceiveMessage", (user, message) => {
    appendMessageToChat(user, message);
});

connection.on("UserJoined", (user) => {
    appendSystemMessage(`${user} joined the chat.`);
});

await connection.start();

async function sendMessage(user, message) {
    await connection.invoke("SendMessage", user, message);
}

.NET 客户端(用于服务器间通信或桌面/移动应用)

var connection = new HubConnectionBuilder()
    .WithUrl("https://example.com/chatHub")
    .WithAutomaticReconnect()
    .Build();
connection.On<string, string>("ReceiveMessage", (user, message) =>
{
    Console.WriteLine($"{user}: {message}");
});

await connection.StartAsync();
await connection.InvokeAsync("SendMessage", "Bot", "Hello from a .NET client!");

SignalR 还提供官方 Java(Android)客户端,并通过社区/第三方支持覆盖其他平台,使其不仅限于 Web 浏览器,也能用于跨平台实时应用。

Blazor Server:SignalR 在幕后

如果你用过 Blazor Server,你其实已经在不知不觉中使用 SignalR 了,Blazor Server 的整个 UI 更新机制(将渲染后的 UI 差异发送到浏览器、接收 DOM 事件回传)就是运行在一条自动管理的 SignalR 连接之上。

8. 流式传输(Streaming)

SignalR 支持服务器到客户端的流式传输,适合发送大批量或持续产生的数据序列,而无需先将所有内容缓冲成单条消息。

public class DataHub : Hub
{
    public async IAsyncEnumerable<int> StreamCounter(
        int count,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        for (int i = 0; i < count; i++)
        {
            await Task.Delay(1000, cancellationToken);
            yield return i;
        }
    }
}
connection.stream("StreamCounter", 10)
    .subscribe({
        next: (item) => console.log("Received:", item),
        complete: () => console.log("Stream complete"),
        error: (err) => console.error(err),
    });

客户端还可以使用 Hub 方法上的 IAsyncEnumerable<T> 或 ChannelReader<T> 参数向服务器流式上传数据——适合诸如持续上传传感器读数序列等场景。

9. 身份验证与授权

SignalR 与 ASP.NET Core 的身份验证和授权管道直接集成。

[Authorize]
public class ChatHub : Hub
{
    public override async Task OnConnectedAsync()
    {
        var userName = Context.User?.Identity?.Name;
        await Clients.Others.SendAsync("UserJoined", userName);
        await base.OnConnectedAsync();
    }
}
[Authorize(Roles = "Admin")]
public async Task BroadcastAdminMessage(string message)
{
    await Clients.All.SendAsync("ReceiveMessage", "Admin", message);
}

为 WebSocket 连接传递访问令牌

浏览器无法在 WebSocket 握手请求中附加自定义请求头,因此 SignalR 的 JS 客户端通过查询字符串参数传递令牌,服务器需要针对 Hub 请求专门配置从查询字符串中读取令牌:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var accessToken = context.Request.Query["access_token"];
                if (!string.IsNullOrEmpty(accessToken) &&
                    context.HttpContext.Request.Path.StartsWithSegments("/chatHub"))
                {
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/chatHub", { accessTokenFactory: () => getAccessToken() })
    .build();

10. 横向扩展:Backplane 问题

默认情况下,SignalR 将连接状态和分组信息保存在单个服务器进程的内存中。单实例运行没有问题,但一旦在负载均衡后面扩展到多个服务器实例,问题就出现了:如果客户端 A 连接到服务器 1、客户端 B 连接到服务器 2,那么从服务器 1 进程发出的”发送给所有客户端”的消息无法到达客户端 B——服务器 1 甚至不知道服务器 2 的存在。

解决方案:Backplane

Backplane 是一个共享消息代理,所有服务器实例都订阅它。任何实例发布的消息都会被转发给所有实例,再由每个实例推送给本地连接的客户端。

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

Azure SignalR Service:托管替代方案

与其自托管 Redis 作为 Backplane,Azure SignalR Service 是一个完全托管的替代方案,它将连接管理整个接管,客户端直接连接到 Azure 服务而非你的应用服务器,你的应用服务器只需向服务发布消息并处理 Hub 逻辑:

builder.Services.AddSignalR().AddAzureSignalR();

这完全消除了 Backplane 的顾虑,还顺带解决了另一个相关问题:由于 WebSocket 连接是长连接且具有粘性(客户端在连接生命周期内需要持续与同一个服务器实例通信),托管服务避免了在负载均衡器上配置粘性会话的需求,而这在自托管多实例部署中是个实实在在的运维难题。

11. 重连与弹性

自动重连

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/chatHub")
    .withAutomaticReconnect([0, 2000, 10000, 30000]) // 重试延迟(毫秒)
    .build();
connection.onreconnecting(error => {
    showConnectionStatus("Reconnecting...");
});

connection.onreconnected(connectionId => {
    showConnectionStatus("Connected");
    // 重新加入各分组/房间——分组关系不会随重连自动恢复
});

connection.onclose(error => {
    showConnectionStatus("Disconnected — please refresh");
});

12. SignalR vs 原生 WebSocket vs gRPC 流式传输

维度原生 WebSocketSignalRgRPC 流式传输
传输降级无。仅支持 WebSocket自动(WebSocket → SSE → 长轮询)无。需要 HTTP/2
浏览器支持原生支持,但需自行编写所有帧处理与重连逻辑一流的 JS/TS 客户端,内置重连需要 gRPC-Web,且浏览器无法做完整的双向流
编程模型原始消息收发,需自行定义协议RPC 风格方法调用(Clients.All.SendAsync(...)RPC 风格,通过 protobuf 强类型化
扩展性完全手动(需自建 Backplane/发布订阅)内置 Redis/Azure SignalR Service Backplane 支持通常在基础设施/服务网格层面处理
最佳适用场景需要完全控制权、自建协议需要实时功能的 Web/移动应用,且希望最少样板代码内部服务间流式通信,不面向浏览器

实用建议

  • 要在 Web 应用中做聊天、实时通知或实时仪表板? → 选 SignalR——它就是为此而生,能替你处理传输降级、重连和扩展问题,否则这些都得自己造轮子。
  • 需要内部后端服务间的双向流? → gRPC 流式传输(参见 gRPC 指南)更合适;它本就不是面向浏览器的。
  • 需要极其底层、完全自定义、对线上协议有绝对控制权? → 选原生 WebSocket,但要接受自己实现重连、降级和扩展逻辑。

快速参考表

概念用途
Hub服务器↔客户端实时 RPC 风格调用的核心类
Clients.All/.Caller/.Others/.User()向客户端推送消息时的定向选项
Groups(分组)命名连接子集,用于定向广播
Hub<T> / 强类型 Hub编译期检查的客户端方法调用
IHubContext<T>在 Hub 外部(服务、控制器)向客户端推送消息
流式传输(IAsyncEnumerable<T>服务器→客户端(或客户端→服务器)流式数据
Backplane(Redis)跨多个自托管服务器实例同步消息
Azure SignalR Service托管替代方案,消除 Backplane/粘性会话顾虑
withAutomaticReconnect()客户端自动重连,可配置重试延迟
access_token 查询参数WebSocket 连接传递 JWT 的方式(请求头不可用)

结语

SignalR 把 Web 开发中真正棘手的领域之一(带优雅降级、重连和横向扩展的实时双向通信)简化为一种简单的 RPC 风格编程模型:在服务器上调用一个方法,在客户端上调用一个方法,其余交给库来处理传输协议、连接生命周期,以及(借助 Backplane 或 Azure SignalR Service)多实例扩展。

它并非适用于所有实时场景,内部服务间流式通信通常更适合用 gRPC,需要完全自定义线上协议的场景可能要用原生 WebSocket。但对于”从 ASP.NET Core 后端向浏览器或 .NET 客户端推送更新”这一常见需求,SignalR 仍然是最直接、支持最完善的路径。在从头手写 WebSocket 处理之前,值得优先考虑它。

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。

(0)

相关推荐