3129 字
约 10 分钟
14
S07-A11 · 接口返回值的语义统一:错误、空值、分页与时间

同一个后台,三个团队各写了一个接口,失败时的表达各不相同:

// 团队 A
500  text/plain: "internal error"
// 团队 B
200  {"code": 1, "msg": "订单状态不允许取消"}
// 团队 C
200  {"success": false, "errorMessage": "conflict"}

前端于是维护三套错误分支、两套分页解析、一套时间格式兼容。每一套单看都自洽,问题在于返回值是一份跨团队契约,它的每一点自由度都由消费端付费。

本文按四类语义拆:错误、空值、分页、时间。判据只有一条——同一种含义,全系统只允许一种表达。


1. 返回值为什么是契约:自由度的代价

服务端每增加一种可选表达,消费端就多一条分支。它不会因为"内部约定过"而消失,只会以 if-else 固化在别人的代码里。

自由度 服务端省了什么 消费端付了什么 收敛后的形态
错误表达 不用设计统一错误体 三套分支加一套兜底 单一错误体加稳定业务码
空值表达 不用回填字段 分不清"缺失"与"为空" 字段不省略,空值显式
分页风格 不用统一翻页协议 两套翻页逻辑 一套游标参数
时间格式 不用统一序列化 时区推断加格式嗅探 带偏移的 ISO 8601

判断清单:

  1. 这份返回值有几个消费方?一个时自由度几乎不产生成本;三个以上时成本随消费方数量增长。
  2. 接口的生命周期多长?临时接口不值得建规范。
  3. 语义分歧是否已经产生了重复代码?重复代码就是已经付出的账单。
  4. 是否有对外版本约束?已发布的语义改造代价高于新增。
  5. 消费方是否跨团队?跨团队时口头约定不成立,只有写进契约文档的才成立。
  6. 是否有契约测试能守住?没有守卫的统一语义会缓慢退化。

判据:当"返回值的形状"出现在别人的代码里,它就不再是私有实现。


2. 错误:状态码、业务码与两者的分工

三种常见做法都不完整:

做法 表现 失效点
只用 HTTP 状态码 各种失败都映射到 4xx / 5xx 领域拒绝原因塞不进状态码,只能塞进文本
只用业务码(永远 200) 传输层永远"成功" 网关、监控、重试策略全部失去判据
两者混用但无分工 状态码与业务码互相矛盾 消费端不知道该信哪个

分工的判据:HTTP 状态码回答"这次调用在传输与协议层面发生了什么",业务码回答"领域层为什么拒绝"。 两者不是二选一,是分层。

// 统一错误体:字段缺失即视为不合规
{
  "code": "ORDER_STATE_CONFLICT",     // 稳定标识,机器判定用
  "message": "订单当前状态不允许取消",  // 可随时改写,人看
  "traceId": "3f2c-9a11-8842",        // 一次调用的串联锚点
  "details": [{ "field": "status", "reason": "SHIPPED" }]
}

三条约束:

  1. 业务码必须稳定。它是消费端 switch 的分支条件,一旦被枚举就不能改,只能新增。把枚举值当文案改,是接口不兼容的头号来源。
  2. message 必须可换。它要能翻译、能润色、能改语气,消费端不允许解析它做判断。
判据 结论
消费端会 switch (code) 业务码必须稳定且文档化
消费端只展示 message message 可自由改写
消费端需要重试 仅靠业务码不够,要有"可重试"的显式标记
同一码在不同上下文含义不同 码粒度太粗,应按领域拆开

3. 空值:缺失、为空、空集合与不存在

这是四类语义里最容易被混用、也最容易造成隐性错误的一类。

表达 服务端意图 消费端读法 混用的后果
字段缺失 未定义 / 未查询 "name" in obj 为 false 与"值为 null"无法区分
null 明确为空 显式判空 需额外约定 null 是否合法
[] 查询成功,结果为空集 遍历零次 常被误判为"无权限"
404 / 不存在 对象本身不存在 走错误分支 与"对象存在但字段为空"混淆

字段缺失与字段为空在消费端是两件完全不同的事:前者通常是版本差异或按需返回,后者是数据事实。混用二者的典型来源是序列化框架的"忽略 null"配置:服务端以为在瘦身,消费端丢掉的是"这个字段有没有被处理"。

判断清单:

  1. 查询无结果返回 null 还是 []?列表一律 [],让消费端不必先判空再遍历。
  2. 单对象不存在返回 404 还是 200 + null?取决于它是"资源不存在"还是"这个字段没值"。
  3. 未查询的字段是省略还是给 null?同一接口内只能选一种,并在文档写明。
  4. 空字符串与 null 是否等价?不等价要写进契约,等价要在入口归一化。
  5. 布尔字段的默认值是什么?缺失被解释为 false 会静默改变业务语义。
// 列表接口的收敛形态:字段不省略,空值显式
{
  "items": [],                                   // 无结果为 [],不是 null
  "page": { "cursor": null, "hasMore": false }
}

4. 分页、时间与落地方式

