springcloud之通过openfeign优化服务调用方式

写在前面

源码

在前面的文章中我们实际上已经完成了优惠券模块微服务化的改造,但是其中还是有比较多可以优化和增强的地方,本文就先来对服务间的通信方式进行优化,具体就是使用openfeign来替换调原来的webclient。下面我们就开始吧!

1:为什么要替换webclient

使用webclient进行服务间调用的方式可能如下:

java 复制代码
webClientBuilder.build()
    // 声明这是一个POST方法
    .post()
    // 声明服务名称和访问路径
    .uri("http://coupon-calculation-serv/calculator/simulate")
    // 传递请求参数的封装
    .bodyValue(order)
    .retrieve()
    // 声明请求返回值的封装类型
    .bodyToMono(SimulationResponse.class)
    // 使用阻塞模式来获取结果
    .block()

这段代码有如下的不足:

1:和业务代码耦合,如请求地址,请求方式这些其实和业务是没有任何关系的,不符合指责隔离的原则
2:每个接口调用都需要写类似的重复代码,编码的效率低

针对以上的问题,springcloud给出的解决方案是openfeign ,可以认为openfeign是一种rpc框架允许我们通过好像调用一个本地的方法一样来调用远端的服务。

2:实战改造

2.1:引入openfeign依赖

首先我们需要在coupon-customer-impl的pom中引入openfeign的基础依赖:

<!-- OpenFeign组件 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

2.2:定义服务的service

我们以调用template服务为例来进行改造,因此首先在coupon-customer-impl模块中定义如下的service:

java 复制代码
@FeignClient(value = "coupon-template-serv-feign", 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中定义了要访问的服务名称以及要web接口的基础路径这样就不用重复在方法上配置了,通过注解@XxxMapping定义的接口的访问路径信息,通过方法的参数来定义入参信息,这样发起服务调用的完整信息就都全了。

2.3:改造接口调用

我们来修改接口/coupon-customer/simulateOrder 来执行试算,当前代码如下:

public SimulationResponse simulateOrderPrice(SimulationOrder order) {
    ...
    return webClientBuilder.build().post()
//                .uri("http://coupon-calculation-serv/calculator/simulate")
            .uri("http://coupon-calculation-serv-feign/calculator/simulate")
            .bodyValue(order)
            .retrieve()
            .bodyToMono(SimulationResponse.class)
            .block();
}            

修改为openfeign后如下:

@Autowired
private CalculationService calculationService;
public SimulationResponse simulateOrderPrice(SimulationOrder order) {
    List<CouponInfo> couponInfos = Lists.newArrayList();
    ...
    System.out.println("calculate by openfeign...");
    return calculationService.simulate(order);
}

最后还需要在main函数上增加注解@EnableFeignClients(basePackages = { "dongshi.daddy" })来设置需要扫描的openfeign服务接口所在的包路径。具体的大家可自行测试。效果是一样的。

3:openfeign原理分析

实战重要,但原理更重要,所以一起来看一波原理吧!

当我们在main上增加了@EnableFeignClients(basePackages = { "dongshi.daddy" })注解后,就会扫描指定包路径下标注了@FeignClient注解的接口,使用jdk的动态代理技术生成动态代理类,之后会将这个生成的动态代理类放到spring容器中,最后注入到需要的类中,这个过程如下:

看到这里不知道你有没有疑问,这个扫描包的过程是怎么开始的,其实秘密藏在@EnableFeignClients注解中,该注解如下:

java 复制代码
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(FeignClientsRegistrar.class)
public @interface EnableFeignClients {
    ...
}

注意在注解上使用了@Import注解,spring会调用类FeignClientsRegistrar的registerBeanDefinitions方法,如下:

java 复制代码
org.springframework.cloud.openfeign.FeignClientsRegistrar#registerBeanDefinitions
public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
    ...
    // 注册feign客户端(重要!!!)
    registerFeignClients(metadata, registry);
}

registerFeignClients方法如下:

