管理變更#

好的設計不會永遠停留在某個時間點。即使今天的設計再優秀,當變化來臨時,它未必仍然適用。好的 API 必須能夠隨著產品或業務的演進而調整與改變

破壞性變更(breaking changes)是許多 API 最常見的陷阱之一。本章討論如何在追求一致性的同時管理變更,並確保 API 演進時能維持向後相容性(backward compatibility)。

專家建議 — Kyle Daigle(GitHub 生態工程總監): API 應該保持一致、清楚且有完善的文件。命名和 URL 等小細節的不一致性會隨著 API 老化而累積成大量混亂。因為你希望避免做出破壞性變更,你最好努力維持一致性,但更重要的是確保新增功能對整合者來說是清楚且明顯的。


追求一致性#

一致性(consistency)是所有優秀體驗的標誌,API 也不例外。一致性建立信任,而信任是打造繁榮開發者生態系統的基石。

以下是一致性的幾個特徵:

  • 開發者能夠建立一個心智模型(mental model),理解如何存取系統中的資料
  • 回應物件採用嚴格型別與有意義的命名——同一個 model 無論在哪個端點都是相同的
  • 開發者可以跨多個端點使用相同的請求模式,減少中介軟體需求並提升應用程式效能與擴展性
  • 請求以可預期的方式失敗,並附帶有意義的錯誤訊息
案例故事:Slack API 的不一致性

Slack 的 API 端點是隨著時間逐步增加的,沒有中央設計監督。公司內的每個產品團隊各自獨立設計和發布 API 方法,結果產生了不一致性。

以下是兩個相似 API 方法的簡化請求模式對照(2017 年):

channels.join(接受頻道名稱)channels.invite(接受頻道 ID)
channels.join({ channel: "channel-name" })channels.invite({ channel: "C12345", user: "U23456" })

一個端點用字串名稱作為 channel 參數,另一個則用 ID。這種不一致性迫使開發者必須同時儲存頻道 ID 和頻道名稱,並建立一層邏輯來決定使用哪一個。如果頻道名稱變更了,開發者還得負責保持名稱同步。

一致性聽起來很簡單,如果你能在沒有歷史包袱的情況下一次設計所有東西,確實不難。但當已有活躍的開發者在使用你的 API 時,事情就變得複雜了。隨著公司和產品演進,新的 API 變更往往是過去一致性未來正確性之間的不斷拉鋸。

案例故事:虛構公司的 API 不一致演化

假設 Company A 發布了一個 API,其中有一個方法 repositories.fetch 可以一次取得使用者的所有 repository。最初產品限制每位使用者只能有 10 個 repository。

原始回應格式如下:

{
  "repositories": [{ "id": 12345 }, { "id": 23456 }]
}

幾個月後,公司推出了不限數量 repository 的新價格方案。一年後,一些超級使用者累積了數百萬個 repository。由於 repositories.fetch 端點一直缺乏分頁機制,API 請求開始逾時,最終導致一次週末服務中斷。

Company A 與合作夥伴 Company B 決定發布一個新端點 repositories.fetchSingle

[
    { "12345": {...} }
]

比較兩個端點的回應,可以發現多處不一致:

  • repositories.fetch 的回應包含一個 "repositories" 鍵,值是包含 "id" 欄位的物件陣列
  • repositories.fetchSingle 回傳一個沒有 "repositories" 鍵的陣列,物件以實際 ID 值而非 "id" 字串作為鍵

雖然這是虛構故事,但在成長中的公司並不罕見。初始版本的 API 發布後,後續變更可能產生不一致性,開發者採用了這些新功能與其不一致性,不一致性就這樣隨著 API 演進而根深蒂固。

幸運的是,有一些技術工具和組織流程可以幫助防止這類不一致性。


自動化測試#

每個影響 API 的人都應該理解並支持一致性,但僅靠組織價值觀很難強制執行。你不能期望人們在每次決策時都重新考慮複雜系統的每個細節,然後做出正確的選擇——這就是自動化測試發揮作用的地方。

