人人都是 API 設計者:我對 RESTful API、GraphQL、RPC API 的思考

人人都是 API 設計者:我對 RESTful API、GraphQL、RPC API 的思考

梁桂釗 | 做者前端

有一段時間沒怎麼寫文章了,今天提筆寫一篇本身對 API 設計的思考。首先,爲何寫這個話題呢?其一,我閱讀了《阿里研究員谷樸:API 設計最佳實踐的思考》一文後受益良多,前兩天並轉載了這篇文章也引起了廣大讀者的興趣,我以爲我應該把我本身的思考整理成文與你們一塊兒分享與碰撞。其二,我以爲我針對這個話題,能夠半個小時以內搞定,爭取在 1 點前關燈睡覺,哈哈。java

如今,咱們來一塊兒探討 API 的設計之道。我會拋出幾個觀點,歡迎探討。mysql

1、定義好的規範,已經成功了一大半

一般狀況下,規範就是你們約定俗成的標準,若是你們都遵照這套標準,那麼天然溝通成本大大下降。例如,你們都但願從阿里的規範上面學習,在本身的業務中也定義幾個領域模型:VO、BO、DO、DTO。其中,DO(Data Object)與數據庫表結構一一對應,經過 DAO 層向上傳輸數據源對象。而 DTO(Data Transfer Object)是遠程調用對象,它是 RPC 服務提供的領域模型。對於 BO(Business Object),它是業務邏輯層封裝業務邏輯的對象,通常狀況下,它是聚合了多個數據源的複合對象。那麼,VO(View Object) 一般是請求處理層傳輸的對象,它經過 Spring 框架的轉換後,每每是一個 JSON 對象。git

人人都是 API 設計者:我對 RESTful API、GraphQL、RPC API 的思考
事實上,阿里這種複雜的業務中若是不劃分清楚 DO、BO、DTO、VO 的領域模型,其內部代碼很容易就混亂了,內部的 RPC 在 service 層的基礎上又增長了 manager 層,從而實現內部的規範統一化。可是,若是隻是單獨的域又沒有太多外部依賴,那麼,徹底不要設計這麼複雜,除非預期到可能會變得龐大和複雜化。對此,設計過程當中因地制宜就顯得特別重要了。github

另一個規範的例子是 RESTful API。在 REST 架構風格中,每個 URI 表明一種資源。所以,URI 是每個資源的地址的惟一資源定位符。所謂資源,實際上就是一個信息實體,它能夠是服務器上的一段文本、一個文件、一張圖片、一首歌曲,或者是一種服務。RESTful API 規定了經過 GET、 POST、 PUT、 PATCH、 DELETE 等方式對服務端的資源進行操做。sql

【GET】          /users                 # 查詢用戶信息列表

【GET】          /users/1001            # 查看某個用戶信息

【POST】         /users                 # 新建用戶信息

【PUT】          /users/1001            # 更新用戶信息(所有字段)

【PATCH】        /users/1001            # 更新用戶信息(部分字段)

【DELETE】       /users/1001            # 刪除用戶信息

事實上,RESTful API 的實現分了四個層級。第一層次(Level 0)的 Web API 服務只是使用 HTTP 做爲傳輸方式。第二層次(Level 1)的 Web API 服務引入了資源的概念。每一個資源有對應的標識符和表達。第三層次(Level 2)的 Web API 服務使用不一樣的 HTTP 方法來進行不一樣的操做,而且使用 HTTP 狀態碼來表示不一樣的結果。第四層次(Level 3)的 Web API 服務使用 HATEOAS。在資源的表達中包含了連接信息。客戶端能夠根據連接來發現能夠執行的動做。一般狀況下,僞 RESTful API 都是基於第一層次與第二層次設計的。例如,咱們的 Web API 中使用各類動詞,例如 get_menu 和 save_menu ,而真正意義上的 RESTful API 須要知足第三層級以上。若是咱們遵照了這套規範,咱們就極可能就設計出通俗易懂的 API。數據庫

