阿里研究員谷樸:API 設計最佳實踐的思考
API 是軟體系統的核心,而軟體系統的複雜度 Complexity 是大規模軟體系統能否成功最重要的因素。但複雜度 Complexity 並非某一個單獨的問題能完全敗壞的,而是在系統設計尤其是 API 設計層面很多很多小的設計考量一點點疊加起來的(也即 John Ousterhout 老爺子說的 Complexity is incremental[8])。成功的系統不是有一些特別閃光的地方,而是設計時點點滴滴的努力積累起來的。
因此,這裡我們試圖思考並給出建議,一方面,什麼樣的 API 設計是好的設計?另一方面,在設計中如何能做到?
API 設計面臨的挑戰千差萬別,很難有處處適用的準則,所以在討論原則和最佳實踐時,無論這些原則和最佳實踐是什麼,一定有適應的場景和不適應的場景。因此我們在下面爭取不僅提出一些建議,也儘量去分析這些建議在什麼場景下適用,這樣我們也可以有針對性的採取例外的策略。
範圍
本文偏重於一般性的 API 設計,並更適用於遠端呼叫(RPC 或者 HTTP/RESTful 的 API),但是這裡沒有特別討論 RESTful API 特有的一些問題。
另外,本文在討論時,假定了客戶端直接和遠端服務端的 API 互動。在阿里,由於多種原因,透過客戶端的 SDK 來間接訪問遠端服務的情況更多一些。這裡並不討論 SDK 帶來的特殊問題,但是將 SDK 提供的方法看作遠端 API 的代理,這裡的討論仍然適用。
API 設計準則:什麼是好的 API
在這一部分,我們試圖總結一些好的 API 應該擁有的特性,或者說是設計的原則。這裡我們試圖總結更加基礎性的原則。所謂基礎性的原則,是那些如果我們很好的遵守了就可以讓 API 在之後演進的過程中避免多數設計問題的原則。
提供清晰的思維模型 (A good API provides a good mental model):API 是用於程式之間的互動,但是一個 API 如何被使用,以及 API 本身如何被維護,是依賴於維護者和使用者能夠對該 API 有清晰的、一致的認識。這種狀況實際上是不容易達到的;
簡單 (A good API is simple):“Make things as simple as possible, but no simpler.” 在實際的系統中,尤其是考慮到系統隨著需求的增加不斷地演化,我們絕大多數情況下見到的問題都是過於複雜的設計,而非過於簡單,因此強調簡單性一般是恰當的;
容許多個實現 (A good API allows multiple implementations):這個原則看上去更具體,但是這是我非常喜歡的一個原則。這是 Sanjay Ghemawat 常常提到的一個原則。一般來說,在討論 API 設計時常常被提到的原則是解耦性原則或者說松耦合原則。然而相比於松耦合原則,這個原則更加有可操作性:如果一個 API 自身可以有多個完全不同的實現,一般來說這個 API 已經有了足夠好的抽象,和自身的某一個具體實現無關,那麼一般也不會出現和外部系統耦合過緊的問題。因此這個原則更本質一些。
最佳實踐
本部分則試圖討論一些更加詳細、具體的建議,可以讓 API 的設計更容易滿足前面描述的基礎原則。
想想優秀的 API 例子:POSIX File API
如果說 API 的設計實踐只能列一條的話,那麼可能最有幫助的和最可操作的就是這一條。本文也可以叫做“透過 File API 體會 API 設計的最佳實踐”。
所以整個最佳實踐可以總結為一句話:“想想 File API 是怎麼設計的。”
首先回顧一下 File API 的主要介面(以 C 為例,很多是 Posix API,選用比較簡單的 I/O 介面為例[1]:
int open(const char *path, int oflag, .../*,mode_t mode */);
int close (int filedes);
int remove( const char *fname );
ssize_t write(int fildes, const void *buf, size_t nbyte);
ssize_t read(int fildes, void *buf, size_t nbyte);
File API 為什麼是經典的好 API 設計?
File API 已經有幾十年歷史(從 1988 年算起將近 40 年),儘管期間硬體軟體系統的發展經歷了好幾代,這套 API 核心保持了穩定。這是極其了不起的。
API 提供了非常清晰的概念模型,每個人都能夠很快理解這套 API 背後的基礎概念:什麼是檔案,以及相關聯的操作(open, close, read, write),清晰明瞭;
支援很多的不同檔案系統實現,這些系統實現甚至於屬於型別非常不同的裝置,例如磁碟、塊裝置、管道(pipe)、共享記憶體、網路、終端 terminal 等等。這些裝置有的是隨機訪問的,有的只支援順序訪問;有的是持久化的有的則不是。然而所有不同的裝置不同的檔案系統實現都可以採用了同樣的介面,使得上層系統不必關注底層實現的不同,這是這套 API 強大的生命力的表現。
例如同樣是開啟檔案的介面,底層實現完全不同,但是透過完全一樣的介面,不同的路徑以及 Mount 機制,實現了同時支援。其他還有 Procfs, pipe 等。
int open(const char *path, int oflag, .../*,mode_t mode */);
例如這裡的 cephfs 和本地檔案系統,底層對應完全不同的實現,但是上層 client 可以不用區分對待,採用同樣的介面來操作,只透過路徑不同來區分。
基於上面的這些原因,我們知道 File API 為什麼能夠如此成功。事實上,它是如此的成功以至於今天的 *-nix 作業系統,everything is filed based。
儘管我們有了一個非常好的例子 File API,但是要設計一個能夠長期保持穩定的 API 是一項及其困難的事情,因此僅有一個好的參考還不夠,下面再試圖展開去討論一些更細節的問題。
Document well 寫詳細的文件
寫詳細的文件,並保持更新。 關於這一點,其實無需贅述,現實是,很多 API 的設計和維護者不重視文件的工作。
在一個面向服務化 /Micro-service 化架構的今天,一個應用依賴大量的服務,而每個服務 API 又在不斷的演進過程中,準確的記錄每個欄位和每個方法,並且保持更新,對於減少客戶端的開發踩坑、減少出問題的機率,提升整體的研發效率至關重要。
Carefully define the "resource" of your API 仔細的定義“資源”
如果適合的話,選用“資源”加操作的方式來定義。今天很多的 API 都可以採用這樣一個抽象的模式來定義,這種模式有很多好處,也適合於 HTTP 的 RESTful API 的設計。但是在設計API時,一個重要的前提是對 Resource 本身進行合理的定義。什麼樣的定義是合理的?Resource 資源本身是對一套 API 操作核心物件的一個抽象 Abstraction。
抽象的過程是去除細節的過程。在我們做設計時,如果現實世界的流程或者操作物件是具體化的,抽象的 Object 的選擇可能不那麼困難,但是對於哪些細節應該包括,是需要很多思考的。例如對於檔案的 API,可以看出,檔案 File 這個 Resource(資源)的抽象,是“可以由一個字串唯一標識的資料記錄”。這個定義去除了檔案是如何標識的(這個問題留給了各個檔案系統的具體實現),也去除了關於如何儲存的組織結構(again,留給了儲存系統)細節。
雖然我們希望 API 簡單,但是更重要的是選擇對的實體來建模。在底層系統設計中,我們傾向於更簡單的抽象設計。有的系統裡面,域模型本身的設計往往不會這麼簡單,需要更細緻的考慮如何定義 Resource。一般來說,域模型中的概念抽象,如果能和現實中人們的體驗接近,會有利於人們理解該模型。選擇對的實體來建模往往是關鍵。結合域模型的設計,可以參考相關的文章,例如阿白老師的文章[2]。
Choose the right level of abstraction 選擇合適的抽象層
與前面的一個問題密切相關的,是在定義物件時需要選擇合適的 Level of abstraction(抽象的層級)。不同概念之間往往相互關聯。仍然以 File API 為例。在設計這樣的 API 時,選擇抽象的層級的可能的選項有多個,例如:
文字、影像混合物件
“資料塊” 抽象
“檔案”抽象
這些不同的層級的抽象方式,可能描述的是同一個東西,但是在概念上是不同層面的選擇。當設計一個 API 用於與資料訪問的客戶端互動時,“檔案 File” 是更合適的抽象,而設計一個 API 用於檔案系統內部或者裝置驅動時,資料塊或者資料塊裝置可能是合適的抽象,當設計一個文件編輯工具時,可能會用到“文字影像混合物件”這樣的檔案抽象層級。
又例如,資料庫相關的 API 定義,底層的抽象可能針對的是資料的儲存結構,中間是資料庫邏輯層需要定義資料互動的各種物件和協議,而在展示(View layer)的時候需要的抽象又有不同[3]。
Prefer using different model for different layers 不同層建議採用不同的資料模型
這一條與前一條密切關聯,但是強調的是不同層之間模型不同。
在服務化的架構下,資料物件在處理的過程中往往經歷多層,例如上面的 View-Logic model-Storage 是典型的分層結構。在這裡我們的建議是不同的 Layer 採用不同的資料結構。John Ousterhout [8] 書裡面則更直接強調:Different layer, different abstraction。
例如網路系統的 7 層模型,每一層有自己的協議和抽象,是個典型的例子。而前面的檔案 API,則是一個 Logic layer 的模型,而不同的檔案儲存實現(檔案系統實現),則採用各自獨立的模型(如快裝置、記憶體檔案系統、磁碟檔案系統等各自有自己的儲存實現 API)。
當 API 設計傾向於不同的層採用一樣的模型的時候(例如一個系統使用後段儲存服務與自身提供的模型之間,見下圖),可能意味著這個 Service 本身的職責沒有定義清楚,是否功能其實應該下沉?
不同的層採用同樣的資料結構帶來的問題還在於 API 的演進和維護過程。一個系統演進過程中可能需要替換掉後端的儲存,可能因為效能最佳化的關係需要分離快取等需求,這時會發現將兩個層的資料繫結一起(甚至有時候直接把前端的 json 儲存在後端),會帶來不必要的耦合而阻礙演進。
當 API 定義了一個資源物件,下面一般需要的是提供命名/標識(Naming and identification)。在 naming/ID 方面,一般有兩個選擇(不是指系統內部的 ID,而是會暴露給使用者的):
用 free-form string 作為 ID(string nameAsId)
用結構化資料表達 naming/ID
何時選擇哪個方法,需要具體分析。採用 Free-form string 的方式定義的命名,為系統的具體實現留下了最大的自由度。帶來的問題是命名的內在結構(如路徑)本身並非 API 強制定義的一部分,轉為變成實現細節。如果命名本身存在結構,客戶端需要有提取結構資訊的邏輯。這是一個需要做的平衡。
例如檔案 API 採用了 free-form string 作為檔名的標識方式,而檔案的 URL 則是檔案系統具體實現規定。這樣,就容許 Windows 作業系統採用"D:\Documents\File.jpg"
而Linux採用"/etc/init.d/file.conf"
這樣的結構了。而如果檔案命名的資料結構定義為
{
disk: string,
path: string
}
這樣結構化的方式,透出了"disk"
和"path"
兩個部分的結構化資料,那麼這樣的結構可能適應於 Windows 的檔案組織方式,而不適應於其他檔案系統,也就是說洩漏了實現細節。
如果資源 Resource 物件的抽象模型自然包含結構化的標識資訊,則採用結構化方式會簡化客戶端與之互動的邏輯,強化概念模型。這時犧牲掉標識的靈活度,換取其他方面的優勢。例如,銀行的轉賬賬號設計,可以表達為:
{
account: number
routing: number
}
這樣一個結構化標識,由賬號和銀行間標識兩部分組成,這樣的設計含有一定的業務邏輯在內,但是這部分業務邏輯是被描述的系統內在邏輯而非實現細節,並且這樣的設計可能有助於具體實現的簡化以及避免一些非結構化的字串標識帶來的安全性問題等。因此在這裡結構化的標識可能更適合。
另一個相關的問題是,何時應該提供一個數字 unique ID? 這是一個經常遇到的問題。有幾個問題與之相關需要考慮:
是否已經有結構化或者字串的標識可以唯一、穩定標識物件?如果已經有了,那麼就不一定需要 numerical ID;
64 位整數範圍夠用嗎?
數字 ID 可能不是那麼使用者友好,對於使用者來講數字的 ID 會有幫助嗎?
如果這些問題都有答案而且不是什麼阻礙,那麼使用數字 ID 是可以的,否則要慎用數字 ID。
Conceptually what are the meaningful operations on this resource? 對於該物件來說,什麼操作概念上是合理的?
在確定下來了資源/物件以後,我們還需要定義哪些操作需要支援。這時,考慮的重點是“概念上合理(Conceptually reasonable)”。換句話說,operation + resource
連在一起聽起來自然而然合理(如果 Resource 本身命名也比較準確的話。當然這個“如果命名準確”是個 big if,非常不容易做到)。操作並不總是 CRUD(create, read, update, delete)。
例如,一個 API 的操作物件是額度(Quota),那麼下面的操作聽上去就比較自然:
Update quota
(更新額度),transfer quota
(原子化的轉移額度)
但是如果試圖Create Quota
,聽上去就不那麼自然,因額度這樣一個概念似乎表達了一個數量,概念上不需要建立。額外需要思考一下,這個物件是否真的需要建立?我們真正需要做的是什麼?
For update operations, prefer idempotency whenever feasible 更新操作,儘量保持冪等性
Idempotency 冪等性,指的是一種操作具備的性質,具有這種性質的操作可以被多次實施並且不會影響到初次實施的結果“the property of certain operations in mathematics and computer science whereby they can be applied multiple times without changing the result beyond the initial application.”[3]
很明顯 Idempotency 在系統設計中會帶來很多便利性,例如客戶端可以更安全的重試,從而讓複雜的流程實現更為簡單。但是 Idempotency 實現並不總是很容易。
Create 型別的idempotency
建立的 Idempotency,多次呼叫容易出現重複建立,為實現冪等性,常見的做法是使用一個client-side generated de-deduplication token(客戶端生成的唯一 ID),在反覆重試時使用同一個 Unique ID,便於服務端識別重複。
Update 型別的 Idempotency
更新值(update)型別的 API,應該避免採用“Delta”語義,以便於實現冪等性。對於更新類的操作,我們再簡化為兩類實現方式:Incremental
(數量增減),如IncrementBy(3)
這樣的語義SetNewTotal
(設定新的總量)
IncrementBy
這樣的語義重試的時候難以避免出錯,而SetNewTotal(3)
(總量設定為x)語義則比較容易具備冪等性。當然在這個例子裡面,也需要看到,IncrementBy
也有有點,即多個客戶請求同時增加的時候,比較容易並行處理,而SetTotal
可能導致並行的更新相互覆蓋(或者相互阻塞)。這裡,可以認為更新增量和設定新的總量這兩種語義是不同的優缺點,需要根據場景來解決。如果必須優先考慮併發更新的情景,可以使用更新增量的語義,並輔助以 Deduplication token 解決冪等性。Delete 型別 idempotency:Delete 的冪等性問題,往往在於一個物件被刪除後,再次試圖刪除可能會由於資料無法被發現導致出錯。這個行為一般來說也沒什麼問題,雖然嚴格意義上不冪等,但是也無副作用。如果需要實現 Idempotency,系統也採用了 Archive->Purge 生命週期的方式分步刪除,或者持久化 Purge log 的方式,都能支援冪等刪除的實現。
Compatibility 相容
API 的變更需要相容,相容,相容!重要的事情說三遍。這裡的相容指的是向後相容,而相容的定義是不會 Break 客戶端的使用,也即老的客戶端能否正常訪問服務端的新版本(如果是同一個大版本下)不會有錯誤的行為。這一點對於遠端的 API(HTTP/RPC)尤其重要。關於相容性,已經有很好的總結,例如[4] 提供的一些建議。
常見的不相容變化包括(但不限於)
刪除一個方法、欄位或者 enum 的數值;
方法、欄位改名;
方法名稱欄位不改,但是語義和行為的變化,也是不相容的。這類比較容易被忽視。更具體描述可以參加[4]。
另一個關於相容性的重要問題是,如何做不相容的 API 變更?通常來說,不相容變更需要透過一個 Deprecation process,在大版本釋出時來分步驟實現。關於 Deprecation process,這裡不展開描述,一般來說,需要保持過去版本的相容性的前提下,支援新老欄位/方法/語義,並給客戶端足夠的升級時間。這樣的過程比較耗時,也正是因為如此,我們才需要如此重視 API 的設計。
有時,一個面向內部的 API 升級,往往開發的同學傾向於選擇高效率,採用一種叫“同步釋出”的模式來做不相容變更,即通知已知的所有的客戶端,自己的服務 API 要做一個不相容變更,大家一起釋出,同時更新,切換到新的介面。這樣的方法是非常不可取的,原因有幾個:
我們經常並不知道所有使用 API 的客戶;
釋出過程需要時間,無法真正實現“同步更新”;
不考慮向後相容性的模式,一旦新的 API 有問題需要回滾,則會非常麻煩,這樣的計劃八成也不會有回滾方案,而且客戶端未必都能跟著回滾。
因此,對於在生產叢集已經得到應用的 API,強烈不建議採用“同步升級”的模式來處理不相容 API 變更。
Batch mutations 批次更新
批次更新如何設計是另一個常見的 API 設計決策。這裡我們常見有兩種模式:
客戶端批次更新,或者
服務端實現批次更新。
如下圖所示:
API 的設計者可能會希望實現一個服務端的批次更新能力,但是我們建議要儘量避免這樣做。除非對於客戶來說提供原子化+事務性的批次很有意義(all-or-nothing),否則實現服務端的批次更新有諸多的弊端,而客戶端批次更新則有優勢:
服務端批次更新帶來了 API 語義和實現上的複雜度。例如當部分更新成功時的語義、狀態表達等
即使我們希望支援批次事物,也要考慮到是否不同的後端實現都能支援事務性
批次更新往往給服務端效能帶來很大挑戰,也容易被客戶端濫用介面
在客戶端實現批次,可以更好的將負載由不同的服務端來承擔(見圖)
客戶端批次可以更靈活的由客戶端決定失敗重試策略
Be aware of the risks in full replace 警惕全體替換更新模式的風險
所謂 Full replacement 更新,是指在 Mutation API 中,用一個全新的 Object/Resource 去替換老的 Object/Resource 的模式。API 寫出來大概是這樣的:
UpdateFoo(Foo newFoo);
這是非常常見的 Mutation 設計模式。但是這樣的模式有一些潛在的風險作為 API 設計者必須瞭解。使用 Full replacement 的時候,更新物件Foo
在服務端可能已經有了新的成員,而客戶端尚未更新並不知道該新成員。服務端增加一個新的成員一般來說是相容的變更,但是,如果該成員之前被另一個知道這個成員的 client 設定了值,而這時一個不知道這個成員的 client 來做 full-replace,該成員可能就會被覆蓋。
更安全的更新方式是採用 Update mask,也即在 API 設計中引入明確的引數指明哪些成員應該被更新。
UpdateFoo {
Foo newFoo;
boolen update_field1; // update mask
boolen update_field2; // update mask
}
或者 update mask 可以用repeated "a.b.c.d“
這樣方式來表達。
不過由於這樣的 API 方式維護和程式碼實現都複雜一些,採用這樣模式的 API 並不多。所以,本節的標題是“be aware of the risk”,而不是要求一定要用 update mask。
Don't create your own error codes or error mechanism 不要試圖建立自己的錯誤碼和返回錯誤機制
API 的設計者有時很想建立自己的 Error code,或者是表達返回錯誤的不同機制,因為每個 API 都有很多的細節的資訊,設計者想表達出來並返回給使用者,想著“使用者可能會用到”。但是事實上,這麼做經常只會使 API 變得更復雜更難用。
Error-handling 是使用者使用 API 非常重要的部分。為了讓使用者更容易的使用 API,最佳的實踐應該是用標準、統一的 Error Code,而不是每個 API 自己去創立一套。例如 HTTP 有規範的error code [7],Google Could API 設計時都採用統一的 Error code 等[5]。
為什麼不建議自己建立 Error code 機制?
Error-handling 是客戶端的事,而對於客戶端來說,是很難關注到那麼多錯誤的細節的,一般來說最多分兩三種情況處理。往往客戶端最關心的是“這個 error 是否應該重試(retryable)”還是應該繼續向上層返回錯誤,而不是試圖區分不同的 error 細節。這時多樣的錯誤程式碼機制只會讓處理變得複雜
有人覺得提供更多的自定義的 error code 有助於傳遞資訊,但是這些資訊除非有系統分別處理才有意義。如果只是傳遞資訊的話,error message 裡面的欄位可以達到同樣的效果。
More
更多的 Design patterns,可以參考[5] Google Cloud API guide,[6] Microsoft API design best practices 等。不少這裡提到的問題也在這些參考的文件裡面有涉及,另外他們還討論到了像 versioning,pagination,filter 等常見的設計規範方面考慮。這裡不再重複。
參考文獻
[1]https://en.wikipedia.org/wiki/Computer_file
[2]
[3]
[4]
[5]
[6]
[7]
[8]A philosophy of software design, John Ousterhout
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31555606/viewspace-2286819/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 阿里研究員谷樸:警惕軟體複雜度困局阿里複雜度
- RESTful API 設計指南——最佳實踐RESTAPI
- 13 個設計 REST API 的最佳實踐RESTAPI
- 設計出色API的最佳實踐與原則 - JamesAPI
- Java的API設計實踐JavaAPI
- restful api最佳實踐RESTAPI
- RESTful API 最佳實踐RESTAPI
- 來自Google資深工程師的API設計最佳實踐Go工程師API
- 設計微服務的最佳實踐微服務
- jQuery程式設計的最佳實踐jQuery程式設計
- go 程式設計師的最佳實踐Go程式設計師
- 綜合Twitter、Github等各大網站API設計經驗:RESTful API實用設計與最佳實踐 - Vinay SahniGithub網站APIREST
- react 設計模式與最佳實踐React設計模式
- MaxCompute表設計最佳實踐
- Android 路由設計最佳實踐Android路由
- 非同步程式設計最佳實踐非同步程式設計
- C 程式設計最佳實踐(轉)程式設計
- RESTful API 設計思路及實踐RESTAPI
- 7個API安全最佳實踐API
- 使用Android API最佳實踐AndroidAPI
- Swift API 設計思考題SwiftAPI
- 最佳實踐1:儘量以對瀏覽器友好的方式設計REST API瀏覽器RESTAPI
- 關卡設計實踐經驗總結:一種開展設計實踐的思考框架框架
- PHP安全程式設計最佳實踐PHP程式設計
- C++程式設計最佳實踐(轉)C++程式設計
- Django RESTful API設計與實踐指南DjangoRESTAPI
- Java程式設計師的八個最佳實踐Java程式設計師
- 資料庫設計的十個最佳實踐資料庫
- 20個資料庫設計的最佳實踐資料庫
- 阿里雲RDS PG最佳實踐阿里
- 我所認為的RESTful API最佳實踐RESTAPI
- 使用Golang建立RESTful API的最佳實踐案例GolangRESTAPI
- 轉享:真實世界的JavaEE模式-重新思考最佳實踐Java模式
- 函數語言程式設計最佳實踐函數程式設計
- Laravel最佳實踐–事件驅動程式設計Laravel事件程式設計
- vSAN 設計、部署、運維最佳實踐運維
- Laravel 最佳實踐 -- 事件驅動程式設計Laravel事件程式設計
- Laravel最佳實踐 -- 事件驅動程式設計Laravel事件程式設計