Appearance
响应结构与命名
本章导读: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 使用
}设计纪律:
- 同一资源在任何端点(列表/详情/嵌套)返回的同名字段含义必须一致;
- 只读派生字段(
viewCount、statusLabel)文档标注"只读",写请求携带时应忽略或拒绝(团队二选一,推荐拒绝→400,尽早暴露客户端 bug); - 表示里不放传输层才有的信息(状态码、
Location)——错误除外,见下一章。
2. 命名风格:一场必须终结的争论
| 风格 | 例 | 阵营 |
|---|---|---|
| camelCase | createdAt | JavaScript/Java 生态、Google AIP、多数国内团队 |
| snake_case | created_at | JSON:API 规范、GitHub API、Python/Rust 生态、K8s 系 |
结论:两种都合法,混用才致命。 推荐流程:
- 团队选定一种(前端主导选 camelCase,Python 服务多则 snake_case);
- 存储层列名与 API 字段不必强行一致——DTO/序列化层做映射,让数据库继续
created_at; - 写进 lint:OpenAPI 文档 + CI 检查(如 spectral 规则)自动拦截违规命名。
通用命名细则(两风格均适用):
- 布尔字段用
is/has/can前缀吗?——JSON:API 与 Google 建议不要用is前缀造成冗余(deleted优于isDeleted);但hasMore、canEdit这类能力位保留has/can语义清晰。团队统一二选一。 - 时间字段统一
xxxAt(createdAt)/ 纯日期xxxDate(birthDate)。 - 计数用
xxxCount;金额用xxxAmount(配currency);百分比xxxPercent(数值 12.5 而非 "12.5%")。 - 关联外键
authorId;展开后的对象author;两者互斥出现(expand控制)。 - 缩写白名单:
id url uri uuid xml json api html http ssh sip(URL 出现在字段/参数里保持全大写或全小写一致,如requestUrl)。 - 避免匈牙利式类型后缀(
strTitlelistTags)——JSON Schema/OpenAPI 已经描述类型。
3. 枚举值:API 的隐藏雷区
json
"status": "published" ✅ 小写 snake_case 的稳定字符串
"status": 1 ❌ 魔法数字——文档外无人可读
"status": "PUBLISHED" ⚠️ 可以,但全 API 必须统一一种 case规则:
- 枚举用字符串不用数字(可读、可 grep、演进时可加值不冲突);
- 枚举值一经发布永不复用/改义——废弃走 Deprecation 流程;
- 客户端必须容忍未知枚举值(服务端未来加值不视为破坏性变更——写进文档;客户端应有 default 分支);
- 状态机文档化:
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. 请求侧的镜像约定
- 请求体只带可写字段;只读字段(
id、createdAt、viewCount)出现在写请求 → 按 §1 纪律处理; - 必填/默认值在 OpenAPI 声明,服务端校验从严(未知字段默认 400——对外的宽松只留给已发版的兼容需求);
- 创建请求若客户端可传
clientRequestId(幂等友好),文档说明其与Idempotency-Key的关系(选其一作为幂等键)。
6. 字段演进守则(避免升版本的底气)
- 只加可选字段与可选枚举值;
- 字段改名 = 加新字段 + 标记旧字段
Deprecated(头部 + 文档)+ 双写一个周期 + 删除; - 语义漂移零容忍:
null不要变[],数字不要变字符串; - 表示体积控制:大字段(正文、base64)走独立子资源或默认省略 +
?fields=显式请求; - 每次对外契约变化都进 CHANGELOG 与 OpenAPI diff(工具:
oasdiff)。
7. 本章小结
- 资源表示四层:标识 / 属性 / 关系 / 元数据;一致性 > 个人口味。
- 命名 camelCase 与 snake_case 二选一后全司统一,用 lint 固化。
- 枚举用字符串、状态机文档化、客户端容忍未知值。
- 推荐"状态码承载协议语义 + problem 扩展字段承载业务码"的折中外壳,放弃"永远 200 + code"的老路。
8. 下一步
错误响应的完整官方答案 → 错误处理规范