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

引言
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 流式传输
| 维度 | 原生 WebSocket | SignalR | gRPC 流式传输 |
|---|---|---|---|
| 传输降级 | 无。仅支持 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 举报,一经查实,本站将立刻删除。