文章摘要: 通常我們需要對 RPC 介面做熔斷處理RPC 介面設計中的異常設計也是一個注意點
RPC 框架的討論一直是各個技術交流群中的熱點話題,阿里的 dubbo,新浪微博的 motan,谷歌的 grpc,以及不久前螞蟻金服開源的 sofa,都是比較出名的 RPC 框架。RPC 框架,或者一部分人習慣稱之為服務治理框架,更多的討論是存在於其技術架構,比如 RPC 的實現原理,RPC 各個分層的意義,具體 RPC 框架的原始碼分析…但卻並沒有太多話題和「如何設計 RPC 介面」這樣的業務架構相關。
可能很多小公司程式設計師還是比較關心這個問題的,這篇文章主要分享下一些個人眼中 RPC 介面設計的最佳實踐。
初識 RPC 介面設計
由於 RPC 中的術語每個程式設計師的理解可能不同,所以文章開始,先統一下 RPC 術語,方便後續闡述。
大家都知道共享介面是 RPC 最典型的一個特點,每個服務對外暴露自己的介面,該模組一般稱之為 api;外部模組想要實現對該模組的遠端呼叫,則需要依賴其 api;每個服務都需要有一個應用來負責實現自己的 api,一般體現為一個獨立的程序,該模組一般稱之為 app。
api 和 app 是構建微服務專案的最簡單組成部分,如果使用 maven 的多 module 組織程式碼,則體現為如下的形式。
serviceA 服務
serviceA/pom.xml 定義父 pom 檔案
serviceA-api serviceA-app pom moe.cnkirito serviceA 1.0.0-SNAPSHOT
serviceA/serviceA-api/pom.xml 定義對外暴露的介面,最終會被打成 jar 包供外部服務依賴
serviceA moe.cnkirito 1.0.0-SNAPSHOT jar serviceA-api
serviceA/serviceA-app/pom.xml 定義了服務的實現,一般是 springboot 應用,所以下面的配置檔案中,我配置了 springboot 應用打包的外掛,最終會被打成 jar 包,作為獨立的程序執行。
serviceA moe.cnkirito 1.0.0-SNAPSHOT jar serviceA-app org.springframework.boot spring-boot-maven-plugin
麻雀雖小,五臟俱全,這樣一個微服務模組就實現了。
舊 RPC 介面的痛點
統一好術語,這一節來描述下我曾經遭遇過的 RPC 介面設計的痛點,相信不少人有過相同的遭遇。
查詢介面過多
各種 findBy 方法,加上各自的過載,幾乎佔據了一個介面 80% 的程式碼量。這也符合一般人的開發習慣,因為頁面需要各式各樣的資料格式,加上查詢條件差異很大,便造成了:一個查詢條件,一個方法的尷尬場景。這樣會導致另外一個問題,需要使用某個查詢方法時,直接新增了方法,但實際上可能這個方法已經出現過了,隱藏在了令人眼花繚亂的方法中。
難以擴充套件
介面的任何改動,比如新增一個入參,都會導致呼叫者被迫升級,這也通常是 RPC 設計被詬病的一點,不合理的 RPC 介面設計會放大這個缺點。
升級困難
在之前的 「初識 RPC 介面設計」一節中,版本管理的粒度是 project,而不是 module,這意味著:api 即使沒有發生變化,app 版本演進,也會造成 api 的被迫升級,因為 project 是一個整體。問題又和上一條一樣了,api 一旦發生變化,呼叫者也得被迫升級,牽一髮而動全身。
難以測試
介面一多,職責隨之變得繁雜,業務場景各異,測試用例難以維護。特別是對於那些有良好習慣編寫單元測試的程式設計師而言,簡直是噩夢,用例也得跟著改。
異常設計不合理
在既往的工作經歷中曾經有一次會議,就 RPC 呼叫中的異常設計引發了爭議,一派人覺得需要有一個業務 CommonResponse,封裝異常,每次呼叫後,優先判斷呼叫結果是否 success,在進行業務邏輯處理;另一派人覺得這比較麻煩,由於 RPC 框架是可以封裝異常呼叫的,所以應當直接 try catch 異常,不需要進行業務包裹。在沒有明確規範時,這兩種風格的程式碼同時存在於專案中,十分難看!
單引數介面
如果你使用過 springcloud ,可能會不適應 http 通訊的限制,因為 @RequestBody 只能使用單一的引數,也就意味著,springcloud 構建的微服務架構下,介面天然是單引數的。而 RPC 方法入參的個數在語法層面是不會受到限制的,但如果強制要求入參為單引數,會解決一部分的痛點。
使用 Specification 模式解決查詢介面過多的問題
public interface StudentApi{ Student findByName(String name); ListfindAllByName(String name); Student findByNameAndNo(String name,String no); Student findByIdcard(String Idcard); }
如上的多個查詢方法目的都是同一個:根據條件查詢出 Student,只不過查詢條件有所差異。試想一下,Student 物件假設有 10 個屬性,最壞的情況下它們的排列組合都可能作為查詢條件,這便是查詢介面過多的根源。
public interface StudentApi{ Student findBySpec(StudentSpec spec); ListfindListBySpec(StudentListSpec spec); Page findPageBySpec(StudentPageSpec spec); }
上述介面便是最通用的單參介面,三個方法幾乎囊括了 99% 的查詢條件。所有的查詢條件都被封裝在了 StudentSpec,StudentListSpec,StudentPageSpec 之中,分別滿足了單物件查詢,批量查詢,分頁查詢的需求。如果你瞭解領域驅動設計,會發現這裏借鑑了其中 Specification 模式的思想。
單引數易於做統一管理
public interface SomeProvider { void opA(ARequest request); void opB(BRequest request); CommonResponseopC(CRequest request); }
入參中的入參雖然形態各異,但由於是單個入參,所以可以統一繼承 AbstractBaseRequest,即上述的 ARequest,BRequest,CRequest 都是 AbstractBaseRequest 的子類。在千米內部專案中,AbstractBaseRequest 定義了 traceId、clientIp、clientType、operationType 等公共入參,減少了重複命名,我們一致認為,這更加的 OO。
有了 AbstractBaseRequest,我們可以更加輕鬆地在其之上做 AOP,千米的實踐中,大概做了如下的操作:
- 請求入參統一校驗(request.checkParam(); param.checkParam();)
- 實體變更統一加鎖,降低鎖粒度
- 請求分類統一處理(if (request instanceof XxxRequest))
- 請求報文統一記日誌(log.setRequest(JsonUtil.getJsonString(request)))
- 操作成功統一發訊息
如果不遵守單引數的約定,上述這些功能也並不是無法實現,但所需花費的精力遠大於單引數,一個簡單的約定帶來的優勢,我們認為是值得的。
單引數入參相容性強
還記得前面的小節中,我提到了 SpringCloud,在 SpringCloud Feign 中,介面的入參通常會被 @RequestBody 修飾,強制做單引數的限制。千米內部使用了 Dubbo 作為 Rpc 框架,一般而言,為 Dubbo 服務設計的介面是不能直接用作 Feign 介面的(主要是因為 @RequestBody 的限制),但有了單引數的限制,便使之成爲了可能。為什麼我好端端的 Dubbo 介面需要相容 Feign 介面?可能會有人發出這樣的疑問,莫急,這樣做的初衷當然不是爲了單純做介面相容,而是想充分利用 HTTP 豐富的技術棧以及一些自動化工具。
- 自動生成 HTTP 介面實現(讓服務端同時支援 Dubbo 和 HTTP 兩種服務介面)
看過我之前文章的朋友應該瞭解過一個設計:千米內部支援的是 Dubbo 協議和 HTTP 協議族(如 JSON RPC 協議,Restful 協議),這並不意味著程式設計師需要寫兩份程式碼,我們可以通過 Dubbo 介面自動生成 HTTP 介面,體現了單引數設計的相容性之強。
- 通過 Swagger UI 實現對 Dubbo 介面的視覺化便捷測試
又是一個相容 HTTP 技術棧帶來的便利,在 Restful 介面的測試中,Swagger 一直是備受青睞的一個工具,但可惜的是其無法對 Dubbo 介面進行測試。相容 HTTP 後,我們只需要做一些微小的工作,便可以實現 Swagger 對 Dubbo 介面的視覺化測試。
- 有利於 TestNg 整合測試
自動生成 TestNG 整合測試程式碼和預設測試用例,這使得服務端介面整合測試變得異常簡單,程式設計師更能集中精力設計業務用例,結合預設用例、JPA 自動建表和 PowerMock 模擬外部依賴介面實現本機環境。
這塊涉及到了公司內部的程式碼,只做下簡單介紹,我們一般通過內部專案 com.qianmi.codegenerator:api-dubbo-2-restful ,com.qianmi.codegenerator:api-request-json 生成自動化的測試用例,方便測試。而這些自動化工具中大量使用了反射,而由於單引數的設計,反射用起來比較方便。
介面異常設計
首先肯定一點,RPC 框架是可以封裝異常的,Exception 也是返回值的一部分。在 go 語言中可能更習慣於返回 err,res 的組合,但 JAVA 中我個人更偏向於 try catch 的方法捕獲異常。RPC 介面設計中的異常設計也是一個注意點。
初始方案
public interface ModuleAProvider { void opA(ARequest request); void opB(BRequest request); CommonResponseopC(CRequest request); }
我們假設模組 A 存在上述的 ModuleAProvider 介面,ModuleAProvider 的實現中或多或少都會出現異常,例如可能存在的異常 ModuleAException,呼叫者實際上並不知道 ModuleAException 的存在,只有當出現異常時,纔會知曉。對於 ModuleAException 這種業務異常,我們更希望呼叫方能夠顯示的處理,所以 ModuleAException 應該被設計成 Checked Excepition。
正確的異常設計姿勢
public interface ModuleAProvider { void opA(ARequest request) throws ModuleAException; void opB(BRequest request) throws ModuleAException; CommonResponseopC(CRequest request) throws ModuleAException; }
上述介面中定義的異常實際上也是一種契約,契約的好處便是不需要敘述,呼叫方自然會想到要去處理 Checked Exception,否則連編譯都過不了。
呼叫方的處理方式
在 ModuleB 中,應當如下處理異常:
public class ModuleBService implements ModuleBProvider { @Reference ModuleAProvider moduleAProvider; @Override public void someOp() throws ModuleBexception{ try{ moduleAProvider.opA(...); }catch(ModuleAException e){ throw new ModuleBException(e.getMessage()); } } @Override public void anotherOp(){ try{ moduleAProvider.opB(...); }catch(ModuleAException e){ // 業務邏輯處理 } } }
someOp 演示了一個異常流的傳遞,ModuleB 暴露出去的異常應當是 ModuleB 的 api 模組中異常類,雖然其依賴了 ModuleA ,但需要將異常進行轉換,或者對於那些意料之中的業務異常可以像 anotherOp() 一樣進行處理,不再傳遞。這時如果新增 ModuleC 依賴 ModuleB,那麼 ModuleC 完全不需要關心 ModuleA 的異常。
異常與熔斷
作為系統設計者,我們應該認識到一點: RPC 呼叫,失敗是常態。通常我們需要對 RPC 介面做熔斷處理,比如千米內部便整合了 Netflix 提供的熔斷元件 Hystrix。Hystrix 需要知道什麼樣的異常需要進行熔斷,什麼樣的異常不能夠進行熔斷。在沒有上述的異常設計之前,回答這個問題可能還有些難度,但有了 Checked Exception 的契約,一切都變得明瞭清晰了。
public class ModuleAProviderProxy { @Reference private ModuleAProvider moduleAProvider; @HystrixCommand(ignoreExceptions = {ModuleAException.class}) public void opA(ARequest request) throws ModuleAException { moduleAProvider.opA(request); } @HystrixCommand(ignoreExceptions = {ModuleAException.class}) public void opB(BRequest request) throws ModuleAException { moduleAProvider.oBB(request); } @HystrixCommand(ignoreExceptions = {ModuleAException.class}) public CommonResponseopC(CRequest request) throws ModuleAException { return moduleAProvider.opC(request); } }
如服務不可用等原因引發的多次介面呼叫超時異常,會觸發 Hystrix 的熔斷;而對於業務異常,我們則認為不需要進行熔斷,因為對於介面 throws 出的業務異常,我們也認為是正常響應的一部分,只不過藉助於 JAVA 的異常機制來表達。實際上,和生成自動化測試類的工具一樣,我們使用了另一套自動化的工具,可以由 Dubbo 介面自動生成對應的 Hystrix Proxy。我們堅定的認為開發體驗和使用者體驗一樣重要,所以公司內部會有非常多的自動化工具。
API 版本單獨演進
引用一段公司內部的真實對話:
A:我下載了你們的程式碼庫怎麼編譯不通過啊,依賴中 xxx-api-1.1.3 版本的 jar 包找不到了,那可都是 RELEASE 版本啊。 B:你不知道我們 nexus 容量有限,只能儲存最新的 20 個 RELEASE 版本嗎?那個 API 現在最新的版本是 1.1.31 啦。 A:啊,這才幾個月就幾十個 RELEASE 版本啦?這介面太不穩定啦。 B: 其實介面一行程式碼沒改,我們業務分析是很牛逼的,一直很穩定。但是這個 API 是和我們專案一起打包的,我們需求更新一次,就釋出一次,API 就被迫一起升級版本。發生這種事,大家都不想的。
在單體式架構中,版本演進的單位是整個專案。微服務解決的一個關鍵的痛點便是其做到了每個服務的單獨演進,這大大降低了服務間的耦合。正如我文章開始時舉得那個例子一樣:serviceA 是一個演進的單位,serviceA-api 和 serviceA-app 這兩個 Module 從屬於 serviceA,這意味著 app 的一次升級,將會引發 api 的升級,因為他們是共生的!而從微服務的使用角度來看,呼叫者關心的是 api 的結構,而對其實現壓根不在乎。所以對於 api 定義未發生變化,其 app 發生變化的那些升級,其實可以做到對呼叫者無感知。在實踐中也是如此
api 版本的演進應該是緩慢的,而 app 版本的演進應該是頻繁的。
所以,對於這兩個演進速度不一致的模組,我們應該單獨做版本管理,他們有自己的版本號。
問題迴歸
**查詢介面過多
**各種 findBy 方法,加上各自的過載,幾乎佔據了一個介面 80% 的程式碼量。這也符合一般人的開發習慣,因為頁面需要各式各樣的資料格式,加上查詢條件差異很大,便造成了:一個查詢條件,一個方法的尷尬場景。這樣會導致另外一個問題,需要使用某個查詢方法時,直接新增了方法,但實際上可能這個方法已經出現過了,隱藏在了令人眼花繚亂的方法中。
解決方案:使用單參+Specification 模式,降低重複的查詢方法,大大降低介面中的方法數量。
難以擴充套件
介面的任何改動,比如新增一個入參,都會導致呼叫者被迫升級,這也通常是 RPC 設計被詬病的一點,不合理的 RPC 介面設計會放大這個缺點。
解決方案:單參設計其實無形中包含了所有的查詢條件的排列組合,可以直接在 app 實現邏輯的新增,而不需要對 api 進行改動(如果是引數的新增則必須進行 api 的升級,引數的廢棄可以用 @Deprecated 標準)。
升級困難
在之前的 「初識 RPC 介面設計」一節中,版本管理的粒度是 project,而不是 module,這意味著:api 即使沒有發生變化,app 版本演進,也會造成 api 的被迫升級,因為 project 是一個整體。問題又和上一條一樣了,api 一旦發生變化,呼叫者也得被迫升級,牽一髮而動全身。
解決方案:以 module 為版本演進的粒度。api 和 app 單獨演進,減少呼叫者的不必要升級次數。
難以測試
介面一多,職責隨之變得繁雜,業務場景各異,測試用例難以維護。特別是對於那些有良好習慣編寫單元測試的程式設計師而言,簡直是噩夢,用例也得跟著改。
解決方案:單引數設計+自動化測試工具,打造良好的開發體驗。
異常設計不合理
在既往的工作經歷中曾經有一次會議,就 RPC 呼叫中的異常設計引發了爭議,一派人覺得需要有一個業務 CommonResponse,封裝異常,每次呼叫後,優先判斷呼叫結果是否 success,在進行業務邏輯處理;另一派人覺得這比較麻煩,由於 RPC 框架是可以封裝異常呼叫的,所以應當直接 try catch 異常,不需要進行業務包裹。在沒有明確規範時,這兩種風格的程式碼同時存在於專案中,十分難看!
解決方案:Checked Exception+正確異常處理姿勢,使得程式碼更加優雅,降低了呼叫方不處理異常帶來的風險。