XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

实时通信 ​

本页讲怎样做实时推送:直接使用 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 ​

  1. AddSignalR(),并用 AddAuthentication().AddXiHan(...) 登记方案 XiHan;浏览器的 WebSocket 与 EventSource 带不了请求头,把 Hub 的路径前缀登记进 QueryTokenPaths。
  2. UseAuthentication()、UseAuthorization() 放在 UseXiHan() 之前。
  3. 映射 Hub 时用 PipelineBypassMetadata.BypassXiHanPipeline(...) 标注绕过内核管道,授权策略指定方案 XiHan。
csharp
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。框架不判定会话,会话状态由应用提供。

csharp
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)));
    }
}
csharp
builder.Services.AddSingleton<IInvocationAuthenticationFilter, SessionGate>();

ISessionStatus 是应用自己的接口,示例只说明挂点的写法。

在 Hub 里调用操作 ​

Hub 方法不在内核管道里,HttpContext.GetXiHanContext() 在这里是无值。要执行操作时以连接的 HttpContext 建调用上下文,再交给 IOperationInvoker:

csharp
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> 推送,背板再转给其他实例上的连接。事件随业务数据在同一事务里写出时,用发件箱。
csharp
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();
    }
}
csharp
using XiHan.Framework.Messaging.Abstractions;

builder.Services.AddMessageHandler<OrderPushHandler>("orders");

消息处理器经完整管道执行,信封扩展属性里的租户在这里已经进入调用上下文,按租户分组推送时从 context 取。主题、处理器与发件箱见消息与事件。

有些事要在每个实例上各做一次,例如吊销会话后断开连在各个实例上的连接、清掉各实例的本地缓存:背板只转发推送,断不了别的实例上的连接。用广播订阅(SubscriptionMode.Broadcast,订阅方式):发起的实例发布一条消息,每个在线实例的处理器各收到一份,各自处理连在自己身上的连接。

csharp
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();
    }
}
csharp
builder.Services.AddMessageHandler<SessionsRevokedHandler>("sessions.revoked", SubscriptionMode.Broadcast);

RealtimeJsonContext 是应用的源生成序列化上下文。广播是尽力而为:发布时不在线的实例收不到(它上线时本来也没有这些连接),失败到上限丢弃不进死信;只装了内存代理的单实例部署里,广播就是本进程收到一次。

相关 ​

Released under The MIT License