RPC 框架的討論一直是各個技術交流羣中的熱點話題,阿里的 dubbo,新浪微博的 motan,谷歌的 grpc,以及不久前螞蟻金服開源的 sofa,都是比較出名的 RPC 框架。RPC 框架,或者一部分人習慣稱之爲服務治理框架,更多的討論是存在於其技術架構,好比 RPC 的實現原理,RPC 各個分層的意義,具體 RPC 框架的源碼分析…但卻並無太多話題和「如何設計 RPC 接口」這樣的業務架構相關。程序員
可能不少小公司程序員仍是比較關心這個問題的,這篇文章主要分享下一些我的眼中 RPC 接口設計的最佳實踐。spring
因爲 RPC 中的術語每一個程序員的理解可能不一樣,因此文章開始,先統一下 RPC 術語,方便後續闡述。json
你們都知道共享接口是 RPC 最典型的一個特色,每一個服務對外暴露本身的接口,該模塊通常稱之爲 api;外部模塊想要實現對該模塊的遠程調用,則須要依賴其 api;每一個服務都須要有一個應用來負責實現本身的 api,通常體現爲一個獨立的進程,該模塊通常稱之爲 app。api
api 和 app 是構建微服務項目的最簡單組成部分,若是使用 maven 的多 module 組織代碼,則體現爲以下的形式。springboot
serviceA 服務restful
serviceA/pom.xml 定義父 pom 文件架構
<modules> <module>serviceA-api</module> <module>serviceA-app</module> </modules> <packaging>pom</packaging> <groupId>moe.cnkirito</groupId> <artifactId>serviceA</artifactId> <version>1.0.0-SNAPSHOT</version>
serviceA/serviceA-api/pom.xml 定義對外暴露的接口,最終會被打成 jar 包供外部服務依賴app
<parent> <artifactId>serviceA</artifactId> <groupId>moe.cnkirito</groupId> <version>1.0.0-SNAPSHOT</version> </parent> <packaging>jar</packaging> <artifactId>serviceA-api</artifactId>
serviceA/serviceA-app/pom.xml 定義了服務的實現,通常是 springboot 應用,因此下面的配置文件中,我配置了 springboot 應用打包的插件,最終會被打成 jar 包,做爲獨立的進程運行。框架
<parent> <artifactId>serviceA</artifactId> <groupId>moe.cnkirito</groupId> <version>1.0.0-SNAPSHOT</version> </parent> <packaging>jar</packaging> <artifactId>serviceA-app</artifactId> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
麻雀雖小,五臟俱全,這樣一個微服務模塊就實現了。maven
統一好術語,這一節來描述下我曾經遭遇過的 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); List<Student> findAllByName(String name); Student findByNameAndNo(String name,String no); Student findByIdcard(String Idcard); }
如上的多個查詢方法目的都是同一個:根據條件查詢出 Student,只不過查詢條件有所差別。試想一下,Student 對象假設有 10 個屬性,最壞的狀況下它們的排列組合均可能做爲查詢條件,這即是查詢接口過多的根源。
public interface StudentApi{ Student findBySpec(StudentSpec spec); List<Student> findListBySpec(StudentListSpec spec); Page<Student> findPageBySpec(StudentPageSpec spec); }
上述接口即是最通用的單參接口,三個方法幾乎囊括了 99% 的查詢條件。全部的查詢條件都被封裝在了 StudentSpec,StudentListSpec,StudentPageSpec 之中,分別知足了單對象查詢,批量查詢,分頁查詢的需求。若是你瞭解領域驅動設計,會發現這裏借鑑了其中 Specification 模式的思想。
public interface SomeProvider { void opA(ARequest request); void opB(BRequest request); CommonResponse<C> opC(CRequest request); }
入參中的入參雖然形態萬千,但因爲是單個入參,因此能夠統一繼承 AbstractBaseRequest,即上述的 ARequest,BRequest,CRequest 都是 AbstractBaseRequest 的子類。在公里內部項目中,AbstractBaseRequest 定義了 traceId、clientIp、clientType、operationType 等公共入參,減小了重複命名,咱們一致認爲,這更加的 OO。
有了 AbstractBaseRequest,咱們能夠更加輕鬆地在其之上作 AOP,公里的實踐中,大概作了以下的操做:
若是不遵照單參數的約定,上述這些功能也並非沒法實現,但所需花費的精力遠大於單參數,一個簡單的約定帶來的優點,咱們認爲是值得的。
還記得前面的小節中,我提到了 SpringCloud,在 SpringCloud Feign 中,接口的入參一般會被 @RequestBody 修飾,強制作單參數的限制。公里內部使用了 Dubbo 做爲 Rpc 框架,通常而言,爲 Dubbo 服務設計的接口是不能直接用做 Feign 接口的(主要是由於 @RequestBody 的限制),但有了單參數的限制,便使之成爲了可能。爲何我好端端的 Dubbo 接口須要兼容 Feign 接口?可能會有人發出這樣的疑問,莫急,這樣作的初衷固然不是爲了單純作接口兼容,而是想充分利用 HTTP 豐富的技術棧以及一些自動化工具。
看過我以前文章的朋友應該瞭解過一個設計:公里內部支持的是 Dubbo 協議和 HTTP 協議族(如 JSON RPC 協議,Restful 協議),這並不意味着程序員須要寫兩份代碼,咱們能夠經過 Dubbo 接口自動生成 HTTP 接口,體現了單參數設計的兼容性之強。
又是一個兼容 HTTP 技術棧帶來的便利,在 Restful 接口的測試中,Swagger 一直是備受青睞的一個工具,但惋惜的是其沒法對 Dubbo 接口進行測試。兼容 HTTP 後,咱們只須要作一些微小的工做,即可以實現 Swagger 對 Dubbo 接口的可視化測試。
自動生成 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); CommonResponse<C> opC(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; CommonResponse<C> opC(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 CommonResponse<C> opC(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+正確異常處理姿式,使得代碼更加優雅,下降了調用方不處理異常帶來的風險。
原文出處:https://www.jianshu.com/p/dca...做者:佔小狼