调用上下文
这一页讲贯穿一次调用的那个对象:InvocationContext 自带的成员、Baggage 与五个已登记的键、内核与安全子系统附加在它上面的信息(安全上下文、结果、调用来源、类型化条目)、各协议怎样从请求构造它、以及 Baggage 跨进程传播时落在哪些载体上。读完这页,你能在操作、中间件与调用链深处的服务里正确地读写上下文,也能为自己发起的调用建出一个合格的上下文。前置知识是执行管道。
为什么需要
一次业务调用需要很多「随行信息」:还剩多少时间、属于哪条链路、来自哪个租户、要用哪种语言回错误、结果是什么。如果这些信息绑在 HttpContext 上,同一份操作到了 gRPC、消息或命令行就要另写一套;如果塞进线程静态变量,异步切换与嵌套调用会把它弄乱。框架用一个与协议无关的 InvocationContext 承载它们:协议适配器在 Ingress 阶段把请求翻译进来,中间件与处理器只读这个对象,Egress 阶段再翻译回协议。什么能跨进程传播、什么只在本进程可读,规范(RFC-0003)说得清清楚楚。
InvocationContext 的成员
InvocationContext 在原语层,没有任何第三方依赖:
| 成员 | 类型 | 含义 |
|---|---|---|
CancellationToken | CancellationToken | 本次调用的取消令牌,宿主取消与截止时间都并进来;异步 API 一路传递它 |
Deadline | DateTimeOffset? | 绝对截止时间,没有则为空 |
IsExpired(now)、RemainingTime(now) | 方法 | 按给定的「现在」判断是否已过期、还剩多久;now 来自容器里的 TimeProvider |
TraceId、SpanId、TraceFlags | string、string、ActivityTraceFlags? | W3C Trace Context:链路 ID、本次调用的跨度 ID、采样标志 |
Baggage | IReadOnlyDictionary<string, string> | 跨进程传播的键值,只保留已登记的键 |
TryGetBaggage(key, out value) | 方法 | 读一个 Baggage 值 |
WithBaggage(key, value)、WithCancellation(token) | 方法 | 返回带新 Baggage 或新令牌的副本;上下文本身不可变 |
Items | IDictionary<object, object?> | 本进程内的附加数据,中间件之间传值用;不传播 |
Services | IServiceProvider | 本次调用的作用域服务提供器,见依赖注入 |
TraceId、Deadline、Baggage 可以传播到下游进程;Items 与 Services 只在本进程、本次调用内有意义。
Baggage 与已登记的键
Baggage 是 W3C 格式的键值对,但框架只传播已登记的键,未登记的键在入站时丢弃。已登记的键在原语层的 BaggageKeys 里:
| 常量 | 键 | 含义 | 谁写入 |
|---|---|---|---|
BaggageKeys.Tenant | xihan.tenant | 调用方声明的租户 | 命令行选项 --tenant、消息扩展 xihantenant;HTTP 入站不收,租户只来自认证与 Tenant 阶段 |
BaggageKeys.Principal | xihan.principal | 发起者的主体 ID,只是标识不是凭据 | 消息扩展 xihanprincipal;HTTP 入站不收 |
BaggageKeys.Correlation | xihan.correlation | 业务关联 ID,用来把一次业务操作引发的多次调用串起来 | HTTP baggage 头、命令行 --correlation、消息扩展 xihancorrelation、作业派发(运行 ID) |
BaggageKeys.IdempotencyKey | xihan.idempotency-key | 幂等键,Idempotency 阶段据此去重 | HTTP baggage 头;消息派发不写它 |
BaggageKeys.Locale | xihan.locale | 调用方区域,失败文案与 key.L() 据此翻译 | HTTP baggage 头或 Accept-Language、命令行 --locale、消息扩展 xihanlocale |
BaggageKeys.Registered 列出全部已登记的键,IsRegistered(key) 与 IsWellFormed(key) 供自定义载体校验。HTTP 入站不接受 xihan.tenant 与 xihan.principal,是因为任何客户端都能写请求头,租户与主体必须由凭据推出;消息派发接受它们,是因为消息的发送方是经过授权的服务,审计把 xihan.principal 记为发起者。
内核附加的信息
内核与安全子系统用扩展方法在上下文上读写结构化数据,不直接操作 Items:
| 扩展方法 | 命名空间 | 含义 |
|---|---|---|
GetSecurity() | XiHan.Framework.Kernel.Security | 安全上下文 SecurityContext:Principal(Id、IsAuthenticated、AuthenticationScheme、Claims、DisplayName)与 Tenant(TenantContext:Id、DisplayName,可空)。Authenticate 与 Tenant 阶段之前读到的是 SecurityContext.Anonymous |
SetSecurity(security)、SetTenant(tenant) | 同上 | 由 Authenticate 与 Tenant 阶段写入,之后的阶段不应再改 |
SetOutcome(result)、SetOutcomeIfMissing(result)、GetOutcome() | XiHan.Framework.Kernel.Pipeline | 本次调用的结果:中间件短路或处理器完成时写入,宿主在 Egress 读出翻译成协议;GetOutcome() 返回 Maybe<Result>,没写过时无值,App.InvokeAsync 把无值当成功 |
GetOrigin()、SetOrigin(origin) | 同上 | 调用来源 InvocationOrigin,见下文;GetOrigin() 返回 Maybe<InvocationOrigin> |
Get<T>(key)、Set<T>(key, value) | 同上 | 用 ContextItemKey<T> 在 Items 里存取类型化的值,Get 返回 Maybe<T> |
最小示例
一个操作读取追踪 ID、剩余时间、主体与关联 ID,并把它们回显;一个中间件用类型化条目把「请求进入时刻」交给下游。
using XiHan.Framework.Kernel.Operations;
using XiHan.Framework.Kernel.Pipeline;
using XiHan.Framework.Kernel.Security;
using XiHan.Framework.Primitives;
namespace Shop.Diagnostics;
public static class ContextItems
{
// 类型化条目的键:中间件写、处理器读,不出本进程
public static readonly ContextItemKey<DateTimeOffset> EnteredAt = new("shop.diagnostics.entered_at");
}
public sealed class StampMiddleware(TimeProvider clock) : IPipelineMiddleware
{
public ValueTask InvokeAsync(InvocationContext context, PipelineStep nextStep)
{
context.Set(ContextItems.EnteredAt, clock.GetUtcNow());
return nextStep(context);
}
}
[Operation(Id, Stability = Stability.Preview, Expose = Expose.Http | Expose.CommandLine)]
public sealed class WhoAmI(TimeProvider clock) : IOperation<WhoAmIRequest, WhoAmIResponse>
{
public const string Id = "diagnostics.whoami";
public ValueTask<Result<WhoAmIResponse>> HandleAsync(WhoAmIRequest request, InvocationContext context)
{
var now = clock.GetUtcNow();
var security = context.GetSecurity();
var correlation = context.TryGetBaggage(BaggageKeys.Correlation, out var value) ? value : null;
var enteredAt = context.Get(ContextItems.EnteredAt);
return ValueTask.FromResult(Result.Success(new WhoAmIResponse(
context.TraceId,
security.Principal.Id,
security.Principal.IsAuthenticated,
security.Tenant?.Id,
correlation,
context.RemainingTime(now)?.TotalMilliseconds,
enteredAt.HasValue ? (now - enteredAt.ValueOrThrow()).TotalMilliseconds : null)));
}
}
public sealed record WhoAmIRequest();
public sealed record WhoAmIResponse(
string TraceId,
string Principal,
bool IsAuthenticated,
string? Tenant,
string? Correlation,
double? RemainingMs,
double? ElapsedMs);特性里登记中间件与操作:context.AddMiddleware<StampMiddleware>(PipelineStage.Observe) 与 context.AddGeneratedOperations();请求与响应类型登记进项目的 JsonSerializerContext。whoami 不是标准动词、请求也没有 id,HTTP 适配器把它映射成 POST /diagnostics:whoami,映射规则见 HTTP 路由与载荷。运行后:
curl -s -X POST localhost:5000/diagnostics:whoami \
-H "Content-Type: application/json" \
-H "baggage: xihan.correlation=order-42" \
-d '{}'{"traceId":"4bf92f3577b34da6a3ce929d0e0e4736","principal":"anonymous","isAuthenticated":false,"tenant":null,"correlation":"order-42","remainingMs":null,"elapsedMs":0.4}再加一个 xihan-deadline: <RFC 3339 时刻> 请求头,remainingMs 就是到该时刻的剩余毫秒数。同一份操作在命令行宿主上是 app diagnostics whoami --correlation order-42 --timeout 30,输出同样的字段,remainingMs 接近 30000,traceId 每次调用新生成。
各协议怎样构造上下文
Ingress 阶段由协议适配器或派发器完成,各自把载体上的信息翻译进上下文:
| 来源 | 入口 | 追踪与截止时间 | Baggage | 调用来源 |
|---|---|---|---|---|
| HTTP | HttpInvocation.CreateContext(httpContext),UseXiHan() 内部调用;端点里用 httpContext.GetXiHanContext() 取已建好的 | traceparent 头;xihan-deadline(RFC 3339)或 grpc-timeout | baggage 头,只收 xihan.correlation、xihan.idempotency-key、xihan.locale;Accept-Language 补 xihan.locale | 协议 http,目标是方法加路径,带远端地址、User-Agent、Referer、路由模板与端点名 |
| 命令行 | CommandLineInvocation.Create(commandContext, operationId),invoke 命令内部调用;自定义命令发起多次调用时用 Create(context, services, operationId) 各建一个作用域 | 每次新生成 trace-id;--timeout <秒> | --tenant、--correlation、--locale | 协议 cli,目标是操作 ID,没有远端地址 |
| 消息 | MessageDispatcher 为每条消息开新作用域 | 信封的 traceparent 扩展 | xihantenant、xihanprincipal、xihancorrelation、xihanlocale 四个扩展进对应的键;不写幂等键 | 协议 messaging,目标是主题 |
| 作业 | JobDispatcher 为每次运行开新作用域 | 新链路 | xihan.correlation 是运行 ID | 协议 job,目标是作业名 |
| gRPC、MCP、工作流 | 对应的协议适配器与引擎 | 见各协议页 | 见各协议页 | 协议 grpc、mcp、workflow |
| 测试 | TestHost.InvokeAsync(handler, deadline, baggage);不需要宿主时 TestInvocationContext.Create(...) | 按参数 | 按参数 | 无 |
自己发起的调用(后台服务、定时任务)用静态方法 App.CreateContext 建上下文,再交给 App.InvokeAsync,这样认证、租户、事务等中间件照常生效:
using Microsoft.Extensions.DependencyInjection;
using XiHan.Framework.Kernel.Hosting;
using XiHan.Framework.Primitives;
namespace Shop.Diagnostics;
public sealed class NightlyReconciler(App app, IServiceScopeFactory scopes)
{
public async Task RunAsync(string runId, CancellationToken cancellationToken)
{
// 一次调用一个作用域:上下文的 Services 就是它
await using var scope = scopes.CreateAsyncScope();
var context = App.CreateContext(
scope.ServiceProvider,
cancellationToken,
deadline: DateTimeOffset.UtcNow.AddMinutes(10),
baggage: new Dictionary<string, string> { [BaggageKeys.Correlation] = runId });
// 处理器作为 Handle 阶段执行;不写结果时视为成功
var result = await app.InvokeAsync(context, static ctx =>
{
ctx.SetOutcome(Result.Success());
return ValueTask.CompletedTask;
});
}
}CreateContext 的可选参数依次是 traceId、spanId、deadline、baggage、traceFlags;不给 traceId 时新开一条链路。调度子系统的作业已经由 JobDispatcher 这样做了,优先用作业与计划而不是自己写循环。
调用来源
协议适配器与派发器在建上下文时写入一次调用来源 InvocationOrigin,之后只读、不跨进程传播:
InvocationOrigin 成员 | 含义 |
|---|---|
Protocol | InvocationProtocols 的常量:Http(http)、Grpc(grpc)、Mcp(mcp)、CommandLine(cli)、Messaging(messaging)、Job(job)、Workflow(workflow) |
Target | HTTP 为方法与路径(不含查询串),gRPC 为方法全名,MCP 为工具名,命令行为操作 ID,消息为主题,作业为作业名,工作流为操作名 |
RemoteAddress | 客户端 IP,不含端口;经反向代理时取 UseForwardedHeaders() 改写后的值,没有网络对端时为空 |
UserAgent、Referer | 对应的请求头,没有时为空 |
Route、EndpointName | 匹配到的端点的路由模板与 WithName 名字,没有匹配到端点或协议没有端点时为空 |
缓存回源、存储维护这类内部调用没有来源,GetOrigin() 返回无值。审计事件本身不带来源字段,需要远端地址或 User-Agent 时由应用写进细节,下面的中间件放在 Observe 阶段开头,被认证或授权拒绝的调用也能带上来源:
using XiHan.Framework.Auditing.Abstractions.Pipeline;
using XiHan.Framework.Kernel.Pipeline;
using XiHan.Framework.Primitives;
namespace Shop.Diagnostics;
public sealed class AuditOriginMiddleware : IPipelineMiddleware
{
public ValueTask InvokeAsync(InvocationContext context, PipelineStep nextStep)
{
if (context.GetOrigin().TryGetValue(out var origin))
{
context.AddAuditDetail("protocol", origin.Protocol);
context.AddAuditDetail("target", origin.Target);
context.AddAuditDetail("remoteAddress", origin.RemoteAddress);
context.AddAuditDetail("userAgent", origin.UserAgent);
}
return nextStep(context);
}
}AddAuditDetail 来自审计抽象包,见审计。
调用链深处取上下文
操作与中间件从参数拿上下文;端点里用 httpContext.GetXiHanContext()(返回 Maybe<InvocationContext>,请求没有经过 UseXiHan() 时无值)。调用链深处拿不到任何参数的服务注入作用域服务 IInvocationAccessor,它的 Current 是 Maybe<InvocationContext>:
using XiHan.Framework.Kernel.Pipeline;
using XiHan.Framework.Kernel.Security;
namespace Shop.Diagnostics;
public sealed class CurrentShopper(IInvocationAccessor accessor)
{
public string? Id => accessor.Current.TryGetValue(out var context)
? context.GetSecurity().Principal.Id
: null;
}内核在调用进入管道前设置 Current,离开时恢复上一层;同一作用域内的嵌套调用各自看到自己的上下文。不在调用内(后台任务、作用域刚创建、调用已结束)时为无值,调用方要处理这种情况而不是 ValueOrThrow()。
Baggage 的传播载体
| 载体 | 入站 | 出站 |
|---|---|---|
| HTTP | baggage 头,W3C 格式,值按 URL 解码 | 调用下游 HTTP 服务时用 HttpInvocation.FormatBaggage(context.Baggage) 生成头值;traceparent 由 .NET 的 HttpClient 按当前 Activity 自动写 |
| CloudEvents 消息 | 扩展 xihantenant、xihanprincipal、xihancorrelation、xihanlocale | 在调用内发布时发布者自动补齐:租户与主体优先取安全上下文,其次取 Baggage;traceparent 取当前 Activity,见发布与订阅 |
| 命令行 | --tenant、--correlation、--locale 选项 | 进程是链路起点,没有出站 |
| 作业 | 运行 ID 写进 xihan.correlation | 作业里发布的消息按消息载体传播 |
不传播的东西也要记住:Items、Services、调用来源、安全上下文都只在本进程有效。认证中间件不把主体回写到 Baggage,Baggage 是跨进程传播的载体,不是进程内状态的副本;消息派发之所以带 xihan.principal,是因为消费端没有凭据可以重新认证,只能记录发起者。
常见问题
为什么我在 HTTP 请求头里放了 xihan.tenant,操作里读不到
HTTP 入站只接受 xihan.correlation、xihan.idempotency-key、xihan.locale 三个键,其余 xihan. 键丢弃。租户来自认证后的主体与 Tenant 阶段的解析规则(请求头、域名、路径等由多租户子系统决定),见租户解析与隔离。
Deadline 与 CancellationToken 是什么关系
Deadline 是绝对时刻,DeadlineMiddleware 在 Resilience 阶段据此派生一个在截止时刻触发的令牌并并入 CancellationToken。处理器只需要传递 CancellationToken;要知道「还剩多久」才读 Deadline 或 RemainingTime(now)。超时得到 KERNEL_TIMEOUT_001,外部取消得到 KERNEL_CANCELLED_001。
我能在中间件里改 Baggage 吗
上下文不可变,WithBaggage 返回的是副本,而管道中的下一步收到的仍是原来的对象。Baggage 在 Ingress 阶段定型,中间件之间传值用 Items(配合 ContextItemKey<T>),要影响下游进程就在出站载体上显式写。
SetOutcome 写了两次会怎样
后写的覆盖先写的,所以规则是「谁短路谁写,处理器之后不要改写别的阶段写的结果」。只想在没有结果时补一个时用 SetOutcomeIfMissing。
处理器既没有 SetOutcome 也没有抛异常,调用结果是什么
成功。App.InvokeAsync 把无值的结果当作 Result.Success(),操作契约的处理器由内核把返回的 Result<TResponse> 写进去,不需要手动调用。
相关包与下一步
- 包参考:Primitives(
InvocationContext、BaggageKeys)、Kernel(扩展方法、InvocationOrigin、IInvocationAccessor)、Hosting.AspNetCore、Hosting.CommandLine - 下一章:配置模型与选项,看
XiHan:*配置节怎样绑定到选项 - 延伸:审计、日志与可观测性、全球化和本地化