持續整合(Continuous Integration, CI)是將所有開發者的工作副本合併到單一共用儲存庫的實踐,通常一天會進行多次。程式碼變更從撰寫到通過審核並合併的工作流程稱為 CI pipeline(如 Figure 7-1)。在允許開發者合併程式碼之前加入自動化測試步驟(見 Figure 7-1 中的步驟 3),是防止不想要的變更——特別是向後不相容的變更——混入 API 的絕佳選擇。

Figure 7.1: A CI pipeline

實作自動化測試套件時,必須做到以下幾點:

  • 給予內部開發者即時回饋
  • 最小化偽陽性(false positives)
  • 賦能內部開發者在 API 設計中做出正確選擇
  • 測試應驗證輸入預期行為以及回應 payload 中的正確資料型別

你也可以在 CI pipeline 中加入額外的審核要求:當偵測到影響 API 輸出的變更時,自動提交給特定人員審核,確保請求和回應與已發布的 API 保持一致。

如果你還沒有 CI,可以先在程式碼主線上持續執行自動化測試以監測 API 健康狀態。盡可能頻繁地執行,然後在建立 CI pipeline 時先設為非阻塞(non-blocking)並監控結果。當偽陰性率降到 0% 時,再將它們設為阻塞性測試。


API 描述語言#

在服務導向架構(service-oriented architecture)中,你可以使用介面描述語言(Interface Description Language, IDL)來定義請求,並使用型別系統來驗證回應。但對外部 API 來說,這些工具沒有優勢,因為你無法控制請求者的行為。

JSON(JavaScript Object Notation)是一種廣泛使用的格式,既靈活又具表達力。隨著 JSON 變得更加豐富,它無法被傳統程式語言的型別系統所約束,因此你需要仔細思考用來管理 API 請求和回應變更的工具。

描述與驗證回應#

你首先要用自動化測試驗證的是回應 payload。有工具可以幫助你以結構化的方式描述 API 介面,並用於生成文件和執行測試。部分工具還允許你指定資料型別和自訂資料型別,例如:

  • json:api
  • JSON Schema
  • Apache Avro

以 JSON Schema 為例,可以定義 repositories.fetch 的 payload 回應:

{
  "$schema": "http://json-schema.org/draft-04/schema#",
  "title": "repositories.fetch",
  "description": "Schema for repositories.fetch response payload",
  "type": "object",
  "additionalProperties": false,
  "required": ["repositories"],
  "properties": {
    "repositories": {
      "type": "array",
      "items": {
        "$ref": "../common_objects_schema.json#/repository"
      }
    }
  }
}

你還可以定義可重用的物件定義,例如單一 repository 的 schema:

{
  "$schema": "http://json-schema.org/draft-04/schema#",
  "repository": {
    "type": "object",
    "additionalProperties": false,
    "required": ["id", "name", "description", "created"],
    "properties": {
      "id": { "type": "integer" },
      "name": { "type": "string" },
      "description": { "type": "string" },
      "created": { "type": "integer" }
    }
  }
}

這些可重用的物件定義是維持 API 一致性的強大基石——定義越嚴格、在其他 JSON Schema 中使用得越多,就越能保證描述同一物件類型的回應具有相似的 payload。

搭配 RSpec 等測試框架,可以呼叫 API 端點並驗證回應是否符合 JSON Schema。你應該在回應 payload 每次變更時更新對應的 JSON Schema。

描述與驗證請求#

由於你無法控制開發者如何使用 API,最好的策略是定義一個清楚的介面,在提供彈性的同時引導開發者做出正確的選擇。好的請求定義介面也允許你定義可重用的型別。

可用的工具包括:

  • JSON Schema — 只要請求是 JSON 格式或可轉換為 JSON,就能用來描述和驗證 API 請求
  • OpenAPI(前身為 Swagger Specification)— 用於描述 REST API,支援自動生成文件與程式碼生成(libraries 和 SDK)

無論選用哪種工具,實作請求規格系統都能帶來多重好處:

  • 文件自動化
  • 請求錯誤檢查與驗證——嚴格型別的請求欄位可在格式錯誤時立即回傳錯誤,節省組裝錯誤回應的工夫
  • 為開發者提供更好的回饋

人類的創造力是無限的,使用者會以你從未想像的方式使用你的 API。文件是關鍵——當你建立請求規格並將其與文件緊密整合時,你就能幫助開發者做出正確的選擇。文件不僅要描述現今的狀態,也要記錄過去的重大變更,以及即將到來的變更。

