API 設計的選型判準——版本方案怎麼選、錯誤格式怎麼定、分頁用什麼機制、冪等條款怎麼寫——多半預設讀者站在決定還沒下過的位置。實際會來查這些判準的人常常站在另一個位置:契約已經上線、當初的決定是別人下的或根本沒人下過、消費者改不改得動不是自己說了算、而重跑那些判準需要的觀測資料還沒有。判準在這個位置上仍然回答「應該選什麼」,卻不回答「已經在那裡的那個怎麼辦」。

本章收的是後者:已經暴露出去的性質怎麼收回來、幾件事同時要做時的順序、以及哪些其實不必收。

已暴露的性質分成四類,收法與成本驅動因子都不同

改造的第一步是把「想改的東西」分類。四類的成本驅動因子不同——加法看工程量、按人分界看狀態維護、需要載具看機制建置、只能等或斷看協調度——因此它們不能共用同一份時程,混在一起排會讓最慢的那類拖垮全部。

類別性質例子收法
加法可解消費者不動也沒事新增請求參數、新增選配 header直接加,舊的照原樣留著
按人分界新舊規則並存,分界畫在消費者而非請求收緊驗證、新增必填欄位、改預設值新註冊的套新規則、既有的沿用舊規則
需要載具新舊行為互斥,分界要畫在請求上錯誤一律回 200 改成回真實 status、錯誤格式換 schema掛在版本切片上釋出,舊切片維持現狀
只能等或斷舊行為必須最終消失移除欄位、cursor 從透明改不透明通知加窗口,窗口到期後主動讓剩下的斷掉

分類前先過一道程序閘門,它跟技術無關:這個動作在契約或採購程序上算不算「介面變更」。合約寫明變更要提前通知並取得書面同意時,即使技術上是純加法、對方一行程式都不必改,程序上仍然是變更——照技術分類直接上線會違約。這一問答完之後,下面三問處理的是技術層的成本分類。

技術層要問三個問題,而第一問常被跳過。

第一問:這是請求側還是回應側的新增。 把兩者放同一格是這個分類最常見的錯誤。請求側新增一個選配參數或 header,既有消費者不送它就完全不受影響,無條件加法可解。回應側新增一個欄位則要看對方怎麼解析——Jackson 的 FAIL_ON_UNKNOWN_PROPERTIES 預設為開、JSON Schema 的 additionalProperties: false 是常見設定,這些消費者會被一個新欄位直接打爆。回應側新增只在文件早有「未知欄位請忽略」的條款、且該條款早於現在這批消費者存在時才算加法可解;否則落到下一問。

第二問:分界能不能畫在消費者身上而非請求上。 這一格常被整個跳過,而它是最便宜的。收緊驗證、新增必填欄位、改預設值這類變更多半可以讓新註冊的套新規則、既有的沿用舊規則——服務端單方面設定,不必發版本、不必等對方。代價是要維護一份「誰適用哪套規則」的狀態,而那份狀態會長期存在。

第三問:舊行為是不是必須最終消失。 答否的落在需要載具——新舊互斥、但可以永久並行在不同的版本切片上。答是的才是只能等或斷。移除欄位常被直覺歸進最後一類,而在有版本機制時它其實是需要載具那一格:新切片沒有那個欄位、舊切片永遠保留它,沒有人需要改。只有當它必須從所有切片消失時才進最後一類,而這個答案不一定由自己決定——法遵、資料最小化政策、稽核結論都會把它變成強制。

分類的產物是一張表,每一列一項擬議變更,欄位是變更、類別、依據(哪一問答了什麼)。只寫類別不寫依據時,「加法可解」會變成最省力的預設答案;歸進那一格的每一列要指得出「舊行為原封不動留著」在程式碼上由什麼保證。

放寬驗證規則(原本擋掉的輸入現在接受)是四類之外的一格:它對既有消費者完全無害,不需要載具、不需要分界、也不需要通知,做了就好。

分頁從 offset 換 cursor 則是請求側加法最典型的例子:?page= 照原樣留著、新增一個 ?cursor= 參數,兩套並存,新整合方用新的、舊整合方繼續用舊的直到它們自然消失。把它寫成「換掉分頁」就會去規劃一次版本升級,而實際要做的只是加一個參數。