分页风格 适用 代价
偏移分页 后台管理、结果集小 深翻页成本随偏移增长,翻页期间数据变动会漏项或重复
游标分页 信息流、大结果集、导出 需要稳定排序键,不能跳页
两者并存 过渡期 两套消费端逻辑,最贵

"总数"这个字段有真实成本。精确计数在大表上通常要做一次额外扫描或覆盖索引计数,量级取决于条件的选择性与索引覆盖程度;高并发下它还可能成为最慢的那条查询。诚实有三种做法:

  • 明确给出近似值,并在字段名或文档里说明(totalEstimate);
  • 只在首页返回总数,翻页时不重复计算;
  • 用 hasMore 取代总数,放弃"跳到第 N 页"这个能力。
// 分页契约字段(含取舍标注)
page:
  cursor: string      // 上一页返回的锚点,首页为空
  size: int           // 单页上限由服务端强制,不信任客户端
  hasMore: bool       // 首选,不需要 COUNT
totalEstimate: int    // 可选,近似语义写进字段名

判断清单:

  1. 排序键是否唯一且不可变?不唯一时游标会漏数据,需要补次级键。
  2. 翻页期间数据变动,重复与漏项是否可接受?不可接受只能改用快照或固定时间窗。
  3. 单页上限是否由服务端强制?由客户端指定时,一次请求就能把库压住。

时间必须带时区。不带时区的字符串在任何跨机器、跨容器链路里都会被某处按本地时区解释一次——反序列化、日志、数据库会话时区、前端 new Date() 都可能是那一处。

时间分歧 后果 收敛做法
epoch 秒与毫秒混用 差 1000 倍,静默出错 字段名或类型中写明单位
有无偏移 跨时区解释错一次 一律带偏移,或统一 UTC
精度不一致 排序、去重失效 同一实体统一精度
用本地时间当唯一键 夏令时切换下不唯一 用 UTC 或带偏移的时刻

统一语义的落地方式有三种,成本递增:响应模板(框架统一包装,改一处生效)→ 代码生成(从契约生成 DTO,签名即约束)→ 契约测试(消费端参与,能拦住退化)。前两者解决"新接口怎么写",只有第三条能解决"半年后有没有走样"。

// 契约测试:把语义固化成断言,而不是文档里的一句话
expect(res.body).toSatisfySchema(ERROR_SCHEMA);      // 错误体字段齐备
expect(res.body.items).toBeArray();                  // 空列表是 [] 不是 null
expect(res.body.page.hasMore).toBeBoolean();         // 无总数也能翻页
expect(res.body.createdAt).toMatch(/[+-]\d{2}:\d{2}$/); // 时间带偏移

版本演进时的兼容要求:新增可空字段是安全改动,改变已有字段的语义不是。把 total 从精确改成近似是破坏性变更;新增 totalEstimate 并保留 total 才是兼容路径。

度量 说明
语义一致率 抽查接口中符合契约的比例
消费端适配代码量 每个新接入方为解析响应写的行数
语义相关缺陷数 归因为"返回值理解错"的 bug
新接入方接入时长 从拿到文档到跑通的时间

5. 排错:五种"文档没错但就是不通"

现象 原因 处理
生产报 500,前端只看得到"服务异常" 统一错误体没覆盖容器级异常(反序列化失败、超时) 网关与全局异常处理器两处都兜底
前端能跑,对账脚本经常少算 脚本消费的是另一个团队的接口,语义不同 把脚本当正式消费方纳入契约
某字段偶发 undefined 序列化配置忽略了 null 关闭忽略 null,或统一用显式空值
时间少 8 小时或日期差一天 链路上有一段按本地时区解释了一次 逐段核对:数据库会话、应用、容器、前端
翻页偶发重复数据 偏移分页遇到并发写入 改用游标,或按不可变键排序

6. 边界:什么情况不必统一

场景 判断 理由
只有一个消费方 不必统一 两边同一份代码,自由度不产生成本
接口生命周期短 可简化 统一成本收不回来
内部临时调试接口 不必 读者就是作者
消费方是同一团队的同一服务 可放宽 变更能同步完成
已有大量外部依赖的历史接口 只新增,不改造 统一收益低于破坏兼容的代价

统一语义的收益是"每个新接入方少学一遍",成本是一次性规范建设加长期守卫。消费方少、生命周期短、读者固定时收益低于沟通成本,此时正确做法是写清三行说明,而不是建一套规范。


自测

  1. 只用 HTTP 状态码表达错误的接口,领域拒绝原因最终去了哪里?
  2. null 与 [] 的混用在什么情况下会在消费端造成静默错误?
  3. "字段缺失"与"字段为空"分别对应服务端的什么意图?
  4. 精确总数在大表上为什么贵,三种诚实的替代做法是什么?
  5. 带偏移与不带偏移的时间字符串,会在链路的哪些环节被区别对待?
  6. 在什么前提下,统一返回值语义的收益低于沟通成本?
S07-A11 · 接口返回值的语义统一:错误、空值、分页与时间
https://www.clxhxhhr.top/posts/6372/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。