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 文字。

是否應該統一用遊標分頁?

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