案例故事:Slack 的 API Metadata 系統

當 Dan Bornstein 加入 Slack 的 Developer Platform 團隊時,儘管 API 已擁有大量使用者,卻沒有標準的 API 請求定義。於是 Dan 建立了 API metadata 系統來描述每個 API 方法的請求。

這個系統的演進過程:

  • 最初基於歷史流量記錄正在使用的請求參數
  • 隨後成為軟體工程師主動描述 API 介面的方式
  • 被用來自動生成文件,甚至將請求欄位轉化為互動式 API 測試工具
  • 接入資料倉儲(data warehouse)以產出 API 流量分析報告
  • 最終成長為 web 請求與回應之間的一個中介層,處理參數驗證、通用錯誤與警告、token 類型驗證等

它成為一種可擴展的方式,在組裝回應之前就捕捉請求中的早期例外。


向後相容性#

如果你使用像 GraphQL 這樣的查詢系統(請求必須指定所需欄位),你可能對哪些欄位正被使用有一定了解。但如果你的 API 像大多數第三方 API 一樣,回傳 JSON payload 中所有相關欄位——即使使用 *QL 框架,開發者也可能選取所有欄位——那你就無法得知哪些欄位正被使用,你只知道它們全部被請求了。

對某些公司和產品來說,向後相容性是不可妥協的。例如 Cloudinary 的 API 維持完全的向後相容性,因為基於其 API 的圖片 URL 無處不在。幸運的是,他們一開始就將介面設計為可擴展且面向未來,使得主要挑戰變成教育客戶認識新功能和新增項目。

向後相容性對任何 API 開發者來說都是重大考量,尤其是有外部使用者的 API 提供者。在公司內部協調並變更 API 回應相對容易,但當依賴方是外部開發者時就困難得多。

案例故事:Slack 的遺失欄位事件

2016 年 3 月,Slack 還沒有 API metadata 系統、JSON Schema 或 RSpec 測試。Slack 平台才在三個月前正式發布。

一個不一致的 API 請求已存在:第三方 bot 可以呼叫 chat.postMessage 並帶上 as_user 參數。當 as_user=false 時,訊息會帶有 is_bot=true 旗標;但當 as_user=true 時,is_bot 欄位完全不存在,導致 bot 冒充的訊息與真正的使用者訊息無法區分。

Slack 決定修正這個問題——在所有訊息 payload 中一律設定 is_bot 欄位,無論值是 truefalse。本意是追求一致性。

但生態系統中最受歡迎的應用之一利用了這個不一致性。該應用不是檢查 is_bot,而是檢查該鍵是否存在來執行商業邏輯。當變更上線後,該應用立即崩潰:

WARNING: Developer reporting an outage due to change in chat.postMessage parameter as_user.

Slack 最終回滾了這個變更,給開發者三個月的時間來調整。4 月 13 日發布部落格文章公告變更,4 月 30 日再次上線。

教訓: 即使你的 API 有錯誤,也需要仔細思考如何推出修正以最小化對開發者的影響。不要僅僅基於你預期開發者會如何使用 API——你無法預測他們會建造什麼,也無法控制他們如何實作。


規劃與溝通變更#

許多技術設計資源可以幫助你為全新專案(greenfield project)打造設計。但如果你的 API 成功了,你將在非全新領域(nongreenfield)工作多年。這意味著你和你的開發者將長期與過去的決策打交道,所有新設計決策都帶有先前的脈絡。

你需要決定系統對不同類型變更的容忍度,並建立與 API 使用者之間的穩健溝通系統。思考你將做出的變更類型、這些變更對開發者的影響,以及適當的溝通方式。


溝通計畫#

建立溝通計畫時,確保開發者有機制來接收更新:

  • RSS feed 是好的起點
  • 最終你也需要能夠針對特定開發者就影響他們的變更進行溝通

以下是一個溝通管道與時程的範例框架:

向後相容的變更向後不相容的變更
範例新增請求參數、新增回應欄位、新增 API 方法移除回應欄位、功能變更、回應型別變更、移除端點
溝通管道RSS feed、API 文件RSS feed、API 文件、寄信給受影響的開發者、發布部落格文章說明變更
通知後到發布的時間隨時18 個月

