XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

操作契约 ​

这页讲框架对外暴露业务的方式:用 [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>:资源是小写复数名词,多个词用连字符连接;动词是小写动词。

text
orders.create    orders.get    orders.list    order-lines.delete    orders.cancel

协议适配器从操作 ID 推导各协议上的名字,推导规则可以按操作覆盖:

动词HTTPgRPCMCP tool
createPOST /{resource}(201){Resource}/Create{resource}_create
getGET /{resource}/{id}{Resource}/Get{resource}_get
listGET /{resource}{Resource}/List{resource}_list
updatePUT /{resource}/{id}{Resource}/Update{resource}_update
deleteDELETE /{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
StabilityStable稳定性;Internal 的操作不对外发布
Expose程序集级缺省,再无则 Expose.All暴露给哪些协议,见下表
Permission无调用所需的权限码,Authorize 阶段判定
Policy无调用所需的授权策略名,与 Permission 二选一
ReadOnlyfalse声明非标准动词的只读操作(如 tree),create / update / delete 不能声明

Expose 是标志位枚举:

值发布到
HttpProtocols.Http,含 OpenAPI
GrpcProtocols.Grpc,含 /operations.proto
McpProtocols.Mcp 的 tool
CommandLineHosting.CommandLine 的命令
Aiai.tools@1 目录里的模型工具
All / None全部 / 都不发布(仍可在进程内经调用器调用)

HandleAsync 的约定:

  • 返回 Result<TResponse>,成功值不能为 null;可恢复的失败返回 Error,不抛异常,写法见结果与错误处理。
  • 取消令牌用 context.CancellationToken,它同时受调用方取消与截止时间约束。
  • 操作类按作用域注册,构造函数注入所需服务;每次调用一个新实例。

最小示例 ​

下面以示例应用的订单能力为底:一个 orders.create 操作,要求权限 orders.write,暴露给 HTTP、gRPC 与 MCP。项目要引用 XiHan.Framework.Kernel.Generators(分析器引用,不进运行时依赖),用 NuGet 包时按分析器引用即可:

bash
dotnet add package XiHan.Framework.Kernel.Generators --prerelease

操作类与请求、响应类型:

csharp
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,生成器从这里取序列化元数据:

csharp
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 声明依赖,写法见特性与清单:

csharp
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 适配器,把操作映射成端点:

csharp
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 授予与条件):

json
{ "XiHan": { "Security": { "InMemory": {
  "Principals": { "alice": { "ApiKey": "dev-alice-key", "DisplayName": "Alice", "Roles": "editor" } },
  "Grants": { "role": { "editor": "orders.write" } }
} } } }

运行后以 alice 的 ApiKey 下单:

bash
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 时取程序集级缺省,一个项目只写一次:

csharp
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
XHG005create / 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 宿主的「区域协商与本地化」,消息键的约定见全球化和本地化:

json
{
  "orders.create.title": "下单",
  "orders.create.description": "按名称与金额下单。"
}

调用过程 ​

协议适配器把线上消息翻译成 OperationRequest(操作 ID 加 UTF-8 JSON 载荷)交给 IOperationInvoker,调用器把操作描述与载荷放进调用上下文,让调用走完整条管道:

  1. Authorize 阶段在端点没有显式授权要求时,按契约的 Permission / Policy 判定。
  2. Validate 阶段按请求 Schema 校验载荷、解码成请求对象、执行 IValidatableObject。
  3. Transaction 阶段开启工作单元。
  4. Handle 阶段执行 HandleAsync,编码响应。

中间件与端点里可以从上下文读当前操作:context.GetOperation() 得到 OperationDescriptor(契约、归属特性、请求与响应类型、Schema),context.GetOperationRequest() 得到解码后的请求对象,context.GetOperationPayload() 得到原始载荷。三个方法都返回 Maybe<T>,当前调用不是操作时为无值。

在进程内调用操作 ​

后台任务、测试或另一个协议入口需要调用操作时,经 IOperationInvoker 走同一条管道。类型化入口不经 JSON 编解码:

csharp
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 操作类,那样会丢掉校验与事务。

相关包与下一步 ​

Released under The MIT License