需要載具那一類要的是版本切片——同時對外供應的一組行為,消費者拿到哪一組由版本識別碼決定。關鍵是不要為每個變更各開一個切片。已經有版本機制時,這些變更掛在新的版本切片上;沒有版本機制時,新端點是最常見的載具,而那實際上是在做一次迷你版本化。這一類的變更不會只有一次,因此第一次就值得把載具做成可重複使用的——但要分清楚兩件事:做出一個能承載切片的機制(新增一個版本維度、決定切片怎麼識別)跟決定版本策略走哪一派(版本放 URI、放 header、用日期釘住消費者拿到的行為,還是不做版本)是兩件事,前者是這一步的產物、後者是整份計畫的最後一步。把兩者綁在一起會讓一次錯誤格式的變更卡在一個還沒有輸入的版本決策上。

只能等或斷這一類要先接受一件事:完成率不會到百分之百,而上限由消費者的可協調度決定(列不列得出名單、發不發得出通知、握不握有 SDK、有沒有強制力四項,見 Consumer Coordinability)。可做的是把「會斷的那些」從未知變成已知——退場前的觀測窗口列得出還剩誰。而「已知」之後還有一個判斷:尾部佔比小的時候接受斷掉,佔比大到某個程度(例如四成裝置永遠不會更新韌體)時,正確結論是不退場、改成雙軌長期並行,而不是照計畫斷。退場的前提是斷掉的代價小於維持的代價,而這個比較要在通知發出去之前做。決定要斷之後,通知鏈的三種工具(公告、in-band warning、brownout)各自觸達不同的人(11.5 的 deprecation 工具箱 有完整展開)。這一類排到最後做,是因為前面三類清完之後它的清單通常會短很多。

「從第一版就要成立」指的是這個介面元素的第一版

各章都有一些判準寫成「從第一版就要成立」,而它們的實際語意是「從這個介面元素的第一版」——新增一個介面元素隨時可以做,因此這類判準多半補得回來。

cursor 的不透明性是最清楚的一個。既有 API 給的是 ?page=3,透明且已被依賴;收緊的做法是新增 ?cursor=,這個參數從它自己的第一版就 opaque。把既有的 page 改成不透明落在只能等或斷那一格,而且沒有必要——舊參數照那一類處理,或乾脆永久留著。同樣的邏輯適用於錯誤格式的演化條款——「未知欄位請忽略」這句話晚寫確實不保護已經發出去的欄位,但它從寫下的那天起保護所有之後新增的,而那才是它的主要價值。

無法用新增元素繞過的判準是依賴落在既有元素的行為上。錯誤訊息的文字內容、ID 的長度與格式、欄位在 JSON 裡的順序、恆定 200 的解析假設,都屬於這一類——新增一個元素不會讓消費者停止依賴既有那個。已經明文承諾過的東西同樣在這一類裡:保存期、total count 的精確度、ID 的型別,它們是顯式的,而顯式只帶來一個好處:通知得出對象。

這一類的處理只有兩條路:靠版本切片切開,或接受它已經是契約的一部分並把它變成明文的不承諾(該做法與相容變更清單的關係見 11.6 向後相容的變更紀律)。

順序:先建證據、再止血、最後動最貴的

排序分三層,因為有兩類工作的位置不是排出來的(以下的準則與排序為機制推導,未見公開規範明文處理)。

第零層先量一個外部數字:消費者側的變更節奏。 對方多久能發一次版、韌體或客戶端的更新覆蓋率爬到多少、簽核要走幾天。這個數不由自己控制,卻決定所有時程的絕對長度——一季才發一次版的內部系統、需要書面同意的合約關係、OTA 覆蓋率停在六成的裝置群,它們的可行時程差一個量級。缺這個數時後面兩層排出來的順序都只有相對意義。

第一層把有外部期限的項目釘住:從到期日往回推最晚開工日,中間算進通知期、對方的發布或審批週期(第零層那個數)、以及留證時間。合規期限與合約通知期屬於這一層,而它們今天沒有在流血、也不見得是誰的輸入,因此後面的準則抓不到它們——這是優先序清單本身表達不了的東西,要先處理掉。

第二層才用準則排剩下的,四條會互相衝突:輸入依賴(這一步的產物是不是後面某一步的輸入)、可開工時間(今天動得了手,還是要先等埋點上線、等對方回覆、等排期)、不可逆成本(做錯了退不退得回來)、急迫性(現在有沒有在流血)。衝突時急迫性壓過全部,其餘三條的優先序是輸入依賴、可開工時間、不可逆成本。

以下五步是這三層在「沒有外部期限、沒有正在流血」這個情境下的解。步驟之間只有第三、四、五步有真實的輸入依賴,前兩步與其餘各步都可以平行——時程壓縮時先平行化,而不是砍步驟。

第一,列出未來一年的契約決定清單,再依它建觀測。 這兩件是同一步的兩半——維度由待決的決定反推,而這份改造計畫本身就是那份清單,因此輸入已經有了(反推方法見 11.12 API 消費者用量觀測)。

