Skip to the content.

MVC Action 如何接入 HTTP 幂等

旧示例要求每种业务操作写一个 Action Filter。创建档案的 Filter 从已绑定的 CreateProfile 参数取值,转而调用 ProfileIdempotencyHandler,带键请求根本不执行 MVC Action。假如后来只修改了 Action 的业务判断或返回内容,带键请求仍会走旧处理器,两条路径便可能得到不同结果。逐个编写 Filter 是这套接入方式造成的,不是请求哈希或 MVC 的要求。

现在 Reference 使用同一个 ReferenceHttpIdempotencyExecutor 处理请求键、指纹、存储、事务和响应快照。Minimal API 和 MVC 各负责把所标记端点的执行过程交给它。MS.Microservice.Idempotency.Mvc 只提供选择 Filter 的 Attribute;共用 Filter 和执行器目前仍在 Reference.Web,并非引用 MVC 包后任意 Controller 就自动具备幂等能力。

标记 Action

Reference 的正式宿主只有 Minimal API。下面的 MVC Controller 位于 test/MS.Microservice.Reference.Web.Tests/ProfileMvcTestController.cs,只在非 AOT 的 TestServer 中映射:

builder.Services.AddControllers().AddApplicationPart(typeof(ProfileMvcTestController).Assembly);
builder.Services.AddScoped<ReferenceHttpIdempotencyResourceFilter>();
var app = builder.Build();
ReferenceHost.MapApplication(app);
app.MapControllers();
[HttpPost]
[RequireHttpIdempotency<ReferenceHttpIdempotencyResourceFilter>("profiles.create")]
public Task<IActionResult> Create([FromBody] CreateProfile request) => CreateCoreAsync(request);

profiles.create 是稳定的操作名。同一个 Filter 也用于测试 Controller 的 Alternate 和 Echo Action,它们各有自己的操作名;未标记的 Plain Action 不经过该 Filter。Attribute 通过 DI 取得指定 Filter,要求它实现 IAsyncResourceFilter。使用方仍须为自己的宿主注册持久化存储、工作单元和 Filter;全局 Http:Idempotency:Enabled 只控制基础设施是否启用。

测试还包含独立的 IdempotentEchoTestController:它的请求是 JsonElement,只用同一个 Attribute 标记 echo.create,没有专属幂等 Filter 或处理器。Reference 的身份来源在宿主注册一次;每个新 Action 只需选择稳定操作名并保留自己的正常业务逻辑。

一次带键请求怎样执行

全局开关关闭,或请求没有 Idempotency-Key 时,Filter 直接继续 MVC 流程。全局开启且 Action 已标记、请求也带键时,它在模型绑定之前调用共用执行器。执行器读取并回卷 Body,因此 MVC 仍能正常绑定 [FromBody] 参数。它把操作名与已认证身份、请求键用于定位记录;请求指纹包含 Method、Path、QueryString、完整的 Content-Type 和 Body 的表示。UTF-8 JSON 直接解析;标为 charset=utf-16 的 JSON 先按 UTF-16 解码。随后递归按字段名排序每层对象;字符串之外的 JSON 空白不参与比较,数组元素仍保持原顺序。非 JSON Body 按原始字节比较。各部分分别带长度写入哈希,避免字段拼接歧义。一个 JSON 对象中若字段名重复,包括只差大小写的 displayName 与 DisplayName,在模型绑定前返回 400,不占用键。

例如,第一次发送 POST /test/mvc/profiles、键 K1 和下面的正文,创建成功返回 201:

{"issuer":"https://issuer.example","subject":"mvc-subject","displayName":"first","roles":["reader","editor"]}

同一身份以 K1 重试完全相同的请求,Filter 不再执行 Action,而是返回首次保存的状态码、Location 和正文字节。只把正文调换为 {"roles":["reader","editor"],"displayName":"first","subject":"mvc-subject","issuer":"https://issuer.example"},或改变对象字段间的空白,仍视为同一请求,重放首次的 201。如果把 roles 数组调换为 ["editor","reader"],同一个键会返回 409。路径、查询字符串或完整的 Content-Type 字符串变化(包括 charset 参数)也会改变指纹;不同操作名可以各自使用 K1。无关的 X-Request-Id 请求头不参与比较。

首次请求查不到记录时,共用执行器在消息 IUnitOfWork 的外层事务内先占用键,再调用 MVC 的余下管线。IAsyncResourceFilter 包围模型绑定、Action、结果序列化和结果 Filter,所以它能在响应发送前取得实际状态码和字节。创建档案的服务在内层调用同一个工作单元,档案、Outbox 和成功响应快照一起提交。事务提交后,才把缓冲的响应写给客户端。相比旧 Filter,带键请求现在确实执行 Action;无键和带键路径不再分别维护业务调用。

业务校验或冲突返回 4xx 时,外层事务回滚占键,再把这次错误送给客户端;修正请求后可复用该键。异常、取消和 5xx 也不保存记录。Reference 执行器只保存 2xx 响应;Content-Type 可以为空。测试中的 ReturnNoContent Action 首次返回无正文、无 Content-Type 的 204,同键重试仍返回 204。底层 IdempotencyResponse 可表示 200–499,但本接入层没有启用错误响应的重放。

接入边界与验证

带键请求的 Body 最多 1 MiB;超过时返回 413,Action 不执行。响应暂存流也限制在 64 KiB:写出超过上限时立即失败,整个事务回滚,不会继续缓存更大的正文。快照只保存状态码、可为空的 Content-Type、Location 和正文;依赖其他响应头或流式输出的 Action 不应直接标记。业务写入和 Outbox 必须参加执行器开启的同一个数据库事务;事务外的邮件或网络调用不在此保证内。

旧 profiles.create 记录的指纹来自绑定后的 CreateProfile JSON,现有记录不会改写。相同身份和键命中这种记录时,新规则的指纹不同,存储返回 DifferentRequest,HTTP 层返回 409。执行器不会按旧格式再次查询、转换记录或执行 Action。记录被清理后,该键才可能作为新请求使用。

SQLite TestServer 测试覆盖多个 Action 共用 Filter、UTF-16 JSON 字段重排、200 和 204 重放、旧创建档案记录同键冲突、不同操作名、查询变化、业务拒绝后复用键,以及异常和大小限制的回滚。这些测试不能证明其他字符集、真实 PostgreSQL 并发或 Wolverine 原生 Outbox 的行为;本次也没有延迟和分配的前后测量,不能宣称性能收益。MVC 示例未进行 Native AOT 发布验证。需要了解 Reference 的配置、Minimal API 接入及迁移行为,可读Profile 与 Order 如何共用 HTTP 幂等。