深度主題文

API 設計 - 如何維持 API 的冪等與向後兼容?

2024年11月11日

💡 E+ 成長計畫深度主題文

本文為 E+ 成長計畫的深度主題文,開放免費閱讀。E+ 的訂閱讀者除了每週會收到一篇新內容外,也享有過去所有內容的閱讀權限。

如果你對於這類深度內容感興趣,歡迎加入 E+ 成長計畫,除了全系列所有完整深度文章外,也可觀看過去所有的直播回放。

API 的冪等 (idempotent) 與向後兼容 (backward compatible),是在設計 API 時,非常重要的要點。因此在這期的後端主題文,我們將會針對這兩點來討論。之所以說這兩點特別重要,是因為當沒有處理好,會導致後續很多問題。

讓 API 穩定可預測

在前後端的世界中,因為溝通不會隨時都是穩定的,例如客戶端的裝置可能出問題、網路可能中間突然斷掉一小段時間,所以在前後端用 API 溝通時,不能只考慮正常的情境 (俗稱 happy path),而是要進一步去考慮出特別的問題時該怎麼處理 (俗稱 edge case)。當能夠去處理各類極端狀況,API 與整個系統將能夠更穩定。

舉例來說,當今天你用一個電子信箱產品,在編寫完草稿後,按下儲存按鈕,不管按一次,還是按十次,都是執行儲存當下的狀態,不會因為你按了十次,中間網路不穩,就存成十份不同的草稿。如果真的是這樣的話,使用者體驗會不太好。

又或者今天假如使用者在電商網站結帳時,中間網路有一度不穩,使用者等了一下看頁面沒反應,以為自己沒按成功,所以多按了一次結帳,這時不會要讓使用者付兩次款。如果變得要付兩次款,那肯定沒有使用者會想用這個電商的產品。

要能夠做到穩定,冪等 (idempotent) 是很關鍵的要點。所謂的冪等性,是指 API 的呼叫或者操作,不論做多少次,都會是相同的結果;或者換個角度說要做到不論請求幾次,API 都不會產生副作用。當能夠做到冪等性,就能夠確保在重試時,不論重試幾次,都能確保只有執行一次,這樣能避免當遇到各類狀況,導致的不必要重複。

事實上,過去業界就有因為沒有處理好 API 冪等,導致造成重大事故的案例。具體來說,先前 Uber 支付組的工程經理 Gergely Orosz 就曾公開分享,當年 Uber Eats 在印度有個重大事故,是某段時間內,綁定印度最大支付商之一 Paytm 的使用者,即使 Paytm 帳戶沒有餘額,也可以無限地在 Uber Eats 上下單。

Gergely 在分享中談到,會出現這個事故,是當年 Paytm 的 API 做了改動。在改動前,Paytm 的 API 一直是維持冪等的,所以 Uber 的支付團隊在串接時,就預設 API 是冪等的,沒有多做處理。

然而 Paytm 在那次看似無害的改動中,沒有維持 API 的冪等性,這造成的問題是,當 Uber 呼叫 Paytm 的 API 時,第一次因為使用者的餘額不足所以回傳原本預期的錯誤,但這時如果使用者再下一次單,Paytm 會回傳另一種錯誤訊息。

兩次回傳不同的錯誤訊息,看似很無害,但是偏偏因為第二種錯誤訊息原本 Uber 團隊不知道,所以沒處理,因此在 Uber 端就讓這種下單通過。而當使用者發現沒餘額時,只要按兩次就變得能下單成功,當時印度各大學迅速傳開,讓 Uber Eats 在短時間被大量下免費的單,而這造成的商業損失非常可觀。

如何讓 API 有冪等性?

相信看完上面的故事,讀者們已經意識到冪等性的重要。如果 Paytm 的 API 在遇到餘額不足,是穩定回傳 Uber 端可以處理的錯誤訊息,意即如果 API 維持冪等就不會出這個事故。

這時下個問題會是如何讓 API 有冪等性?

以 RESTful API 來說,有些請求相對不用擔心冪等性問題。舉例來說,GET 請求就是,因為假如某個資料存在伺服器,不論請求幾次,資料沒變的狀況下,就會都拿到一樣的資料。

