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

结论:先把响应包裹、错误格式、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 崩。
七、演进策略
| 变更 | 是否破坏兼容 | 处理方式 |
|---|---|---|
| 新增可选字段 | 否 | 直接发 |
| 新增必填字段 | 是 | 加默认值,或按版本发 |
| 删除字段 | 是 | 先标记废弃,观测调用方后再删 |
| 改字段类型 | 是 | 新增字段名,双写过渡 |
| 改枚举值集合 | 视情况 | 客户端必须有未知值兜底 |
| 改字段语义 | 是 | 视为新字段 |
三条纪律:
- 客户端对未知字段必须宽容(不要
additionalProperties: false卡死自己); - 客户端对未知枚举值必须有兜底分支;
- 废弃字段先保留至少一个大版本,并在响应头或文档里标注
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 文本。
是否应该统一用游标分页?
不必。后台管理表格需要跳页和总数,偏移分页更合适;面向用户的无限流用游标分页,深翻页稳定、数据变动时不会重复或漏项。选哪种取决于场景是否需要跳页与总数。