良好的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...做者:占小狼

相关文章
相关标签/搜索