API 是模塊或者子系統之間交互的接口定義。好的系統架構離不開好的 API 設計,而一個設計不夠完善的 API 則註定會致使系統的後續發展和維護很是困難。程序員
接下來,阿里巴巴研究員谷樸將給出建議,什麼樣的 API 設計是好的設計?好的設計該如何作?數據庫
做者簡介:張瓅玶 (谷樸),阿里巴巴研究員,負責阿里雲容器平臺集羣管理團隊。本科和博士畢業於清華大學。後端
API 設計面臨的挑戰千差萬別,很難有到處適用的準則,因此在討論原則和最佳實踐時,不管這些原則和最佳實踐是什麼,必定有適應的場景和不適應的場景。所以咱們在下文中不只提出一些建議,也儘可能去分析這些建議在什麼場景下適用,這樣咱們也能夠有針對性地採起例外的策略。設計模式
爲何去討論這些問題? API 是軟件系統的核心,而軟件系統的複雜度 Complexity 是大規模軟件系統可否成功最重要的因素。但複雜度 Complexity 並不是某一個單獨的問題能徹底敗壞的,而是在系統設計尤爲是API設計層面不少不少小的設計考量一點點疊加起來的(John Ousterhout老爺子說的Complexity is incremental【8】)。數組
成功的系統不是有一些特別閃光的地方,而是設計時點點滴滴的努力積累起來的。緩存
本文偏重於通常性的API設計,並更適用於遠程調用(RPC或者HTTP/RESTful的API),可是這裏沒有特別討論RESTful API特有的一些問題。安全
另外,本文在討論時,假定了客戶端直接和遠程服務端的API交互。在阿里,因爲多種緣由,經過客戶端的 SDK 來間接訪問遠程服務的狀況更多一些。這裏並不討論 SDK 帶來的特殊問題,可是將 SDK 提供的方法看做遠程 API 的代理,這裏的討論仍然適用。網絡
在這一部分,咱們試圖總結一些好的 API 應該擁有的特性,或者說是設計的原則。這裏咱們試圖總結更加基礎性的原則。所謂基礎性的原則,是那些若是咱們很好地遵照了就可讓 API 在以後演進的過程當中避免多數設計問題的原則。數據結構
提供清晰的思惟模型 provides a good mental model架構
爲何這一點重要?由於 API 的設計自己最關鍵的難題並非讓客戶端與服務端軟件之間如何交互,而是設計者、維護者、API使用者這幾個程序員羣體之間在 API 生命週期內的互動。一個 API 如何被使用,以及API自己如何被維護,是依賴於維護者和使用者可以對該 API 有清晰的、一致的認識。這很是依賴於設計者提供了一個清晰易於理解的模型。這種情況其實是不容易達到的。
就像下圖所示,設計者心中有一個模型,而使用者看到和理解的模型多是另外一個模式,這個模式若是比較複雜的話,使用者使用的方式又可能與本身理解的不徹底一致。 對於維護者來講,問題是相似的。
而好的 API 讓維護者和使用者可以很容易理解到設計時要傳達的模型。帶來理解、調試、測試、代碼擴展和系統維護性的提高 。
簡單 is simple
「Make things as simple as possible, but no simpler.」 在實際的系統中,尤爲是考慮到系統隨着需求的增長不斷地演化,咱們絕大多數狀況下見到的問題都是過於複雜的設計,在 API 中引入了過多的實現細節(見下一條),同時也有很多的例子是Oversimplification 引發的,一些不應被合併的改變合併了,致使設計很不合理。
過於簡單化的例子:過去曾經見過一個系統,將一個用戶的資源帳戶模型的 account balance 和 transactions 都簡化爲用 transactions 一個模型來表達,邏輯在於 account balance 能夠由歷史的 transactions 累計獲得。可是這樣的過於簡化的模型設計帶來了不少的問題,尤爲在引入分期付款、預定交易等概念以後,暴露了不少複雜的邏輯給一些只須要獲取簡單信息的客戶端(如計算這個用戶是否還有足夠的餘額交易變得和不少業務邏輯耦合),屬於典型的模型過分簡化帶來的設計複雜度上升的案例。
允許多個實現 allows multiple implementations
這個原則看上去更具體,也是我很是喜歡的一個原則。Sanjay Ghemawat 經常提到該原則。通常來講,在討論 API 設計時經常被提到的原則是解耦性原則或者說鬆耦合原則。然而相比於鬆耦合原則,這個原則更加有可覈實性:若是一個 API 自身能夠有多個徹底不一樣的實現,通常來講這個API已經有了足夠好的抽象,那麼通常也不會出現和外部系統耦合過緊的問題。所以這個原則更本質一些。
舉個例子,好比咱們已經有一個簡單的 API
QueryOrderResponse queryOrder(string orderQuery)
可是有場景需求但願老是讀取到最新更新數據,不接受緩存,因而工程師考慮。
QueryOrderResponse queryOrder(string orderQuery, boolean useCache)
增長一個字段 useCache 來判斷如何處理這樣的請求。
這樣的改法看上去合理,但實際上泄漏了後端實現的細節(後端採用了緩存),後續若是採用一個新的不帶緩存的後端存儲實現,再支持這個 useCache 的字段就很尷尬了。
在工程中,這樣的問題能夠用不一樣的服務實例來解決,經過不一樣訪問的 endpoint 配置來區分。
本部分則試圖討論一些更加詳細、具體的建議,可讓 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】。
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。
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 機制?
更多的Design patterns,能夠參考[5] Google Cloud API guide,[6] Microsoft API design best practices等。很多這裏提到的問題也在這些參考的文檔裏面有涉及,另外他們還討論到了像versioning,pagination,filter等常見的設計規範方面考慮。這裏再也不重複。
原文連接 本文爲雲棲社區原創內容,未經容許不得轉載。