App 使用者回報截圖:畫面上的錯誤提示顯示著 [object Object]。
任何寫過 JavaScript 的人看到這串字都知道發生了什麼:一個物件被硬轉成字串顯示了。但這次的問題嚴重在——回報的使用者用的是已經上架、我無法即時更新的 App 版本。
後端某個 API 的錯誤回應,原本的格式是:
{ "detail": "額度已用完,升級方案可提高上限" }
某次改版時,為了讓前端能做更細緻的處理,我把它改成了結構化格式:
{ "detail": { "code": "QUOTA_EXCEEDED", "message": "額度已用完", "upgrade_url": "..." } }
網頁版同步改了解析邏輯,沒事。但已上架的 App 版本,程式碼裡寫的是直接把 detail 當字串顯示——它收到一個物件,於是使用者看到 [object Object]。
網頁版隨時可以改、可以同步部署,但 App 走商店審核、使用者更新意願不一,舊版本會在使用者手機裡存活數月甚至數年。這代表:
任何會被 App 讀取的 API 欄位,它的型別跟語意是一份長期契約。你改的不是「自己的程式碼」,是所有歷史版本共同依賴的介面。
後端工程師的直覺是「前後端一起改就好」——這個直覺在有原生 App 的架構裡是危險的。正確的心智模型是把已上架版本當成一個「你永遠無法要求它改碼的第三方消費者」。
第一道:欄位型別用測試釘死。 專案裡加了一支測試,明確驗證所有錯誤回應的 detail 欄位必須是字串。任何人(包括 agent、包括未來的我)改動這個欄位的型別,CI 直接紅燈,並且測試名稱會告訴你為什麼:
def test_error_detail_must_be_string():
"""已上架 App 版本直接把 detail 當字串顯示,
塞物件會變 [object Object](地雷清單 #8)。"""
resp = client.post("/api/some-endpoint", ...)
assert isinstance(resp.json()["detail"], str)
第二道:擴充用加法,不用改法。 需要結構化資訊,就新增欄位(detail_meta),舊欄位保持原型別不動。舊版 App 讀舊欄位,新版讀新欄位,兩者並存。
第三道:寫進地雷清單。 條目寫的是後果而不是規則:「402 類錯誤回應的 detail 必須是字串——已上架的 App 版本直接顯示它。」
介面的相容性承諾不是對「現在的消費者」,是對「所有還活著的消費者」。你控制不了消費者什麼時候死,所以承諾預設是永久的——這就是為什麼 API 設計裡「加欄位」永遠優於「改欄位」。