在前边章节中,我们借助 Nacos 的服务发现能力,使用 WebClient 实现了服务间调用。从功能层面上来讲,我们已经完美地实现了微服务架构下的远程服务调用,但是从易用性的角度来看,这种实现方式似乎对开发人员并不怎么友好。为了发起一个服务请求,我把整个服务调用的所有信息都写在了代码中,从请求类型、请求路径、再到封装的参数和返回类型。编程体验相当麻烦不说,更关键的是这些代码没有很好地践行职责隔离的原则。
1 OpenFeign 介绍
在业务层中我们应该关注具体的业务实现,而 WebClient 的远程调用引入了很多与业务无关的概念,比如请求地址、请求类型等等。从职责分离的角度来说,我们应该尽量把这些业务无关的逻辑,从业务代码中剥离出去。
那么,Spring Cloud 中有没有一个组件,在实现远程服务调用的同时,既能满足简单易用的接入要求,又能很好地将业务无关的代码与业务代码隔离开呢?因此接下来将介绍 Spring Cloud 中的一个叫做 OpenFeign 的组件,看看它是如何简化远程服务调用的。
OpenFeign 组件的前身是 Netflix Feign 项目,它最早是作为 Netflix OSS 项目的一部分,由 Netflix 公司开发。后来 Feign 项目被贡献给了开源组织,于是才有了我们今天使用的 Spring Cloud OpenFeign 组件。
OpenFeign 提供了一种声明式的远程调用接口,它可以大幅简化远程调用的编程体验。在了解 OpenFeign 的原理之前,我们先来体验一下 OpenFeign 的最终疗效。下边是一个 Hello World 的小案例,带你看一下由 OpenFeign 发起的远程服务调用的代码风格是什么样的。
String response = helloWorldService.hello("Yinyu Y.");
使用 OpenFeign 组件来实现远程调用非常简单,就像我们使用本地方法一样,只要一行代码就能实现 WebClient 组件好几行代码干的事情。而且这段代码不包含任何业务无关的信息,完美实现了调用逻辑和业务逻辑之间的职责分离。
2 OpenFeign 动态代理原理
OpenFeign 使用了一种“动态代理”技术来封装远程服务调用的过程,我们在上面的例子中看到的 helloWorldService 其实是一个特殊的接口,它是由 OpenFeign 组件中的 FeignClient 注解所声明的接口,接口中的代码如下所示。
@FeignClient(value = "hello-world-serv")
public interface HelloWorldService {
@PostMapping("/sayHello")
String hello(String guestName);
}
远程服务调用的信息被写在了 FeignClient 接口中。在上面的代码里,你可以看到,服务的名称、接口类型、访问路径已经通过注解做了声明。OpenFeign 通过解析这些注解标签生成一个“动态代理类”,这个代理类会将接口调用转化为一个远程服务调用的 Request,并发送给目标服务。
在项目初始化阶段,OpenFeign 会生成一个代理类,对所有通过该接口发起的远程调用进行动态代理:
上图中的步骤 1 到步骤 3 是在项目启动阶段加载完成的,只有第 4 步“调用远程服务”是发生在项目的运行阶段。
下面解释一下上图中的几个关键步骤:
首先,在项目启动阶段,OpenFeign 框架会发起一个主动的扫包流程,从指定的目录下扫描并加载所有被 @FeignClient 注解修饰的接口。
然后,OpenFeign 会针对每一个 FeignClient 接口生成一个动态代理对象,即图中的 FeignProxyService,这个代理对象在继承关系上属于 FeignClient 注解所修饰的接口的实例。
接下来,这个动态代理对象会被添加到 Spring 上下文中,并注入到对应的服务里,也就是图中的 LocalService 服务。
最后,LocalService 会发起底层方法调用。实际上这个方法调用会被 OpenFeign 生成的代理对象接管,由代理对象发起一个远程服务调用,并将调用的结果返回给 LocalService。
那么OpenFeign 是如何通过动态代理技术创建代理对象的:
项目加载:在项目的启动阶段,EnableFeignClients 注解扮演了“启动开关”的角色,它使用 Spring 框架的 Import 注解导入了 FeignClientsRegistrar 类,开始了 OpenFeign 组件的加载过程。
扫包:FeignClientsRegistrar 负责 FeignClient 接口的加载,它会在指定的包路径下扫描所有的 FeignClients 类,并构造 FeignClientFactoryBean 对象来解析 FeignClient 接口。
解析 FeignClient 注解:FeignClientFactoryBean 有两个重要的功能,一个是解析 FeignClient 接口中的请求路径和降级函数的配置信息;另一个是触发动态代理的构造过程。其中,动态代理构造是由更下一层的 ReflectiveFeign 完成的。
构建动态代理对象:ReflectiveFeign 包含了 OpenFeign 动态代理的核心逻辑,它主要负责创建出 FeignClient 接口的动态代理对象。ReflectiveFeign 在这个过程中有两个重要任务,一个是解析 FeignClient 接口上各个方法级别的注解,将其中的远程接口 URL、接口类型(GET、POST 等)、各个请求参数等封装成元数据,并为每一个方法生成一个对应的 MethodHandler 类作为方法级别的代理;另一个重要任务是将这些 MethodHandler 方法代理做进一步封装,通过 Java 标准的动态代理协议,构建一个实现了 InvocationHandler 接口的动态代理对象,并将这个动态代理对象绑定到 FeignClient 接口上。这样一来,所有发生在 FeignClient 接口上的调用,最终都会由它背后的动态代理对象来承接。
那么上面说到的“元数据的解析”是如何完成的呢?它依赖于 OpenFeign 组件中的 Contract 协议解析功能。Contract 是 OpenFeign 组件中定义的顶层抽象接口,它有一系列的具体实现,其中和我们实战项目有关的是 SpringMvcContract 这个类,从这个类的名字中我们就能看出来,它是专门用来解析 Spring MVC 标签的。
SpringMvcContract 的继承结构是 SpringMvcContract->BaseContract->Contract。这里拿一段 SpringMvcContract 的代码,帮助你深入理解它是如何将注解解析为元数据的。这段代码的主要功能是解析 FeignClient 方法级别上定义的 Spring MVC 注解。
// 解析FeignClient接口方法级别上的RequestMapping注解
protected void processAnnotationOnMethod(MethodMetadata data, Annotation methodAnnotation, Method method) {
// 省略部分代码...
// 如果方法上没有使用RequestMapping注解,则不进行解析
// 其实GetMapping、PostMapping等注解都属于RequestMapping注解
if (!RequestMapping.class.isInstance(methodAnnotation)
&& !methodAnnotation.annotationType().isAnnotationPresent(RequestMapping.class)) {
return;
}
// 获取RequestMapping注解实例
RequestMapping methodMapping = findMergedAnnotation(method, RequestMapping.class);
// 解析Http Method定义,即注解中的GET、POST、PUT、DELETE方法类型
RequestMethod[] methods = methodMapping.method();
// 如果没有定义methods属性则默认当前方法是个GET方法
if (methods.length == 0) {
methods = new RequestMethod[] { RequestMethod.GET };
}
checkOne(method, methods, "method");
data.template().method(Request.HttpMethod.valueOf(methods[0].name()));
// 解析Path属性,即方法上写明的请求路径
checkAtMostOne(method, methodMapping.value(), "value");
if (methodMapping.value().length > 0) {
String pathValue = emptyToNull(methodMapping.value()[0]);
if (pathValue != null) {
pathValue = resolve(pathValue);
// 如果path没有以斜杠开头,则补上/
if (!pathValue.startsWith("/") && !data.template().path().endsWith("/")) {
pathValue = "/" + pathValue;
}
data.template().uri(pathValue, true);
if (data.template().decodeSlash() != decodeSlash) {
data.template().decodeSlash(decodeSlash);
}
}
}
// 解析RequestMapping中定义的produces属性
parseProduces(data, method, methodMapping);
// 解析RequestMapping中定义的consumer属性
parseConsumes(data, method, methodMapping);
// 解析RequestMapping中定义的headers属性
parseHeaders(data, method, methodMapping);
data.indexToExpander(new LinkedHashMap<>());
}
通过上面的方法,我们可以看到,OpenFeign 对 RequestMappings 注解的各个属性都做了解析。
如果你在项目中使用的是 GetMapping、PostMapping 之类的注解,没有使用 RequestMapping,那么 OpenFeign 还能解析吗?当然可以。以 GetMapping 为例,它对 RequestMapping 注解做了一层封装。如果你查看下面关于 GetMapping 注解的代码,你会发现这个注解头上也挂了一个 RequestMapping 注解。因此 OpenFeign 可以正确识别 GetMapping 并完成加载。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.GET)
public @interface GetMapping {
// ...省略部分代码
}
到这里,相信你已经了解了 OpenFeign 的工作流程,下边将 coupon-customer-serv 中的 WebClient 调用替换为基于 OpenFeign 的远程服务调用。
3 OpenFeign 实现服务间调用
OpenFeign 能实现对实战项目中的 WebClient 请求做大幅度的简化,让跨服务请求就像调用本地方法一样简单。
本章节改造的项目是 coupon-customer-serv 服务,因为它内部需要调用 template 和 calculation 两个服务完成自己的业务逻辑,非常适合用 Feign 来做跨服务调用的改造。
在集成 OpenFeign 组件之前,我们需要把它的依赖项 spring-cloud-starter-OpenFeign 添加到 coupon-customer-impl 子模块内的 pom.xml 文件中。
<!-- OpenFeign组件 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
在上面的代码中,你并不需要指定组件的版本号,因为我们在顶层项目中定义的 spring-cloud-dependencies 依赖项中已经定义了各个 Spring Cloud 的版本号,它们会随着 Maven 项目的继承关系传递到子模块中。
添加好依赖项之后,我们就可以进行大刀阔斧的 OpenFeign 改造了。在 coupon-customer-impl 子模块下的 CouponCustomerServiceImpl 类中,我们通过 WebClient 分别调用了 template 和 calculation 的服务。本章节将带你对 template 的远程调用过程进行改造,将其替换为 OpenFeign 风格的调用。
3.1 改造 Template 远程调用
OpenFeign 组件通过接口代理的方式发起远程调用,那么我们改造过程的第一步就是要定义一个 OpenFeign 接口。
这需要在 coupon-customer-impl 项目下创建了一个 package,它的路径是 com.yinyu.coupon.customer.feign。在这个路径下我定义了一个叫做 TemplateService 的 Interface,用来实现对 coupon-template-serv 的远程调用代理。我们来看一下这个接口的源代码。
@FeignClient(value = "coupon-template-serv", path = "/template")
public interface TemplateService {
// 读取优惠券
@GetMapping("/getTemplate")
CouponTemplateInfo getTemplate(@RequestParam("id") Long id);
// 批量获取
@GetMapping("/getBatch")
Map<Long, CouponTemplateInfo> getTemplateInBatch(@RequestParam("ids") Collection<Long> ids);
}
在上面的代码中,我们在接口上声明了一个 FeignClient 注解,它专门用来标记被 OpenFeign 托管的接口。在 FeignClient 注解中声明的 value 属性是目标服务的名称,在代码中我指定了 coupon-template-serv,你需要确保这里的服务名称和 Nacos 服务器上显示的服务注册名称是一样的。
此外,FeignClient 注解中的 path 属性是一个可选项,如果你要调用的目标服务有一个统一的前置访问路径,比如 coupon-template-serv 所有接口的访问路径都以 /template 开头,那么你可以通过 path 属性来声明这个前置路径,这样一来,你就不用在每一个方法名上的注解中带上前置 Path 了。
在项目的启动阶段,OpenFeign 会查找所有被 FeignClient 注解修饰的接口,并代理该接口的所有方法调用。当我们调用接口方法的时候,OpenFeign 就会根据方法上定义的注解自动拼装 HTTP 请求路径和参数,并向目标服务发起真实调用。
因此,我们还需要在方法上定义 spring-web 注解(如 GetMapping、PostMapping),让 OpenFeign 拼装出正确的 Request URL 和请求参数。这时你要注意,OpenFeign 接口中定义的路径和参数必须与你要调用的目标服务中的保持一致。
接下来你就可以替换 CouponCustomerServiceImpl 中的业务逻辑调用了。首先,我们在 CouponCustomerServiceImpl 接口中注入刚才定义的 TemplateService 接口。
@Autowired
private TemplateService templateService;
然后,我们就可以对具体的业务逻辑进行替换了。以 CouponCustomerServiceImpl 类中的 placeOrder 下单接口为例,其中有一步是调用 coupon-template-serv 获取优惠券模板数据,这个服务请求是使用 WebClient 发起的,我们来看一下改造之前的方法实现。
webClientBuilder.build().get()
.uri("http://coupon-template-serv/template/getTemplate?id=" + templateId)
.retrieve()
.bodyToMono(CouponTemplateInfo.class)
.block();
从上面的代码中你可以看出,我们写了一大长串的代码,只为了发起一次服务请求。如果使用 OpenFeign 接口来替换,那画风就不一样了,我们看一下改造后的服务调用过程。
templateService.getTemplate(couponInfo.getTemplateId())
你可以看到,使用 OpenFeign 接口发起远程调用就像使用本地服务一样简单。和 WebClient 的调用方式相比,OpenFeign 组件不光可以提高代码可读性和可维护性,还降低了远程调用的 Coding 成本。
在 CouponCustomerServiceImpl 类中的 findCoupon 方法里,我们调用了 coupon-template-serv 的批量查询接口获取模板信息,这个过程也可以使用 OpenFeign 接口实现,下面是具体的实现代码。
// 获取这些优惠券的模板ID
List<Long> templateIds = coupons.stream()
.map(Coupon::getTemplateId)
.distinct()
.collect(Collectors.toList());
// 发起请求批量查询券模板
Map<Long, CouponTemplateInfo> templateMap = templateService
.getTemplateInBatch(templateIds);
其他的地方也是类似的,到这里,我们已经把 template 服务的远程调用改成了 OpenFeign 接口调用的方式。
3.2 改造 Calculation 远程调用
首先,我们在 TemplateService 同样的目录下创建一个新的接口,名字是 CalculationService,后面你会使用它作为 coupon-calculation-serv 的代理接口。我们来看一下这个接口的源码。
@FeignClient(value = "coupon-calculation-serv", path = "/calculator")
public interface CalculationService {
// 订单结算
@PostMapping("/checkout")
ShoppingCart checkout(ShoppingCart settlement);
// 优惠券试算
@PostMapping("/simulate")
SimulationResponse simulate(SimulationOrder simulator);
}
我在接口类之上声明了一个 FeignClient 注解,指向了 coupon-calculation-serv 服务,并且在 path 属性中注明了服务访问的前置路径是 /calculator 👇
@FeignClient(value = "coupon-calculation-serv", path = "/calculator")
public interface CalculationService {
// 优惠券结算
@PostMapping("/checkout")
ShoppingCart checkout(ShoppingCart settlement);
// 优惠券列表挨个试算
// 给客户提示每个可用券的优惠额度,帮助挑选
@PostMapping("/simulate")
SimulationResponse simulate(SimulationOrder simulator);
}
在接口中我还定义了两个方法,分别指向 checkout 用户下单接口和 simulate 优惠券试算接口,这两个接口的访问路径和 coupon-calculation-serv 中定义的路径是一模一样的。
有了前面 template 服务的改造经验,相信你应该很轻松就能搞定 calculation 服务调用的改造。首先,我们需要把刚才定义的 CalculationService 注入到 CouponCustomerServiceImpl 中。
@Autowired
private CalculationService calculationService;
然后,你只用在调用 coupon-calculation-serv 服务的地方,将 WebClient 调用替换成下面这种 OpenFeign 调用的方式就可以了:
// order清算
ShoppingCart checkoutInfo = calculationService.checkout(order);
// order试算
calculationService.simulate(order)
到这里,我们就完成了 template 和 calculation 服务调用过程的改造。在我们启动项目来验证改造成果之前,还有最为关键的一步需要完成,那就是配置 OpenFeign 的加载路径。
3.3 配置 OpenFeign 的加载路径
此时,如果你是使用的是 IDEA 等编译器,那么注入 template 和 calculation 服务将会报错 👇
因此我们打开 coupon-customer-serv 项目的启动类,你可以通过在类名之上添加一个 EnableFeignClients 注解的方式定义 OpenFeign 接口的加载路径,你可以参考以下代码。
// 省略其他无关注解
@EnableFeignClients(basePackages = {"com.geekbang"})
public class Application {
}
在这段代码中,我们在 EnableFeignClients 注解的 basePackages 属性中定义了一个 com.yinyu 的包名,这个注解就会告诉 OpenFeign 在启动项目的时候做一件事儿:找到所有位于 com.yinyu 包路径(包括子 package)之下使用 FeignClient 修饰的接口,然后生成相关的代理类并添加到 Spring 的上下文中。这样一来,我们才能够在项目中用 Autowired 注解注入 OpenFeign 接口。
上面就是使用包路径扫描的方式来加载 FeignClient 接口。除此之外,你还可以通过直接加载指定 FeignClient 接口类的方式,或者从指定类所在的目录进行扫包的方式来加载 FeignClient 接口:
// 通过指定Client类来加载
@EnableFeignClients(clients = {TemplateService.class, CalculationService.class})
// 扫描特定类所在的包路径下的FeignClient
@EnableFeignClients(basePackageClasses = {TemplateService.class})
在这三种加载方式中,我比较推荐你在项目中使用一劳永逸的“包路径”加载的方式。因为不管以后你添加了多少新的 FeignClient 接口,只要这些接口位于 com.geekbang 包路径之下,你就不用操心加载路径的配置。
到这里,我们就完成了 OpenFeign 的实战项目改造,你可以在本地启动项目来验证改造后的程序是否可以正常工作。
问题:如果你的服务需要暴露给很多业务方使用,每个业务方都要维护一套独立的 OpenFeign 接口似乎也不太方便,你能想到什么更好的接口管理办法吗?
解决:每个服务提供方的api模块单独添加一个openfeign的模块,服务调用方添加对应的openfeign模块即可
4 OpenFeign 组件进阶技巧
本章节将进一步深入 OpenFeign 的功能特性,学习几个 OpenFeign 的进阶使用技巧:异常信息排查、超时判定和服务降级。
异常信息排查是我们开发人员每天都要面对的事情。如果你正在开发一个大型微服务应用,你经常需要集成一些由其他团队开发的 API,这就免不了要参与各种联调和问题排查。如果你是一个经验丰富的老码农,那你一定经常说这样一句话:“你的 Request 参数是什么?”这句台词在我们平时的 API 联调和线上异常排查中出镜率很高,因为服务请求的入参和出参是分析和排查问题的重要线索。
为了获得服务请求的参数和返回值,我们经常使用的一个做法就是打印日志。你可以在程序中使用 log.info 或者 log.debug 方法将服务请求的入参和返回值一一打印出来。但是,对一些复杂的业务场景来说就没有那么轻松了。
假如你在开发的是一个下单服务,执行一次下单流程前前后后要调用十多个微服务。你需要在请求发送的前后分别打印 Request 和 Response,不仅麻烦不说,我们还未必能把包括 Header 在内的完整请求信息打印出来。我们如何才能引入一个既简单又不需要硬编码的日志打印功能,让它自动打印所有远程方法的 Request 和 Response,方便我们做异常信息排查呢?接下来介绍一个 OpenFeign 的小功能,轻松实现远程调用参数的日志打印。
4.1 日志信息打印
为了让 OpenFeign 可以主动将请求参数打印到日志中,我们需要做两个代码层面的改动。
首先,你需要在配置文件中指定 FeignClient 接口的日志级别为 Debug。这样做是因为 OpenFeign 组件默认将日志信息以 debug 模式输出,而默认情况下 Spring Boot 的日志级别是 Info,因此我们必须将应用日志的打印级别改为 debug 后才能看到 OpenFeign 的日志。
我们打开 coupon-customer-impl 模块的 application.yml 配置文件,在其中加上以下几行 logging 配置项。
logging:
level:
com.geekbang.coupon.customer.feign.TemplateService: debug
com.geekbang.coupon.customer.feign.CalculationService: debug
在上面的配置项中,我指定了 TemplateService 和 CalculationService 的日志级别为 debug,而其它类的日志级别不变,仍然是默认的 Info 级别。
接下来,你还需要在应用的上下文中使用代码的方式声明 Feign 组件的日志级别。这里的日志级别并不是我们传统意义上的 Log Level,它是 OpenFeign 组件自定义的一种日志级别,用来控制 OpenFeign 组件向日志中写入什么内容。你可以打开 coupon-customer-impl 模块的 Configuration 配置类,在其中添加这样一段代码。
@Bean
Logger.Level feignLogger() {
return Logger.Level.FULL;
}
在上面这段代码中,我指定了 OpenFeign 的日志级别为 Full,在这个级别下所输出的日志文件将会包含最详细的服务调用信息。OpenFeign 总共有四种不同的日志级别,我来带你了解一下这四种级别下 OpenFeign 向日志中写入的内容。
NONE:不记录任何信息,这是 OpenFeign 默认的日志级别
BASIC:只记录服务请求的 URL、HTTP Method、响应状态码(如 200、404 等)和服务调用的执行时间
HEADERS:在 BASIC 的基础上,还记录了请求和响应中的 HTTP Headers
FULL:在 HEADERS 级别的基础上,还记录了服务请求和服务响应中的 Body 和 metadata,FULL 级别记录了最完整的调用信息
我们将 Feign 的日志级别指定为 Full,并启动项目发起一个远程调用,你就可以在日志中看到整个调用请求的信息,包括请求路径、Header 参数、Request Payload 和 Response Body。以下是调用日志的示例,你可以参考一下。
---> POST http://coupon-calculation-serv/calculator/simulate HTTP/1.1
Content-Length: 458
Content-Type: application/json
{"products":[{"productId":null,"price":3000, xxxx省略请求参数
---> END HTTP (458-byte body)
<--- HTTP/1.1 200 (29ms)
connection: keep-alive
content-type: application/json
date: Sat, 27 Nov 2021 15:11:26 GMT
keep-alive: timeout=60
transfer-encoding: chunked
{"bestCouponId":26,"couponToOrderPrice":{"26":15000}}
<--- END HTTP (53-byte body)
有了这些详细的日志信息,你在开发联调阶段排查异常问题就易如反掌了。到这里,我们就详细了解了 OpenFeign 的日志级别设置。接下来将介绍如何在 OpenFeign 中配置超时判定条件。
4.2 OpenFeign 超时判定
超时判定是一种保障可用性的手段。如果你要调用的目标服务的 RT(Response Time)值非常高,那么你的调用请求也会处于一个长时间挂起的状态,这是造成服务雪崩的一个重要因素。为了隔离下游接口调用超时所带来的的影响,我们可以在程序中设置一个超时判定的阈值,一旦下游接口的响应时间超过了这个阈值,那么程序会自动取消此次调用并返回一个异常。
我们以 coupon-customer-serv 为例,customer 服务依赖 template 服务来读取优惠券模板的信息,如果你想要对 template 的远程服务调用添加超时判定配置,那么我们可以在 coupon-customer-impl 模块下的 application.yml 文件中添加下面的配置项。
feign:
client:
config:
# 全局超时配置
default:
# 网络连接阶段1秒超时
connectTimeout: 1000
# 服务请求响应阶段5秒超时
readTimeout: 5000
# 针对某个特定服务的超时配置
coupon-template-serv:
connectTimeout: 1000
readTimeout: 2000
从上面这段代码中可以看出,所有超时配置都放在 feign.client.config 路径之下,我在这个路径下面声明了两个节点:default 和 coupon-template-serv。
default 节点配置了全局层面的超时判定规则,它的生效范围是所有 OpenFeign 发起的远程调用。coupon-template-serv 下面配置的超时规则只针对向 template 服务发起的远程调用。如果你想要对某个特定服务配置单独的超时判定规则,那么可以用同样的方法,在 feign.client.config 下添加目标服务名称和超时判定规则。
这里需要你注意的一点是,如果你同时配置了全局超时规则和针对某个特定服务的超时规则,那么后者的配置会覆盖全局配置,并且优先生效。
在超时判定的规则中我定义了两个属性:connectTimeout 和 readTimeout。其中,connectTimeout 的超时判定作用于“建立网络连接”的阶段;而 readTimeout 的超时判定则作用于“服务请求响应”的阶段(在网络连接建立之后)。我们常说的 RT(即服务响应时间)受后者影响比较大。另外,这两个属性对应的超时时间单位都是毫秒。
配置好超时规则之后,我们可以验证一下。你可以在 template 服务中使用 Thread.sleep 方法强行让线程挂起几秒钟,制造一个超时场景。这时如果你通过 customer 服务调用了 template 服务,那么在日志中可以看到下面的报错信息,提示你服务请求超时。
[TemplateService#getTemplate] <--- ERROR SocketTimeoutException: Read timed out (2077ms)
[TemplateService#getTemplate] java.net.SocketTimeoutException: Read timed out
到这里,相信你已经清楚如何通过 OpenFeign 的配置项来设置超时判定规则了。接下来了解一下 OpenFeign 是如何通过降级来处理服务异常的。
4.3 OpenFeign 降级
降级逻辑是在远程服务调用发生超时或者异常(比如 400、500 Error Code)的时候,自动执行的一段业务逻辑。你可以根据具体的业务需要编写降级逻辑,比如执行一段兜底逻辑将服务请求从失败状态中恢复,或者发送一个失败通知到相关团队提醒它们来线上排查问题。
降级逻辑是在远程服务调用发生超时或者异常(比如 400、500 Error Code)的时候,自动执行的一段业务逻辑。你可以根据具体的业务需要编写降级逻辑,比如执行一段兜底逻辑将服务请求从失败状态中恢复,或者发送一个失败通知到相关团队提醒它们来线上排查问题。
后边会使用 Spring Cloud Alibaba 的组件 Sentinel 讲解如何搭建中心化的服务容错控制逻辑,这是一种重量级的服务容错手段。
借助 OpenFeign 实现 Client 端的服务降级,尽管它的功能远不如 Sentinel 强大,但它相比于 Sentinel 而言更加轻量级且容易实现,足以满足一些简单的服务降级业务需求。
OpenFeign 对服务降级的支持是借助 Hystrix 组件实现的,由于 Hystrix 已经从 Spring Cloud 组件库中被移除,所以我们需要在 coupon-customer-impl 子模块的 pom 文件中手动添加 hystrix 项目的依赖。
<!-- hystrix组件,专门用来演示OpenFeign降级 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
<version>2.2.10.RELEASE</version>
<exclusions>
<!-- 移除Ribbon负载均衡器,避免冲突 -->
<exclusion>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-netflix-ribbon</artifactId>
</exclusion>
</exclusions>
</dependency>
添加好依赖项之后,我们就可以编写 OpenFeign 的降级类了。OpenFeign 支持两种不同的方式来指定降级逻辑,一种是定义 fallback 类,另一种是定义 fallback 工厂。
通过 fallback 类实现降级是最为简单的一种途径,如果你想要为 TemplateService 这个 FeignClient 接口指定一段降级流程,那么我们可以定义一个降级类并实现 TemplateService 接口,可以参考一下这个 TemplateServiceFallback 类。
@Slf4j
@Component
public class TemplateServiceFallback implements TemplateService {
@Override
public CouponTemplateInfo getTemplate(Long id) {
log.info("fallback getTemplate");
return null;
}
@Override
public Map<Long, CouponTemplateInfo> getTemplateInBatch(Collection<Long> ids) {
log.info("fallback getTemplateInBatch");
return null;
}
}
在上面的代码中,我们可以看出 TemplateServiceFallback 实现了 TemplateService 中的所有方法。我们以其中的 getTemplate 方法为例,如果在实际的方法调用过程中,OpenFeign 接口的 getTemplate 远程调用发生了异常或者超时的情况,那么 OpenFeign 会主动执行对应的降级方法,也就是 TemplateServiceFallback 类中的 getTemplate 方法。
你可以根据具体的业务场景,编写合适的降级逻辑。
降级类定义好之后,你还需要在 TemplateService 接口中将 TemplateServiceFallback 类指定为降级类,这里你可以借助 FeignClient 接口的 fallback 属性来配置,你可以参考下面的代码。
@FeignClient(value = "coupon-template-serv", path = "/template",
// 通过fallback指定降级逻辑
fallback = TemplateServiceFallback.class)
public interface TemplateService {
// ... 省略方法定义
}
如果你想要在降级方法中获取到异常的具体原因,那么你就要借助 fallback 工厂的方式来指定降级逻辑了。按照 OpenFeign 的规范,自定义的 fallback 工厂需要实现 FallbackFactory 接口,可以参考一下这个 TemplateServiceFallbackFactory 类。
@Slf4j
@Component
public class TemplateServiceFallbackFactory implements FallbackFactory<TemplateService> {
@Override
public TemplateService create(Throwable cause) {
// 使用这种方法你可以捕捉到具体的异常cause
return new TemplateService() {
@Override
public CouponTemplateInfo getTemplate(Long id) {
log.info("fallback factory method test");
return null;
}
@Override
public Map<Long, CouponTemplateInfo> getTemplateInBatch(Collection<Long> ids) {
log.info("fallback factory method test");
return Maps.newHashMap();
}
};
}
}
从上面的代码中,你可以看出,抽象工厂 create 方法的入参是一个 Throwable 对象。这样一来,我们在降级方法中就可以获取到原始请求的具体报错异常信息了。
当然了,你还需要将这个工厂类添加到 TemplateService 注解中,这个过程和指定 fallback 类的过程有一点不一样,你需要借助 FeignClient 注解的 fallbackFactory 属性来完成。你可以参考下面的代码。
@FeignClient(value = "coupon-template-serv", path = "/template",
// 通过抽象工厂来定义降级逻辑
fallbackFactory = TemplateServiceFallbackFactory.class)
public interface TemplateService {
// ... 省略方法定义
}
到这里,我们就完成了 OpenFeign 进阶功能的学习。
以下是实践层面的建议:
在日志打印方面,OpenFeign 的日志信息是测试开发联调过程中的好帮手,但是在生产环境中你是用不上的,因为几乎所有公司的生产环境都不会使用 Debug 级别的日志,最多是 Info 级别。
在超时判定方面,有时候我们在线上会使用多维度的超时判定,比如 OpenFeign + 网关层超时判定 + Sentinel 等等判定。它们可以互相作为兜底方案,一旦某个环节突然发生故障,另一个可以顶上去。但这就形成了一个木桶理论,也就是几种判定规则中最严格的那个规则会优先生效。
关于服务降级的方案选型,很多开发人员过于追求功能强大的新技术,但我们做技术选型的时候也要考虑开发成本和维护成本。
比如像 Sentinel 这类中心化的服务容错控制台,它的功能固然强大,各种花式玩法它都考虑到了。但相对应地,如果你要在项目中引入 Sentinel,在运维层面你要多维护一个 Sentinel 服务集群,并且在代码中接入 Sentinel 也是一个成本项。如果你只需要一些简单的降级功能,那 OpenFeign+Hystrix 的 Client 端降级方案就完全可以满足你的要求,我认为没必要拿大炮打苍蝇,过于追求一步到位的高大上方案。