注意的是,定義好的規範,咱們已經成功了一大半。若是這套規範是業內標準,那麼咱們能夠大膽實踐,不要擔憂別人不會用,只要把業界標準丟給他好好學習一下就能夠啦。例如,Spring 已經在 Java 的生態中舉足輕重,若是一個新人不懂 Spring 就有點說不過去了。可是,不少時候由於業務的限制和公司的技術,咱們可能使用基於第一層次與第二層次設計的僞 RESTful API,可是它不必定就是落後的,很差的,只要團隊內部造成規範,下降你們的學習成本便可。不少時候,咱們試圖改變團隊的習慣去學習一個新的規範,所帶來的收益(投入產出比)甚微,那就得不償失了。json

總結一下,定義好的規範的目的在於,下降學習成本,使得 API 儘量通俗易懂。固然,設計的 API 通俗易懂還有其餘方式,例如咱們定義的 API 的名字易於理解,API 的實現儘量通用等。api

2、探討 API 接口的兼容性

API 接口都是不斷演進的。所以,咱們須要在必定程度上適應變化。在 RESTful API 中,API 接口應該儘可能兼容以前的版本。可是,在實際業務開發場景中,可能隨着業務需求的不斷迭代,現有的 API 接口沒法支持舊版本的適配,此時若是強制升級服務端的 API 接口將致使客戶端舊有功能出現故障。實際上,Web 端是部署在服務器,所以它能夠很容易爲了適配服務端的新的 API 接口進行版本升級,然而像 Android 端、IOS 端、PC 端等其餘客戶端是運行在用戶的機器上,所以當前產品很難作到適配新的服務端的 API 接口,從而出現功能故障,這種狀況下,用戶必須升級產品到最新的版本才能正常使用。爲了解決這個版本不兼容問題,在設計 RESTful API 的一種實用的作法是使用版本號。通常狀況下,咱們會在 url 中保留版本號,並同時兼容多個版本。緩存

【GET】  /v1/users/{user_id}  // 版本 v1 的查詢用戶列表的 API 接口

【GET】  /v2/users/{user_id}  // 版本 v2 的查詢用戶列表的 API 接口

如今,咱們能夠不改變版本 v1 的查詢用戶列表的 API 接口的狀況下,新增版本 v2 的查詢用戶列表的 API 接口以知足新的業務需求,此時,客戶端的產品的新功能將請求新的服務端的 API 接口地址。雖然服務端會同時兼容多個版本,可是同時維護太多版本對於服務端而言是個不小的負擔,由於服務端要維護多套代碼。這種狀況下,常見的作法不是維護全部的兼容版本,而是隻維護最新的幾個兼容版本,例如維護最新的三個兼容版本。在一段時間後,當絕大多數用戶升級到較新的版本後,廢棄一些使用量較少的服務端的老版本API 接口版本,並要求使用產品的很是舊的版本的用戶強制升級。注意的是,「不改變版本 v1 的查詢用戶列表的 API 接口」主要指的是對於客戶端的調用者而言它看起來是沒有改變。而實際上,若是業務變化太大,服務端的開發人員須要對舊版本的 API 接口使用適配器模式將請求適配到新的API 接口上。

有趣的是,GraphQL 提供不一樣的思路。GraphQL 爲了解決服務 API 接口爆炸的問題,以及將多個 HTTP 請求聚合成了一個請求,提出只暴露單個服務 API 接口,而且在單個請求中能夠進行多個查詢。GraphQL 定義了 API 接口,咱們能夠在前端更加靈活調用,例如,咱們能夠根據不一樣的業務選擇並加載須要渲染的字段。所以,服務端提供的全量字段,前端能夠按需獲取。GraphQL 能夠經過增長新類型和基於這些類型的新字段添加新功能,而不會形成兼容性問題。

