Skip to content

响应结构与命名

本章导读:RFC 给了方法的语义,却没有规定"JSON 里放什么、叫什么"。这一章制定团队级契约:资源表示的字段分层(标识/属性/关系/元数据)、命名风格、只读字段、以及国内外两种主流响应外壳(纯资源风格 vs 统一 code/data 包装)的取舍。

1. 一个"好资源表示"的分层解剖

jsonc
{
  // ① 标识层:客户端用它构造/缓存 URI
  "id": "42",

  // ② 属性层:业务数据本体
  "title": "RESTful API 设计规范",
  "status": "published",          // 枚举!见 §3
  "viewCount": 1024,
  "tags": ["rest", "http"],

  // ③ 关系层:指向其它资源的引用
  "authorId": "7",
  "links": {
    "author": "/v1/users/7",
    "self": "/v1/articles/42"
  },

  // ④ 元数据层:生命周期与并发控制
  "createdAt": "2026-09-18T08:30:00Z",
  "updatedAt": "2026-09-18T10:12:31Z",
  "version": 7                     // 或配合 ETag 使用
}

设计纪律:

  • 同一资源在任何端点(列表/详情/嵌套)返回的同名字段含义必须一致
  • 只读派生字段(viewCountstatusLabel)文档标注"只读",写请求携带时应忽略或拒绝(团队二选一,推荐拒绝→400,尽早暴露客户端 bug);
  • 表示里不放传输层才有的信息(状态码、Location)——错误除外,见下一章。

2. 命名风格:一场必须终结的争论

风格阵营
camelCasecreatedAtJavaScript/Java 生态、Google AIP、多数国内团队
snake_casecreated_atJSON:API 规范、GitHub API、Python/Rust 生态、K8s 系

结论:两种都合法,混用才致命。 推荐流程:

  1. 团队选定一种(前端主导选 camelCase,Python 服务多则 snake_case);
  2. 存储层列名与 API 字段不必强行一致——DTO/序列化层做映射,让数据库继续 created_at
  3. 写进 lint:OpenAPI 文档 + CI 检查(如 spectral 规则)自动拦截违规命名。

通用命名细则(两风格均适用):

  • 布尔字段用 is/has/can 前缀吗?——JSON:API 与 Google 建议不要用 is 前缀造成冗余(deleted 优于 isDeleted);但 hasMorecanEdit 这类能力位保留 has/can 语义清晰。团队统一二选一。
  • 时间字段统一 xxxAtcreatedAt)/ 纯日期 xxxDatebirthDate)。
  • 计数用 xxxCount;金额用 xxxAmount(配 currency);百分比 xxxPercent(数值 12.5 而非 "12.5%")。
  • 关联外键 authorId;展开后的对象 author;两者互斥出现(expand 控制)。
  • 缩写白名单:id url uri uuid xml json api html http ssh sip(URL 出现在字段/参数里保持全大写或全小写一致,如 requestUrl)。
  • 避免匈牙利式类型后缀(strTitle listTags)——JSON Schema/OpenAPI 已经描述类型。

3. 枚举值:API 的隐藏雷区

json
"status": "published"      ✅ 小写 snake_case 的稳定字符串
"status": 1                ❌ 魔法数字——文档外无人可读
"status": "PUBLISHED"      ⚠️ 可以,但全 API 必须统一一种 case

规则:

  1. 枚举用字符串不用数字(可读、可 grep、演进时可加值不冲突);
  2. 枚举值一经发布永不复用/改义——废弃走 Deprecation 流程;
  3. 客户端必须容忍未知枚举值(服务端未来加值不视为破坏性变更——写进文档;客户端应有 default 分支);
  4. 状态机文档化:draft → pendingReview → published → archived,标注哪些迁移合法、非法迁移返回 409 Conflict

4. 响应外壳之争:包装还是不包装

4.1 风格 A:纯资源风格(国际主流)

成功响应就是资源本身;错误用 application/problem+json(见下一章)。

json
// GET /articles/42 → 200
{ "id": "42", "title": "…" }

// POST /articles → 201
{ "id": "43",  }

// 失败 → 422 + application/problem+json
{ "type": "https://api.example.com/problems/validation", "title": "…", "status": 422,  }

优点:协议语义饱满(状态码即结果)、缓存/SDK/网关全部正常工作、与 OpenAPI/RFC 一致。缺点:习惯老 RPC 的团队需要适应"HTTP 状态码即业务码"。

4.2 风格 B:统一信封(国内常见)

json
// HTTP 200 永远返回(或状态码仅作粗粒度参考)
{
  "code": 0,             // 0=成功,非 0=业务码
  "message": "ok",
  "data": { "id": "42", "title": "…" }
}

优点:客户端只需一条路径判断 code;移动端网关统一处理。缺点:状态码荒废导致生态功能尽失(缓存失效判断、401 重登、SDK 错误类型映射全部要靠私有 code 表);HTTP 层监控看不到成功率。

4.3 折中建议(推荐)

  • 传输/鉴权/资源不存在等协议错误:严格按状态码(401/403/404/429/5xx)。
  • 业务校验错误:状态码给语义(400/409/422),body 内可附细粒度业务码
json
// HTTP/1.1 422 Unprocessable Content
// Content-Type: application/problem+json
{
  "type": "https://api.example.com/errors/article-title-too-long",
  "title": "标题超长",
  "status": 422,
  "code": "ARTICLE_TITLE_TOO_LONG",   // 机器可读的业务错误码(大写下划线)
  "detail": "标题长度 233 超过限制 200",
  "instance": "/v1/articles",
  "errors": [ { "field": "title", "constraint": "maxLength=200" } ]
}

即:状态码说"发生了什么类型的事",业务码说"具体哪条规则",两者不互斥。

5. 请求侧的镜像约定

  • 请求体只带可写字段;只读字段(idcreatedAtviewCount)出现在写请求 → 按 §1 纪律处理;
  • 必填/默认值在 OpenAPI 声明,服务端校验从严(未知字段默认 400——对外的宽松只留给已发版的兼容需求);
  • 创建请求若客户端可传 clientRequestId(幂等友好),文档说明其与 Idempotency-Key 的关系(选其一作为幂等键)。

6. 字段演进守则(避免升版本的底气)

  1. 只加可选字段与可选枚举值;
  2. 字段改名 = 加新字段 + 标记旧字段 Deprecated(头部 + 文档)+ 双写一个周期 + 删除;
  3. 语义漂移零容忍:null 不要变 [],数字不要变字符串;
  4. 表示体积控制:大字段(正文、base64)走独立子资源或默认省略 + ?fields= 显式请求;
  5. 每次对外契约变化都进 CHANGELOG 与 OpenAPI diff(工具:oasdiff)。

7. 本章小结

  • 资源表示四层:标识 / 属性 / 关系 / 元数据;一致性 > 个人口味。
  • 命名 camelCase 与 snake_case 二选一后全司统一,用 lint 固化。
  • 枚举用字符串、状态机文档化、客户端容忍未知值。
  • 推荐"状态码承载协议语义 + problem 扩展字段承载业务码"的折中外壳,放弃"永远 200 + code"的老路。

8. 下一步

错误响应的完整官方答案 → 错误处理规范