ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Blazor SignalR 实时消息怎么做?从 NotificationHub 到站内通知

Blazor SignalR 实时消息怎么做?从 NotificationHub 到站内通知 站内消息看起来只是服务器推一条消息给前端真正落地时要回答三个问题怎么保证用户只能收到自己的消息用户离线时消息去哪了多实例部署时推送怎么到达正确的连接这篇用 EasyAdminBlazor 的NotificationHub逐个回答。一、整体链路业务代码审批提交、文件通知、系统广播… ↓ AdminMessageService.PublishInternalMessage / PublishSystemMessage ↓ 落库SysMessage SysMessageUser ↓ IHubContextNotificationHub.Clients.Group($user_{userId}) .SendAsync(ReceiveNotification, msg) ↓ 浏览器收到 → 角标刷新 / 消息列表更新注意一个关键设计先落库再推送。推送失败不影响消息本身用户下次打开消息中心照样能看到。二、Hub 的第一原则不接受客户端传 userId很多SignalR 用户组的实现长这样// ❌ 危险写法publicTaskJoinGroup(longuserId)Groups.AddToGroupAsync(Context.ConnectionId,$user_{userId});客户端只要调用JoinGroup(别人的Id)就能订阅别人的通知。这是很典型的越权订阅漏洞。EasyAdminBlazor 的做法是取消这类方法改由连接生命周期自动完成[Authorize]publicclassNotificationHub:Hub{/// summary用户组名称user_{userId}/summarypublicstaticstringGetUserGroup(longuserId)$user_{userId};/// summary/// 连接建立时自动加入当前登录用户自己的用户组。/// 用户 Id 来自服务端权威身份ClaimTypes.NameIdentifier / 登录票据 / 登录 Cookie/// 不再接受客户端传入的 userId防止订阅他人通知。/// /summarypublicoverrideasyncTaskOnConnectedAsync(){varuserIdGetCurrentUserId();if(userId0){_logger.LogWarning(NotificationHub 拒绝未认证连接{ConnectionId},Context.ConnectionId);Context.Abort();return;}awaitGroups.AddToGroupAsync(Context.ConnectionId,GetUserGroup(userId));awaitbase.OnConnectedAsync();}publicoverrideasyncTaskOnDisconnectedAsync(Exception?exception){varuserIdGetCurrentUserId();if(userId0){awaitGroups.RemoveFromGroupAsync(Context.ConnectionId,GetUserGroup(userId));}awaitbase.OnDisconnectedAsync(exception);}}三个要点类上有[Authorize]未认证连接进不来组名由服务端计算客户端无法指定拿不到用户 Id 时直接Context.Abort()而不是静默加入一个空组。三、用户 Id 从哪来三级回退privatelongGetCurrentUserId(){// 1) 标准认证声明varnameIdentifierContext.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;if(long.TryParse(nameIdentifier,NumberStyles.Integer,CultureInfo.InvariantCulture,outvarclaimUserId)claimUserId0){returnclaimUserId;}varhttpContextContext?.GetHttpContext();if(httpContextnull)return0;// 2) 服务端自连的短期签名票据vartickethttpContext.Request.Query[ticket].ToString();if(!string.IsNullOrEmpty(ticket)){varticketUserIdTryReadTicket(ticket);if(ticketUserId0)returnticketUserId;}// 3) 浏览器直连的登录 CookievarcookiehttpContext.Request.Cookies[_cookieKey];if(!string.IsNullOrEmpty(cookie))returnTryReadTicket(cookie);return0;}为什么要三级来源场景ClaimTypes.NameIdentifier认证中间件正常工作时的首选?ticket查询参数服务端 Self-Connect 场景Blazor Server 服务端自己建立 SignalR 连接时浏览器 Cookie 不会带上只能用短期票据登录 Cookie浏览器直连票据本身是加密的并且带时效校验privatelongTryReadTicket(stringprotectedValue){try{vardecrypted_loginTicketProtector.Unprotect(Uri.UnescapeDataString(protectedValue));if(string.IsNullOrEmpty(decrypted))return0;varpartsdecrypted.Split(|);if(parts.Length2||!long.TryParse(parts[0],NumberStyles.Integer,CultureInfo.InvariantCulture,outvaruserId)||userId0){return0;}if(DateTime.TryParse(parts[1],CultureInfo.InvariantCulture,DateTimeStyles.RoundtripKind,outvarissuedAt)DateTime.UtcNow-issuedAt.ToUniversalTime()TimeSpan.FromDays(7)){return0;}returnuserId;}catch(Exception){// 票据无效/密钥不匹配时按未登录处理return0;}}票据格式是userId|loginTime用 Data Protection 加密purpose 是EasyAdminBlazor.LoginTicket.v1与登录流程共用。超过 7 天自动失效密钥不匹配时按未登录处理。四、服务端推送AdminMessageService是消息的统一入口publicclassAdminMessageService(IAggregateRootRepositorySysMessagerepo,IHubContextNotificationHubhubContext,AdminContextadmin,IRedisService?redisServicenull)1. 两种消息/// summary发站内信/summarypublicasyncTaskPublishInternalMessage(SysMessagemsg){msg.MessageTypeMessageType.Internal;awaitPublishMessage(msg);}/// summary发布系统通知/summarypublicasyncTaskPublishSystemMessage(long[]recUserIds,stringsubject,stringcontent,string?linkUrlnull){awaitPublishMessage(newSysMessage{RecUserIdsrecUserIds,Subjectsubject,Contentcontent,LinkUrllinkUrl??string.Empty,MessageTypeMessageType.System});}站内信的发送人取当前登录用户系统通知没有发送人。两者共用同一条落库 推送链路。2. 落库 推送await_repo.InsertAsync(msg);// 通过 SignalR 推送通知给收件人if(msg.RecUserIds!null){foreach(varrecUserIdinmsg.RecUserIds){await_hubContext.Clients.Group($user_{recUserId}).SendAsync(ReceiveNotification,msg);}}else{await_hubContext.Clients.All.SendAsync(ReceiveNotification,msg);}awaitRaiseMessagesChangedAsync();推送目标用的是Clients.Group($user_{id})和 Hub 里GetUserGroup的规则一致——两端必须用同一个命名规则这也是测试里专门断言GetUserGroup(12345) user_12345的原因。3. 收件人数据模型// 如果只有一个收件人则直接跟消息一起存if(msg.RecUserIds!nullmsg.RecUserIds.Length1){msg.RecUserIdmsg.RecUserIds[0];msg.IsReadfalse;}else{msg.Users[];// 插入多个收件人if(msg.RecUserIds!nullmsg.RecUserIds.Length1){foreach(variteminmsg.RecUserIds){msg.Users.Add(new(){Iditem});}}if(msg.RecUserIdsnull)// 所有人{foreach(variteminawait_admin.GetAllUsers()){varuserIditem.Id;if(userId!_admin.User.Id)msg.Users.Add(new(){IduserId});}}msg.RecUserIdsmsg.Users.Select(xx.Id).ToArray();}场景存储方式单收件人直接写SysMessage.RecUserIdIsRead多收件人写SysMessageUser关联记录每人一条含各自的已读状态全员广播展开成多收件人排除自己多收件人时必须用关联表因为已读是每人独立的。4. 标记已读只影响自己方法上的注释把意图写得很清楚/// summary/// 批量设置消息为已读。/// 不信任外部传入的完整实体只取 Id 列表并结合当前登录用户重新构造更新条件/// 确保只能标记发给当前用户的消息防止越权修改他人消息状态。/// /summarypublicasyncTaskUpdateMessagesStatus(ListSysMessageunreadMessagesForUser)varuserId_admin.User.Id;// 单收件人消息仅更新收件人是当前用户的记录await_repo.Orm.UpdateSysMessage().Set(xx.IsRead,true).Where(xmessageIds.Contains(x.Id)x.RecUserIduserId).ExecuteAffrowsAsync();// 多收件人消息仅更新关联记录属于当前用户的记录varnowDateTime.Now;await_repo.Orm.UpdateSysMessageUser().Set(xx.IsRead,true).Set(xx.ReadTime,now).Where(xmessageIds.Contains(x.MessageId)x.UserIduserId).ExecuteAffrowsAsync();两个WHERE都带当前用户条件。否则 A 点了标记已读B 的消息也会变成已读。五、界面刷新事件服务里暴露了一个事件供右上角角标这类界面订阅/// summary/// 消息状态变化新增 / 标记已读时触发供右上角通知角标等界面刷新。/// 订阅者异常不会影响消息本身的处理。/// /summarypubliceventFuncTask?MessagesChanged;privateasyncTaskRaiseMessagesChangedAsync(){if(MessagesChangedisnull)return;foreach(varhandlerinMessagesChanged.GetInvocationList().CastFuncTask()){try{awaithandler();}catch(Exception){// 界面刷新失败不应影响消息读写}}}逐个订阅者 try/catch 是细节一个组件的刷新异常不应该让发消息这个动作失败。六、连接保活与断线容忍Blazor Server 和 SignalR 都是长连接后台标签页被浏览器冻结时心跳发不出去默认 30 秒就会断线重连。框架在注册时调大了容忍度builder.Services.AddSignalR(options{options.KeepAliveIntervalTimeSpan.FromSeconds(15);options.ClientTimeoutIntervalTimeSpan.FromMinutes(3);});// 电路断开后保留时长后台 Tab 冻结导致连接断开时保留电路供切回时快速恢复builder.Services.ConfigureMicrosoft.AspNetCore.Components.Server.CircuitOptions(options{options.DisconnectedCircuitRetentionPeriodTimeSpan.FromMinutes(10);options.DisconnectedCircuitMaxRetained200;});配置值作用KeepAliveInterval15 秒服务端 ping 频率ClientTimeoutInterval3 分钟客户端多久没响应算断线DisconnectedCircuitRetentionPeriod10 分钟断线后电路保留时长DisconnectedCircuitMaxRetained200最多保留多少个断开的电路这几个参数直接影响切回标签页后页面是不是还活着的体验也影响内存占用保留 200 个电路是有代价的大并发场景要按服务器内存调整。七、离线消息与聊天消息的落库在线用户走 SignalR 实时推送离线用户下次进入消息中心时从数据库读取。这两条路径是靠先落库统一的。聊天场景还多一层消息先写 Redis 列表再由后台任务批量落库。// Chat.razor写入_redisService?.LPush(${admin.TenantCachePrefix}chat_messages:{receiverId},JsonConvert.SerializeObject(newMessage));// AdminMessageService批量落库带租户前缀的分布式锁varlockObj_redis.Lock(${_admin.TenantCachePrefix}{LockKey},LockExpireSeconds);if(lockObj!null){varkeys_redis.Keys(${_admin.TenantCachePrefix}chat_messages:*);..._redis.ReleaseLock(lockObj);}细节见第 19 篇Redis 缓存与分布式锁。八、测试锁定了哪些安全约束NotificationHubTests.cs用反射 源码检查锁定了几条不能退化的约束测试断言Hub_RequiresAuthorizationHub 上有[Authorize]Hub_DoesNotExposeClientControllableGroupMethods不得存在JoinGroup/LeaveGroup这类可传 userId 的方法Hub_OverridesLifecycleMethodsOnConnectedAsync/OnDisconnectedAsync必须在 Hub 里重写GetUserGroup_UsesUserIdSuffix组名规则user_{id}GetUserGroup_DifferentUsers_DoNotCollide不同用户不同组Hub_ReadsUserIdFromClaims源码里出现ClaimTypes.NameIdentifier身份来自服务端Hub_DoesNotTrustClientUserIdParameter源码里不得出现信任外部 userId 的签名最后两条用读源码的方式断言很直接以后有人为了方便加回JoinGroup(string userId)测试立刻失败。九、常见问题现象原因处理收不到实时消息但消息列表里有SignalR 连接断了或未认证看浏览器控制台与 Hub 日志确认登录态连接建立后立刻断开GetCurrentUserId()返回 0检查认证中间件、登录票据密钥Data Protection配置多实例部署下部分用户收不到Hub 连接在不同的实例上需要 Redis backplane 或粘性会话消息本身已落库不影响最终一致切回标签页后页面白屏/重连Circuit 已释放调整DisconnectedCircuitRetentionPeriod标记已读把别人的也标了自定义代码漏了用户条件参考UpdateMessagesStatus的双WHERE写法十、小结一个安全的站内消息系统关键就四条身份只信服务端Hub 不接受客户端 userId订阅自己的组由连接建立时自动完成先落库再推送推送失败不影响消息可达性离线用户也能看到已读按人存多收件人用关联表更新必须带当前用户条件连接参数要调Blazor Server 后台标签页冻结的场景下默认超时太激进。EasyAdminBlazor 的NotificationHub把这四条都写进了代码和测试里可以直接对照实现自己的通知系统。如果你正在用 .NET 10 Blazor 做后台需要站内消息、待办提醒这类实时能力可以看看 EasyAdminBlazor 的实现Hub 与消息服务分层清晰离线消息、已读状态、多租户前缀都考虑到了。文档https://easyadmin.wang-zhan.com.cn/doc源码https://gitee.com/gudufy/EasyAdminBlazor
返回列表