Skip to content
bitzorcas
中EN

Concept

实时通信

当前 Chat SignalR 适配器、持久化事实、成员授权、断线恢复与 Null 组合边界。

Last updated

BitzOrcas 已为 Chat 实现 SignalR:ChatRealtimeHub 提供连接入口,SignalRChatRealtimeAdapter 按频道成员的 user id 广播消息与已读标记。实时层不是写入入口,也不是事实来源。

authorized REST command → repository save → realtime adapter → SignalR clients
client reconnect ─────────────────────────→ REST history cursor

关键路径图

下图展示了实时通信服务架构:从客户端 WebSocket 连接建立,到 Redis Backplane 跨节点广播,再到租户频道安全鉴权的流转全景。

Repository Save 后的聚合

Handler 直接调用 Adapter

SignalR Hub

Membership 用户路由

客户端刷新

当前边界

  • Chat 生产组合可以注册 SignalR;缺失时 NullChatRealtimeAdapter 保持 REST 可用。
  • Documents 协作与白板仍允许 Null Hub,不能描述成已交付实时协作。
  • Notifications 的 Inbox 是事实来源;是否配置实时提示取决于宿主适配器。
  • SSE 当前没有生产端点,详见SSE。

Hub 类型本身没有客户端可调用方法,并标注 [Authorize];它是被动传输端点。配置 Chat:Realtime:Enabled=true 时注册 SignalR 与 scoped adapter,并将 Hub 映射到 Chat:Realtime:HubPath,默认 /hubs/chat。

{
"Chat": {
"Realtime": {
"Enabled": true,
"HubPath": "/hubs/chat"
}
}
}

未启用时,不映射 Hub,Application 的 NullChatRealtimeAdapter 让 REST 主链继续工作。这是可选传输降级,不代表宣称 Chat Realtime 的 Edition 已满足就绪。

授权

连接认证只确认调用者身份。每次订阅或广播还要依据持久化 Membership 路由,不能让客户端声明任意 group。成员移除后应停止后续广播,历史读取仍由 REST 授权决定。

当前 adapter 并不使用 SignalR Group,而是从已加载的 channel.Memberships 取 distinct user id,然后调用 Clients.Users(recipients)。因此 IUserIdProvider 必须把 SignalR connection 的 user identifier 与 Membership.UserId 对齐;只配置 JWT 认证而未验证 user mapping,可能导致收不到或错投。

// 广播目标来自持久化聚合成员,不接受客户端提交的 group 名。
var recipients = channel.Memberships
.Select(member => member.UserId)
.Where(userId => !string.IsNullOrWhiteSpace(userId))
.Distinct(StringComparer.Ordinal)
.ToArray();
// SignalR 使用 User 路由;IUserIdProvider 是身份契约的一部分。
await hubContext.Clients.Users(recipients)
.SendAsync(ChatRealtimeMethods.MessageSent, payload, cancellationToken);

payload 仍包含 tenant/channel 标识,但这些字段只是客户端校验与路由证据,不能替代服务端 recipients 选择。

事件契约

当前公开方法至少包括 MessageSent 和 ReadMarkerUpdated。消息 payload 包含 tenant、channel、message、sender、正文、mention user ids、attachment file ids 与 sent time;已读 payload 包含 user、message 和 read time。

正文通过实时通道发送,日志和 telemetry 不应序列化完整 payload。客户端必须按稳定 method name 与版本化 DTO 消费;字段新增可向后兼容,重命名/删除需要协议版本。

提交后投递的演进

理想链路是事务内保存事实与 Outbox,提交后由事件消费者调用实时 adapter。这样实时失败可以独立重试,数据库回滚不会产生 ghost notification。

Command transaction: aggregate + repository + outbox → commit
Post-commit consumer: outbox event → realtime adapter → SignalR
Client: realtime hint → REST cursor/query confirms fact

即便迁移到 post-commit,SignalR 仍是至少一次提示。consumer 需要稳定 event/message id;客户端去重并从 REST 事实源恢复。

断线与扩展

SignalR 消息可能丢失、重复或乱序。payload 带稳定消息/事件 ID 和序列信息,客户端重连后通过历史游标补齐。多实例部署需要经过验证的 SignalR scale-out/backplane 组合,不能假设进程内连接表跨实例共享。

当前注册只调用 AddSignalR(),源码中没有 Redis/Azure SignalR scale-out 配置。多副本时连接只存在于落点实例,其他实例发出的 Clients.Users 不会自动覆盖全部连接。生产要显式接入 backplane/service 并验证同一用户多连接、多实例和断线重连。

慢客户端与大消息需要 MaximumReceiveMessageSize、keepalive、client timeout、transport fallback 和代理 WebSocket 配置。当前组合未在本页可验证出专属限制,采用方应由部署测试给出容量边界。

客户端恢复模式

// 实时事件只是提示;messageId 用于去重,REST cursor 补齐遗漏事实。
connection.on("chat.message.sent", event => {
if (seen.has(event.messageId)) return;
seen.add(event.messageId);
inbox.apply(event);
});
// 重连后从最后确认的游标查询,而不是假设离线期间没有消息。
connection.onreconnected(() => history.catchUp(lastCommittedCursor));

GA 检查

  • WebSocket 升级、Token 过期、成员移除和跨租户 group 有测试。
  • 断线重连从持久化历史恢复。
  • 多实例广播、背压和最大消息大小经过压测。
  • 日志不记录 Token 和完整聊天正文。
  • 宣称实时能力的 Edition 不允许 Null 适配器通过就绪检查。

还要故障注入“Save 后实时发送失败”“实时已发后事务回滚”“消息重复/乱序”“IUserIdProvider 不匹配”和“实例 A 连接、实例 B 广播”。当前直接 Handler 广播窗口没有消除前,不应以严格提交后事件语义对外承诺。

Chat 业务边界见聊天模块。

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%