除了主動廣播資訊,你也可以在回應 payload 或 header 中附加變更資訊,例如:

{
  "repositories": [{ "id": 12345 }, { "id": 23456 }],
  "response_metadata": {
    "response_change": {
      "date": "January 1, 2021",
      "severity": 1,
      "affected_object": "repository",
      "details": "Starting January 1, 2021, a new field `date` will be added to each repository object"
    }
  }
}

溝通計畫需要在給開發者足夠的變更通知與讓 API 持續演進之間取得平衡。思考如何將溝通自動化,與程式碼發布同步進行,以減少手動為每個溝通管道客製化的負擔。

專家建議 — Desiree Motamedi Ward(Facebook 開發者產品行銷負責人): 我們在管理變更時絕對會保持溝通管道暢通。我們非常主動地確保開發者知道會發生什麼。我們透過各種管道——部落格文章、電子郵件和其他外展方式——做了大量工作。我們理解這有多重要。


新增(Adding)#

在所有你能對 API 做的變更中,新增是最容易的。無論是新增端點還是新增欄位,如果做得正確,這些變更通常很容易執行。

新增回應欄位時需考慮的向後相容性問題:

  • 該欄位之前是否被設定過? 如果之前未設定但你決定一律設定,問一問是否有開發者依賴於「該欄位不存在」這個事實
  • 所有人都想要新欄位嗎? 有時你需要提供讓開發者選擇加入(opt in)的機制,例如新端點或新請求參數

新增過多的請求參數會讓 API 端點變得難以用 JSON Schema 等工具描述,從而更難用自動化工具測試。新增端點時也要確保與現有端點一致,開發者有無縫的升級路徑(現有授權是否適用於新端點?),且不會因為有限的功能而過度佔用命名空間。


移除(Removing)#

隨著 API 持續演進,終究會有你想要完全棄用(deprecate)的端點和欄位。這類變更需要大量的溝通和基礎設施投入。

專家建議 — Yochay Kiriaty(Microsoft Azure 首席專案經理): 不要過度複雜化你的 API,也不要過度面向未來設計。過度面向未來往往讓 API 變得太通用或太複雜。在你平台上建造應用的開發者是為了「現在」而建造,他們喜歡快速移動,不一定會想十步以後的事。你的 API 應該迎合這種心態。

當你從開發者手中拿走某樣東西時,需要用胡蘿蔔——也就是激勵措施——來讓他們順利過渡到新功能。你需要在公告棄用之前想清楚:

  • 你正在啟用什麼新功能?
  • 是否有舊端點無法修復但新方式可以解決的問題?
  • 有時你甚至需要將新功能與終端使用者功能一起打包,讓它對開發者更有吸引力
案例故事:Slack 的 Conversations API

從 2015 到 2016 年,Slack 快速發布功能,用於取得頻道的 API 方法變得越來越複雜且難以維護。2017 年 Shared Channels 的推出成為關鍵轉折點。因此他們透過一套全新的 API 方法——Conversations API——發布了 Shared Channels。

他們讓切換變得容易,並透過僅在新 API 方法中提供獨家資料來激勵開發者遷移。

棄用時程的關鍵考量:

  • 開始溝通後,給開發者充足的時間停止使用被棄用的欄位或端點
  • 過短的棄用時程會侵蝕開發者的信任並阻礙 API 的採用
  • 許多公司會制定政策,規定支援 API 版本的最短期限(例如 Salesforce 承諾每個 API 版本從首次發布起至少支援三年,棄用前至少提前一年通知客戶)

某些 API 規格有棄用的標準做法。例如 GraphQL 允許將特定欄位標記為 deprecated:

3.1.2.2 Object Field deprecation 物件中的欄位可根據應用需要標記為 deprecated。查詢這些欄位仍然合法(以確保現有客戶端不會因變更而中斷),但這些欄位應在文件和工具中適當標示。 — GraphQL spec, working draft, October 2016


版本控制(Versioning)#

為了將變更打包成可理解的區塊,並讓開發者有辦法理解你的 API,你可能需要考慮版本控制

累加式變更策略(Additive-Change Strategy)#

