基本介绍
1.什么是signalR
SignalR 是微软开发的一个开源库,它可以让服务器端代码能够即时推送内容到连接的客户端,用来简化向客户端应用程序添加实时功能的过程。
- 大白话的意思就是微软搞了一个可以用来做服务端推送的库,并且都是帮你封装好了的,你不用操心,用就完了
特点:
概念 | 说明 |
---|---|
双工通信 | 服务端和客户端可以互相发送数据,互不干扰,实现双向实时通信。例如,Web API 的 Controller 是单向请求-响应模式,而 SignalR 的 Hub 支持服务端主动推消息给客户端,客户端也能调用服务端方法,形成双向交互。 |
传输降级 | SignalR 会自动选择最佳的通信方式。优先尝试 WebSocket,若不支持则依次降级为 Server-Sent Events 或长轮询等。整个过程对开发者透明,确保在各种浏览器和网络环境下都能建立连接。 |
简化开发 | SignalR 内置了心跳检测、连接存活检查、断线自动重连等机制,开发者无需手动实现这些复杂逻辑,极大降低了实时通信功能的开发难度。 |
Hub | 可以理解为一个“实时通信中转站”。就像你和朋友之间有一个快递站(Hub),你想发消息(寄快递),就把包裹交给 Hub;朋友也能通过同一个 Hub 寄快递给你。Hub 负责把消息准确送达对方,并通知接收方有新消息到来。它是 SignalR 的核心通信中心。 |
主要应用场景:
应用场景 | 说明 | 是否推荐 |
---|---|---|
实时消息传递 | 搭建聊天室、下象棋等需要低延迟双向通信的场景,非常适合 SignalR。 | ✅ 推荐 |
实时消息通知 | 向客户端推送通知(如系统提醒、订单状态更新等),是 SignalR 的典型用例。 | ✅ 推荐 |
数据统计看板 | 实时将动态数据推送给前端看板(如销售数据、监控指标),体验流畅。 | ✅ 推荐 |
服务之间通信 | 理论上可行,但不建议使用 SignalR 进行服务间通信。应使用 gRPC、REST、消息队列等更合适的方案。 | ❌ 不建议 |
当然根据他的特性还能能延伸出更多应用场景,但目前在实际开发中,我使用SignalR的场景就是:
1.在业务系统内部作为站内信发送通知。
2.作为前端实时数据看板展示的业务数据,刚毕业那会,前端用js 开一堆定时器来请求后端接口刷新数据报表,每隔2天就让客户去F5一下浏览器,客户问为什么,当时的解释是电脑不行 😄。
2.WebSocket 和 SignalR
其实说到推送相关的话题,有很多实现方式,应用上使用纯WebSocket也可以实现,简单方便,最终还是得结合自己的实际需求来权衡,例如拿巴掌拍蚊子,和拿大炮打蚊子,方法上都行得通,最重要的是把握这个“度”,这里就不一一列举了.
为什么不直接用 WebSocket | 说明 |
---|---|
1. 开发效率高 | SignalR 封装了底层细节,提供了开箱即用的 API,屏蔽了连接管理、序列化、异常处理等复杂逻辑,大幅提升了开发效率。虽然相比原生 WebSocket 有一定性能损耗,但换来了极高的生产力。 |
2. 支持传输降级 | SignalR 能根据客户端环境自动选择最佳传输方式(WebSocket → Server-Sent Events → 长轮询)。在复杂网络环境或老旧浏览器中仍能保持连接,而纯 WebSocket 在不支持或被代理阻塞时会直接失败。 |
3. 省去基础设施开发 | 若使用原生 WebSocket,需自行实现心跳检测、断线重连、消息确认、集群同步等机制,开发和维护成本高。SignalR 已内置这些功能,开箱即用。 |
4. 已有成熟框架,何不善用? | SignalR 是一个经过生产验证的成熟框架,解决了实时通信中的常见痛点。既然有稳定可靠的轮子,就没有必要重复造轮子,可以更专注于业务逻辑的实现。 |
上手实践
1.基本概念
角色 | 说明 |
---|---|
服务端 | 为你提供消息推送服务的后端应用程序,负责处理连接、业务逻辑,并通过 SignalR 向客户端主动发送数据。 |
客户端 | 接收消息的一方,可以是浏览器(JavaScript)、移动应用、桌面程序等,通过连接到 Hub 来接收服务端推送的实时消息。 |
Hub | SignalR 中客户端与服务端进行消息交换和推送的核心抽象代理。它相当于一个“通信中心”,客户端和服务端通过 Hub 进行双向方法调用和消息传递。 |
属性 | 说明 |
---|---|
ConnectionId | 获取连接的唯一 ID(在连接时 SignalR 分配)。 |
UserIdentifier | 用户标识一般是用户id,关联连接和用户。 |
User | 当前用户的 ClaimsPrincipal(身份信息)。 |
Items | 一些共同的数据可以在连接建立后加载,然后存到Items,在后续不同的方法中都可以访问到,不过数据仅存在内存中,连接断开后自动销毁 |
ConnectionAborted | 获取一个 CancellationToken,它会在客户端连接中止时发出通知,比 OnDisconnectedAsync更早触发,更及时。 |
Items属性举例
// 连接建立查询数据库获取用户信息
public override async Task OnConnectedAsync()
{var user = await db.GetUserAsync(Context.UserIdentifier);Context.Items["UserProfile"] = user; // 缓存用户数据
}// 在其他方法中获取Items拿到
public async Task SendMessage(string message)
{var user = (UserProfile)Context.Items["UserProfile"]; // 直接读取缓存// ... 使用用户数据
}
2.基本使用
后端代码使用.net7,客户端使用js,分为2部分,基本使用,以及更加贴合业务的实现
先铺垫一下,这一部分有条件自己试一下向个人,所有人,以及组发送消息的api,后续会分享它内部实现,也很巧妙。
1.首先注入使用SignalR需要的相关服务和配置
services.AddSignalR(options =>
{options.ClientTimeoutInterval = TimeSpan.FromMinutes(1);options.KeepAliveInterval = TimeSpan.FromSeconds(10);options.EnableDetailedErrors = true;
})
2.定义一个Hub
// Hubs/PushMsgHub.cs
using Microsoft.AspNetCore.SignalR;public class PushMsgHub : Hub
{// 连接事件public override async Task OnConnectedAsync(){await base.OnConnectedAsync();}// 断开连接事件public override async Task OnDisconnectedAsync(Exception? exception){UserManager.RemoveUser(Context.ConnectionId);await base.OnDisconnectedAsync(exception);}// 客户端调用此方法登录并注册用户信息public async Task Login(string userId, string name, string companyId, string orgId){var user = new User{ConnectionId = Context.ConnectionId,UserId = userId,Name = name,CompanyId = companyId,OrgId = orgId};UserManager.AddUser(user);await Clients.Caller.SendAsync("ReceiveMessage", "系统", $"{name}登录成功!");}// 发送消息给指定用户public async Task SendMessageToUser(string toUserId, string message){var fromUser = UserManager.GetUserByConnectionId(Context.ConnectionId);var toUser = UserManager.GetUserById(toUserId);if (toUser != null){await Clients.Client(toUser.ConnectionId).SendAsync("ReceiveMessage", $"{fromUser.Name} (私信)", message);}else{await Clients.Caller.SendAsync("ReceiveMessage", "系统", "用户不在线或不存在。");}}// 发送给组织内所有用户public async Task SendMessageToOrg(string message){var fromUser = UserManager.GetUserByConnectionId(Context.ConnectionId);var users = UserManager.GetUsersByOrg(fromUser.OrgId);foreach (var user in users){await Clients.Client(user.ConnectionId).SendAsync("ReceiveMessage", $"【组织】{fromUser.Name}", message);}}// 发送给公司内所有用户public async Task SendMessageToCompany(string message){var fromUser = UserManager.GetUserByConnectionId(Context.ConnectionId);var users = UserManager.GetUsersByCompany(fromUser.CompanyId);foreach (var user in users){await Clients.Client(user.ConnectionId).SendAsync("ReceiveMessage", $"【公司】{fromUser.Name}", message);}}
}
3.然后将Hub
映射到中间件管道路由中
app.MapHub<ChatHub>("/PushMsgHub");
4.html
和js
测试代码
<!-- index.html -->
<!DOCTYPE html>
<html>
<head><title>SignalR Demo</title>
</head>
<body><h2>SignalR 消息系统</h2><div><label>用户ID: <input id="userId" value="u001" /></label><label>姓名: <input id="name" value="张三" /></label><label>公司ID: <input id="companyId" value="comp001" /></label><label>组织ID: <input id="orgId" value="org001" /></label><button onclick="login()">登录</button></div><hr /><div><h3>发送私信</h3><input id="toUserId" placeholder="目标用户ID" value="u002" /><input id="privateMsg" placeholder="输入私信内容" /><button onclick="sendPrivate()">发送</button></div><div><h3>发送组织消息</h3><input id="orgMsg" placeholder="组织内广播消息" /><button onclick="sendToOrg()">发送</button></div><div><h3>发送公司消息</h3><input id="companyMsg" placeholder="公司内广播消息" /><button onclick="sendToCompany()">发送</button></div><hr /><h3>消息记录</h3><ul id="messages"></ul><script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.0/signalr.min.js"></script><script>const connection = new signalR.HubConnectionBuilder().withUrl("http://localhost:5200/PushMsgHub").build();function log(message) {const li = document.createElement("li");li.textContent = message;document.getElementById("messages").appendChild(li);}connection.on("ReceiveMessage", (user, message) => {log(`${user}: ${message}`);});connection.start().then(() => log("连接到 SignalR 服务器")).catch(err => console.error(err));async function login() {const userId = document.getElementById("userId").value;const name = document.getElementById("name").value;const companyId = document.getElementById("companyId").value;const orgId = document.getElementById("orgId").value;await connection.invoke("Login", userId, name, companyId, orgId);}async function sendPrivate() {const toUserId = document.getElementById("toUserId").value;const msg = document.getElementById("privateMsg").value;await connection.invoke("SendMessageToUser", toUserId, msg);}async function sendToOrg() {const msg = document.getElementById("orgMsg").value;await connection.invoke("SendMessageToOrg", msg);}async function sendToCompany() {const msg = document.getElementById("companyMsg").value;await connection.invoke("SendMessageToCompany", msg);}</script>
</body>
</html>
3.强类型Hub用法
与传统的直接继承 Hub 相比,有设计上的优势,先看下面不使用强类型的截图。
1.先定义一个接口
public interface IPushMessageHubAsync
{Task ReceiveMessage(string message);
}
2.然后优化这个Hub像这样写
public class MsgPushHub : Hub<IPushMessageHubAsync>
{public async Task SendMessage(string message){await Clients.All.ReceiveMessage(message);}
}
IPushMessageHubAsync: 用于约定具体推送的业务类型,这里的接口名就是实际推送到客户端的名字,同理如果有报表展示的需要可以定义一个为 Hub
- 罗列的对比
对比项 | Hub<IClientContract> (强类型) |
直接继承 Hub (弱类型) |
---|---|---|
类型安全 | ✅ 编译时检查方法名、参数 | ❌ 运行时才报错(字符串魔法值) |
修改 | ✅ 改方法名时 IDE 自动提示,接口与实现同步更新 | ❌ 手动修改所有字符串调用,易遗漏出错 |
代码可读性 | ✅ 接口明确定义服务端可调用的客户端方法,通信契约清晰 | ❌ 方法名散落在 SendAsync("MethodName") 中,不易维护 |
单元测试 | ✅ 可轻松 Mock 客户端接口,便于测试 Hub 逻辑 | ❌ 需模拟字符串发送逻辑,测试复杂且脆弱 |
4.鉴权
1.安装 NuGet 包(如果还没加)
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
2.标准的.netCore集成鉴权
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>{options.TokenValidationParameters = new TokenValidationParameters{//.....};// SignalR 要求在 WebSocket 模式下从查询字符串传递 tokenoptions.Events = new JwtBearerEvents{OnMessageReceived = context =>{var accessToken = context.Request.Query["access_token"];// 如果是 SignalR 长连接,且路径是 /chatHub,则从查询字符串读取 tokenvar path = context.HttpContext.Request.Path;if (!string.IsNullOrEmpty(accessToken) &&path.StartsWithSegments("/PushMsgHub")) // 你的 Hub 路径{context.Token = accessToken;}return Task.CompletedTask;}};});
3.然后再hub上使用 Authorize
标记
[Authorize]
public class MsgPushHub : Hub<IPushMessageHubAsync>
{public async Task SendMessage(string message){await Clients.All.ReceiveMessage(message);}
}
3.遇到的问题
1.上面的方式在单体系统是不会有问题的,但是如果服务实例负载均衡后开启了多个实例,会存在回话丢失的问题
A)
产生的原因其实根据上面的图就能理解,因为SignalR 默认使用内存状态存储连接信息,每个实例独立维护自己的客户端连接表。
解决方案:
1.启用粘性会话网关层根据ip和实例绑定,确保同一个客户端的所有请求都路由到同一个后端实例。不需要额外组件(如 Redis),配置简单,适合小规模部署。但是也存在一些缺点如下:
- 单节点故障:如果该实例宕机,所有连接丢失。
- 负载不均:某些实例可能连接过多。
- 无法弹性伸缩:新增/删除实例时,部分用户会断连。
- 违反微服务无状态原则。
2.启用底板机制横向扩展,因为启用横向扩展后,所有会话状态(连接、组、用户映射)均存储在外部,例如Redis中,而不是单机内存,但是需要引入外部依赖,增加复杂度,但是也有显著的优点如下:
- 真正的高可用和弹性伸缩。
- 实例宕机不影响整体服务(其他实例可接管)。
- 支持动态扩缩容。
- 符合云原生架构。
1.集成SignalR.StackExchangeRedis
1.先安装扩展库
dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis
2.在注册服务时加入扩展的代码
builder.Services.AddSignalR()
.AddStackExchangeRedis("redis-connection-string", options =>
{options.Configuration.ChannelPrefix = "SignalR"; // 可选:命名空间前缀
});
对比项 | 粘性会话 | 背板机制 |
---|---|---|
架构模式 | 有状态 | 无状态 |
扩展性 | 差(受限于单实例容量) | 好(可水平扩展) |
可用性 | 差(实例宕机即断连) | 好(故障转移) |
部署复杂度 | 低 | 中(需 Redis) |
消息一致性 | 依赖路由 | 通过中间件保证 |
推荐 | ❌ 不推荐用于生产 | ✅ 推荐 |
适用场景 | 小型项目、测试环境 | 生产环境、微服务、云部署 |