介面回傳的 JSON 該用什麼結構
結論:三種常見結構各有代價:裸回傳最簡潔但擴充時要破壞相容;固定信封安全但冗餘;JSON:API 規範完整但重。中小型專案最實用的是「HTTP 狀態碼表結果 + 固定信封包業務資料」。

結論:用 4xx/5xx 狀態碼表達成敗,用 { "data": ..., "meta": ... } 承載業務資料,錯誤時換成 error 信封;內部專案做到這一步就夠,只有對外開放平台才需要再引入 JSON:API 或 OpenAPI。
先排除最差的一種:永遠回 200,把成敗寫在 code 欄位裡。
三種主流結構
A. 裸回傳
{ "id": 1, "name": "demo", "price": 99 }
最簡潔,HTTP 語意完整(404 就是 404)。問題在於想加元資訊(分頁、警告、除錯資訊)時沒有位置,只能臨時加欄位,破壞原有契約。
B. 固定信封
{
"data": { "id": 1, "name": "demo" },
"meta": { "requestId": "abc", "took": 12 }
}
業務資料與元資訊分離,加欄位不破壞 data 的形狀。代價是多一層巢狀。
C. 全資訊信封(不推薦)
{ "code": 0, "message": "success", "data": {...} }
問題不在結構本身,而在配套做法:HTTP 狀態碼永遠 200,錯誤碼自訂。後果是 CDN、閘道、熔斷、重試、監控全部看不懂真實成敗。
推薦組合
用 HTTP 狀態碼表達結果,用信封承載業務資料。
成功:
{
"data": { "id": 1, "name": "demo" },
"meta": { "requestId": "req_8f2a", "took": 12 }
}
失敗:
{
"error": {
"code": "ORDER_NOT_FOUND",
"message": "訂單不存在或已被刪除",
"details": [{ "field": "id", "issue": "not_found" }]
},
"meta": { "requestId": "req_8f2a" }
}
狀態碼用 4xx/5xx,code 用機器可讀的穩定識別(不是給人看的中文),message 給人看,details 放欄位級錯誤供表單回填。
幾條硬規則
1. 列表一定要有分頁元資訊。 沒有 total 或游標,前端就沒法做分頁控件。
2. 空列表回 [],不要回 null。 前端 .map 直接崩是常見事故。
3. 時間統一 ISO 8601 帶時區。 見 時間欄位用什麼格式。
4. 欄位命名全站統一。 snake_case 還是 camelCase 選一個。跨語言時轉換放在邊界層做一次。
5. 金額用整數最小單位或字串。 99.99 在浮點裡不精確,9999(分)或 "99.99" 都行,但要在文件裡寫死。
6. 別把 null 和「欄位不存在」混用。 兩者語意不同,見 空值用 null 還是省略欄位。
什麼時候上規範
- 對外開放平台、多方對接:考慮 JSON:API 或 OpenAPI 描述 + 統一信封
- 內部前後端一體:固定信封足夠,別過度設計
- 需要強型別契約:OpenAPI/JSON Schema 先行,結構從 Schema 產生
規範的價值在於「不用每次討論」,成本在於「每個新人都要學」。五個人的專案上 JSON:API,多數時候是負收益。
常見問題
裸回傳和固定信封該怎麼選?
要分頁、警告、requestId 這類元資訊時用固定信封,讓 meta 與 data 分開成長。完全不需要元資訊、且能接受以後加欄位會破壞契約時,才用裸回傳。
為什麼不該永遠回 200?
CDN、閘道、熔斷、重試和監控都只看 HTTP 狀態碼來決定是否快取、重試或告警。狀態碼永遠是 200 時,它們全都把失敗當成成功,每個告警還要自己解析業務碼。
列表介面是否必須帶分頁元資訊?
是。沒有 total 或游標,前端就做不出分頁控件;同時空列表要回 [] 而不是 null,否則前端的 .map 會直接報錯。