PUT 也是,因為 PUT 是一次修改整個資源,假如有多個請求送來,就以最後送到的請求即可。同樣地 DELETE 刪除某個資源後,就沒有該資源,多發幾個過來的結果都是該資源被刪除,所以也是冪等。

然而,我們很常用的 POST 請求會是相對需要特別處理的。就像電商下訂單時的支付,通常會是用 POST 請求。而最常見的冪等處理方式會是加上冪等鑰 (idempotent key)。所謂的冪等鑰,是一個獨特的 id,讓伺服器端知道這個請求已經被處理過了。

所以如果有網路中斷,或者使用者快速連擊,當同一個請求帶著相同的冪等鑰,伺服器端就知道不用再處理該請求。在系統設計中,遇到追問如何在分散式系統中,避免請求被重複處理,冪等鑰是最基本一定要想到的解法。

舉例來說,全球支付 API 龍頭之一的 Stripe,在 API 設計中,就有冪等鑰的欄位

Stripe API 文件
Stripe API 文件

具體來說,假如要呼叫 CreatePayment 的 API,客戶端可以先產生一組冪等鑰 (例如用 uuid 來產生),這時如果使用者重複點擊,因為帶著的是同一組冪等鑰,所以伺服器端知道已經處理過,就不會重新處理這個支付請求。

先前 Stripe 有一篇《Designing robust and predictable APIs with idempotency》技術文,深入淺出地談了如何透過冪等鑰來提高 API 可預測性,非常推薦一讀。

讓 API 可相容

談完可預測性後,接著來談可相容性。所謂的相容性 (compatibility),是指提供 API 的一方與消費 API 的一方,彼此可以溝通無誤。而對 API 來說,更關鍵的相容,應該是要做到向後相容 (backward compatible)。所謂的向後相容,是指當 API 更新了一個版本後,原本的消費者不用做任何改動,也可以維持正常運作。常見導致不相容的原因包含欄位重新命名、新增或刪減欄位等,這些改動會迫使客戶端需要跟著改。

讓我們以先前在 E+ 主題文 《API 設計 — 好的 API 設計有什麼特點?》 提過的供應鏈來比喻,就像供應商提供的零件,需要有特定的規格,假如今天某個零件改規格,原本依照先前規格造車的廠商,就必須改設計,因此可能會導致廠商抱怨連連;API 一樣有規格,如果今天團隊串的 API,突然改規格,很可能導致原本串接的程式碼需要整段重寫。因此所謂的向後相容,就會像供應商在不變規格的狀況下,提供更高品質的零件。

向後相容對公開的 API 又會特別重要,所謂公開 API 是指讓外部使用者消費的 API,例如 OpenAI 的 Chat API,是全世界的開發者都可以串。當 API 是對外公開的,設計完成後,要改的成本就會很大,因為一個改動,可能導致大量的客戶端要改動;更有甚者,沒辦法確定所有消費該 API 的客戶端,都有收到修改的通知,所以如果有客戶端沒改到,很可能出現使用上的錯誤。

以供應鏈來比喻,假如今天你買了一台車,結果要維修時,修車廠跟你說上游供應商現在已經不生產這種零件了,現在都要用新規格的零件,假如你的車要換這種新規格零件,要先花大錢修改一番,不然新零件裝不上去。這時你聽到,肯定會對供應商有所怨言。

而 API 也是一樣的道理,如果沒有做到相容,改動 API 後導致客戶端無法消費,就很可能造成使用 API 的人的抱怨。舉例來說,假如你更改 API 的欄位 (例如更動欄位名稱、移除掉某個欄位),但是客戶端不知道,所以用了錯的或不存在的欄位,導致收到錯誤,然後必須大改才可以相容,這時客戶端肯定開心不起來。

如何做到向後相容?

要處理向後相容,一個最直觀的方式是做好版本管理。因為隨著產品功能調整、上游的改動,或者各類其他原因,API 不會是一成不變,而是會一直演進,而在演進的過程中,做好版本管理 (versioning),就能有效避免在改動後,先前的客戶端消費到不相容的 API。

