1. 程式人生 > >阿里研究員谷樸:API 設計最佳實踐的思考

阿里研究員谷樸: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有清晰的、一致的認識。這種狀況實際上是不容易達到的。
  • __簡單 is simple__:“Make things as simple as possible, but no simpler.” 在實際的系統中,尤其是考慮到系統隨著需求的增加不斷的演化,我們絕大多數情況下見到的問題都是__過於複雜__的設計,而非過於簡單,因此強調簡單性一般是恰當的。
  • __容許多個實現 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】。

001

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儲存在後端),會帶來不必要的耦合而阻礙演進。

002

Naming and identification of the resource 命名與標識

當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設計決策。這裡我們常見有兩種模式:

  • 客戶端批量更新,或者
  • 服務端實現批量更新。

    如下圖所示。
    

003

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】File wiki https://en.wikipedia.org/wiki/Computer_file
【2】阿白,域模型設計系列文章,https://yq.aliyun.com/articles/6383
【3】Idempotency, wiki https://en.wikipedia.org/wiki/Idempotence
【4】Compatibility https://cloud.google.com/apis/design/compatibility
【5】API Design patterns for Google Cloud, https://cloud.google.com/apis/design/design_patterns
【6】API design best practices, Microsoft https://docs.microsoft.com/en-us/azure/architecture/best-practices/api-design
【7】Http status code https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
【8】A philosophy of software design, John Ousterhout

004