良好的RPC接口設計,須要注意這些方面

RPC 框架的討論一直是各個技術交流羣中的熱點話題,阿里的 dubbo,新浪微博的 motan,谷歌的 grpc,以及不久前螞蟻金服開源的 sofa,都是比較出名的 RPC 框架。RPC 框架,或者一部分人習慣稱之爲服務治理框架,更多的討論是存在於其技術架構,好比 RPC 的實現原理,RPC 各個分層的意義,具體 RPC 框架的源碼分析…但卻並無太多話題和「如何設計 RPC 接口」這樣的業務架構相關。程序員

clipboard.png

可能不少小公司程序員仍是比較關心這個問題的,這篇文章主要分享下一些我的眼中 RPC 接口設計的最佳實踐。spring

初識 RPC 接口設計

因爲 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 接口的痛點

統一好術語,這一節來描述下我曾經遭遇過的 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,公里的實踐中,大概作了以下的操做:

  • 請求入參統一校驗(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 模擬外部依賴接口實現本機環境。

clipboard.png

這塊涉及到了公司內部的代碼,只作下簡單介紹,咱們通常經過內部項目 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...做者:佔小狼

相關文章
相關標籤/搜索