在累加式變更策略中,所有更新都與之前的版本相容。以下變更被視為向後不相容,在此策略下應避免:

  • 移除或重新命名 API 或 API 參數
  • 變更回應欄位的型別
  • 變更現有 API 的行為
  • 變更錯誤碼和錯誤契約(fault contracts)

在此策略下,你只做新增——新增輸出欄位或新增端點——但永遠不變更回應欄位型別或移除回應欄位,除非讓使用者透過請求參數選擇加入。這意味著你會不斷新增越來越多的參數來改變 API 請求的回應。

明確版本策略(Explicit-Version Strategy)#

建立明確編號的版本系統時,首先需要決定使用者如何與版本互動,這通常稱為版本方案(versioning scheme)。

專家建議 — Chris Messina(Uber 開發者體驗負責人): 從一開始我們就知道 API 會有迭代——Uber 的發展速度太快了。因此每個端點都有版本控制,並且方便存取歷史文件。

三種版本方案:

1. URI 組件版本控制

在 URI 中指定版本,通常放在資源路徑之前。例如:

  • Uber Ride Requests API:https://api.uber.com/v1.2/requests
  • Twitter Ads API:https://ads-api.twitter.com/2/accounts

優點:

  • 許多程式語言、函式庫和 SDK 支援使用 base URI 輕鬆綁定版本
  • 對 GET 請求來說,開發者可直接在瀏覽器中除錯和檢查端點

注意事項:

  • 此模式暗示 REST 範式中資源的某種持久性——如果你還未準備好將這些端點作為永久連結支援,則不應使用
  • 需要準備支援 300 級 HTTP 狀態碼來指示重新導向

2. HTTP Header 版本控制

透過 HTTP header 指定版本,例如:

  • Stripe 的 Stripe-Version header
  • Accept: application/json; version=1
  • 自訂媒體類型:Accept: application/custom_media+type.api.v1+json

優點是減少 URI 膨脹,但在瀏覽器中實驗較不方便,且如果客戶端將不同版本的請求解讀為相同請求,可能影響快取。

3. 請求參數版本控制

透過查詢參數指定版本,例如 Google Maps API:https://maps.googleapis.com/maps/api/js?v=3

與 URI 組件方案有相似的好處,但由於查詢參數的高度變異性且在 URI 之後才解析,路由可能更困難。


版本控制的程式碼實作#

在幕後,你需要決定如何確保舊版本的向後相容性。開發者可能在你的 API 上建立業務,多年不升級舊版本。維護多個版本通常會導致程式碼分叉(forked code bases)或程式碼路徑分叉(forked code paths)。

常見的實作方式有三種:

版本化函式名稱 — 建立新函式呼叫舊函式:

Figure 7.2: Diagram of versioned function names

版本化 Controller — 請求路由到負責執行的新 controller:

Figure 7.3: Diagram of versioned controller

版本間轉換層 — 維護一個主函式並持續更新,同時為每個先前版本建立轉換函式,將資料轉換為對應版本的 schema 後再回傳:

Figure 7.4: Pseudocode sample of a transformation layer between versions


語意化版本控制(Semantic Versioning, SemVer)#

SemVer 提供一個標準來描述變更,格式為 MAJOR.MINOR.PATCH

  • MAJOR — 向後不相容的 API 變更(如 v1 到 v2)
  • MINOR — 以向後相容方式新增功能(如 v1.1 到 v1.2)
  • PATCH — 向後相容的 bug 修正(如 v1.1.0 到 v1.1.1)

以下是主要與次要變更的範例分類:

主要變更(Major)次要變更(Minor)
影響相同格式請求輸出的商業邏輯變更新增端點
移除端點新增請求參數
停止支援某個請求參數新增回應欄位
產品棄用

即使使用 SemVer,你仍需決定如何將版本粒度套用到請求上,以及每個版本允許哪些類型的變更。例如,你可能決定自動將所有開發者推進到新的 MINOR 版本,因為這些是向後相容的變更。

你也需要將 API 版本整合到文件中,並在文件的各個層級清楚溝通版本資訊。

Figure 7.5: Banner from Uber's API documentation about previous versions

最後,你可以透過在版本升級中釋出備受期待的新功能來激勵使用者採用新版本。


