破壞性 API 變更詳解

破壞性 API 變更,是指對 API 已發布合約所做的、能讓一個嚴格按舊合約寫對的用戶端在新合約下失敗的任何變更——用戶端自己什麼都沒改。這一句話就是全部理論;本文其餘部分都是它的推論:哪些變更算數、哪些著名的陷阱看起來無害實則有害、如何在規範升級部署之前審查它、以及版本策略如何為用戶端爭取時間。想把理論用在兩份真實文件上,就用 OpenAPI Diff 對比新舊規範,對照本文閱讀判定結果。

合約是對未來的一份承諾

API 規範不是隨手寫的文件;它是一份承諾。當規範宣告 GET /pets 接受一個可選的 limit 參數、並以 200 回傳一個包含字串 name 的物件時,每個用戶端作者都正是按這些來建構的:不需要時就省略參數、按狀態碼分支、把欄位當字串讀。用戶端的正確性是按承諾來評判的,因此「破壞性」的定義是不對稱的——只要一個寫對的舊用戶端可能失敗,這項變更就是破壞性的,哪怕大多數用戶端碰巧能挺過去。一個由舊規範生成的嚴格 SDK 因類型變更而失敗,就已經足夠:被打破的是承諾,不是用戶端。

這個框架能化解大多數關於某項變更「算不算數」的爭論。刪除端點是破壞性的,因為承諾裡包含它的存在。新增端點不是,因為沒有任何舊用戶端對自己從不呼叫的路徑許下過什麼。新增一個可選的回應欄位是安全的,因為舊剖析器會忽略未知欄位——在任何像樣的格式裡,只增不減的輸出不可能讓讀取方失效。相反,新增一個必填輸入會伸進每個已有的請求裡,讓那些沒帶該欄位的請求失效。檢驗方法永遠相同:把昨天的合法流量放到明天的規則下重演一遍,看看有什麼會失敗。

實踐中有兩個推論重要。第一,合約是已發布的內容,而不是伺服器今天碰巧做的事:用戶端靠嗅探流量發現的未文件化欄位不在承諾之內,刪除它不禮貌,但嚴格來說不算破壞——這正說明應該只把你打算保留的東西寫進文件。第二,合約包括它所依託的標準的整個表面:RFC 9110 所固定的 HTTP 方法與狀態語義也是承諾的一部分,因此重新定義 200 或 404 的含義,即使規範檔案裡根本沒提狀態含義,也構成一次合約變更。

經典陷阱

生產環境中最常見的破壞是添加必填欄位。團隊往請求主體裡加一個欄位、因為新業務規則需要它而標為必填、然後發布——於是每一個現有用戶端,由於都不傳送這個欄位,開始收到 400。安全版本很無聊:把欄位加為可選並在伺服器端給預設值,或者只在新端點上要求它。第二個陷阱是刪除列舉值:某個狀態值、類別或排序方式因為新介面不再提供而被退役,而存量用戶端資料、舊版本手機應用和合作夥伴整合在多年裡還會繼續傳送它。保留接受一個列舉值幾乎不花成本;撤回它,應該按刪除端點來對待。

類型變更是第三個陷阱,有收窄和放寬兩種偽裝。收窄——字串變列舉、整數變小範圍、自由物件變固定形態——會拒絕以前合法的輸入,表面上就是破壞性的。放寬更微妙:把回應欄位從字串改成字串或空、把整數 id 改成字串 id,看起來大方,卻會弄壞每個按舊類型建構的嚴格剖析器和生成的 SDK。回應一側還有一面鏡像陷阱:一直存在的欄位變成可選。用戶端無條件地讀它;第一個省略它的回應,就是別人程式庫裡的一次空指標。

第四個陷阱是語義型的,沒有任何對比工具能抓到它:欄位保持名字和類型,卻改變了含義。價格悄悄地從分變成元、時間戳從 UTC 變成當地時間、曾經有序的清單不再有序——規範對比是空的,而每個用戶端都是錯的。語義破壞正是機械對比只是審查的下限而非上限的原因:它清掉那些不可能弄壞任何人的變更,讓人的注意力集中在那些只是可能弄壞人的變更上。

在部署之前審查供應商的規範升級

供應商不斷發布新的規範版本,而「升級 SDK 然後重新部署」正是安靜的破壞抵達生產環境的方式。一套有紀律的審查只需要十五分鐘。第一,拿到兩份文件——你的部署今天實際運行所依據的規範,和候選規範——並機械地對比它們,而不是讀變更日誌,因為變更日誌是行銷文件:它們列功能,不列刪除。OpenAPI Diff 在本機生成這份清單;當 API 是內部的時候,它的規範不應該被貼上進陌生人的託管工具,這一點很重要。

第二,把破壞性清單當作對照你自己用法的檢查表來逐項核對。大多數供應商的破壞對你來說是理論上的:一個你從不呼叫的端點被刪了、一種你從不建立的資源多了一個必填欄位。危險的條目是那些與你的流量相交的——這正是審查需要你的呼叫清單、而不只是對比結果的原因。第三,檢查未文件化的表面:你按其分支的狀態碼、你剖析的錯誤形態、你讀取的標頭。同一種保護 webhook 消費者的習慣——固定並測試負載合約,正如 webhook 簽名詳解 所述——適用於任何由別人掌控的合約。

