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