版本控制案例研究:Stripe#

Stripe 是一家線上支付公司。由於公司依賴第三方開發者實作其 API 來產生收入,它是一個在維護向後相容性方面投入大量心力的典型案例。截至 2017 年,Stripe API 仍然維持與 2011 年創立以來每個版本的相容性。

專家建議 — Romain Huet(Stripe 開發者關係負責人): 頻繁地改進 API 是好事;破壞開發者已建造的東西則不是。找到一個優雅的平衡至關重要——其中一種方式是完全避免破壞性變更。

這一直是 Stripe 的做法:我們提供向後相容性以確保今天寫的程式碼在多年後仍然可用。我們在開發者開始使用時鎖定其 API 版本。除非他們主動選擇升級(因為想利用新功能),否則我們不會要求他們這麼做。在幕後,我們為每個新 API 版本引入離散的程式控制閘(programming gates),這些閘門有條件地提供新功能或變更的存取、隔離請求和回應的邏輯層,並從主程式碼庫中隱藏向後相容性的概念。

Stripe 的版本控制機制:

  • 採用滾動版本(rolling versions),以發布日期命名
  • 開發者首次發出 API 請求時,帳號會被釘選(pinned)到當時最新的 API 版本
  • 之後的請求不需要指定版本
  • 升級版本時,Stripe 提供一個儀表板讓開發者自助變更預設版本
  • 開發者也可以在個別請求中透過 Stripe-Version header 覆蓋版本(如 Stripe-Version: 2018-02-28

在幕後,Stripe 的實作細節:

  • 每個可能的回應都用一個稱為 API resource 的 class 來編碼
  • 擁有自己的領域特定語言(DSL)來定義資源的可能欄位
  • 版本變更被封裝在版本變更模組中,定義了變更的文件說明、轉換函式,以及可被修改的 API resource 類型集合
  • 這使得 Stripe 能在服務部署新版本時程式化地產生 changelog

版本控制案例研究:Google+ Hangouts#

Google+ Hangouts API 使用增量版本來溝通變更群組,但不是讓開發者管理端點的邏輯或回應。當端點需要重新命名或變更時,Google 會新增一個 release note 標示舊端點已被棄用。

Figure 7.6: Google+ Hangouts API release notes indicating a function was renamed

Google 於 2017 年 1 月 10 日宣布不再支援該 API。應用程式只能運行到 2017 年 4 月 25 日,僅有少數例外(Slack、Dialpad、RingCentral、Toolbox、Control Room 和 Cameraman)。Google 不僅在文件中加入公告橫幅,還在回應中加入註記說明端點將在 4 月 25 日後停止運作。

Figure 7.7: Banner for Google+ Hangouts API indicating support termination


流程管理#

無論你選擇哪種機制,版本控制都會為 API 增加流程開銷。需要考慮的事項包括:

  • 需要完善的文件來充分描述 changelog 以及各版本之間的差異
  • 需要弄清如何部署版本化的程式碼,以及如何配置存取控制層以保留舊版本的功能
  • 維護超過一個已棄用版本是非常複雜的,尤其是上游核心函式庫可能影響 API 輸出

你還需要思考團隊能同時支援多少個版本

  • 如何對需要套用到多個版本的安全修正排定優先順序?
  • 是否會對某些變更升級所有先前版本?
  • 開發者支援人員如何有效地協助遇到問題的開發者?

在某些情況下,維護版本的成本超過其帶來的效益。不做版本控制的好處是避免了級聯依賴和複雜的維護工作。只有單一 API 層時,程式碼對內部開發者來說透明且易讀——可維護性的價值不應被低估。你可以考慮延遲版本控制,直到有足夠的基礎設施來支援開發者。

無論你是否決定做版本控制,管理變更意味著在維護向後相容性以足夠的速度釋出變更之間找到平衡,讓你的開發者在平台上取得成功。別忘了收集回饋、為開發者優化,並且適度地進行所有變更。


結語#

本章討論了透過管理變更來持續開發和精煉 API 的許多面向。擁有一套強大的流程和系統來管理持續變更,是釋放 API 全部潛力的關鍵。有了管理變更的能力,你不會被困在過去,而是能夠持續為未來改進你的 API。