操作契约
这页讲框架对外暴露业务的方式:用 [Operation] 与 IOperation<TRequest, TResponse> 把一个业务动作声明成操作,由源生成器产出登记代码与 JSON Schema,再由协议适配器把同一份契约发布到 HTTP、gRPC、MCP 与命令行。读完这页,我们能写出一个带权限要求的操作,让它出现在 POST /orders 上,并且知道一次调用在管道里经过了哪些阶段。
前置知识:特性与清单、执行管道与 ASP.NET Core 宿主。
为什么需要
传统写法里,控制器方法既是业务入口又是 HTTP 细节的载体:路由、状态码、模型绑定、权限特性都写在上面。要再暴露给 gRPC 或让大模型调用,就得再写一套入口,几套入口的校验与授权规则很容易不一致。
框架把业务入口从协议里抽出来:一个操作就是一个类,只声明操作 ID、权限与暴露的协议,输入输出是普通的记录类型。路由、protobuf 消息、MCP tool 的输入 Schema 都由适配器从这一份契约推导,校验与授权在管道里做一次,对每种协议都成立。路由不从方法名推导,HTTP 上长什么样由操作 ID 与请求的形状决定。
操作 ID
操作 ID 的文法是 <resource>.<verb>:资源是小写复数名词,多个词用连字符连接;动词是小写动词。
orders.create orders.get orders.list order-lines.delete orders.cancel协议适配器从操作 ID 推导各协议上的名字,推导规则可以按操作覆盖:
| 动词 | HTTP | gRPC | MCP tool |
|---|---|---|---|
create | POST /{resource}(201) | {Resource}/Create | {resource}_create |
get | GET /{resource}/{id} | {Resource}/Get | {resource}_get |
list | GET /{resource} | {Resource}/List | {resource}_list |
update | PUT /{resource}/{id} | {Resource}/Update | {resource}_update |
delete | DELETE /{resource}/{id} | {Resource}/Delete | {resource}_delete |
| 其他 | POST /{resource}/{id}:{verb} 或 POST /{resource}:{verb} | {Resource}/{Verb} | {resource}_{verb} |
命令行上是 <resource> <verb> --字段 值。请求类型是否声明 id 属性、是否带嵌套对象会改变 HTTP 路由的形态,完整规则见 HTTP 路由与载荷。
声明操作的两个部件
一个操作由两部分组成:[Operation(Id, ...)] 是契约,声明操作 ID、稳定性、暴露的协议、权限或策略与是否只读;IOperation<TRequest, TResponse> 是实现,HandleAsync(TRequest request, InvocationContext context) 返回 ValueTask<Result<TResponse>>。
[Operation] 的属性:
| 属性 | 缺省 | 说明 |
|---|---|---|
Id | 必填 | 操作 ID |
Stability | Stable | 稳定性;Internal 的操作不对外发布 |
Expose | 程序集级缺省,再无则 Expose.All | 暴露给哪些协议,见下表 |
Permission | 无 | 调用所需的权限码,Authorize 阶段判定 |
Policy | 无 | 调用所需的授权策略名,与 Permission 二选一 |
ReadOnly | false | 声明非标准动词的只读操作(如 tree),create / update / delete 不能声明 |
Expose 是标志位枚举:
| 值 | 发布到 |
|---|---|
Http | Protocols.Http,含 OpenAPI |
Grpc | Protocols.Grpc,含 /operations.proto |
Mcp | Protocols.Mcp 的 tool |
CommandLine | Hosting.CommandLine 的命令 |
Ai | ai.tools@1 目录里的模型工具 |
All / None | 全部 / 都不发布(仍可在进程内经调用器调用) |
HandleAsync 的约定:
- 返回
Result<TResponse>,成功值不能为null;可恢复的失败返回Error,不抛异常,写法见结果与错误处理。 - 取消令牌用
context.CancellationToken,它同时受调用方取消与截止时间约束。 - 操作类按作用域注册,构造函数注入所需服务;每次调用一个新实例。
最小示例
下面以示例应用的订单能力为底:一个 orders.create 操作,要求权限 orders.write,暴露给 HTTP、gRPC 与 MCP。项目要引用 XiHan.Framework.Kernel.Generators(分析器引用,不进运行时依赖),用 NuGet 包时按分析器引用即可:
dotnet add package XiHan.Framework.Kernel.Generators --prerelease操作类与请求、响应类型:
using XiHan.Framework.Kernel.Operations;
using XiHan.Framework.Persistence.Abstractions.Identifiers;
using XiHan.Framework.Persistence.Abstractions.Repositories;
using XiHan.Framework.Primitives;
namespace Shop.Orders;
[Operation(Id, Stability = Stability.Preview, Expose = Expose.Http | Expose.Grpc | Expose.Mcp, Permission = "orders.write")]
public sealed class CreateOrder(IRepository<Order, Guid> orders, IIdGenerator<Guid> ids) : IOperation<PlaceOrderRequest, OrderResponse>
{
public const string Id = "orders.create";
public async ValueTask<Result<OrderResponse>> HandleAsync(PlaceOrderRequest request, InvocationContext context)
{
var order = new Order { Id = ids.NewId(), Name = request.Name, Amount = request.Amount };
// 仓储返回 Result:失败直接作为操作结果,由宿主翻译成协议上的错误
var added = await orders.AddAsync(order, context.CancellationToken);
return added.IsFailure ? added.Error! : new OrderResponse(order.Id, order.Name, order.Amount);
}
}
public sealed record PlaceOrderRequest(string Name, int Amount);
public sealed record OrderResponse(Guid Id, string Name, int Amount);Order 是实现了实体协议的聚合根,写法见领域模型;仓储与标识生成器见仓储读写与标识生成。
请求与响应类型登记进项目的 JsonSerializerContext,生成器从这里取序列化元数据:
using System.Text.Json.Serialization;
namespace Shop.Orders;
// camelCase 与不区分大小写:HTTP 适配器把路由值、查询串与请求体合并成 JSON 对象交给操作;
// 允许从字符串读数字:路由值与查询串都是字符串
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString)]
[JsonSerializable(typeof(PlaceOrderRequest))]
[JsonSerializable(typeof(OrderResponse))]
internal sealed partial class OrdersJsonContext : JsonSerializerContext;特性在 Configure 里登记权限码与操作。清单按能力 ID 声明依赖,写法见特性与清单:
using XiHan.Framework.Kernel;
using XiHan.Framework.Kernel.Hosting;
using XiHan.Framework.Kernel.Security;
using XiHan.Framework.Persistence.Abstractions;
using XiHan.Framework.Primitives;
using XiHan.Framework.Primitives.Manifests;
using XiHan.Framework.Security.Abstractions;
namespace Shop.Orders;
public sealed class OrdersFeature : IFeature
{
public Manifest Manifest { get; } = new()
{
Id = "shop.orders",
Package = "Shop",
Version = FrameworkInfo.Version,
Primitives = 1,
Stability = Stability.Preview,
Kind = ManifestKind.Capability,
Requires =
[
new CapabilityRequirement(KernelCapabilities.Hosting, KernelCapabilities.Version),
new CapabilityRequirement(PersistenceCapabilities.Relational, PersistenceCapabilities.Version),
new CapabilityRequirement(SecurityCapabilities.Authorization, SecurityCapabilities.Version),
],
Contributes = new Contributions { Permissions = ["orders.write"] },
Entry = typeof(OrdersFeature).FullName,
};
public void Configure(FeatureContext context)
{
// 权限码先登记,操作契约才能引用它;没登记的权限码让装配失败
context.AddPermission(new PermissionDefinition("orders.write", "Place orders"));
// 登记代码由 Kernel.Generators 从 [Operation] 生成,含请求 / 响应 JSON Schema
context.AddGeneratedOperations();
}
}最后在 Program.cs 里装配特性与 HTTP 适配器,把操作映射成端点:
using Shop.Orders;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Profiles.Standard;
using XiHan.Framework.Protocols.Http;
using XiHan.Framework.Protocols.Http.Routing;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Insert(0, OrdersJsonContext.Default));
builder.AddXiHan(xihan => xihan
.UseStandardProfile()
.UseFeature<HttpProtocolFeature>()
.UseFeature<OrdersFeature>());
var app = builder.Build();
app.UseXiHan();
app.MapXiHanOperations(); // orders.create → POST /orders
app.Run();Standard 组合带内存的 ApiKey 认证与授予,在 appsettings.json 里给一个主体与一条角色授予(配置的全貌见认证与 RBAC 授予与条件):
{ "XiHan": { "Security": { "InMemory": {
"Principals": { "alice": { "ApiKey": "dev-alice-key", "DisplayName": "Alice", "Roles": "editor" } },
"Grants": { "role": { "editor": "orders.write" } }
} } } }运行后以 alice 的 ApiKey 下单:
curl -s -X POST localhost:5000/orders -H "Authorization: ApiKey dev-alice-key" \
-H "Content-Type: application/json" -d '{"name":"widget","amount":3}'得到 201 与响应类型的 JSON:{"id":"...","name":"widget","amount":3}。不带 Authorization 头时得到 401 SECURITY_DENIED_001;换一个角色里没有 orders.write 的主体调用,得到 403 SECURITY_DENIED_004。
程序集级缺省
不写 Expose 时取程序集级缺省,一个项目只写一次:
using XiHan.Framework.Kernel.Operations;
[assembly: OperationDefaults(Expose = Expose.Http | Expose.Grpc)]既没写 Expose 又没有程序集级缺省时,操作隐式地暴露给全部协议。生成器诊断 XHG004 能报告这种依赖隐式 Expose.All 的操作;它缺省关闭,在 .editorconfig 里写 dotnet_diagnostic.XHG004.severity = error 即强制每个操作写明。
只读操作
只读由契约推出:动词是 get 或 list,或以 ReadOnly = true 声明的非标准动词,OperationContract.IsReadOnly 为真。HTTP 适配器据此让没有嵌套对象的只读自定义动词使用 GET,MCP 适配器据此给出只读提示。
装配了持久化时,Transaction 阶段为只读操作开只读工作单元:不开数据库事务,处理器里的新增、更新、删除返回 PERSISTENCE_UNEXPECTED_001(操作实现的缺陷,HTTP 500)。只读操作要写数据(记访问次数、补建缺失的行),就不要声明只读,见工作单元与事务。
登记序列化类型与 Schema
Kernel.Generators 从 JsonSerializerContext 取序列化元数据,并为每个操作生成请求与响应的 JSON Schema(draft 2020-12)。属性名按序列化上下文的命名策略;不可空且没有默认值的属性进 required;嵌套对象进 $defs;封闭泛型的定义名带实参名(PageResultOfOrderResponse)。请求类型上的 DataAnnotations 折进 Schema,由 Validate 阶段执行:
| 注解 | Schema |
|---|---|
[Required] | 进 required 并去掉可空;字符串另加 minLength: 1(AllowEmptyStrings = true 除外) |
[Range] | minimum / maximum,开区间用 exclusiveMinimum / exclusiveMaximum |
[StringLength]、[MinLength]、[MaxLength]、[Length] | 字符串的 minLength / maxLength,集合的 minItems / maxItems |
[RegularExpression] | pattern |
其他注解(如 [EmailAddress])不折进 Schema,需要时在请求类型上实现 IValidatableObject,见数据校验。项目开启 GenerateDocumentationFile 时,类型与属性的 XML <summary>(位置记录取主构造参数的 <param>)折进 Schema 的 description,OpenAPI 与 MCP 的字段说明由此而来。
登记操作与生成器诊断
特性在 Configure 里调用生成的 AddGeneratedOperations(),一次登记项目里全部操作;也可以按操作调用生成的 Add<类名>()。生成的代码在项目 RootNamespace 下的 GeneratedOperations 静态类里。生成器的诊断:
| 诊断 | 情形 |
|---|---|
XHG001 | 标注 [Operation] 的类没有实现 IOperation<TRequest, TResponse> |
XHG002 | 请求或响应类型没有被任何 JsonSerializerContext 登记 |
XHG003 | 操作 ID 不符合 <resource>.<verb> 文法 |
XHG004 | 缺省关闭:操作依赖隐式的 Expose.All |
XHG005 | create / update / delete 声明了 ReadOnly = true |
XHG006 | 警告:字典的键类型 System.Text.Json 不支持,运行期序列化会抛出 |
不用生成器时用 context.AddOperation<TOperation, TRequest, TResponse>(contract, requestInfo, responseInfo) 手写登记,契约(OperationContract)、序列化元数据与 Schema 自己给出。
操作登记表 OperationRegistry 的规则:ID 必须唯一(重复报 KERNEL_OPERATION_001),容量 10000(满了报 KERNEL_OPERATION_002),都是配置期异常;Permission 引用的权限码要用 AddPermission 登记(可以在别的特性里,只要装配完成时已有),装配完成时一次核对,未登记即 KERNEL_VALIDATION_005、启动失败并一次列出全部;Policy 引用的策略在授权能力启动时核对,不存在即 SECURITY_VALIDATION_004、启动失败。装配完成之后登记的操作(插件握手之后)在登记时核对;插件的代理操作按归属成组加载与卸载,见隔离级别与清单。策略的核对见授权与权限。
标题与描述
面向人的标题与描述不写在契约里,放在消息目录,按约定键查找:
| 键 | 用途 |
|---|---|
<操作 ID>.title | 短语,如「下单」;MCP tool 的 title、OpenAPI 的 summary |
<操作 ID>.description | 行为与规则;MCP tool 的 description、OpenAPI 的 description |
键里操作 ID 的 - 换成 _(order_lines.list.description)。描述不写授权要求,适配器按契约统一呈现。文案写在应用的 Localization/<区域>.json 里,登记方式见 ASP.NET Core 宿主的「区域协商与本地化」,消息键的约定见全球化和本地化:
{
"orders.create.title": "下单",
"orders.create.description": "按名称与金额下单。"
}调用过程
协议适配器把线上消息翻译成 OperationRequest(操作 ID 加 UTF-8 JSON 载荷)交给 IOperationInvoker,调用器把操作描述与载荷放进调用上下文,让调用走完整条管道:
- Authorize 阶段在端点没有显式授权要求时,按契约的
Permission/Policy判定。 - Validate 阶段按请求 Schema 校验载荷、解码成请求对象、执行
IValidatableObject。 - Transaction 阶段开启工作单元。
- Handle 阶段执行
HandleAsync,编码响应。
中间件与端点里可以从上下文读当前操作:context.GetOperation() 得到 OperationDescriptor(契约、归属特性、请求与响应类型、Schema),context.GetOperationRequest() 得到解码后的请求对象,context.GetOperationPayload() 得到原始载荷。三个方法都返回 Maybe<T>,当前调用不是操作时为无值。
在进程内调用操作
后台任务、测试或另一个协议入口需要调用操作时,经 IOperationInvoker 走同一条管道。类型化入口不经 JSON 编解码:
using Microsoft.Extensions.DependencyInjection;
using XiHan.Framework.Kernel.Hosting;
using XiHan.Framework.Kernel.Operations;
using XiHan.Framework.Primitives;
namespace Shop.Orders;
public sealed class NightlyOrderSeeder(IServiceScopeFactory scopes)
{
public async Task<Result<OrderResponse>> SeedAsync(CancellationToken cancellationToken)
{
await using var scope = scopes.CreateAsyncScope();
var context = App.CreateContext(scope.ServiceProvider, cancellationToken);
var invoker = scope.ServiceProvider.GetRequiredService<IOperationInvoker>();
return await invoker.InvokeAsync<PlaceOrderRequest, OrderResponse>(context, CreateOrder.Id, new PlaceOrderRequest("seed", 1));
}
}| 方法 | 语义 |
|---|---|
InvokeAsync(context, OperationRequest) | 以 JSON 载荷调用,经完整管道;返回 OperationResponse(结果加响应载荷) |
InvokeAsync<TRequest, TResponse>(context, id, request) | 类型化调用,经完整管道;请求在 Validate 阶段按序列化元数据编码一次再校验 |
ExecuteAsync(...) 两种重载 | 调用方已在管道内(如宿主端点)时直接执行,不再经过管道、不校验 |
这样构造的上下文没有凭据与租户:经过 Authenticate 阶段时是匿名调用,带 Permission 的操作会被拒绝。后台调用需要身份时,用 context.SetCredential(new AuthenticationCredential(scheme, value))(Security.Abstractions)放入凭据;需要租户时在 App.CreateContext 的 baggage 参数里给出 xihan.tenant(BaggageKeys.Tenant),由 Tenant 阶段对照租户存储核对。细节见认证与租户解析与隔离。
调用器自己的错误码:操作没有登记是 KERNEL_NOTFOUND_001(调用不进管道);载荷不是 JSON、为 null 或类型不匹配是 KERNEL_VALIDATION_001(reason 是 JSON 路径,如 $.quantity);类型化调用的请求或响应类型与登记不符是 KERNEL_VALIDATION_002;不满足请求 Schema 与自校验失败分别是 KERNEL_VALIDATION_003 与 KERNEL_VALIDATION_004。
常见问题
一个操作能不能有多个 HTTP 路由,或者按方法名自动生成一组端点
不能。一个操作就是一个类、一条操作 ID,HTTP 上对应一条路由。需要两个动作就写两个操作(orders.create 与 orders.cancel),它们各有自己的权限要求与 Schema。要换路由用配置覆盖,见 HTTP 路由与载荷。
HandleAsync 里能不能抛异常表示业务失败
不要。可恢复的失败(找不到、冲突、校验不过、无权)返回 Error,宿主按错误码的 KIND 段映射状态码并写 Problem Details;抛出的异常由内核翻译成 KERNEL_UNEXPECTED_001、HTTP 500,并按缺陷记录。Error 的构造与错误码规则见结果与错误处理。
请求类型没有任何字段怎么写
写一个空记录即可,如示例应用的 public sealed record ListOrdersRequest;。它仍要登记进 JsonSerializerContext,HTTP 上这个操作接受空载荷,GET /orders 不带任何参数就能调用。
后台任务调用操作时总被 401 拒绝
App.CreateContext 建出的上下文没有凭据,Authenticate 阶段按匿名处理,带 Permission 的操作就会被拒。给上下文放入服务账号的凭据(SetCredential),或者让后台任务只调用不要求权限的操作。不要绕过管道直接 new 操作类,那样会丢掉校验与事务。
相关包与下一步
- 相关包:Kernel、Kernel.Generators、Protocols.Http、Hosting.AspNetCore
- 下一步:HTTP 路由与载荷,看操作 ID 与请求形状怎样决定 HTTP 路由,以及载荷从哪里来
- 同一份契约的其他载体:gRPC、MCP、命令行宿主、工具与知识库
- 相关章节:授权与权限、数据校验、结果与错误处理
- 规范:RFC-0011《操作契约与 XGI》、
conventions/operation-ids.md《操作 ID》