人人都是 API 設計者:我對 RESTful API、GraphQL、RPC API 的思考
此外,在使用 RPC API 過程當中,咱們特別須要注意兼容性問題,二方庫不能依賴 parent,此外,本地開發可使用 SNAPSHOT,而線上環境禁止使用,避免發生變動,致使版本不兼容問題。咱們須要爲每一個接口都應定義版本號,保證後續不兼容的狀況下能夠升級版本。例如,Dubbo 建議第三位版本號一般表示兼容升級,只有不兼容時才須要變動服務版本。

關於規範的案例,咱們能夠看看 k8s 和 github,其中 k8s 採用了 RESTful API,而 github 部分採用了 GraphQL。

3、提供清晰的思惟模型

所謂思惟模型,個人理解是針對問題域抽象模型,對域模型的功能有統一認知,構建某個問題的現實映射,並劃分好模型的邊界,而域模型的價值之一就是統一思想,明確邊界。假設,你們沒有清晰的思惟模型,那麼也不存在對 API 的統一認知,那麼就極可能出現下面圖片中的現實問題。

人人都是 API 設計者:我對 RESTful API、GraphQL、RPC API 的思考

4、以抽象的方式屏蔽業務實現

我認爲好的 API 接口具備抽象性,所以須要儘量的屏蔽業務實現。那麼,問題來了,咱們怎麼理解抽象性?對此,咱們能夠思考 java.sql.Driver 的設計。這裏,java.sql.Driver 是一個規範接口,而 com.mysql.jdbc.Driver
則是 mysql-connector-java-xxx.jar 對這個規範的實現接口。那麼,切換成 Oracle 的成本就很是低了。

通常狀況下,咱們會經過 API 對外提供服務。這裏,API 提供服務的接口的邏輯是固定的,換句話說,它具備通用性。可是,但咱們遇到具備相似的業務邏輯的場景時,即核心的主幹邏輯相同,而細節的實現略有不一樣,那咱們該何去何從?不少時候,咱們會選擇提供多個 API 接口給不一樣的業務方使用。事實上,咱們能夠經過 SPI 擴展點來實現的更加優雅。什麼是 SPI?SPI 的英文全稱是 Serivce Provider Interface,即服務提供者接口,它是一種動態發現機制,能夠在程序執行的過程當中去動態的發現某個擴展點的實現類。所以,當 API 被調用時會動態加載並調用 SPI 的特定實現方法。

此時,你是否是聯想到了模版方法模式。模板方法模式的核心思想是定義骨架,轉移實現,換句話說,它經過定義一個流程的框架,而將一些步驟的具體實現延遲到子類中。事實上,在微服務的落地過程當中,這種思想也給咱們提供了很是好的理論基礎。

人人都是 API 設計者:我對 RESTful API、GraphQL、RPC API 的思考

如今,咱們來看一個案例:電商業務場景中的未發貨僅退款。這種狀況在電商業務中很是場景,用戶下單付款後因爲各類緣由可能就申請退款了。此時,由於不涉及退貨,因此只須要用戶申請退款並填寫退款緣由,而後讓賣家審覈退款。那麼,因爲不一樣平臺的退款緣由可能不一樣,咱們能夠考慮經過 SPI 擴展點來實現。

人人都是 API 設計者:我對 RESTful API、GraphQL、RPC API 的思考

此外,咱們還常用工廠方法+策略模式來屏蔽內部的複雜性。例如,咱們對外暴露一個 API 接口 getTask(int operation),那麼咱們就能夠經過工廠方法來建立實例,經過策略方法來定義不一樣的實現。其中,operation 就是具體的指令。

@Component

public class TaskManager {

    private static final Logger logger = LoggerFactory.getLogger(TaskManager.class);

    private static TaskManager instance;

    public Map<Integer, ITask> taskMap = new HashMap<Integer, ITask>();

    public static TaskManager getInstance() {

        return instance;

    }

    public ITask getTask(int operation) {

        return taskMap.get(operation);

    }

