如何基于 Spring Cloud Feign 实现强类型接口调用RESTful服务
文章目录
- 如何基于 Spring Cloud Feign 实现强类型接口调用RESTful服务
- 背景
- 问题
- 方案
- 实践
- 代码仓库
- 公众号
背景
编程语言的强弱类型
编程语言有强、弱类型之分,进一步,还有动态、静态之分;比方说 Java、C# 是强类型的,而且是静态语言;那么JavaScript、php是弱类型的,而且是动态语言。
强类型静态语言 常常会被称为类型安全,强类型的语言一般在编译期会做强制类型检查,提前避免这些类型错误。
弱类型动态语言 虽然也有类型的概念,但是比较松散灵活,弱类型动态语言大多是解释型语言,一般没有强制类型检查,类型问题一般要在运行期才会暴露出来。
强弱类型语言各有优劣,相互补充,各有适用的场景;比方说服务器端开发,我们经常用强类型的;前端界面开发,我们经常会用这种 JavaScript 弱类型的,所以他们各有适用的场景。
服务 API 接口的强弱类型
对于服务 API 接口,它也有强弱类型之分,传统的这种RPC服务一般是强类型的,RPC通常采用定制的二进制协议对消息进行编码和解码,采用TCP传输消息。
RPC服务通常有严格的契约,也就是 contract 这样一个概念;开发服务前,先要定义 IDL(Interface description language,接口描述语言) ,用 IDL 定义契约,再通过这个契约自动生成强类型的服务器端和客户端的接口,服务调用的时候直接使用强类型客户端,不需要再手动进行消息的编码和解码。
GRPC 和 Thrif 是目前主流的两种强类型的 RPC 框架,而现代的 RESTful 服务一般是弱类型的。
RESTful 服务 通常采用 JSON 作为传输消息,然后使用 HTTP 作为传输协议。 RESTful 服务一般没有严格的契约的概念,它使用普通的 http client 就可以调用,但是调用方通常需要对这个JSON消息进行手动编码和解码工作。
在现实世界当中,大部分服务框架都是弱类型 RESTful 服务框架,比方说 Java 生态当中, Spring Boot 可以认为是目前主流的弱类型 RESTful 框架之一,当然这种区分不是业界标准,只是基于经验总结出来的一种区分的方法。
问题
强类型服务接口的优点: 是接口规范、自动代码生成、自动编码解码、编译期自动类型检查。
强类型服务接口的缺点:
- 首先,是客户端和服务器端的强耦合,任何一方升级改动可能会造成另一方break;
- 另外,自动代码生成需要工具支持,而开发这些工具的成本也比较高;
- 还有,强类型接口开发测试不太友好,一般浏览器、Postman这样的工具无法直接访问这些强类型接口。
弱类型服务接口的优点: 是客户端和服务器端不强耦合,不需要开发特别的代码生成工具,一般的 http client 就可以调用;
开发测试友好,普通的浏览器、Postman可以轻松访问。
弱类型服务接口的缺点: 是需要调用方手动的编码解码消息;没有自动代码生成,没有编译期接口类型检查,代码不容易规范;开发效率相对低,而且容易出现运行期的错误。
问题: 那么我们在服务开发过程中如何设计强类型接口?有没有办法结合强弱类型服务接口各自的好处,同时又规避他们的不足呢?
方案
方案: 在 Spring RESTful 服务弱类型接口的基础上,借助 Spring Cloud Feign 支持的强类型接口调用的特性,实现了强类型 REST 接口调用机制,同时兼备强弱类型服务接口的好处。
Spring Cloud Feign 本质上是一种动态代理机制,你只要给出一个 RESTful API 对应的 Java 接口,它就可以在运行期动态的拼装出对应接口的强类型客户端,这个拼装出来的客户端的简化结构和请求响应的流程,如下图:
客户端请求响应的流程:
- 当客户应用发起一个请求,传过来一个请求 Bean(Request Bean),这个请求通过 Java 接口,首先会被动态代理截获,然后通过相应的编码器(Encoder)进行编码,成为 Request JSON;
- Request JSON 根据需要,可以经过一些拦截器(Interceptor),做进一步处理,处理完以后,传递给 HTTP Client。由他将这个 Request JSON 通过 HTTP 协议发送到服务器端;
- 当服务器端响应回来的时候,相应的 Request JSON 首先会被 HTTP Client 接收到,同样可以经过一些拦截器做进一步的响应处理;
- 然后转发给解码器(Decoder)进行解码,解码成 Response Bean,最后这个 Response Bean 再通过 Java 接口返回给调用方。
整个请求响应流程的关键步骤就是编码和解码
- 也就是 Java 对象和 JSON 消息的互转,这个过程也被称为序列化和反序列化,另外一个叫法,就是叫 JSON 对象绑定。
- 对一个服务框架而言,序列化、反序列化器的性能,对框架的性能影响是最大的;可以认为 Encoder 和 Decoder 决定了服务框架的总体的性能。
虽然我们开发出来的服务是弱类型的 RESTful 的服务,但是因为有 Spring Cloud Feign 的支持,我们只要简单的给出一个强类型的 Java API 接口(这个工作量不大的),就自动获得了强类型的客户端。
也就是说,利用 Spring Cloud Feign ,我们可以同时获得强弱类型的好处,包括:编译期自动类型检查、不需要手动编码解码、不需要开发代码生成工具、而且客户端和服务器端不强耦合。这样可以同时规范代码风格,提升开发测试效率。
这也就解释了为什么每个微服务都是由两个子模块组成的:一个是api接口模块,一个是服务实现模块,api接口模块里就是强类型的 Java API 接口,包括请求响应这些 DTO ,它可以直接被 Spring Cloud Feign 引用并动态拼装出这个强类型的客户端。
例:account服务,对应的有 account-api 就是接口模块,account-svc就是服务实现模块。并且,account-svc也是依赖account-api的,这种接口和实现分离的组织方式,一方面代码结构比较清晰,另一方面也益于这个接口模块的重用。
当然,这个技术仅限于 Java Spring 开发的客户端,如果是其他语言的客户端,一方面仍然可以采用基本的 HTTP Client 的方式去调用,也可以基于服务接口 Swagger 文档自动生成其他语言的强类型客户端。
采用强类型接口,如何做到既能同时处理正常响应又能处理异常的响应,而且这个接口又是统一的呢?
- 问题:控制器可能会返回正常的响应,也可能会返回异常响应。
- 应对:采用了一种特殊的设计方式,我们的响应 Response 对象都是继承自 BaseResponse 对象。
例:当客户端向服务器端的 Controller 发送请求操作的时候:
- 如果响应异常,那么他就只返回对应的 BaseResponse 异常 JSON 消息,因为 ListAccountResponse 继承自 BaseResponse ,所以这个异常 JSON 消息也可以序列化自动绑定到 ListAccountResponse 上。只不过绑定后,ListAccountResponse 对象自己扩展的字段为空,没有数据而已,但他还是一个正常的 ListAccountResponse 对象。然后,客户端根据 Response 的错误码指示进行异常处理即可。
- 如果响应正常,返回 ListAccountResponse 的 JSON 消息就可以被正常的绑定到 ListAccountResponse 对象上。
通过这种简单的继承机制,我们实现了一个强类型接口,可以同时处理正常、异常两种情况。
注意:网上也有种做法,采用的是泛型加组合的方式来设计 Response,也就是说具体响应作为泛型被包含在一个通用的响应中的这种方式。如果用 Spring Cloud Feign 这种泛型设计是不能work的,因为 Java 中的泛型信息在运行期会被擦除。
封装消息+捎带
目前,业界的 RESTful API 的设计,通常采用 HTTP 协议状态码来传递和表达错误语义。
但是我们的设计是将错误码打包在一个正常的 JSON 消息当中,也就是 BaseResponse 当中。这是一种称为封装消息加上捎带的一种设计模式,这样的设计的目标是为了支持强类型的客户端,同时简化和规范这个错误处理。
如果借用 HTTP 协议状态码来传递和表达错误语义,虽然也可以开发对应的强类型客户端,但是内部的调用处理逻辑就会变得比较复杂。你要去处理各种 HTTP 的错误码,开发成本会比较高,没有我们这个简单。
提醒: 除了 BaseReponse 这个继承之外,如果你在建模响应的时候,尽量内部不要再使用其他的继承关系,或者是泛型也尽量的不要采用。除了是那种 collection ,它的泛型是可以的,其他的泛型或者是继承关系,尽量避免。否则还是容易出反序列化问题的。
实践
1.创建项目
创建项目 rpc-feign-rest-demo ,其子项目代码组织如下:
<modules>
<module>common-lib</module>
<module>account-api</module>
<module>account-svc</module>
<module>demo-web</module>
</modules>
- common-lib 公共依赖模块
- account-api account服务接口模块
- account-svc account服务实现模块
- demo-web 服务间强类型接口调用验证模块
2.引入依赖
rpc-feign-rest-demo 父pom指定 Spring Cloud 和 Spring Boot 的版本:Greenwich.SR6 + 2.1.18.RELEASE。
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring.boot.version>2.1.18.RELEASE</spring.boot.version>
<spring.cloud.version>Greenwich.SR6</spring.cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring Cloud -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring.cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
common-lib 公共模块引入公用 spring-boot-starter-web 和 spring-cloud-starter-openfeign 依赖 。
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>
3.实现 RESTful API 服务端
使用 @Validated
注解开启请求接口参数校验,@Valid
指定校验参数。
@RestController
@RequestMapping(AccountConstant.ACCOUNT_V1+"/account")
@Validated
public class AccountController {
/**
* 创建新账户
*/
@PostMapping(path = "/create")
@Authorize(value = {
AuthConstant.AUTHORIZATION_SUPPORT_USER,
AuthConstant.AUTHORIZATION_DEMO_WEB_SERVICE
})
public GenericAccountResponse createAccount(@RequestBody @Valid CreateAccountRequest request){
//...
GenericAccountResponse response = new GenericAccountResponse(accountDto);
return response;
}
//...
}
4.编写强类型客户端接口
通过 url 指定 account 服务调用地址 。
@FeignClient(name = AccountConstant.SERVICE_NAME, path = AccountConstant.ACCOUNT_V1+"/account", url = "${rpc.account-service-endpoint}")
public interface AccountClient {
/**
* 创建新账户
*/
@PostMapping(path = "/create")
GenericAccountResponse createAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid CreateAccountRequest request);
//...
}
5.客户端调用范例
1) 开启 Feign Client 调用
在调用服务启动类使用 EnableFeignClients
开启 Feign Client 调用,并配置 client 包,可以配置多个。
@EnableFeignClients(basePackages = {"com.chen.solution.rpc.account"})
@SpringBootApplication
public class DemoWebApplication {
public static void main(String[] args) {
SpringApplication.run(DemoWebApplication.class, args);
}
}
2) 启动 account-svc 服务
启动用户服务 AccountApplication 。
3) 服务间调用 Feign Client 范例
@RestController
@RequestMapping("/account/client")
@Validated
public class AccountDemoController {
@Resource
private AccountClient accountClient;
@PostMapping("/create")
public GenericAccountResponse createAccount(@RequestBody @Valid CreateAccountRequest request){
GenericAccountResponse resp = null;
try {
resp = accountClient.createAccount(AuthConstant.AUTHORIZATION_DEMO_WEB_SERVICE, request);
} catch (Exception ex) {
String errMsg = "could not create account";
handleErrorAndThrowException(log, ex, errMsg);
}
if (!resp.isSuccess()) {
handleErrorAndThrowException(log, resp.getMessage());
}
AccountDto account = resp.getAccount();
GenericAccountResponse response = new GenericAccountResponse(account);
return response;
}
//...
}
4) 单元测试范例
@RunWith(SpringRunner.class)
@SpringBootTest
@EnableFeignClients(basePackages = {"com.chen.solution.rpc.account.api.client"})
@Import(TestWebConfig.class)
public class AccountControllerTest {
@Resource
private AccountClient accountClient;
@Test
public void testUpdateAccount(){
String name = "testAccount";
String email = "test@sin.com";
String phoneNumber = "18234523421";
CreateAccountRequest createAccountRequest = CreateAccountRequest.builder()
.name(name)
.email(email)
.phoneNumber(phoneNumber)
.build();
// create one account
GenericAccountResponse accountResponse = accountClient.createAccount(AuthConstant.AUTHORIZATION_DEMO_WEB_SERVICE, createAccountRequest);
log.info(accountResponse.toString());
assertThat(accountResponse.isSuccess()).isTrue();
//...
}
//...
}
6.微服务之间调用的权限控制
通过自定义注解 @Authorize
对服务接口进行权限控制,在接口声明时,通过注解控制接口被允许访问的业务服务。
@PostMapping(path = "/create")
@Authorize(value = {
AuthConstant.AUTHORIZATION_SUPPORT_USER,
AuthConstant.AUTHORIZATION_DEMO_WEB_SERVICE
})
public GenericAccountResponse createAccount(@RequestBody @Valid CreateAccountRequest request){
//...
}
通过继承 HandlerInterceptorAdapter 自定义请求拦截 AuthorizeInterceptor ,实现接口访问权限控制。
public class AuthorizeInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(!(handler instanceof HandlerMethod)){
return true;
}
HandlerMethod handlerMethod = (HandlerMethod)handler;
Authorize authorize = handlerMethod.getMethod().getAnnotation(Authorize.class);
if(authorize == null){
// no need to authorize
return true;
}
//...
return true;
}
}