舉例來說,原本第一版的 API 是帶有 /v1,這時如果有任何改動,可以有另一個 /v2 的版本。當有了這樣的標示,要改動就會變得容易許多,API 的消費者可以直接切換到下個版本,就能夠消費新版本的 API,而想繼續用原本版本的消費者,可以維持不動。

不過在多數的狀況下,API 的設計要做到客戶端連改都不用改,這樣對客戶端來說才是最友善的。只是如果真的非得要有不同的版本,該如何做好版本管理呢?

如何做版本管理?

我們可以把相容性看成一種光譜,光譜的兩端分別是客戶端完全不用改,以及客戶端需要跟著改。讓我們具體來看在這光譜上,不同的版本管理策略。

任何改動都發新的版本:只要有改動,就發新的版本,即使功能沒變也是。舉例來說,如果是效能優化,但是功能維持,這樣仍需要發一個新的版本,這做法的優缺點分別如下

  • 優點:穩定性極高,客戶端可以確保現在用的版本,在相容性上不會出問題。
  • 缺點:客戶端改動成本大,如果想要獲得新的功能,客戶端就必須跟著串接新的版本。

穩定與預覽版本:把 API 拆成兩種版本,一種是線上穩定版 (stable version),另一種是預覽版本 (preview version)。線上版本是唯一的版本,任何新功能都會先發到預覽版本,客戶端可以先在預覽版本測試新功能。當預覽版本穩定後,會把原本的線上版本棄用。

  • 優點:版本管理變很容易,只需用兩個版本即可,不用同時管多個版本。
  • 缺點:穩定性相對低,每當要棄用原本的線上版本,需要通知所有消費該 API 的客戶端,讓客戶端跟著改動,不然可能會出現不相容的問題。因此在這種做法下,API 的設計者需要特別花心思在向後相容上。

除了這兩種方式外,語意化版本也是常見的策略,我們在下一個段落會談到。

如何標示版本?

最直觀可能想到的,會是如前面提到的例子,會在 URL 加上 /v1/v2 來做區分;或者在請求參數帶上版本,例如在 URL 後面帶一個 ?version=v1。目前業界比較常見的會是帶在請求中,舉例來說 Stripe 的 API 版本會帶上日期標示 -H "Stripe-Version: 2024-06-20" 在請求的標頭中,OpenAI 的 API 則會在請求中帶上模型,例如 "model": "gpt-4o"

從上面可以看到,在標示版本時,會有不同的方式,Stripe 是以時間為標記,而 OpenAI 是以模型版本為標示,這種都是常見的做法。而另一中目前業界常見的管理方式是語意化版本 (semantic versioning),所謂的語意化版本,是會 major.minor.patch 來做版本標示。

具體來說,假如原本在 1.0.0 版本,同樣是發布新版本:

  • 如果是修復一個小 bug,則可以變成 1.0.1
  • 如果是新增一個相對大的新功能,但仍是向後相容的,則會是 1.1.0
  • 如果某個改動會導致不相容,那就會是 2.0.0

語意化版本可以很清楚讓消費 API 的使用者知道,如果是 minorpatch 的改動,不用去擔心相容性的問題,所以升版不需用去任何改動;同時因為細緻的區分,使用 API 的人可以更輕易判斷要不要升版。

小結

今天的主題文中,我們談了 API 的穩定性與相容性,這兩個都是在 API 設計上相當重要的概念。推薦讀者在設計 API 時,務必要把這兩個概念放在腦中。

當然,設計 API 時還有很多要照顧的細節,包含如何設計出符合使用者需求的 API、API 設計完後該如何透過文件化讓使用者可以更輕易使用該 API,這些主題我們將在下一期的主題文與讀者們一起探討。

💡 E+ 成長計畫深度主題文

本文為 E+ 成長計畫的深度主題文,開放免費閱讀。E+ 的訂閱讀者除了每週會收到一篇新內容外,也享有過去所有內容的閱讀權限。

如果你對於這類深度內容感興趣,歡迎加入 E+ 成長計畫,除了全系列所有完整深度文章外,也可觀看過去所有的直播回放。