    /**

     * 初始化處理過程

     */

    @PostConstruct

    private void init() {

        logger.info("init task manager");

        instance = new TaskManager();

        // 單聊消息任務

        instance.taskMap.put(EventEnum.CHAT_REQ.getValue(), new ChatTask());

        // 羣聊消息任務

        instance.taskMap.put(EventEnum.GROUP_CHAT_REQ.getValue(), new GroupChatTask());

        // 心跳任務

        instance.taskMap.put(EventEnum.HEART_BEAT_REQ.getValue(), new HeatBeatTask());

    }

}

還有一種屏蔽內部複雜性設計就是外觀接口,它是將多個服務的接口進行業務封裝與整合並提供一個簡單的調用接口給客戶端使用。這種設計的好處在於,客戶端再也不須要知道那麼多服務的接口,只須要調用這個外觀接口便可。可是,壞處也是顯而易見的,即增長了服務端的業務複雜度,接口性能不高,而且複用性不高。所以,因地制宜,儘量保證職責單一,而在客戶端進行「樂高式」組裝。若是存在 SEO 優化的產品,須要被相似於百度這樣的搜索引擎收錄,能夠當首屏的時候,經過服務端渲染生成 HTML,使之讓搜索引擎收錄,若不是首屏的時候,能夠經過客戶端調用服務端 RESTful API 接口進行頁面渲染。

此外,隨着微服務的普及,咱們的服務愈來愈多,許多較小的服務有更多的跨服務調用。所以,微服務體系結構使得這個問題更加廣泛。爲了解決這個問題,咱們能夠考慮引入一個「聚合服務」,它是一個組合服務,能夠將多個微服務的數據進行組合。這樣設計的好處在於,經過一個「聚合服務」將一些信息整合完後再返回給調用方。注意的是,「聚合服務」也能夠有本身的緩存和數據庫。事實上,聚合服務的思想無處不在,例如 Serverless 架構。咱們能夠在實踐的過程當中採用 AWS Lambda 做爲 Serverless 服務背後的計算引擎,而 AWS Lambda 是一種函數即服務(Function-as-a-Servcie,FaaS)的計算服務,咱們直接編寫運行在雲上的函數。那麼,這個函數能夠組裝現有能力作服務聚合。

人人都是 API 設計者:我對 RESTful API、GraphQL、RPC API 的思考

固然,還有不少很好的設計,我也會在陸續在公衆號中以續補的方式進行補充與探討。

5、考慮背後的性能

咱們須要考慮入參字段的各類組合致使數據庫的性能問題。有的時候,咱們可能暴露太多字段給外部組合使用,致使數據庫沒有相應的索引而發生全表掃描。事實上,這種狀況在查詢的場景特別常見。所以,咱們能夠只提供存在索引的字段組合給外部調用,或者在下面的案例中,要求調用方必填 taskId 和 caseId 來保證咱們數據庫合理使用索引,進一步保證服務提供方的服務性能。

Result<Void> agree(Long taskId, Long caseId, Configger configger);

同時,對於報表操做、批量操做、冷數據查詢等 API 應該能夠考慮異步能力。

GraphQL 雖然解決將多個 HTTP 請求聚合成了一個請求,可是 schema 會逐層解析方式遞歸獲取所有數據。例如分頁查詢的統計總條數,本來 1 次能夠搞定的查詢,演變成了 N + 1 次對數據庫查詢。此外,若是寫得不合理還會致使惡劣的性能問題,所以,咱們在設計的過程當中特別須要注意。

6、異常響應與錯誤機制

