JSON 工具箱

JSON API 数据契约设计要点

结论:六件事一旦上线就很难改:响应包裹、错误格式、分页形态、ID 与时间的表示、null 与缺省的语义、版本演进。

进阶约 13 分钟发布于 2026-10-30更新于 2026-10-30
JSON API 数据契约设计要点封面

结论:先把响应包裹、错误格式、ID 与时间、null 与缺省这四项写进契约文档并配好校验,再谈分页与命名细节;顶层永远用 data 包裹,后续加分页与追踪 id 才不会破坏已有调用方。

接口设计没有标准答案,但有一些「选了就很难反悔」的决定,最好在写第一行代码前想清楚。

一、响应要不要包裹

三种风格:

// 风格 A:裸返回
{ "id": 1, "name": "张三" }

// 风格 B:数据包裹
{ "data": { "id": 1, "name": "张三" } }

// 风格 C:数据 + 元信息
{ "data": { "id": 1, "name": "张三" }, "meta": { "requestId": "a1b2" } }
风格 优点 缺点
裸返回 最简洁,前端少一层 无法附加元信息;顶层是数组时有 JSON 劫持与演进风险
数据包裹 便于加 meta / errors / links 多一层嵌套,前端要写 res.data.data
数据 + 元信息 可观测性最好 需要严格约定哪些字段属于 meta

建议:列表与详情统一用 data 包裹,顶层永远不是数组。这样后续加分页、加追踪 id 都不用破坏契约。

二、错误格式:照抄 RFC 9457

自创错误结构是团队协作成本的巨大来源。RFC 9457(取代 RFC 7807)定义了 application/problem+json:

{
  "type": "https://bbdrug.com/problems/insufficient-balance",
  "title": "余额不足",
  "status": 422,
  "detail": "账户余额 12.00 元,本次扣款需要 30.00 元",
  "instance": "/accounts/A1001/payments/p-889",
  "balance": "12.00",
  "required": "30.00"
}
字段 含义 是否必填
type 稳定的问题类型 URI 建议必填
title 面向人的简短说明 建议必填
status HTTP 状态码 建议必填
detail 本次发生的具体说明 可选
instance 出问题的资源实例 可选
扩展字段 任意业务字段 可选

扩展字段(上面的 balance / required)是规范允许的,前端可以据此做精确提示,而不用解析 detail 文本。

三、分页:游标优于偏移

// 偏移分页
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }

// 游标分页
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
维度 偏移分页 游标分页
实现难度 低(LIMIT/OFFSET) 中(需要稳定排序键)
深翻页性能 差(offset 越大越慢) 好
数据变动时 会重复/漏项 稳定
能否跳页 能 不能
是否需要总数 需要额外 count 不需要

建议:后台管理表格用偏移分页(要跳页、要总数),面向用户的无限流用游标分页。

四、ID 与时间

4.1 ID 一律用字符串

理由在类型那篇讲过:JavaScript 端超过 2^53 会静默丢精度。 即使当前 ID 是自增小整数,用字符串也能为将来换雪花 ID 留后路。

{ "id": "1000000000000000001" }

4.2 时间用 RFC 3339 字符串

{
  "createdAt": "2026-10-30T10:41:45+08:00",
  "updatedAt": "2026-10-30T02:41:45Z"
}
  • 不要用毫秒时间戳数字:可读性差,且单位(秒/毫秒)经常搞错;
  • 明确时区:带 Z 或带偏移量,不要用「无时区的本地时间」;
  • 字段名统一 xxxAt,一眼看出是时间点。

五、null 与缺省

必须选一种并写进文档:

方案 表达「没有值」 表达「不修改」
方案 A 字段值为 null 请求里不传该字段
方案 B 字段不存在 请求里不传该字段

方案 A 的问题是「不传」与「传 null」在 PATCH 里含义不同,容易歧义; 方案 B 的问题是前端 JSON.stringify 会把 undefined 字段丢掉,正好符合语义。

实践建议:

  • 响应体用方案 A(null 明确表示没有值,前端不用区分 undefined);
  • 请求体(尤其 PATCH)用方案 B,并配合 Merge Patch 或 JSON Patch 明确语义。

六、字段命名与结构

决策 常见选择 注意
命名风格 camelCase(JS 生态)/ snake_case(Python、DB) 全项目统一,不要混
枚举值 小写下划线 "pending_review" 不要用数字,可读性差
布尔字段 isActive / enabled 不用 status: 0/1
数组字段 复数名词 items 空数组返回 [],不要返回 null
嵌套深度 不超过 3 层 更深就拆分或提供 expand 参数

空数组返回 [] 而不是 null 这一条很重要:前端 data.items.map(...) 不会因为 null 崩。

七、演进策略

变更 是否破坏兼容 处理方式
新增可选字段 否 直接发
新增必填字段 是 加默认值,或按版本发
删除字段 是 先标记废弃,观测调用方后再删
改字段类型 是 新增字段名,双写过渡
改枚举值集合 视情况 客户端必须有未知值兜底
改字段语义 是 视为新字段

三条纪律:

  1. 客户端对未知字段必须宽容(不要 additionalProperties: false 卡死自己);
  2. 客户端对未知枚举值必须有兜底分支;
  3. 废弃字段先保留至少一个大版本,并在响应头或文档里标注 Deprecation。

八、一份完整的示例

{
  "data": [
    {
      "id": "1001",
      "name": "JSON 权威指南",
      "price": "89.50",
      "tags": ["json", "spec"],
      "publishedAt": "2026-09-01T00:00:00+08:00",
      "author": null
    }
  ],
  "page": { "nextCursor": null, "hasMore": false },
  "meta": { "requestId": "req-7f3a", "tookMs": 12 }
}

这份响应体现了前面所有约定:data 包裹、ID 字符串、金额字符串、时间带时区、 null 表示缺失、空数组、游标分页、meta 放可观测信息。

九、检查清单

  • [ ] 顶层不是数组,统一用 data 包裹;
  • [ ] 错误响应遵循 RFC 9457,含稳定的 type;
  • [ ] 分页形态与业务场景匹配;
  • [ ] ID 是字符串,时间是 RFC 3339;
  • [ ] null 与缺省语义写进文档;
  • [ ] 命名风格全项目统一,枚举用小写字符串;
  • [ ] 客户端对未知字段与未知枚举有兜底;
  • [ ] 破坏性变更走版本或双写过渡。

常见问题

如何设计可演进的 JSON 接口?

列表与详情统一用 data 包裹、顶层永远不是数组,后续加分页和追踪 id 都不用破坏契约;ID 用字符串、时间用 RFC 3339,null 与缺省的语义提前写进文档。新增字段保持可选且语义独立,客户端对未知字段与未知枚举留兜底,破坏性变更走版本或双写过渡。

为什么错误格式不要自创?

自创错误结构是团队协作成本的巨大来源。照抄 RFC 9457 的 application/problem+json,用稳定的 type 标识问题类型,并允许 balance、required 这类扩展字段,前端就能做精确提示而不用解析 detail 文本。

是否应该统一用游标分页?

不必。后台管理表格需要跳页和总数,偏移分页更合适;面向用户的无限流用游标分页,深翻页稳定、数据变动时不会重复或漏项。选哪种取决于场景是否需要跳页与总数。