端点里使用框架
这页讲不是操作契约的普通最小 API 端点怎样使用框架:它们在 UseXiHan() 之后处于管道的 Handle 阶段内,可以读调用上下文里的主体与租户、把失败结果交给宿主写成 Problem Details、挂上由 Authorize 阶段判定的授权要求,也可以选择绕过管道并改用 ASP.NET Core 认证方案 XiHan。读完这页,我们能判断一个端点该写成操作还是普通端点,并在普通端点里正确地拿到框架的东西。
前置知识:ASP.NET Core 宿主、执行管道与调用上下文。
为什么需要
不是所有 HTTP 入口都适合写成操作:文件下载、重定向、健康面板、临时的诊断端点、SignalR Hub 都有自己的 HTTP 形态,硬套 IOperation<TRequest, TResponse> 反而别扭。但这些端点同样需要知道调用方是谁、属于哪个租户,失败时也应该和操作一样写出 Problem Details,而不是另起一套错误格式。
宿主把普通端点放进管道的 Handle 阶段:请求到达端点时 Authenticate、Tenant、Authorize 阶段已经执行完,端点只要从 HttpContext 取出调用上下文就能读到结果;把失败写回上下文,宿主在管道结束后统一翻译。真正不能进管道的长连接端点,再用 PipelineBypassMetadata 标出来,认证交给方案 XiHan。
端点在管道里的位置
UseXiHan() 之后映射的端点,每个请求都先经过内核管道的前十个阶段,端点委托本身就是 Handle 阶段,端点返回后再走 Egress。于是在端点里:
| 要做的事 | 入口 | 说明 |
|---|---|---|
| 取当前调用上下文 | http.GetXiHanContext() | 返回 Maybe<InvocationContext>,没经过 UseXiHan() 时为无值 |
| 读主体与租户 | context.GetSecurity() | SecurityContext:Principal(Id、IsAuthenticated、声明)与 Tenant(可空) |
| 返回框架错误 | context.SetOutcome(Error) | 失败结果留在上下文,宿主写 Problem Details |
| 取调用方语言的文案 | key.L() | 按上下文里协商出的区域翻译 |
| 要求已认证 / 权限 / 策略 | RequireXiHanAuthentication() 等 | 写在端点元数据上,Authorize 阶段判定 |
| 读截止时间与取消 | context.Deadline、context.CancellationToken | 与操作里相同 |
端点委托里抛出的异常由内核翻译成 KERNEL_UNEXPECTED_001、HTTP 500;可恢复的失败不要抛,用 SetOutcome。
最小示例
下面三个端点来自示例应用的 Program.cs,接 ASP.NET Core 宿主里的装配代码:
using SimpleApp;
using SimpleApp.Orders;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.Kernel.Pipeline;
using XiHan.Framework.Kernel.Security;
using XiHan.Framework.Primitives;
using XiHan.Framework.Profiles.Standard;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Insert(0, SampleJsonContext.Default));
builder.AddXiHan(xihan => xihan.UseStandardProfile().UseFeature<OrdersFeature>());
var app = builder.Build();
app.UseXiHan();
// 读安全上下文:Authenticate 与 Tenant 阶段已经写好主体与租户
app.MapGet("/whoami", (HttpContext http) =>
{
var security = http.GetXiHanContext().ValueOrThrow().GetSecurity();
return new WhoAmI(security.Principal.Id, security.Principal.IsAuthenticated, security.Tenant?.Id);
}).ExcludeFromDescription();
// 返回框架错误:失败结果留在上下文里,管道结束后宿主写成 Problem Details
app.MapGet("/missing", (HttpContext http) =>
{
http.GetXiHanContext().ValueOrThrow().SetOutcome(Error.NotFound("SAMPLES_NOTFOUND_001", SampleMessages.ResourceMissing));
return Results.Empty;
});
// 授权要求:没有凭据的请求在 Authorize 阶段被拒,端点委托不执行
app.MapGet("/orders/stats", (OrderEventCounter counter) => new OrderStatistics(counter.Handled))
.RequireXiHanAuthentication()
.ExcludeFromDescription();
app.Run();OrdersFeature、OrderEventCounter、OrderStatistics、SampleJsonContext 与 WhoAmI(记录:Principal、IsAuthenticated、Tenant)都来自示例应用(见示例说明);SampleMessages.ResourceMissing 是消息键常量 samples.resource.missing,各语言文案在 Localization/<区域>.json 里。WhoAmI 与 OrderStatistics 登记进源生成的序列化上下文。运行后:
curl -s localhost:5000/whoami
curl -s localhost:5000/whoami -H "Authorization: ApiKey dev-alice-key"
curl -s localhost:5000/missing -H "Accept-Language: zh-CN"
curl -s -i localhost:5000/orders/stats第一条返回匿名主体:{"principal":"","isAuthenticated":false,"tenant":null}(匿名主体的 ID 是空串);第二条返回 alice 与她的租户 acme;第三条返回 404 的 Problem Details,code 是 SAMPLES_NOTFOUND_001,detail 是中文文案;第四条返回 401,code 是 SECURITY_DENIED_001,响应头带 WWW-Authenticate: ApiKey。
读调用上下文
HttpContext.GetXiHanContext() 返回 Maybe<InvocationContext>:请求经过了 UseXiHan() 时有值;绕过管道的端点、或者根本没调用 UseXiHan() 时为无值。端点确定自己在管道内时用 ValueOrThrow(),不确定时用 TryGetValue(out var context) 分支处理。
上下文里的内容按阶段写入,端点执行时都已就位:GetSecurity() 取 Authenticate 与 Tenant 阶段写好的 SecurityContext;TraceId、Baggage、Deadline 来自请求头;GetOperation() 在普通端点上为无值,因为它不是操作。读主体声明的写法见认证。
调用链深处拿不到 HttpContext 的服务(领域服务、仓储装饰器、消息处理器里的辅助类)不要依赖 HTTP,改用作用域服务 IInvocationAccessor.Current 取当前调用的上下文,它同样返回 Maybe<InvocationContext>,对 HTTP、gRPC、命令行与消息投递都成立,见调用上下文。
返回错误
端点把失败写进调用上下文,由宿主统一翻译:
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.Kernel.Pipeline;
using XiHan.Framework.Primitives;
app.MapGet("/reports/{id}", (string id, HttpContext http) =>
{
// 结果写进上下文,Egress 由宿主翻译为 Problem Details;消息参数随错误一起带走,文案在 Egress 翻译
http.GetXiHanContext().ValueOrThrow().SetOutcome(
Error.NotFound("SHOP_NOTFOUND_002", "shop.report.not_found", MessageArguments.Of(("id", id))));
return Results.Empty;
});几条规则:
SetOutcome接受Result,Error隐式转换成失败结果;调用后端点返回Results.Empty即可,宿主在响应还没开始写出时写 Problem Details。端点已经写出正文后再SetOutcome,宿主保留端点的内容,只有结果记入日志与审计。- 状态码由错误码的 KIND 段映射(
NOTFOUND是 404),对照表见 HTTP 路由与载荷;detail按调用方区域翻译,details带标量参数。 Error只携带消息键与参数,不带文案;键与各语言文件的约定见结果与错误处理与全球化和本地化。- 操作契约不需要这样做:
HandleAsync返回的Result就是调用结果。
绕过管道的端点没有上下文,要自己写错误时用 ProblemResponse:FromError(error, instance, traceId) 构造响应体,StatusCodeFor(error) 给出状态码,WriteTo(writer) 写出 JSON,结果与宿主写出的一致。
端点上的授权要求
普通端点用三个约定扩展挂授权要求,UseXiHan() 把它放进上下文,Authorize 阶段判定:
| 扩展 | 要求 |
|---|---|
RequireXiHanAuthentication() | 已认证 |
RequireXiHanPermission(permissionCode, resource) | 持有权限码;resource 可选,限定到某个资源 |
RequireXiHanPolicy(policyName, resource) | 命名策略允许;resource 可选 |
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
app.MapGet("/orders/stats", (OrderEventCounter counter) => new OrderStatistics(counter.Handled))
.RequireXiHanAuthentication();
app.MapGet("/orders/export", () => Results.Text("id,name"))
.RequireXiHanPermission("orders.read");判定由安全子系统完成:没有装配 security.authorization@1 时要求只进入上下文,不被判定。权限码必须由某个特性用 FeatureContext.AddPermission 登记;装配了授权能力时,UseXiHan() 在宿主建请求管道时核对全部端点的要求,未登记的权限码与不存在的策略让宿主启动失败(SECURITY_VALIDATION_004,一次列出全部)。端点上的要求优先于操作契约上的权限或策略。认证与授权的配置见认证与授权与权限。
这三个扩展与 ASP.NET Core 自己的 RequireAuthorization() 不是一回事:前者由内核管道的 Authorize 阶段判定,对管道内的端点用;后者由 ASP.NET Core 授权中间件判定,只给下文绕过管道的端点用。
端点与 OpenAPI 文档
MapXiHanOpenApi() 生成的文档也会收录普通端点,条件是端点有 HTTP 方法元数据、带 Accepts 或 Produces 描述元数据之一,并且没有 ExcludeFromDescription()。最小 API 为返回类型自动加上 Produces 元数据,所以返回 JSON 对象的端点缺省会进文档,而文档对 JSON 类型只引用已登记操作的 Schema,不另做推导:引用不到时整份文档生成失败(PROTOCOLS_VALIDATION_010)。
因此示例里 /whoami、/orders/stats 这类返回自定义 JSON 的端点都加了 ExcludeFromDescription();/missing 返回 Results.Empty,没有响应类型,不进文档。响应正文确实与某个操作的请求或响应同形时,用 WithXiHanOperationSchema(request:, response:) 声明引用,端点就能进文档并共用组件。规则与写法见规范化接口文档。
绕过管道的端点
SignalR Hub、Server-Sent Events 这类长连接端点不经内核管道:不受截止时间约束,内核停止时也不会被 503 打断,因此 Authenticate 阶段管不到它们。给端点加 PipelineBypassMetadata,UseXiHan() 看到它就直接放行,不建调用上下文;探针、OpenAPI 文档、.proto 与 gRPC 方法的端点都带着它。
这类端点需要认证时,Hosting.AspNetCore 提供 ASP.NET Core 认证方案 XiHan,把凭据交给内核已装配的同一个 IAuthenticationHandler,JWT、OpenID Connect、ApiKey 不用各接一遍:
AddAuthentication().AddXiHan(...)登记方案;浏览器的 WebSocket 与 EventSource 带不了请求头,把允许查询串令牌的路径前缀加进QueryTokenPaths。UseAuthentication()、UseAuthorization()放在UseXiHan()之前。- 映射端点时用
PipelineBypassMetadata.BypassXiHanPipeline(...)标注绕过,RequireAuthorization的策略指定方案XiHan。
using Microsoft.AspNetCore.Authorization;
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();
var xihanScheme = new AuthorizeAttribute { AuthenticationSchemes = InvocationAuthenticationDefaults.AuthenticationScheme };
PipelineBypassMetadata.BypassXiHanPipeline(app.MapHub<NotificationHub>("/hubs/notifications")).RequireAuthorization(xihanScheme);
app.Run();方案 XiHan 的规则:
| 项 | 行为 |
|---|---|
| 生效范围 | 只认带 PipelineBypassMetadata 的端点;普通请求由内核管道认证,方案对它们返回未认证,不重复认证 |
| 凭据 | 先取 Authorization 头;没有时,请求路径(相对 PathBase)按路径段、不区分大小写落在 QueryTokenPaths 的某个前缀下,且 access_token 只有一个非空值,才作为 Bearer 凭据;其他路径的查询串令牌一律忽略 |
| 路径前缀 | 不能为空也不能是 /,方案初始化时校验,违反时抛出 ArgumentException |
| 认证 | 交给方案名匹配的内核认证处理器;没有凭据是未认证,方案不支持、凭据无效都是 401,质询与管道一致 |
| 会话门禁 | 认证成功后按登记顺序调用应用的 IInvocationAuthenticationFilter,任一返回失败即 401 |
| 主体 | 转成认证类型为 XiHan 的 ClaimsPrincipal:声明保留原始类型名,另加 ClaimTypes.NameIdentifier 承载主体 ID 并作名称声明,角色声明类型取 XiHan:Security:RoleClaim(缺省 role) |
| 登记 | AddXiHan 可以多次调用,方案只登记一次、各次的配置依次生效;不设为缺省方案 |
绕过管道的端点里 GetXiHanContext() 为无值。要在里面发起内核调用(如 Hub 方法调用操作),用 HttpInvocation.CreateContext(httpContext, services) 以请求建调用上下文,凭据按同一规则带上,调用经 IOperationInvoker 走完整管道时照常重新认证与授权。Hub 的完整写法、会话门禁与跨实例推送见实时通信。
常见问题
这个入口该写成操作还是普通端点
输入输出是 JSON 对象、要暴露给多种协议或进 OpenAPI 文档、需要按 Schema 校验的,写成操作(操作契约)。返回文件、文本、重定向,或者形态只在 HTTP 上有意义的(下载、SSE、Hub),写普通端点。普通端点拿不到 Validate 阶段的 Schema 校验与 Transaction 阶段按契约的只读判断,其余阶段一样。
GetXiHanContext().ValueOrThrow() 抛了异常
说明请求没有经过 UseXiHan():要么端点映射在 UseXiHan() 之前,要么端点带了 PipelineBypassMetadata,要么根本没调用 UseXiHan()。核对中间件顺序;绕过管道的端点要用 HttpInvocation.CreateContext(httpContext, services) 自己建上下文。
加了 RequireXiHanPermission 之后应用启动失败
权限码没有被任何特性用 AddPermission 登记,或者 RequireXiHanPolicy 引用的策略不存在。宿主在建请求管道时核对全部端点的要求,一次列出全部不合格项(SECURITY_VALIDATION_004)。在拥有这个权限的特性里登记权限码,或者改用已登记的码。
端点返回了 JSON,为什么 /openapi.json 整个 500 了
文档对 JSON 类型只引用已登记操作的 Schema,端点返回的类型不是任何操作的请求或响应类型时报 PROTOCOLS_VALIDATION_010。给端点加 ExcludeFromDescription(),或者用 WithXiHanOperationSchema 声明它与哪个操作同形,或者把它改写成操作。
UseAuthentication() 为什么要放在 UseXiHan() 之前
方案 XiHan 只服务绕过管道的端点,这类端点在 UseXiHan() 里被直接放行,认证与授权中间件放在前面,它们的握手在进入 UseXiHan() 之前就已经完成判定。放在后面时,管道内的普通请求也会在 Handle 阶段再经过一次 ASP.NET Core 认证中间件,而它们已经由内核管道认证过了。按约定放在 UseXiHan() 之前,顺序只有这一种。
相关包与下一步
- 相关包:Hosting.AspNetCore、Protocols.Http、Kernel
- 下一步:实时通信,用 SignalR 做推送,Hub 的认证交给方案
XiHan - 相关章节:调用上下文、结果与错误处理、认证、授权与权限、规范化接口文档
- 规范:RFC-0008《认证与授权》的「绕过管道的端点」一节
