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 是首选的横向扩展方案,配置极简。