有一段時間沒怎麼寫文章了,今天提筆寫一篇自己對 API 設計的思考。首先,為什麼寫這個話題呢?其一,我閱讀了《阿里研究員谷樸:API 設計最佳實踐的思考》一文後受益良多,前兩天並轉載了這篇文章也引發了廣大讀者的興趣,我覺得我應該把我自己的思考整理成文與大家一起分享與碰撞。其二,我覺得我針對這個話題,可以半個小時之內搞定,爭取在 1 點前關燈睡覺,哈哈。
現在,我們來一起探討 API 的設計之道。我會拋出幾個觀點,歡迎探討。
一、定義好的規範,已經成功了一大半
通常情況下,規範就是大家約定俗成的標準,如果大家都遵守這套標準,那麼自然溝通成本大大降低。例如,大家都希望從阿里的規範上面學習,在自己的業務中也定義幾個領域模型:VO、BO、DO、DTO。其中,DO(Data Object)與數據庫表結構一一對應,通過 DAO 層向上傳輸數據源對象。 而 DTO(Data Transfer Object)是遠程調用對象,它是 RPC 服務提供的領域模型。對於 BO(Business Object),它是業務邏輯層封裝業務邏輯的對象,一般情況下,它是聚合了多個數據源的複合對象。那麼,VO(View Object) 通常是請求處理層傳輸的對象,它通過 Spring 框架的轉換後,往往是一個 JSON 對象。
事實上,阿里這種複雜的業務中如果不劃分清楚 DO、BO、DTO、VO 的領域模型,其內部代碼很容易就混亂了,內部的 RPC 在 service 層的基礎上又增加了 manager 層,從而實現內部的規範統一化。但是,如果只是單獨的域又沒有太多外部依賴,那麼,完全不要設計這麼複雜,除非預期到可能會變得龐大和複雜化。對此,設計過程中因地制宜就顯得特別重要了。
另外一個規範的例子是 RESTful API。在 REST 架構風格中,每一個 URI 代表一種資源。因此,URI 是每一個資源的地址的唯一資源定位符。所謂資源,實際上就是一個信息實體,它可以是服務器上的一段文本、一個文件、一張圖片、一首歌曲,或者是一種服務。RESTful API 規定了通過 GET、 POST、 PUT、 PATCH、 DELETE 等方式對服務端的資源進行操作。
【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,但是它不一定就是落後的,不好的,只要團隊內部形成規範,降低大家的學習成本即可。很多時候,我們試圖改變團隊的習慣去學習一個新的規範,所帶來的收益(投入產出比)甚微,那就得不償失了。
總結一下,定義好的規範的目的在於,降低學習成本,使得 API 儘可能通俗易懂。當然,設計的 API 通俗易懂還有其他方式,例如我們定義的 API 的名字易於理解,API 的實現儘可能通用等。
二、探討 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 可以通過增加新類型和基於這些類型的新字段添加新功能,而不會造成兼容性問題。
此外,在使用 RPC API 過程中,我們特別需要注意兼容性問題,二方庫不能依賴 parent,此外,本地開發可以使用 SNAPSHOT,而線上環境禁止使用,避免發生變更,導致版本不兼容問題。我們需要為每個接口都應定義版本號,保證後續不兼容的情況下可以升級版本。例如,Dubbo 建議第三位版本號通常表示兼容升級,只有不兼容時才需要變更服務版本。
關於規範的案例,我們可以看看 k8s 和 github,其中 k8s 採用了 RESTful API,而 github 部分採用了 GraphQL。
- https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.10/
- https://developer.github.com/v4/
三、提供清晰的思維模型
所謂思維模型,我的理解是針對問題域抽象模型,對域模型的功能有統一認知,構建某個問題的現實映射,並劃分好模型的邊界,而域模型的價值之一就是統一思想,明確邊界。假設,大家沒有清晰的思維模型,那麼也不存在對 API 的統一認知,那麼就很可能出現下面圖片中的現實問題。
四、以抽象的方式屏蔽業務實現
我認為好的 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 的特定實現方法。
此時,你是不是聯想到了模版方法模式。模板方法模式的核心思想是定義骨架,轉移實現,換句話說,它通過定義一個流程的框架,而將一些步驟的具體實現延遲到子類中。事實上,在微服務的落地過程中,這種思想也給我們提供了非常好的理論基礎。
現在,我們來看一個案例:電商業務場景中的未發貨僅退款。這種情況在電商業務中非常場景,用戶下單付款後由於各種原因可能就申請退款了。此時,因為不涉及退貨,所以只需要用戶申請退款並填寫退款原因,然後讓賣家審核退款。那麼,由於不同平臺的退款原因可能不同,我們可以考慮通過 SPI 擴展點來實現。
此外,我們還經常使用工廠方法+策略模式來屏蔽外部的複雜性。例如,我們對外暴露一個 API 接口 getTask(int operation),那麼我們就可以通過工廠方法來創建實例,通過策略方法來定義不同的實現。
@Component
public class TaskManager {
private static final Logger logger = LoggerFactory.getLogger(TaskManager.class);
private static TaskManager instance;
public MapInteger, ITask> taskMap = new HashMap<integer>();
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());
}
}
/<integer>
還有一種屏蔽內部複雜性設計就是外觀接口,它是將多個服務的接口進行業務封裝與整合並提供一個簡單的調用接口給客戶端使用。這種設計的好處在於,客戶端不再需要知道那麼多服務的接口,只需要調用這個外觀接口即可。但是,壞處也是顯而易見的,即增加了服務端的業務複雜度,接口性能不高,並且複用性不高。因此,因地制宜,儘可能保證職責單一,而在客戶端進行“樂高式”組裝。如果存在 SEO 優化的產品,需要被類似於百度這樣的搜索引擎收錄,可以當首屏的時候,通過服務端渲染生成 HTML,使之讓搜索引擎收錄,若不是首屏的時候,可以通過客戶端調用服務端 RESTful API 接口進行頁面渲染。
此外,隨著微服務的普及,我們的服務越來越多,許多較小的服務有更多的跨服務調用。因此,微服務體系結構使得這個問題更加普遍。為了解決這個問題,我們可以考慮引入一個“聚合服務”,它是一個組合服務,可以將多個微服務的數據進行組合。這樣設計的好處在於,通過一個“聚合服務”將一些信息整合完後再返回給調用方。注意的是,“聚合服務”也可以有自己的緩存和數據庫。 事實上,聚合服務的思想無處不在,例如 Serverless 架構。我們可以在實踐的過程中採用 AWS Lambda 作為 Serverless 服務背後的計算引擎,而 AWS Lambda 是一種函數即服務(Function-as-a-Servcie,FaaS)的計算服務,我們直接編寫運行在雲上的函數。那麼,這個函數可以組裝現有能力做服務聚合。
當然,還有很多很好的設計,我也會在陸續在公眾號中以續補的方式進行補充與探討。
五、考慮背後的性能
我們需要考慮入參字段的各種組合導致數據庫的性能問題。有的時候,我們可能暴露太多字段給外部組合使用,導致數據庫沒有相應的索引而發生全表掃描。事實上,這種情況在查詢的場景特別常見。因此,我們可以只提供存在索引的字段組合給外部調用,或者在下面的案例中,要求調用方必填 taskId 和 caseId 來保證我們數據庫合理使用索引,進一步保證服務提供方的服務性能。
ResultVoid> agree(Long taskId, Long caseId, Configger configger);
同時,對於報表操作、批量操作、冷數據查詢等 API 應該可以考慮異步能力。
此外,GraphQL 雖然解決將多個 HTTP 請求聚合成了一個請求,但是 schema 會逐層解析方式遞歸獲取全部數據。例如分頁查詢的統計總條數,原本 1 次可以搞定的查詢,演變成了 N + 1 次對數據庫查詢。此外,如果寫得不合理還會導致惡劣的性能問題,因此,我們在設計的過程中特別需要注意。
六、異常響應與錯誤機制
業內對 RPC API 拋出異常,還是拋出錯誤碼已經有太多的爭論。《阿里巴巴 Java 開發手冊》建議:跨應用 RPC 調用優先考慮使用 isSuccess() 方法、“錯誤碼”、“錯誤簡短信息”。關於 RPC 方法返回方式使用 Result 方式的理由 : 1)使用拋異常返回方式,調用方如果沒有捕獲到,就會產生運行時錯誤。2)如果不加棧信息,只是 new 自定義異常,加入自己的理解的 error message,對於調用端解決問題的幫助不會太多。如果加了棧信息,在頻繁調用出錯的情況下,數據序列化和傳輸的性能損耗也是問題。當然,我也支持這個論點的實踐擁護者。
public ResultXxxDTO> 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 字段用來表示某類錯誤的錯誤碼,在微服務中應該加上“{biz_name}/”前綴以便於定位錯誤發生在哪個業務系統上。我們來看一個案例,假設“用戶中心”某個接口沒有權限獲取資源而出現錯誤,我們的業務系統可以響應“UC/AUTH_DENIED”,並且通過自動生成的 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"
}
七、思考 API 的冪等性
冪等機制的核心是保證資源唯一性,例如客戶端重複提交或服務端的多次重試只會產生一份結果。支付場景、退款場景,涉及金錢的交易不能出現多次扣款等問題。事實上,查詢接口用於獲取資源,因為它只是查詢數據而不會影響到資源的變化,因此不管調用多少次接口,資源都不會改變,所以是它是冪等的。而新增接口是非冪等的,因為調用接口多次,它都將會產生資源的變化。因此,我們需要在出現重複提交時進行冪等處理。那麼,如何保證冪等機制呢?事實上,我們有很多實現方案。其中,一種方案就是常見的創建唯一索引。在數據庫中針對我們需要約束的資源字段創建唯一索引,可以防止插入重複的數據。但是,遇到分庫分表的情況是,唯一索引也就不那麼好使了,此時,我們可以先查詢一次數據庫,然後判斷是否約束的資源字段存在重複,沒有的重複時再進行插入操作。注意的是,為了避免併發場景,我們可以通過鎖機制,例如悲觀鎖與樂觀鎖保證數據的唯一性。這裡,分佈式鎖是一種經常使用的方案,它通常情況下是一種悲觀鎖的實現。但是,很多人經常把悲觀鎖、樂觀鎖、分佈式鎖當作冪等機制的解決方案,這個是不正確的。除此之外,我們還可以引入狀態機,通過狀態機進行狀態的約束以及狀態跳轉,確保同一個業務的流程化執行,從而實現數據冪等。事實上,並不是所有的接口都要保證冪等,換句話說,是否需要冪等機制可以通過考量需不需要確保資源唯一性,例如行為日誌可以不考慮冪等性。當然,還有一種設計方案是接口不考慮冪等機制,而是在業務實現的時候通過業務層面來保證,例如允許存在多份數據,但是在業務處理的時候獲取最新的版本進行處理。
閱讀更多 拖延症晚期 的文章