实时通信
本页讲怎样做实时推送:直接使用 ASP.NET Core SignalR,Hub 的认证交给内核已装配的认证处理器,适用于要在 XiHan 应用里接 WebSocket、Server-Sent Events 一类长连接的项目。用到的 API 都在 Hosting.AspNetCore 包。
做法
框架不包装 SignalR,框架里没有 Hub 基类、连接登记表、推送抽象或背板。应用直接用 ASP.NET Core 共享框架自带的 SignalR(AddSignalR、MapHub、IHubContext<THub>),不需要额外的框架包。
框架只解决衔接上的两件事:
- 认证。Hub 是长连接,不经内核管道(不受截止时间约束,内核停止时也不被 503 打断),管道的 Authenticate 阶段管不到它。
Hosting.AspNetCore提供 ASP.NET Core 认证方案XiHan,把凭据交给内核已装配的IAuthenticationHandler,与管道里是同一个处理器,JWT、OpenID Connect、ApiKey 不用各接一遍(ADR-0051)。 - 在 Hub 里发起内核调用。
HttpInvocation.CreateContext(httpContext, services)以连接的HttpContext建调用上下文,凭据随之带上,调用经管道时照常重新认证、按契约授权。
接入 Hub
AddSignalR(),并用AddAuthentication().AddXiHan(...)登记方案XiHan;浏览器的 WebSocket 与 EventSource 带不了请求头,把 Hub 的路径前缀登记进QueryTokenPaths。UseAuthentication()、UseAuthorization()放在UseXiHan()之前。- 映射 Hub 时用
PipelineBypassMetadata.BypassXiHanPipeline(...)标注绕过内核管道,授权策略指定方案XiHan。
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Hosting.AspNetCore.Authentication;
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.Profiles.Standard;
var builder = WebApplication.CreateBuilder(args);
builder.AddXiHan(xihan => xihan.UseStandardProfile());
builder.Services.AddSignalR();
// 查询串 access_token 只在 /hubs 下接受:查询串会进访问日志与代理日志
builder.Services.AddAuthentication().AddXiHan(options => options.QueryTokenPaths.Add("/hubs"));
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseXiHan();
PipelineBypassMetadata.BypassXiHanPipeline(app.MapHub<NotificationHub>("/hubs/notifications"))
.RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = InvocationAuthenticationDefaults.AuthenticationScheme });
app.Run();
public sealed class NotificationHub : Hub;方案 XiHan 的规则:
| 项 | 行为 |
|---|---|
| 生效范围 | 只认带 PipelineBypassMetadata 的端点;普通请求由内核管道认证,方案对它们返回未认证,不重复认证 |
| 凭据 | 先取 Authorization 头;没有时,请求路径(相对 PathBase)按路径段、不区分大小写落在 QueryTokenPaths 的某个前缀下,且 access_token 只有一个非空值,才作为 Bearer 凭据;其他路径的查询串令牌一律忽略 |
| 路径前缀 | 不能为空也不能是 /,方案初始化时校验,违反时抛出 ArgumentException |
| 认证 | 交给方案名匹配的内核认证处理器;方案不支持、凭据无效都是 401,质询与管道一致(Bearer 带 error="invalid_token") |
| 主体 | 转成认证类型为 XiHan 的 ClaimsPrincipal:声明保留原始类型名,另加 ClaimTypes.NameIdentifier 承载主体 ID 并作名称声明(SignalR 的 UserIdentifier 随之可用),角色声明类型取 XiHan:Security:RoleClaim(缺省 role) |
| 登记 | AddXiHan 可以多次调用,方案只登记一次、各次的配置依次生效;不设为缺省方案 |
会话门禁
令牌在有效期内、但对应的会话已经注销时,方案 XiHan 本身认不出来。应用实现 IInvocationAuthenticationFilter 做这类核对:内核处理器认证成功后按登记顺序调用,任一返回失败即 401。框架不判定会话,会话状态由应用提供。
using XiHan.Framework.Hosting.AspNetCore.Authentication;
using XiHan.Framework.Kernel.Security;
using XiHan.Framework.Primitives;
using XiHan.Framework.Security.Abstractions;
public interface ISessionStatus
{
bool IsActive(string sessionId);
}
public sealed class SessionGate(ISessionStatus sessions) : IInvocationAuthenticationFilter
{
public ValueTask<Result> AuthenticatedAsync(Principal principal, InvocationContext context, CancellationToken cancellationToken)
{
// 会话已注销按凭据无效拒绝:客户端据 401 重新登录
var active = principal.ClaimValues("sid").All(sessions.IsActive);
return ValueTask.FromResult(active ? Result.Success() : Result.Failure(Error.Denied(SecurityErrors.InvalidCredential, SecurityMessages.InvalidToken)));
}
}builder.Services.AddSingleton<IInvocationAuthenticationFilter, SessionGate>();ISessionStatus 是应用自己的接口,示例只说明挂点的写法。
在 Hub 里调用操作
Hub 方法不在内核管道里,HttpContext.GetXiHanContext() 在这里是无值。要执行操作时以连接的 HttpContext 建调用上下文,再交给 IOperationInvoker:
using Microsoft.AspNetCore.SignalR;
using SimpleApp.Orders;
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.Kernel.Operations;
public sealed class OrdersHub(IOperationInvoker operations, IServiceProvider services) : Hub
{
public async Task<OrderList> GetOrders()
{
var http = Context.GetHttpContext() ?? throw new HubException("The connection has no HTTP context.");
// 凭据按方案 XiHan 的同一规则带上;调用经完整管道:重新认证、按契约授权、记审计
var call = HttpInvocation.CreateContext(http, services);
var result = await operations.InvokeAsync<ListOrdersRequest, OrderList>(call, ListOrders.Id, new ListOrdersRequest());
return result.TryGetValue(out var orders) ? orders : throw new HubException(result.Error?.Code);
}
}ListOrders、ListOrdersRequest、OrderList 来自 samples/SimpleApp。上下文用 Hub 方法自己的服务作用域(构造函数注入的 IServiceProvider),不记入 HttpContext.Items。每次调用都重新认证,长连接上的令牌过期后,新的调用会被拒绝。
跨实例推送
IHubContext<THub> 只能推给连在本实例上的连接。多实例部署时:
- 推到其他实例上的连接要靠 SignalR 自己的横向扩展,如 Redis 背板或托管的 SignalR 服务;框架不提供背板。
- 业务事件借消息子系统跨进程送达。缺省的竞争订阅是工作队列,每条消息只交给一个实例的处理器(至少一次),这个实例经
IHubContext<THub>推送,背板再转给其他实例上的连接。事件随业务数据在同一事务里写出时,用发件箱。
using Microsoft.AspNetCore.SignalR;
using XiHan.Framework.Messaging.Abstractions.Handlers;
using XiHan.Framework.Primitives;
public sealed class OrderPushHandler(IHubContext<NotificationHub> hub) : IMessageHandler
{
public async ValueTask<Result> HandleAsync(MessageDelivery delivery, InvocationContext context, CancellationToken cancellationToken)
{
await hub.Clients.All.SendAsync("orderPlaced", delivery.Envelope.Subject, cancellationToken);
return Result.Success();
}
}using XiHan.Framework.Messaging.Abstractions;
builder.Services.AddMessageHandler<OrderPushHandler>("orders");消息处理器经完整管道执行,信封扩展属性里的租户在这里已经进入调用上下文,按租户分组推送时从 context 取。主题、处理器与发件箱见消息与事件。
有些事要在每个实例上各做一次,例如吊销会话后断开连在各个实例上的连接、清掉各实例的本地缓存:背板只转发推送,断不了别的实例上的连接。用广播订阅(SubscriptionMode.Broadcast,订阅方式):发起的实例发布一条消息,每个在线实例的处理器各收到一份,各自处理连在自己身上的连接。
using XiHan.Framework.Messaging.Abstractions.Handlers;
using XiHan.Framework.Primitives;
// 应用自己的连接登记:断开本实例上属于这些会话的连接
public interface ILocalConnections
{
ValueTask DisconnectAsync(IReadOnlyCollection<string> sessionIds, CancellationToken cancellationToken);
}
public sealed class SessionsRevokedHandler(ILocalConnections connections) : IMessageHandler
{
public async ValueTask<Result> HandleAsync(MessageDelivery delivery, InvocationContext context, CancellationToken cancellationToken)
{
// 数据是被吊销的会话 ID;每个实例只断自己的连接,没有连接时什么也不做
var sessionIds = System.Text.Json.JsonSerializer.Deserialize(delivery.Envelope.Data.Span, RealtimeJsonContext.Default.StringArray) ?? [];
await connections.DisconnectAsync(sessionIds, cancellationToken);
return Result.Success();
}
}builder.Services.AddMessageHandler<SessionsRevokedHandler>("sessions.revoked", SubscriptionMode.Broadcast);RealtimeJsonContext 是应用的源生成序列化上下文。广播是尽力而为:发布时不在线的实例收不到(它上线时本来也没有这些连接),失败到上限丢弃不进死信;只装了内存代理的单实例部署里,广播就是本进程收到一次。
相关
- Hosting.AspNetCore 包
- 宿主与 Web 应用:内核管道与绕过管道的端点
- 认证:内核认证处理器与 Provider
- 消息与事件
- 规范:RFC-0008《认证与授权》的「绕过管道的端点」一节;ADR-0051