java 复制代码
public void registerFeignClients(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
    // 最终存储所有openfeign的接口
    LinkedHashSet<BeanDefinition> candidateComponents = new LinkedHashSet<>();
    Map<String, Object> attrs = metadata.getAnnotationAttributes(EnableFeignClients.class.getName());
    final Class<?>[] clients = attrs == null ? null : (Class<?>[]) attrs.get("clients");
    if (clients == null || clients.length == 0) {
        ClassPathScanningCandidateComponentProvider scanner = getScanner();
        ...
        Set<String> basePackages = getBasePackages(metadata);
        for (String basePackage : basePackages) {
            candidateComponents.addAll(scanner.findCandidateComponents(basePackage));
        }
    }
    else {
        ...
    }

    for (BeanDefinition candidateComponent : candidateComponents) {
        if (candidateComponent instanceof AnnotatedBeanDefinition) {
            // verify annotated class is an interface
            ...
            // 注册feign客户端
            registerFeignClient(registry, annotationMetadata, attributes);
        }
    }
}

registerFeignClients方法如下:

java 复制代码
private void registerFeignClient(BeanDefinitionRegistry registry, AnnotationMetadata annotationMetadata,
        Map<String, Object> attributes) {
    String className = annotationMetadata.getClassName();
    ...
    FeignClientFactoryBean factoryBean = new FeignClientFactoryBean();
    ...
    BeanDefinitionBuilder definition = BeanDefinitionBuilder.genericBeanDefinition(clazz, () -> {
        ...
        // 获取基于jdk的动态代理类
        return factoryBean.getObject();
    });
    ...
}

factoryBean.getObject方法最终调用到如下方法:

java 复制代码
feign.ReflectiveFeign#newInstance
public <T> T newInstance(Target<T> target) {
    // 解析openfeign方法为MethodHandler,作为方法代理
    Map<String, MethodHandler> nameToHandler = targetToHandlersByName.apply(target);
    Map<Method, MethodHandler> methodToHandler = new LinkedHashMap<Method, MethodHandler>();
    List<DefaultMethodHandler> defaultMethodHandlers = new LinkedList<DefaultMethodHandler>();

    for (Method method : target.type().getMethods()) {
      ...
    }
    // 封装methodToHandler创建动态代理要使用的InvocationHandler
    InvocationHandler handler = factory.create(target, methodToHandler);
    // 生成动态代理
    T proxy = (T) Proxy.newProxyInstance(target.type().getClassLoader(),
        new Class<?>[] {target.type()}, handler);

    ...
    // 返回动态代理
    return proxy;
  }

到这里就成功获取动态代理类了。总结这个过程如下:

1:项目加载:在项目的启动阶段,EnableFeignClients 注解扮演了"启动开关"的角色,它使用 Spring 框架的 Import 注解导入了 FeignClientsRegistrar 类,开始了 OpenFeign 组件的加载过程。
2:扫包:FeignClientsRegistrar 负责 FeignClient 接口的加载,它会在指定的包路径下扫描所有的 FeignClients 类,并构造 FeignClientFactoryBean 对象来解析 FeignClient 接口。
3:解析 FeignClient 注解:FeignClientFactoryBean 有两个重要的功能,一个是解析 FeignClient 接口中的请求路径和降级函数的配置信息;另一个是触发动态代理的构造过程。其中,动态代理构造是由更下一层的 ReflectiveFeign 完成的。
4:构建动态代理对象:ReflectiveFeign 包含了 OpenFeign 动态代理的核心逻辑,它主要负责创建出 FeignClient 接口的动态代理对象。ReflectiveFeign 在这个过程中有两个重要任务,一个是解析 FeignClient 接口上各个方法级别的注解,将其中的远程接口 URL、接口类型(GET、POST 等)、各个请求参数等封装成元数据,并为每一个方法生成一个对应的 MethodHandler 类作为方法级别的代理;另一个重要任务是将这些 MethodHandler 方法代理做进一步封装,通过 Java 标准的动态代理协议,构建一个实现了 InvocationHandler 接口的动态代理对象,并将这个动态代理对象绑定到 FeignClient 接口上。这样一来,所有发生在 FeignClient 接口上的调用,最终都会由它背后的动态代理对象来承接。

最后上述流程中解析接口中方法和注解信息为MethodHandler的过程在如下方法中完成:

java 复制代码
// org.springframework.cloud.openfeign.support.SpringMvcContract#processAnnotationOnMethod
// 解析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<>());
}