業內對 RPC API 拋出異常,仍是拋出錯誤碼已經有太多的爭論。《阿里巴巴 Java 開發手冊》建議:跨應用 RPC 調用優先考慮使用 isSuccess() 方法、「錯誤碼」、「錯誤簡短信息」。關於 RPC 方法返回方式使用 Result 方式的理由 : 1)使用拋異常返回方式,調用方若是沒有捕獲到,就會產生運行時錯誤。2)若是不加棧信息,只是 new 自定義異常,加入本身的理解的 error message,對於調用端解決問題的幫助不會太多。若是加了棧信息,在頻繁調用出錯的狀況下,數據序列化和傳輸的性能損耗也是問題。固然,我也支持這個論點的實踐擁護者。

public Result<XxxDTO> getXxx(String param) {

    try {

        // ...

        return Result.create(xxxDTO);

    } catch (BizException e) {

        log.error("...", e);

        return Result.createErrorResult(e.getErrorCode(), e.getErrorInfo(), true);

    }

}

在 Web API 設計過程當中,咱們會使用 ControllerAdvice 統一包裝錯誤信息。而在微服務複雜的鏈式調用中,咱們會比單體架構更難以追蹤與定位問題。所以,在設計的時候,須要特別注意。一種比較好的方案是,當 RESTful API 接口出現非 2xx 的 HTTP 錯誤碼響應時,採用全局的異常結構響應信息。其中,code 字段用來表示某類錯誤的錯誤碼,在微服務中應該加上「{bizname}/」前綴以便於定位錯誤發生在哪一個業務系統上。咱們來看一個案例,假設「用戶中心」某個接口沒有權限獲取資源而出現錯誤,咱們的業務系統能夠響應「UC/AUTHDENIED」,而且經過自動生成的 UUID 值的 request_id 字段,在日誌系統中得到錯誤的詳細信息。

HTTP/1.1 400 Bad Request

Content-Type: application/json

{

   "code": "INVALID_ARGUMENT",

   "message": "{error message}",

   "cause": "{cause message}",

   "request_id": "01234567-89ab-cdef-0123-456789abcdef",

   "host_id": "{server identity}",

   "server_time": "2014-01-01T12:00:00Z"

}

7、思考 API 的冪等性

冪等機制的核心是保證資源惟一性,例如客戶端重複提交或服務端的屢次重試只會產生一份結果。支付場景、退款場景,涉及金錢的交易不能出現屢次扣款等問題。事實上,查詢接口用於獲取資源,由於它只是查詢數據而不會影響到資源的變化,所以無論調用多少次接口,資源都不會改變,因此是它是冪等的。而新增接口是非冪等的,由於調用接口屢次,它都將會產生資源的變化。所以,咱們須要在出現重複提交時進行冪等處理。那麼,如何保證冪等機制呢?事實上,咱們有不少實現方案。其中,一種方案就是常見的建立惟一索引。在數據庫中針對咱們須要約束的資源字段建立惟一索引,能夠防止插入重複的數據。可是,遇到分庫分表的狀況是,惟一索引也就不那麼好使了,此時,咱們能夠先查詢一次數據庫,而後判斷是否約束的資源字段存在重複,沒有的重複時再進行插入操做。注意的是,爲了不併發場景,咱們能夠經過鎖機制,例如悲觀鎖與樂觀鎖保證數據的惟一性。這裏,分佈式鎖是一種常用的方案,它一般狀況下是一種悲觀鎖的實現。可是,不少人常常把悲觀鎖、樂觀鎖、分佈式鎖看成冪等機制的解決方案,這個是不正確的。除此以外,咱們還能夠引入狀態機,經過狀態機進行狀態的約束以及狀態跳轉,確保同一個業務的流程化執行,從而實現數據冪等。事實上,並非全部的接口都要保證冪等,換句話說,是否須要冪等機制能夠經過考量需不須要確保資源惟一性,例如行爲日誌能夠不考慮冪等性。固然,還有一種設計方案是接口不考慮冪等機制,而是在業務實現的時候經過業務層面來保證,例如容許存在多份數據,可是在業務處理的時候獲取最新的版本進行處理。

(未完待續,計劃再寫幾篇相關的內容,敬請期待~)

相關文章
相關標籤/搜索