摘要:通常情況下,偽都是基于第一層次與第二層次設計的。為了解決這個版本不兼容問題,在設計的一種實用的做法是使用版本號。例如,建議第三位版本號通常表示兼容升級,只有不兼容時才需要變更服務版本。
原文地址:梁桂釗的博客
博客地址:blog.720ui.com
歡迎關注公眾號:「服務端思維」。一群同頻者,一起成長,一起精進,打破認知的局限性。
有一段時間沒怎么寫文章了,今天提筆寫一篇自己對 API 設計的思考。首先,為什么寫這個話題呢?其一,我閱讀了《阿里研究員谷樸:API 設計最佳實踐的思考》一文后受益良多,前兩天并轉載了這篇文章也引發(fā)了廣大讀者的興趣,我覺得我應該把我自己的思考整理成文與大家一起分享與碰撞。其二,我覺得我針對這個話題,可以半個小時之內搞定,爭取在 1 點前關燈睡覺,哈哈。
現在,我們來一起探討 API 的設計之道。我會拋出幾個觀點,歡迎探討。
一、定義好的規(guī)范,已經成功了一大半通常情況下,規(guī)范就是大家約定俗成的標準,如果大家都遵守這套標準,那么自然溝通成本大大降低。例如,大家都希望從阿里的規(guī)范上面學習,在自己的業(yè)務中也定義幾個領域模型:VO、BO、DO、DTO。其中,DO(Data Object)與數據庫表結構一一對應,通過 DAO 層向上傳輸數據源對象。 而 DTO(Data Transfer Object)是遠程調用對象,它是 RPC 服務提供的領域模型。對于 BO(Business Object),它是業(yè)務邏輯層封裝業(yè)務邏輯的對象,一般情況下,它是聚合了多個數據源的復合對象。那么,VO(View Object) 通常是請求處理層傳輸的對象,它通過 Spring 框架的轉換后,往往是一個 JSON 對象。
事實上,阿里這種復雜的業(yè)務中如果不劃分清楚 ?DO、BO、DTO、VO 的領域模型,其內部代碼很容易就混亂了,內部的 RPC 在 service 層的基礎上又增加了 manager 層,從而實現內部的規(guī)范統(tǒng)一化。但是,如果只是多帶帶的域又沒有太多外部依賴,那么,完全不要設計這么復雜,除非預期到可能會變得龐大和復雜化。對此,設計過程中因地制宜就顯得特別重要了。
另外一個規(guī)范的例子是 RESTful API。在 REST 架構風格中,每一個 URI 代表一種資源。因此,URI 是每一個資源的地址的唯一資源定位符。所謂資源,實際上就是一個信息實體,它可以是服務器上的一段文本、一個文件、一張圖片、一首歌曲,或者是一種服務。RESTful API 規(guī)定了通過 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 狀態(tài)碼來表示不同的結果。第四層次(Level 3)的 Web API 服務使用 HATEOAS。在資源的表達中包含了鏈接信息??蛻舳丝梢愿鶕溄觼戆l(fā)現可以執(zhí)行的動作。通常情況下,偽 RESTful API 都是基于第一層次與第二層次設計的。例如,我們的 Web API 中使用各種動詞,例如 get_menu?和 save_menu?,而真正意義上的?RESTful API 需要滿足第三層級以上。如果我們遵守了這套規(guī)范,我們就很可能就設計出通俗易懂的 API。
注意的是,定義好的規(guī)范,我們已經成功了一大半。如果這套規(guī)范是業(yè)內標準,那么我們可以大膽實踐,不要擔心別人不會用,只要把業(yè)界標準丟給他好好學習一下就可以啦。例如,Spring 已經在 Java 的生態(tài)中舉足輕重,如果一個新人不懂 Spring 就有點說不過去了。但是,很多時候因為業(yè)務的限制和公司的技術,我們可能使用基于第一層次與第二層次設計的偽 RESTful API,但是它不一定就是落后的,不好的,只要團隊內部形成規(guī)范,降低大家的學習成本即可。很多時候,我們試圖改變團隊的習慣去學習一個新的規(guī)范,所帶來的收益(投入產出比)甚微,那就得不償失了。
總結一下,定義好的規(guī)范的目的在于,降低學習成本,使得 API 盡可能通俗易懂。當然,設計的 API?通俗易懂還有其他方式,例如我們定義的 API 的名字易于理解,API 的實現盡可能通用等。
二、探討 API 接口的兼容性API 接口都是不斷演進的。因此,我們需要在一定程度上適應變化。在 RESTful API 中,API 接口應該盡量兼容之前的版本。但是,在實際業(yè)務開發(fā)場景中,可能隨著業(yè)務需求的不斷迭代,現有的 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 接口以滿足新的業(yè)務需求,此時,客戶端的產品的新功能將請求新的服務端的 API 接口地址。雖然服務端會同時兼容多個版本,但是同時維護太多版本對于服務端而言是個不小的負擔,因為服務端要維護多套代碼。這種情況下,常見的做法不是維護所有的兼容版本,而是只維護最新的幾個兼容版本,例如維護最新的三個兼容版本。在一段時間后,當絕大多數用戶升級到較新的版本后,廢棄一些使用量較少的服務端的老版本API 接口版本,并要求使用產品的非常舊的版本的用戶強制升級。注意的是,“不改變版本 v1 的查詢用戶列表的 API 接口”主要指的是對于客戶端的調用者而言它看起來是沒有改變。而實際上,如果業(yè)務變化太大,服務端的開發(fā)人員需要對舊版本的 API 接口使用適配器模式將請求適配到新的API 接口上。
有趣的是,GraphQL 提供不同的思路。GraphQL 為了解決服務 API 接口爆炸的問題,以及將多個 HTTP 請求聚合成了一個請求,提出只暴露單個服務 API 接口,并且在單個請求中可以進行多個查詢。GraphQL 定義了 API 接口,我們可以在前端更加靈活調用,例如,我們可以根據不同的業(yè)務選擇并加載需要渲染的字段。因此,服務端提供的全量字段,前端可以按需獲取。GraphQL 可以通過增加新類型和基于這些類型的新字段添加新功能,而不會造成兼容性問題。
此外,在使用 RPC API 過程中,我們特別需要注意兼容性問題,二方庫不能依賴 parent,此外,本地開發(fā)可以使用 SNAPSHOT,而線上環(huán)境禁止使用,避免發(fā)生變更,導致版本不兼容問題。我們需要為每個接口都應定義版本號,保證后續(xù)不兼容的情況下可以升級版本。例如,Dubbo 建議第三位版本號通常表示兼容升級,只有不兼容時才需要變更服務版本。
關于規(guī)范的案例,我們可以看看 k8s 和 github,其中 k8s 采用了 RESTful API,而 github 部分采用了 GraphQL。
kubernetes.io/docs/refere…
developer.github.com/v4/
三、提供清晰的思維模型所謂思維模型,我的理解是針對問題域抽象模型,對域模型的功能有統(tǒng)一認知,構建某個問題的現實映射,并劃分好模型的邊界,而域模型的價值之一就是統(tǒng)一思想,明確邊界。假設,大家沒有清晰的思維模型,那么也不存在對 API 的統(tǒng)一認知,那么就很可能出現下面圖片中的現實問題。
四、以抽象的方式屏蔽業(yè)務實現
我認為好的 API 接口具有抽象性,因此需要盡可能的屏蔽業(yè)務實現。那么,問題來了,我們怎么理解抽象性?對此,我們可以思考?java.sql.Driver 的設計。這里,java.sql.Driver 是一個規(guī)范接口,而 com.mysql.jdbc.Driver 則是 mysql-connector-java-xxx.jar 對這個規(guī)范的實現接口。那么,切換成 Oracle 的成本就非常低了。
一般情況下,我們會通過 API 對外提供服務。這里,API 提供服務的接口的邏輯是固定的,換句話說,它具有通用性。但是,但我們遇到具有類似的業(yè)務邏輯的場景時,即核心的主干邏輯相同,而細節(jié)的實現略有不同,那我們該何去何從?很多時候,我們會選擇提供多個 API 接口給不同的業(yè)務方使用。事實上,我們可以通過 SPI 擴展點來實現的更加優(yōu)雅。什么是 SPI?SPI 的英文全稱是 Serivce Provider Interface,即服務提供者接口,它是一種動態(tài)發(fā)現機制,可以在程序執(zhí)行的過程中去動態(tài)的發(fā)現某個擴展點的實現類。因此,當 API 被調用時會動態(tài)加載并調用 SPI 的特定實現方法。
此時,你是不是聯想到了模版方法模式。模板方法模式的核心思想是定義骨架,轉移實現,換句話說,它通過定義一個流程的框架,而將一些步驟的具體實現延遲到子類中。事實上,在微服務的落地過程中,這種思想也給我們提供了非常好的理論基礎。
現在,我們來看一個案例:電商業(yè)務場景中的未發(fā)貨僅退款。這種情況在電商業(yè)務中非常場景,用戶下單付款后由于各種原因可能就申請退款了。此時,因為不涉及退貨,所以只需要用戶申請退款并填寫退款原因,然后讓賣家審核退款。那么,由于不同平臺的退款原因可能不同,我們可以考慮通過 SPI 擴展點來實現。
此外,我們還經常使用工廠方法+策略模式來屏蔽外部的復雜性。例如,我們對外暴露一個 API 接口?getTask(int operation),那么我們就可以通過工廠方法來創(chuàng)建實例,通過策略方法來定義不同的實現。
@Component
public class TaskManager {
private static final Logger logger = LoggerFactory.getLogger(TaskManager.class);
private static TaskManager instance;
public MapInteger, ITask> taskMap = new HashMap();
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());
}
}
還有一種屏蔽內部復雜性設計就是外觀接口,它是將多個服務的接口進行業(yè)務封裝與整合并提供一個簡單的調用接口給客戶端使用。這種設計的好處在于,客戶端不再需要知道那么多服務的接口,只需要調用這個外觀接口即可。但是,壞處也是顯而易見的,即增加了服務端的業(yè)務復雜度,接口性能不高,并且復用性不高。因此,因地制宜,盡可能保證職責單一,而在客戶端進行“樂高式”組裝。如果存在 SEO 優(yōu)化的產品,需要被類似于百度這樣的搜索引擎收錄,可以當首屏的時候,通過服務端渲染生成 HTML,使之讓搜索引擎收錄,若不是首屏的時候,可以通過客戶端調用服務端 RESTful API 接口進行頁面渲染。
此外,隨著微服務的普及,我們的服務越來越多,許多較小的服務有更多的跨服務調用。因此,微服務體系結構使得這個問題更加普遍。為了解決這個問題,我們可以考慮引入一個“聚合服務”,它是一個組合服務,可以將多個微服務的數據進行組合。這樣設計的好處在于,通過一個“聚合服務”將一些信息整合完后再返回給調用方。注意的是,“聚合服務”也可以有自己的緩存和數據庫。 事實上,聚合服務的思想無處不在,例如?Serverless 架構。我們可以在實踐的過程中采用 AWS Lambda 作為 Serverless 服務背后的計算引擎,而 AWS Lambda 是一種函數即服務(Function-as-a-Servcie,FaaS)的計算服務,我們直接編寫運行在云上的函數。那么,這個函數可以組裝現有能力做服務聚合。
當然,還有很多很好的設計,我也會在陸續(xù)在公眾號中以續(xù)補的方式進行補充與探討。
五、考慮背后的性能我們需要考慮入參字段的各種組合導致數據庫的性能問題。有的時候,我們可能暴露太多字段給外部組合使用,導致數據庫沒有相應的索引而發(fā)生全表掃描。事實上,這種情況在查詢的場景特別常見。因此,我們可以只提供存在索引的字段組合給外部調用,或者在下面的案例中,要求調用方必填 taskId 和 caseId 來保證我們數據庫合理使用索引,進一步保證服務提供方的服務性能。
ResultVoid> agree(Long taskId, Long caseId, Configger configger);
同時,對于報表操作、批量操作、冷數據查詢等 API 應該可以考慮異步能力。
此外,GraphQL 雖然解決將多個 HTTP 請求聚合成了一個請求,但是 schema 會逐層解析方式遞歸獲取全部數據。例如分頁查詢的統(tǒng)計總條數,原本 1 次可以搞定的查詢,演變成了 N + 1 次對數據庫查詢。此外,如果寫得不合理還會導致惡劣的性能問題,因此,我們在設計的過程中特別需要注意。
六、異常響應與錯誤機制業(yè)內對 RPC API 拋出異常,還是拋出錯誤碼已經有太多的爭論?!栋⒗锇桶?Java 開發(fā)手冊》建議:跨應用 RPC 調用優(yōu)先考慮使用 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 統(tǒng)一包裝錯誤信息。而在微服務復雜的鏈式調用中,我們會比單體架構更難以追蹤與定位問題。因此,在設計的時候,需要特別注意。一種比較好的方案是,當 RESTful API 接口出現非 2xx 的 HTTP 錯誤碼響應時,采用全局的異常結構響應信息。其中,code 字段用來表示某類錯誤的錯誤碼,在微服務中應該加上“{biz_name}/”前綴以便于定位錯誤發(fā)生在哪個業(yè)務系統(tǒng)上。我們來看一個案例,假設“用戶中心”某個接口沒有權限獲取資源而出現錯誤,我們的業(yè)務系統(tǒng)可以響應“UC/AUTH_DENIED”,并且通過自動生成的 UUID 值的 request_id 字段,在日志系統(tǒng)中獲得錯誤的詳細信息。
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 的冪等性
冪等機制的核心是保證資源唯一性,例如客戶端重復提交或服務端的多次重試只會產生一份結果。支付場景、退款場景,涉及金錢的交易不能出現多次扣款等問題。事實上,查詢接口用于獲取資源,因為它只是查詢數據而不會影響到資源的變化,因此不管調用多少次接口,資源都不會改變,所以是它是冪等的。而新增接口是非冪等的,因為調用接口多次,它都將會產生資源的變化。因此,我們需要在出現重復提交時進行冪等處理。那么,如何保證冪等機制呢?事實上,我們有很多實現方案。其中,一種方案就是常見的創(chuàng)建唯一索引。在數據庫中針對我們需要約束的資源字段創(chuàng)建唯一索引,可以防止插入重復的數據。但是,遇到分庫分表的情況是,唯一索引也就不那么好使了,此時,我們可以先查詢一次數據庫,然后判斷是否約束的資源字段存在重復,沒有的重復時再進行插入操作。注意的是,為了避免并發(fā)場景,我們可以通過鎖機制,例如悲觀鎖與樂觀鎖保證數據的唯一性。這里,分布式鎖是一種經常使用的方案,它通常情況下是一種悲觀鎖的實現。但是,很多人經常把悲觀鎖、樂觀鎖、分布式鎖當作冪等機制的解決方案,這個是不正確的。除此之外,我們還可以引入狀態(tài)機,通過狀態(tài)機進行狀態(tài)的約束以及狀態(tài)跳轉,確保同一個業(yè)務的流程化執(zhí)行,從而實現數據冪等。事實上,并不是所有的接口都要保證冪等,換句話說,是否需要冪等機制可以通過考量需不需要確保資源唯一性,例如行為日志可以不考慮冪等性。當然,還有一種設計方案是接口不考慮冪等機制,而是在業(yè)務實現的時候通過業(yè)務層面來保證,例如允許存在多份數據,但是在業(yè)務處理的時候獲取最新的版本進行處理。
(完,轉載請注明作者及出處。)
寫在末尾【服務端思維】:我們一起聊聊服務端核心技術,探討一線互聯網的項目架構與實戰(zhàn)經驗。同時,擁有眾多技術大牛的「后端圈」大家庭,期待你的加入,一群同頻者,一起成長,一起精進,打破認知的局限性。
更多精彩文章,盡在「服務端思維」!
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規(guī)行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://systransis.cn/yun/7245.html
摘要:通常情況下,偽都是基于第一層次與第二層次設計的。為了解決這個版本不兼容問題,在設計的一種實用的做法是使用版本號。例如,建議第三位版本號通常表示兼容升級,只有不兼容時才需要變更服務版本。 原文地址:梁桂釗的博客博客地址:http://blog.720ui.com 歡迎關注公眾號:「服務端思維」。一群同頻者,一起成長,一起精進,打破認知的局限性。 有一段時間沒怎么寫文章了,今天提筆寫一篇...
摘要:我們知道是一種從服務器公開數據的流行方式。描述所有的可能類型系統(tǒng)基于類型和字段的方式進行組織,而非入口端點。因此,需要對后端進行調整,以滿足新的數據需求,這會降低生產力并顯著降低將用戶反饋集成到產品中的能力。 showImg(https://segmentfault.com/img/remote/1460000017875905?w=2234&h=974); 在前幾天的《StateOf...
摘要:我們知道是一種從服務器公開數據的流行方式。描述所有的可能類型系統(tǒng)基于類型和字段的方式進行組織,而非入口端點。因此,需要對后端進行調整,以滿足新的數據需求,這會降低生產力并顯著降低將用戶反饋集成到產品中的能力。 showImg(https://segmentfault.com/img/remote/1460000017875905?w=2234&h=974); 在前幾天的《StateOf...
摘要:我們知道是一種從服務器公開數據的流行方式。描述所有的可能類型系統(tǒng)基于類型和字段的方式進行組織,而非入口端點。因此,需要對后端進行調整,以滿足新的數據需求,這會降低生產力并顯著降低將用戶反饋集成到產品中的能力。 showImg(https://segmentfault.com/img/remote/1460000017875905?w=2234&h=974); 在前幾天的《StateOf...
摘要:我們知道是一種從服務器公開數據的流行方式。描述所有的可能類型系統(tǒng)基于類型和字段的方式進行組織,而非入口端點。因此,需要對后端進行調整,以滿足新的數據需求,這會降低生產力并顯著降低將用戶反饋集成到產品中的能力。 showImg(https://segmentfault.com/img/remote/1460000017875905?w=2234&h=974); 在前幾天的《StateOf...
閱讀 1194·2021-11-25 09:43
閱讀 1611·2021-10-25 09:47
閱讀 2494·2019-08-30 13:46
閱讀 783·2019-08-29 13:45
閱讀 1309·2019-08-26 13:29
閱讀 3020·2019-08-23 15:30
閱讀 1137·2019-08-23 14:17
閱讀 1349·2019-08-23 13:43