4:openfeign的高级用法

本部分看下openfeign的高级用法,包括,请求日志打印(类似于chrome的network抓包),超时判定,服务降级

4.1:日志打印

在custom模块配置日志级别为debug:

logging:
  level:
    com.broadview.coupon: debug
    dongshi.daddy.feign.rpc.CalculationService: debug
    dongshi.daddy.feign.rpc.TemplateService: debug

在custom的configuration类中配置日志输出的详细程度:

@Bean
Logger.Level feignLogger() {
    return Logger.Level.FULL;
}

这里配置为FULL,即打印最详细的信息,比较全的定义如下:

java 复制代码
// feign.Logger.Level
/**
* Controls the level of logging.
*/
public enum Level {
    /**
     * No logging. 啥也不打印,也是一种特殊的打印,没毛病
     */
    NONE,
    /**
     * Log only the request method and URL and the response status code and execution time. 打印请求方法,url,响应状态码,执行时间等
     */
    BASIC,
    /**
     * Log the basic information along with request and response headers. 在BASIC的基础上增加头信息打印
     */
    HEADERS,
    /**
     * Log the headers, body, and metadata for both requests and responses. 在HEADERS的基础上增加body打印,一般这种,因为post参数都是在body中的,而我们排查问题一般都要看参数的
     */
    FULL
}

接着测试:

http://localhost:20001/coupon-customer/simulateOrder

{
    "products": [
        {
            "price": 3000,
            "count": 2,
            "shopId": 3
        },
        {
            "price": 1000,
            "count": 10,
            "shopId": 1
        }
    ],
    "couponIDs": [
        10
    ],
    "userId": 1
}

日志打印:

2024-01-02 10:50:57.255 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] ---> POST http://coupon-calculation-serv-feign/calculator/simulate HTTP/1.1
2024-01-02 10:50:57.256 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] Content-Length: 456
2024-01-02 10:50:57.256 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] Content-Type: application/json
2024-01-02 10:50:57.256 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] 
2024-01-02 10:50:57.256 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] {"products":[{"productId":null,"price":3000,"count":2,"shopId":3},{"productId":null,"price":1000,"count":10,"shopId":1}],"couponIDs":[10],"couponInfos":[{"id":10,"templateId":2,"userId":1,"shopId":null,"status":1,"template":{"id":2,"name":"晚间双倍立减券","desc":"满50随机立减最多5元,晚间减10元","type":"4","shopId":1000,"rule":{"discount":{"quota":500,"threshold":5000},"limitation":10,"deadline":null},"available":true}}],"userId":1}
2024-01-02 10:50:57.256 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] ---> END HTTP (456-byte body)
2024-01-02 10:50:57.603 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] <--- HTTP/1.1 200 (346ms)
2024-01-02 10:50:57.603 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] connection: keep-alive
2024-01-02 10:50:57.603 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] content-type: application/json
2024-01-02 10:50:57.604 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] date: Tue, 02 Jan 2024 02:50:57 GMT
2024-01-02 10:50:57.604 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] keep-alive: timeout=60
2024-01-02 10:50:57.604 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] transfer-encoding: chunked
2024-01-02 10:50:57.604 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] 
2024-01-02 10:50:57.604 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] {"bestCouponId":10,"couponToOrderPrice":{"10":16000}}
2024-01-02 10:50:57.604 DEBUG 14396 --- [io-20001-exec-3] d.daddy.feign.rpc.CalculationService     : [CalculationService#simulate] <--- END HTTP (53-byte body)

4.2:超时判定

配置超过一定请求的等待时长后就按照超时处理。首先在custom模块中配置超时配置:

feign:
  client:
    config:
      # 全局超时配置
      default:
        # 网络连接阶段1秒超时
        connectTimeout: 1000
        # 服务请求响应阶段5秒超时
        readTimeout: 5000
      # 针对某个特定服务的超时配置
      coupon-template-serv-feign:
        connectTimeout: 1000
        readTimeout: 2000

注意单位是毫秒。

为了模拟超时,我们在template模块的接口dongshi.daddy.feign.controller.CouponTemplateController#getTemplate中手动增加一个sleep:

java 复制代码
// 读取优惠券
@GetMapping("/getTemplate")
public CouponTemplateInfo getTemplate(@RequestParam("id") Long id){
    log.info("Load template, id={}", id);
    try {
        // 休眠二十秒模拟超时
        TimeUnit.SECONDS.sleep(20);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return couponTemplateService.loadTemplateInfo(id);
}

接着测试:

http://localhost:20001/coupon-customer/requestCoupon

{
    "userId": 1,
    "couponTemplateId": 2
}

{
    "timestamp": "2024-01-02T03:22:48.683+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "message": "Read timed out executing GET http://coupon-template-serv-feign/template/getTemplate?id=2",
    "path": "/coupon-customer/requestCoupon"
}

4.3:降级

场景,超时后执行降级逻辑。首先在custom模块加入依赖:

xml 复制代码
<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>

接着开启降级:

feign:
  ...
  circuitbreaker:
    enabled: true

实现远程调用服务接口定义降级类:

java 复制代码
@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;
    }
}

