雪花臺灣

阿里研究員谷樸: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

最佳實踐

本部分則試圖討論一些更加詳細、具體的建議,可以讓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設計?

例如同樣是打開文件的介面,底層實現完全不同,但是通過完全一樣的介面,不同的路徑以及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存儲在後端),會帶來不必要的耦合而阻礙演進。

Naming and identification of the resource 命名與標識

當API定義了一個資源對象,下面一般需要的是提供命名/標識(Naming and identification)。在naming/ID方面,一般有兩個選擇(不是指系統內部的ID,而是會暴露給用戶的):

何時選擇哪個方法,需要具體分析。採用Free-form string的方式定義的命名,為系統的具體實現留下了最大的自由度。帶來的問題是命名的內在結構(如路徑)本身並非API強制定義的一部分,轉為變成實現細節。如果命名本身存在結構,客戶端需要有提取結構信息的邏輯。這是一個需要做的平衡。

例如文件API採用了free-form string作為文件名的標識方式,而文件的URL則是文件系統具體實現規定。這樣,就容許Windows操作系統採用"D:DocumentsFile.jpg"而Linux採用"/etc/init.d/file.conf"這樣的結構了。而如果文件命名的數據結構定義為

{
disk: string,
path: string
}

這樣結構化的方式,透出了"disk""path"兩個部分的結構化數據,那麼這樣的結構可能適應於Windows的文件組織方式,而不適應於其他文件系統,也就是說泄漏了實現細節。

如果資源Resource對象的抽象模型自然包含結構化的標識信息,則採用結構化方式會簡化客戶端與之交互的邏輯,強化概念模型。這時犧牲掉標識的靈活度,換取其他方面的優勢。例如,銀行的轉賬賬號設計,可以表達為

{
account: number
routing: number
}

這樣一個結構化標識,由賬號和銀行間標識兩部分組成,這樣的設計含有一定的業務邏輯在內,但是這部分業務邏輯是被描述的系統內在邏輯而非實現細節,並且這樣的設計可能有助於具體實現的簡化以及避免一些非結構化的字元串標識帶來的安全性問題等。因此在這裡結構化的標識可能更適合。

另一個相關的問題是,何時應該提供一個數字unique 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),那麼下面的操作聽上去就比較自然:

但是如果試圖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實現並不總是很容易。

IncrementBy 這樣的語義重試的時候難以避免出錯,而SetNewTotal(3)(總量設置為x)語義則比較容易具備冪等性。

當然在這個例子裡面,也需要看到,IncrementBy也有有點,即多個客戶請求同時增加的時候,比較容易並行處理,而SetTotal可能導致並行的更新相互覆蓋(或者相互阻塞)。這裡,可以認為更新增量設置新的總量這兩種語義是不同的優缺點,需要根據場景來解決。如果必須優先考慮並發更新的情景,可以使用更新增量的語義,並輔助以Deduplication token解決冪等性。

Compatibility 兼容

API的變更需要兼容,兼容,兼容!重要的事情說三遍。這裡的兼容指的是向後兼容,而兼容的定義是不會Break客戶端的使用,也即老的客戶端能否正常訪問服務端的新版本(如果是同一個大版本下)不會有錯誤的行為。這一點對於遠程的API(HTTP/RPC)尤其重要。關於兼容性,已經有很好的總結,例如【4】提供的一些建議。

常見的不兼容變化包括(但不限於)

另一個關於兼容性的重要問題是,如何做不兼容的API變更?通常來說,不兼容變更需要通過一個Deprecation process,在大版本發布時來分步驟實現。關於Deprecation process,這裡不展開描述,一般來說,需要保持過去版本的兼容性的前提下,支持新老欄位/方法/語義,並給客戶端足夠的升級時間。這樣的過程比較耗時,也正是因為如此,我們才需要如此重視API的設計。

有時,一個面向內部的API升級,往往開發的同學傾向於選擇高效率,採用一種叫」同步發布「的模式來做不兼容變更,即通知已知的所有的客戶端,自己的服務API要做一個不兼容變更,大家一起發布,同時更新,切換到新的介面。這樣的方法是非常不可取的,原因有幾個:

因此,對於在生產集羣已經得到應用的API,強烈不建議採用「同步升級」的模式來處理不兼容API變更。

Batch mutations 批量更新

批量更新如何設計是另一個常見的API設計決策。這裡我們常見有兩種模式:

API的設計者可能會希望實現一個服務端的批量更新能力,但是我們建議要盡量避免這樣做。除非對於客戶來說提供原子化+事務性的批量很有意義(all-or-nothing),否則實現服務端的批量更新有諸多的弊端,而客戶端批量更新則有優勢:

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。

Dont 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機制?

More

更多的Design patterns,可以參考[5] Google Cloud API guide,[6] Microsoft API design best practices等。不少這裡提到的問題也在這些參考的文檔裡面有涉及,另外他們還討論到了像versioning,pagination,filter等常見的設計規範方面考慮。這裡不再重複。

參考文獻

【1】File wiki en.wikipedia.org/wiki/C

【2】阿白,域模型設計系列文章,yq.aliyun.com/articles/【3】Idempotency, wiki en.wikipedia.org/wiki/I【4】Compatibility cloud.google.com/apis/d【5】API Design patterns for Google Cloud, cloud.google.com/apis/d【6】API design best practices, Microsoft docs.microsoft.com/en-u【7】Http status code en.wikipedia.org/wiki/L【8】A philosophy of software design, John Ousterhout

本文作者:jessie筱姜

原文鏈接

更多技術乾貨敬請關注云棲社區知乎機構號:阿里云云棲社區 - 知乎

本文為雲棲社區原創內容,未經允許不得轉載。


推薦閱讀:
相關文章