REST 资源、状态与错误契约:接口怎样表达稳定业务语义
接口字段都能解析,为什么客户端仍会做出错误动作? 这类事故通常不是序列化库突然失效,而是双方只共享了字段形状,没有共享字段身份、业务含义、状态前提和演进规则。REST 契约的核心不是 URL 风格,而是资源身份、状态转换、方法语义、失败分类和并发前提。HTTP 状态表达通用协议结果,稳定 problem type 表达业务失败身份。
契约不是一份文档,而是跨时间的承诺
客户端针对资源 URI 发出带方法、条件头和表示格式的请求;服务端完成认证授权、前置条件判断、状态转换和持久化,再用状态码、响应头、表示或 Problem Details 返回结果。安全、幂等与可缓存是方法语义,不由路径名字决定。
一次契约调用怎样从制品走到业务状态
先为资源写状态机,再把每个操作映射到允许的前置状态、成功状态、幂等键、条件请求、权限和错误类型。type URI、业务 code 与 HTTP status 保持稳定,detail 只面向本次实例且不得泄露内部实现。
用 200 包裹所有失败会让代理、重试器和监控失去语义;把错误 message 当机器判断依据会被文案和国际化破坏;PUT/PATCH 混用、删除异步却返回同步完成、缺少 ETag 条件更新都会制造并发覆盖。
身份比名字更重要
名称则应帮助理解但不承担唯一判断。客户端不得解析 detail 文本决定业务分支,不得从 URL 最后一段猜资源类型,也不得把生成类名当 wire identity。名称、文案和本地化可以迭代,稳定 ID 才能连接日志、指标、审计和兼容门禁。
兼容性不是单向的布尔值
兼容矩阵的行是活跃消费者及版本,列是候选契约和生产者行为。矩阵单元格保存结构验证、生成编译、样本回放、错误分支与性能限制结果。只测试最新 SDK 会让旧移动端、离线任务、合作方和延迟消息成为盲区。
结构验证必须建立引用闭包
错误契约必须告诉调用方下一步
错误至少分为:输入永久错误、认证/授权、资源状态冲突、并发前置条件失败、依赖暂时不可用、容量保护和未知内部错误。分类决定客户端能否原样重试、修改请求后重试、刷新凭证、等待 Retry-After 或停止并告警。把所有异常映射成 500 会诱导重试风暴,把所有业务失败放进 200 会破坏代理和 SLO。
Problem Details 的 type 是问题类型主身份,status 反映 HTTP 通用语义,title 是简短稳定概述,detail 描述本次实例,instance 可关联具体发生。扩展字段只暴露接口层有用且可授权的信息;SQL、堆栈、类名、内部地址、令牌和他人资源标识不得进入 detail。
可运行模型一:让破坏性变更显形
javac --release 17 -Xlint:all -Werror RestStateMachineDemo.java
java RestStateMachineDemo预期输出:
operations=6 validTransitions=5 invalid=1 safeReads=2 idempotentWrites=2 stable=true第一个模型把兼容判断压缩为稳定数量关系:新增 required、字段号复用、阻塞消费者、浮点误差或状态机非法转换。它不替代真实解析器,却能让评审者看到“哪些输入导致哪条门禁失败”,并能稳定用于回归。
修改模型输入做反向实验:把 optional 变 required、从 reserved 集合取字段号、新增旧消费者不能识别的事件、让金额经过 double 或移除问题类型。若模型仍报告 publishable,说明门禁只检查了表面结构。
可运行模型二:验证运行时仍然收敛
运行 ProblemTypeRegistryDemo.java:
javac --release 17 -Xlint:all -Werror ProblemTypeRegistryDemo.java
java ProblemTypeRegistryDemo预期输出:
problems=5 uniqueTypes=5 duplicateCodes=0 statusMismatches=0 internalDetailsLeaked=0 stable=true第二个模型覆盖引用缺失、deadline 传播、事件重放、presence、消费者契约或回滚制品。结构兼容只是第一道门,运行时还必须在超时、重复、乱序、缺省值和版本并存时保持可判定结果。
验收不应只断言进程退出码。需要核对输出中的失败数、阻塞消费者、最终版本、剩余预算、错误泄漏和 rollbackArtifact。任何非零破坏都应关联 owner、例外期限与迁移计划,不能以“先上线观察”替代证据。
Deadline、幂等和重试也是契约
契约应声明最大请求/响应、流式背压、分页大小和批次上限。生成 SDK 若默认无限读取响应或自动重试非幂等调用,同样属于契约缺陷。性能限制必须进入规范扩展、客户端策略或配套机器可读策略,不能只藏在运维文档。
时间、金额、ID、枚举与 Null 要逐个定义
枚举新增必须假设旧消费者会收到未知值。客户端模型保留 UNKNOWN/UNRECOGNIZED 和原始值,不应在未知值上崩溃或默认映射为某个真实业务状态。服务端在收缩枚举前先观察未知值流量,确认所有生产者完成迁移。
分页不只是 page/size 字段。契约要定义稳定排序、唯一 tie-breaker、快照语义、游标有效期、权限绑定和最大页大小。游标应视为不透明且防篡改;改变排序或过滤后不得沿用旧游标。totalElements 若代价高或只是估算,应明确语义而非伪装精确。
事件契约还要承担历史
同步 API 可以通过部署窗口逐步迁移,事件则可能在日志、重试队列、对象存储和审计归档中保留多年。消费者升级后仍要能重放旧事件;新事件投入生产后,滞后消费者仍可能是旧代码。因此事件兼容矩阵必须覆盖历史语料和最大保留期。
发布门禁要横跨生产者和消费者
安全边界必须写进 Schema 和生成器
个人数据字段标注分类、用途、日志策略和删除传播。SDK 的 toString、调试拦截器与自动日志默认脱敏;事件契约记录数据保留与擦除关联键。契约制品公开范围也要分级,内部管理接口不应因为生成文档而自动暴露。
观测要能回答哪个版本在破坏谁
发布看板至少显示活跃消费者版本分布、最老受支持版本、候选变更破坏数、SDK 生成/编译结果、provider/consumer 通过率、unknown enum、解析失败和弃用接口流量。没有消费者版本可见性,就无法安全删除旧字段或旧路径。
回滚必须能复现旧制品与旧语义
版本号是沟通工具,不是兼容机制。路径版本、媒体类型版本、RPC 新方法或事件新类型都只建立新身份;迁移、双写、流量观测、消费者支持与终止条件仍需单独设计。能兼容扩展时避免无意义大版本,确实改变语义时也不要用小版本掩盖。
架构决策必须留下可复审证据
REST 契约的核心不是 URL 风格,而是资源身份、状态转换、方法语义、失败分类和并发前提。HTTP 状态表达通用协议结果,稳定 problem type 表达业务失败身份。 这一主题最终要守住:同一业务动作在重试、并发和不同语言客户端中保持相同资源语义与错误身份。若只能证明最新生产者与最新客户端的 happy path,就还没有建立可演进契约。
契约源、解析 bundle、digest、生成器版本与测试报告可追溯。明确区分传输、结构、业务、运行和演进五层语义。所有机器身份删除后保留并禁止复用,名称不承担隐式判断。
兼容矩阵覆盖所有活跃消费者、历史样本与双向部署顺序。引用闭包、dialect、循环、外部资源和解析预算经过门禁。错误类型、状态、可重试性和 detail 脱敏具有稳定规则。
deadline、取消、幂等键、分页游标和容量上限进入契约。时间、金额、ID、枚举、Null 与未知值有跨语言语义。SDK 实际生成并编译,Provider 与 Consumer 测试均执行。
expand/migrate/contract、弃用终止条件和回滚制品经过演练。两个 Java 17 模型以严格编译运行,输出与正文逐项一致。
以下一手资料用于核对协议与规范语义,实际采用版本仍需匹配解析器、生成器和语言运行时能力:
RFC 9110: HTTP Semantics。RFC 9457: Problem Details for HTTP APIs