最後,分階段上線。讓一個預發布用戶端指向新版本,重演錄製下來的生產請求(一份去識別化的擷取,按 HAR 指南 規定的方式處理),在任何使用者之前比較回應。如果供應商提供棄用窗口或版本鎖定,就用它:最便宜的破壞性變更,是你按自己的節奏、在用戶端準備好之後採納的那一次,而不是供應商發布日的那一次。

版本策略:URL、標頭,還是永不破壞

對破壞性變更的經典回答是版本識別符,經典的爭論是把它放在哪裡。URL 版本(/v1/、/v2/)明確、快取友好、路由簡單、在每一行日誌裡都可見;它的代價是它把整個 API 一起版本化,於是一個破壞性端點會拖著每個用戶端一起遷移。標頭版本——版本標頭或帶日期的內容協商方案——讓 URL 保持乾淨、還能按資源單獨版本化,代價是不可見:版本從日誌、連結和瀏覽器網址列裡消失,偵錯變成考古。兩者都沒有錯;業界只是更願意付 URL 的代價,因為凌晨三點時可維運性勝過優雅。

更深的策略是讓版本變得罕見。只增不減地演進——永不刪除、永不設為必填、永不改類型;只添加可選的東西——能讓單個版本無限期地活下去,存活最久的公共 API 事實上就是這樣運作的。它要求一開始就有紀律:寬容的輸入接受、從第一天起就把 id 定為字串類型(以便範圍可以增長)、為可擴展而設計的列舉、以及在撤回任何東西之前長時間警告的棄用流程。語義化版本——如 semver.org 所編纂,而 OpenAPI 倡議自己的規範刻意把這件事留給發布者——提供了信號層:主版本提升宣告一次破壞,用戶端可以據此鎖定。

無論選擇哪種策略,它只有作為有機制支撐的社會合約才管用:各版本必須在宣告過的窗口內真正並行運行,棄用必須在用戶端作者看得見的地方宣布(變更日誌、回應標頭、給大消費者的直接通知),舊版本的使用量必須被度量,於是「已經沒有人用了」是一個事實而不是希望。一個發布了卻不能並行運行的版本不是策略;它只是給破壞改了個名字。

對比工具的位置

機械對比工具在兩個時刻證明自己的價值。在審查中,它把「有什麼被弄壞了嗎?」從一下午的逐行對照,變成一份可以逐項勾掉的清單——而且同樣有用的是,它能證明一個否定結論:當一個版本聲稱自己只增不減時,一份空的破壞性清單是證據,而不是感覺。在持續整合中,同樣的比較可以對每個動到規範的拉取請求運行,在破壞性變更沒有伴隨專案政策所要求的版本提升或批准時,讓建構失敗。分類表就是政策;工具只是拒絕讓它在截止時間被忘掉。

它的侷限定義了其餘的工作流。對比工具只讀已宣告的合約:它看不見行為、效能、語義,以及用戶端所依賴的未文件化怪癖,而且它會摘要病態地深的模式而不是無限展開。因此完整的門檻是一條流水線,而不是一個頁面:機械對比管合約、重演真實用戶端呼叫的合約測試管行為、分階段發布管語義、監控管三者都漏掉東西的那一天。這樣使用時,對比是最便宜的一層——十五秒,刪掉最尷尬的一類事故:那種破壞其實一直寫在規範裡、只是沒人對比過兩份文件的事故。

這才是本文真正主張的習慣。規範儲存起來便宜,對比起來更便宜;事故不便宜。把你擁有或消費的每份合約的每個已發布版本都儲存下來,在每個候選版本上線之前與生產中的版本對比,並把破壞性清單當作這次發布的真正議程。做到這一點的團隊,不再爭論某項變更「本應不應該」弄壞任何人——他們有那份清單,帶著日期、歸檔著,在它上線之前就有了。

來源與延伸閱讀

常見問題

添加必填欄位總是破壞性的嗎?

是的,在任何現有用戶端已經在呼叫的端點上:昨天合法的請求今天被拒絕,這就是破壞性的定義。標準的出路是:把欄位加為可選並在伺服器端給預設值;只在新端點或新版本上要求它;或者宣布一個棄用窗口,在此期間該欄位是「可接受但會警告」的,之後才變成必填。

URL 版本還是標頭版本——哪個更好?

對大多數團隊來說是 URL 版本:它在日誌和連結中可見、易於路由和快取、而且對它是什麼很誠實。標頭版本適合需要按資源粒度版本化、且已經有強可觀測性的平台。兩者都勝過最差的選項——無版本的破壞性變更。最好的策略其實是只增不減的演進,它讓版本化成為罕見事件而不是例行稅負。

工具能抓到每一個破壞性變更嗎?

不能。對比工具能抓到規範宣告的一切:刪除、新增必填輸入、類型變更、列舉撤回。它抓不到語義變更——同一欄位、新的含義——也抓不到規範從未宣告的行為。這就是為什麼機械對比應該與合約測試和分階段發布一起進入流水線,而不是被單獨供起來。