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"
}

写在后面

参考文章列表

限流,熔断,降级分析

相关推荐
Miketutu1 小时前
Spring MVC消息转换器
java·spring
小小虫码3 小时前
项目中用的网关Gateway及SpringCloud
spring·spring cloud·gateway
带刺的坐椅8 小时前
无耳科技 Solon v3.0.7 发布(2025农历新年版)
java·spring·mvc·solon·aop
精通HelloWorld!11 小时前
使用HttpClient和HttpRequest发送HTTP请求
java·spring boot·网络协议·spring·http
LUCIAZZZ12 小时前
基于Docker以KRaft模式快速部署Kafka
java·运维·spring·docker·容器·kafka
拾忆,想起12 小时前
如何选择Spring AOP的动态代理?JDK与CGLIB的适用场景
spring boot·后端·spring·spring cloud·微服务
鱼骨不是鱼翅14 小时前
Spring Web MVC基础第一篇
前端·spring·mvc
hong_zc16 小时前
Spring MVC (三) —— 实战演练
java·spring·mvc
Future_yzx17 小时前
Spring AOP 入门教程:基础概念与实现
java·开发语言·spring
安清h17 小时前
【基于SprintBoot+Mybatis+Mysql】电脑商城项目之用户注册
数据库·后端·mysql·spring·mybatis