同一个后台,三个团队各写了一个接口,失败时的表达各不相同:
// 团队 A
500 text/plain: "internal error"
// 团队 B
200 {"code": 1, "msg": "订单状态不允许取消"}
// 团队 C
200 {"success": false, "errorMessage": "conflict"}
前端于是维护三套错误分支、两套分页解析、一套时间格式兼容。每一套单看都自洽,问题在于返回值是一份跨团队契约,它的每一点自由度都由消费端付费。
本文按四类语义拆:错误、空值、分页、时间。判据只有一条——同一种含义,全系统只允许一种表达。
1. 返回值为什么是契约:自由度的代价
服务端每增加一种可选表达,消费端就多一条分支。它不会因为"内部约定过"而消失,只会以 if-else 固化在别人的代码里。
| 自由度 | 服务端省了什么 | 消费端付了什么 | 收敛后的形态 |
|---|---|---|---|
| 错误表达 | 不用设计统一错误体 | 三套分支加一套兜底 | 单一错误体加稳定业务码 |
| 空值表达 | 不用回填字段 | 分不清"缺失"与"为空" | 字段不省略,空值显式 |
| 分页风格 | 不用统一翻页协议 | 两套翻页逻辑 | 一套游标参数 |
| 时间格式 | 不用统一序列化 | 时区推断加格式嗅探 | 带偏移的 ISO 8601 |
判断清单:
- 这份返回值有几个消费方?一个时自由度几乎不产生成本;三个以上时成本随消费方数量增长。
- 接口的生命周期多长?临时接口不值得建规范。
- 语义分歧是否已经产生了重复代码?重复代码就是已经付出的账单。
- 是否有对外版本约束?已发布的语义改造代价高于新增。
- 消费方是否跨团队?跨团队时口头约定不成立,只有写进契约文档的才成立。
- 是否有契约测试能守住?没有守卫的统一语义会缓慢退化。
判据:当"返回值的形状"出现在别人的代码里,它就不再是私有实现。
2. 错误:状态码、业务码与两者的分工
三种常见做法都不完整:
| 做法 | 表现 | 失效点 |
|---|---|---|
| 只用 HTTP 状态码 | 各种失败都映射到 4xx / 5xx | 领域拒绝原因塞不进状态码,只能塞进文本 |
| 只用业务码(永远 200) | 传输层永远"成功" | 网关、监控、重试策略全部失去判据 |
| 两者混用但无分工 | 状态码与业务码互相矛盾 | 消费端不知道该信哪个 |
分工的判据:HTTP 状态码回答"这次调用在传输与协议层面发生了什么",业务码回答"领域层为什么拒绝"。 两者不是二选一,是分层。
// 统一错误体:字段缺失即视为不合规
{
"code": "ORDER_STATE_CONFLICT", // 稳定标识,机器判定用
"message": "订单当前状态不允许取消", // 可随时改写,人看
"traceId": "3f2c-9a11-8842", // 一次调用的串联锚点
"details": [{ "field": "status", "reason": "SHIPPED" }]
}
三条约束:
- 业务码必须稳定。它是消费端
switch的分支条件,一旦被枚举就不能改,只能新增。把枚举值当文案改,是接口不兼容的头号来源。 - message 必须可换。它要能翻译、能润色、能改语气,消费端不允许解析它做判断。
| 判据 | 结论 |
|---|---|
消费端会 switch (code) |
业务码必须稳定且文档化 |
| 消费端只展示 message | message 可自由改写 |
| 消费端需要重试 | 仅靠业务码不够,要有"可重试"的显式标记 |
| 同一码在不同上下文含义不同 | 码粒度太粗,应按领域拆开 |
3. 空值:缺失、为空、空集合与不存在
这是四类语义里最容易被混用、也最容易造成隐性错误的一类。
| 表达 | 服务端意图 | 消费端读法 | 混用的后果 |
|---|---|---|---|
| 字段缺失 | 未定义 / 未查询 | "name" in obj 为 false |
与"值为 null"无法区分 |
null |
明确为空 | 显式判空 | 需额外约定 null 是否合法 |
[] |
查询成功,结果为空集 | 遍历零次 | 常被误判为"无权限" |
| 404 / 不存在 | 对象本身不存在 | 走错误分支 | 与"对象存在但字段为空"混淆 |
字段缺失与字段为空在消费端是两件完全不同的事:前者通常是版本差异或按需返回,后者是数据事实。混用二者的典型来源是序列化框架的"忽略 null"配置:服务端以为在瘦身,消费端丢掉的是"这个字段有没有被处理"。
判断清单:
- 查询无结果返回
null还是[]?列表一律[],让消费端不必先判空再遍历。 - 单对象不存在返回 404 还是
200 + null?取决于它是"资源不存在"还是"这个字段没值"。 - 未查询的字段是省略还是给
null?同一接口内只能选一种,并在文档写明。 - 空字符串与
null是否等价?不等价要写进契约,等价要在入口归一化。 - 布尔字段的默认值是什么?缺失被解释为 false 会静默改变业务语义。
// 列表接口的收敛形态:字段不省略,空值显式
{
"items": [], // 无结果为 [],不是 null
"page": { "cursor": null, "hasMore": false }
}
4. 分页、时间与落地方式
| 分页风格 | 适用 | 代价 |
|---|---|---|
| 偏移分页 | 后台管理、结果集小 | 深翻页成本随偏移增长,翻页期间数据变动会漏项或重复 |
| 游标分页 | 信息流、大结果集、导出 | 需要稳定排序键,不能跳页 |
| 两者并存 | 过渡期 | 两套消费端逻辑,最贵 |
"总数"这个字段有真实成本。精确计数在大表上通常要做一次额外扫描或覆盖索引计数,量级取决于条件的选择性与索引覆盖程度;高并发下它还可能成为最慢的那条查询。诚实有三种做法:
- 明确给出近似值,并在字段名或文档里说明(
totalEstimate); - 只在首页返回总数,翻页时不重复计算;
- 用
hasMore取代总数,放弃"跳到第 N 页"这个能力。
// 分页契约字段(含取舍标注)
page:
cursor: string // 上一页返回的锚点,首页为空
size: int // 单页上限由服务端强制,不信任客户端
hasMore: bool // 首选,不需要 COUNT
totalEstimate: int // 可选,近似语义写进字段名
判断清单:
- 排序键是否唯一且不可变?不唯一时游标会漏数据,需要补次级键。
- 翻页期间数据变动,重复与漏项是否可接受?不可接受只能改用快照或固定时间窗。
- 单页上限是否由服务端强制?由客户端指定时,一次请求就能把库压住。
时间必须带时区。不带时区的字符串在任何跨机器、跨容器链路里都会被某处按本地时区解释一次——反序列化、日志、数据库会话时区、前端 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. 边界:什么情况不必统一
| 场景 | 判断 | 理由 |
|---|---|---|
| 只有一个消费方 | 不必统一 | 两边同一份代码,自由度不产生成本 |
| 接口生命周期短 | 可简化 | 统一成本收不回来 |
| 内部临时调试接口 | 不必 | 读者就是作者 |
| 消费方是同一团队的同一服务 | 可放宽 | 变更能同步完成 |
| 已有大量外部依赖的历史接口 | 只新增,不改造 | 统一收益低于破坏兼容的代价 |
统一语义的收益是"每个新接入方少学一遍",成本是一次性规范建设加长期守卫。消费方少、生命周期短、读者固定时收益低于沟通成本,此时正确做法是写清三行说明,而不是建一套规范。
自测
- 只用 HTTP 状态码表达错误的接口,领域拒绝原因最终去了哪里?
null与[]的混用在什么情况下会在消费端造成静默错误?- "字段缺失"与"字段为空"分别对应服务端的什么意图?
- 精确总数在大表上为什么贵,三种诚实的替代做法是什么?
- 带偏移与不带偏移的时间字符串,会在链路的哪些环节被区别对待?
- 在什么前提下,统一返回值语义的收益低于沟通成本?