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 文字。
是否應該統一用遊標分頁?
不必。後臺管理表格需要跳頁和總數,偏移分頁更合適;面向使用者的無限流用遊標分頁,深翻頁穩定、資料變動時不會重複或漏項。選哪種取決於場景是否需要跳頁與總數。