
使用NestJS和Prisma構建REST 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 的設計實踐只能列一條的話,那么可能最有幫助的和最可操作的就是這一條。本文也可以叫做“通過 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 是一項及其困難的事情,因此僅有一個好的參考還不夠,下面再試圖展開去討論一些更細節的問題。
寫詳細的文檔,并保持更新。 關于這一點,其實無需贅述,現實是,很多 API 的設計和維護者不重視文檔的工作。
在一個面向服務化 /Micro-service 化架構的今天,一個應用依賴大量的服務,而每個服務 API 又在不斷的演進過程中,準確的記錄每個字段和每個方法,并且保持更新,對于減少客戶端的開發踩坑、減少出問題的幾率,提升整體的研發效率至關重要。
如果適合的話,選用“資源”加操作的方式來定義。今天很多的 API 都可以采用這樣一個抽象的模式來定義,這種模式有很多好處,也適合于 HTTP 的 RESTful API 的設計。但是在設計API時,一個重要的前提是對 Resource 本身進行合理的定義。什么樣的定義是合理的?Resource 資源本身是對一套 API 操作核心對象的一個抽象 Abstraction。
抽象的過程是去除細節的過程。在我們做設計時,如果現實世界的流程或者操作對象是具體化的,抽象的 Object 的選擇可能不那么困難,但是對于哪些細節應該包括,是需要很多思考的。例如對于文件的 API,可以看出,文件 File 這個 Resource(資源)的抽象,是“可以由一個字符串唯一標識的數據記錄”。這個定義去除了文件是如何標識的(這個問題留給了各個文件系統的具體實現),也去除了關于如何存儲的組織結構(again,留給了存儲系統)細節。
雖然我們希望 API 簡單,但是更重要的是選擇對的實體來建模。在底層系統設計中,我們傾向于更簡單的抽象設計。有的系統里面,域模型本身的設計往往不會這么簡單,需要更細致的考慮如何定義 Resource。一般來說,域模型中的概念抽象,如果能和現實中人們的體驗接近,會有利于人們理解該模型。選擇對的實體來建模往往是關鍵。結合域模型的設計,可以參考相關的文章,例如阿白老師的文章[2]。
與前面的一個問題密切相關的,是在定義對象時需要選擇合適的 Level of abstraction(抽象的層級)。不同概念之間往往相互關聯。仍然以 File API 為例。在設計這樣的 API 時,選擇抽象的層級的可能的選項有多個,例如:
這些不同的層級的抽象方式,可能描述的是同一個東西,但是在概念上是不同層面的選擇。當設計一個 API 用于與數據訪問的客戶端交互時,“文件 File” 是更合適的抽象,而設計一個 API 用于文件系統內部或者設備驅動時,數據塊或者數據塊設備可能是合適的抽象,當設計一個文檔編輯工具時,可能會用到“文本圖像混合對象”這樣的文件抽象層級。
又例如,數據庫相關的 API 定義,底層的抽象可能針對的是數據的存儲結構,中間是數據庫邏輯層需要定義數據交互的各種對象和協議,而在展示(View layer)的時候需要的抽象又有不同[3]。
這一條與前一條密切關聯,但是強調的是不同層之間模型不同。
在服務化的架構下,數據對象在處理的過程中往往經歷多層,例如上面的 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 的方式定義的命名,為系統的具體實現留下了最大的自由度。帶來的問題是命名的內在結構(如路徑)本身并非 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? 這是一個經常遇到的問題。有幾個問題與之相關需要考慮:
如果這些問題都有答案而且不是什么阻礙,那么使用數字 ID 是可以的,否則要慎用數字 ID。
在確定下來了資源/對象以后,我們還需要定義哪些操作需要支持。這時,考慮的重點是“概念上合理(Conceptually reasonable)”。換句話說,operation + resource
連在一起聽起來自然而然合理(如果 Resource 本身命名也比較準確的話。當然這個“如果命名準確”是個 big if,非常不容易做到)。操作并不總是 CRUD(create, read, update, delete)。
例如,一個 API 的操作對象是額度(Quota),那么下面的操作聽上去就比較自然:
Update quota
(更新額度),transfer quota
(原子化的轉移額度)但是如果試圖Create Quota
,聽上去就不那么自然,因額度這樣一個概念似乎表達了一個數量,概念上不需要創建。額外需要思考一下,這個對象是否真的需要創建?我們真正需要做的是什么?
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 實現并不總是很容易。
Incremental
(數量增減),如IncrementBy(3)
這樣的語義SetNewTotal
(設置新的總量)IncrementBy
這樣的語義重試的時候難以避免出錯,而SetNewTotal(3)
(總量設置為x)語義則比較容易具備冪等性。當然在這個例子里面,也需要看到,IncrementBy
也有有點,即多個客戶請求同時增加的時候,比較容易并行處理,而SetTotal
可能導致并行的更新相互覆蓋(或者相互阻塞)。這里,可以認為更新增量和設置新的總量這兩種語義是不同的優缺點,需要根據場景來解決。如果必須優先考慮并發更新的情景,可以使用更新增量的語義,并輔助以 Deduplication token 解決冪等性。API 的變更需要兼容,兼容,兼容!重要的事情說三遍。這里的兼容指的是向后兼容,而兼容的定義是不會 Break 客戶端的使用,也即老的客戶端能否正常訪問服務端的新版本(如果是同一個大版本下)不會有錯誤的行為。這一點對于遠程的 API(HTTP/RPC)尤其重要。關于兼容性,已經有很好的總結,例如[4] 提供的一些建議。
常見的不兼容變化包括(但不限于)
另一個關于兼容性的重要問題是,如何做不兼容的 API 變更?通常來說,不兼容變更需要通過一個?Deprecation process,在大版本發布時來分步驟實現。關于 Deprecation process,這里不展開描述,一般來說,需要保持過去版本的兼容性的前提下,支持新老字段/方法/語義,并給客戶端足夠的升級時間。這樣的過程比較耗時,也正是因為如此,我們才需要如此重視 API 的設計。
有時,一個面向內部的 API 升級,往往開發的同學傾向于選擇高效率,采用一種叫“同步發布”的模式來做不兼容變更,即通知已知的所有的客戶端,自己的服務 API 要做一個不兼容變更,大家一起發布,同時更新,切換到新的接口。這樣的方法是非常不可取的,原因有幾個:
因此,對于在生產集群已經得到應用的 API,強烈不建議采用“同步升級”的模式來處理不兼容 API 變更。
批量更新如何設計是另一個常見的 API 設計決策。這里我們常見有兩種模式:
如下圖所示:
API 的設計者可能會希望實現一個服務端的批量更新能力,但是我們建議要盡量避免這樣做。除非對于客戶來說提供原子化+事務性的批量很有意義(all-or-nothing),否則實現服務端的批量更新有諸多的弊端,而客戶端批量更新則有優勢:
所謂 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。
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 等常見的設計規范方面考慮。這里不再重復。
本文章轉載微信公眾號@服務端思維