配置降级类:

@FeignClient(value = "coupon-template-serv-feign", path = "/template",
        fallback = TemplateServiceFallback.class)
//        fallbackFactory = TemplateServiceFallbackFactory.class)
public interface TemplateService {
    // 读取优惠券
    @GetMapping("/getTemplate")
    CouponTemplateInfo getTemplate(@RequestParam("id") Long id);
    
    // 批量获取
    @GetMapping("/getBatch")
    Map<Long, CouponTemplateInfo> getTemplateInBatch(@RequestParam("ids") Collection<Long> ids);
}

测试:

http://localhost:20001/coupon-customer/requestCoupon

{
    "userId": 1,
    "couponTemplateId": 2
}

{
    "timestamp": "2024-01-02T05:56:50.772+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "message": "Invalid template id",
    "path": "/coupon-customer/requestCoupon"
}

降级类还可以使用降级工厂,定义如下:

java 复制代码
@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, cause: " + cause.getMessage());
                return null;
            }

            @Override
            public Map<Long, CouponTemplateInfo> getTemplateInBatch(Collection<Long> ids) {
                log.info("fallback factory method test");
                return Maps.newHashMap();
            }
        };
    }
}

接着配置降级工厂:

@FeignClient(value = "coupon-template-serv-feign", path = "/template",
//        fallback = TemplateServiceFallback.class)
        fallbackFactory = TemplateServiceFallbackFactory.class)
public interface TemplateService {
   ...
}

测试:

http://localhost:20001/coupon-customer/requestCoupon

{
    "userId": 1,
    "couponTemplateId": 2
}

{
    "timestamp": "2024-01-02T06:04:37.660+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "message": "Invalid template id",
    "path": "/coupon-customer/requestCoupon"
}

写在后面

参考文章列表

限流,熔断,降级分析

相关推荐
ajsbxi1 小时前
苍穹外卖学习记录
java·笔记·后端·学习·nginx·spring·servlet
鹿屿二向箔2 小时前
基于SSM(Spring + Spring MVC + MyBatis)框架的咖啡馆管理系统
spring·mvc·mybatis
NoneCoder2 小时前
Java企业级开发系列(1)
java·开发语言·spring·团队开发·开发
paopaokaka_luck9 小时前
【360】基于springboot的志愿服务管理系统
java·spring boot·后端·spring·毕业设计
Yaml411 小时前
Spring Boot 与 Vue 共筑二手书籍交易卓越平台
java·spring boot·后端·mysql·spring·vue·二手书籍
aloha_78911 小时前
从零记录搭建一个干净的mybatis环境
java·笔记·spring·spring cloud·maven·mybatis·springboot
wyh要好好学习13 小时前
SpringMVC快速上手
java·spring
尢词13 小时前
SpringMVC
java·spring·java-ee·tomcat·maven
茶馆大橘13 小时前
微服务系列五:避免雪崩问题的限流、隔离、熔断措施
java·jmeter·spring cloud·微服务·云原生·架构·sentinel
wrx繁星点点13 小时前
享元模式:高效管理共享对象的设计模式
java·开发语言·spring·设计模式·maven·intellij-idea·享元模式