觀測這一半的成本還要再拆,因為兩塊差一個量級。埋 per-endpoint 與 per-parameter 的計數是加法可解、當天可開工。補歸屬層(每個 key 對得上一個組織)在既有 API 上多半不是:key 當初若沒綁組織發出去,事後從裸 key 反推通常補不回來,要重發或要求對方重新登記——那是只能等或斷。前半立刻做,後半獨立排期,而在它完成之前消費者名單只有不完整的版本。

這一步供給後面兩件明確的事:分頁要換機制得先看真實流量翻多深,而版本選型要問的「列不列得出消費者名單」與「協調不動的尾部佔多少」都直接來自這一層。它不供給錯誤格式那一步——「誰依賴一律回 200 的行為」在服務端本來就觀察不到,因為服務端看得到自己回了什麼、看不到 client 怎麼解讀。它排第一的第二個理由跟輸入無關:它不動契約,因此不需要跟任何消費者協調,可以立刻開工。

第二,用不需要消費者改動的手段止血。 這一步不依賴第一步,排在它後面純粹是因為觀測可以當天開工而止血要動程式碼——真的在流血時兩者對調。會流血的問題通常是重複寫入或效能,而這兩類多半有服務端單方面可做的解:重複寫入用 natural key 加唯一約束擋在資料庫層,不必等消費者送冪等 header(見 Idempotency key 標準化之爭 的前置閘門段);深頁掃描先用覆蓋索引與 deferred join 把常數壓下來,再談換不換機制。這一步的價值在於它把時間買回來——後面幾步都需要跨組織協調,而客訴每天都在發生。

止血手段要先分可回收與不可回收。加索引、加快取、暫時降配額都可回收,做錯了拿掉就好。natural key 去重不同:按前一節的分類它其實是只能等或斷的變體——斷掉的是「刻意重送兩筆內容相同的請求」這個少見用法,而它做得起止血的速度是因為那個用法罕見,不是因為它零協調。它同時把「什麼算重複」變成服務端的永久語意,而且是一條不會被宣告出去的契約。急迫性壓得過排序,壓不過這個確認:上唯一約束前要先確認那組欄位在現有資料裡真的唯一,以及重複送出是不是某些消費者的合法用法。

第三,錯誤格式與 status 語意。 它排在分頁與版本之前的理由是後兩者的執行都會產生新的錯誤情境(分頁參數失效、版本不支援),錯誤格式先定下來,後面兩步才不必各自發明一次。這一步有一個前置:改回真實 status 需要載具,因此沒有版本機制的服務要在這一步順便把載具做出來(見上一節)——做載具,而非決定版本流派。

第四,分頁。 多數是加法(新增 cursor 參數),因此不需要等版本機制,也不需要跨組織協調。

第五,版本策略最後重決。 它最貴也最不可逆,而它的選型要問的問題直接以第一步的產出為輸入——消費者名單與尾部佔比。第三、四步跟它的關係是另一種:那兩步不必等版本決定就能做,而先決定版本流派會讓它們被綁進一個還沒有輸入的判斷裡。

資料正確性事故發生時這份順序整個重排:止血跳到最前面,觀測往後挪。觀測是為了做對決定,而資料正在錯的時候,決定得晚一點沒關係。

判讀訊號

訊號判讀
改造計畫的第一項是「升級到 v2」順序顛倒,版本是最貴且需要別人輸入的一步
一份計畫裡混了不同類別、共用同一個時程只能等或斷的那類會拖垮加法可解的那類
為單一變更另開端點,而已有版本機制載具重複,之後會有兩套並行的變更管道
討論停在「當初就該這樣設計」把可補救的(新增介面元素)跟不可補救的(依賴落在既有元素上)混為一談
每一步都要等消費者配合沒有先盤點哪些是服務端單方面做得到的,止血的時間被協調成本吃掉
觀測還沒建、卻已經在討論退場日期退場計畫沒有收件人,日期是猜的

六個訊號分成兩組。分類沒做的那組長出三種樣子:不同類別混在同一個時程裡、可補救的跟不可補救的被混為一談、以及為單一變更另開載具(那是把需要載具那一類當成一次性的事)。排序錯了的那組長出另外三種:最貴且需要輸入的一步被排在最前面、需要輸入的決定被排在產生輸入的動作之前、以及每一步都在等消費者配合(沒先盤點服務端單方面做得到的那些)。分類與排序是改造計畫的兩個獨立失效點,而它們的症狀在計畫書上長得很像。

下一步路由

本章的分類、判準與排序為機制推導,未見公開規範明文處理。