Feign在实际项目中使用详解

Feign在实际项目中使用详解

  • 简介
  • 一 Feign客户端应该如何提供?
  • 二 Feign调用的接口要不要进行包装?
    • 2.1.问题描述
    • 2.2.问题解决
  • 三 Feign如何抓取业务生产端的业务异常?
    • 3.1.分析
    • 3.2.Feign捕获不到异常
    • 3.3.异常被额外封装
    • 3.4.解决方案
  • 案例源码

简介

我们在平时学习中简单知道调用feign接口或者做服务降级;但是在企业级项目中使用feign时会面临以下几个问题:

  1. Feign客户端应该如何提供?
  2. Feign调用的接口要不要进行包装?
  3. Feign如何抓取业务生产端的业务异常?

一 Feign客户端应该如何提供?

feign接口到底改如何对外提供?
分析:

  1. 消费者端需要引用到这些feign接口,那么feign接口直接写在消费者项目中的话,那如果另外一个也需要feign接口那是不是又得写一遍!自然而然的就会考虑到将feign接口独立出来。谁需要feign接口谁添加相应的依赖即可。

  2. feign接口中包含实体对象。那这些实体一般情况下我们都是在provider中,通过feign接口改造时我们需要将controller中用到的实体类进行提取。可以进行如下两种方式处理
    方式一将实体类提取出来放在独立模块中,provider和feign接口分别依赖实体类模块;
    方式二将实体类放在feign接口的模块中,provider依赖这个feign模块;
    项目中按照方式一来处理的情况比较多,这样不会造成依赖到不需要使用的代码;

    Feign在实际项目中使用详解_第1张图片

二 Feign调用的接口要不要进行包装?

2.1.问题描述

平前后端分离项目中,后端给前端返回接口数据时一般会统一返回格式;我们的Controller基本上会是这样的:

    @GetMapping("getTest")
    public Result<TestVO> getTest() {
        TestVO testVO = new TestVO("1", "测试标题", "无内容", "小明");
        return Result.success(testVO);
    

而Feign的接口定义需要跟实现类保持一致;

Feign在实际项目中使用详解_第2张图片
所以我们在使用这个方法的feign接口时,情况是这样的。

    @GetMapping("getContent")
    public Result<String> getContent() {
        String content=null;
        Result<TestVO> test = commentRestApi.getTest();
        if (test.isSuccess()) {
            TestVO data = test.getData();
             content = data.getContent();
        }else {
            throw new  RuntimeException(test.getMessage());
        }
        
        return Result.success(content);
    }

这里要先获取到​​Result​​​包装类,再通过判断返回结果解成具体的​​TestVO ​​对象,很明显这段代码有两个问题:

  • 每个Controller接口都需要手动使用Result.success对结果进行包
  • Feign调用时又需要从包装类解装成需要的实体对象

那项目中的接口有很多很多个,不断的做这种操作是不是太鸡肋了!!!无疑是增加了不必要的开发负担。

2.2.问题解决

优化的目标也很明确:​

  • 当我们通过Feign调用时,直接获取到实体对象,不需要额外的解装。
  • 前端通过网关直接调用时,返回统一的包装体。

这里我们可以借助​​ResponseBodyAdvice​​来实现,通过对Controller返回体进行增强,如果识别到是Feign的调用就直接返回对象,否则给我们加上统一包装结构。(SpringBoot统一封装controller层返回的结果)

新的问题: 如何识别出是Feign的调用还是网关直接调用呢?

基于自定义注解实现和基于Feign拦截器实现。

  • ​基于自定义注解实现​

    自定义一个注解,比如@ResponseNotIntercept​​,给Feign的接口标注上此注解,这样在使用ResponseBodyAdvice匹配时可以通过此注解进行匹配。
    Feign在实际项目中使用详解_第3张图片
    不过这种方法有个弊端,就是前端和feign没法公用,如一个接口​​user/get/{id}​​既可以通过feign调用也可以通过网关直接调用,采用这种方法就需要写2个不同路径的接口。

  • ​基于Feign拦截器实现​

    对于Feign的调用,在Feign拦截器上加上特殊标识,在转换对象时如果发现是feign调用就直接返回对象。

Feign在实际项目中使用详解_第4张图片
Feign在实际项目中使用详解_第5张图片

第二种方式具体实现步骤:

  1. 在feign拦截器中给feign请求添加特定请求头​​T_REQUEST_ID

/**
 * @ClassName: OpenFeignConfig Feign拦截器
 * @Description: 对于Feign的调用,在请求头中加上特殊标识
 * @Author: wang xiao le
 * @Date: 2023/08/25 23:13
 **/
@ConditionalOnClass(Feign.class)
@Configuration
public class OpenFeignConfig implements RequestInterceptor {

    /**
     * Feign请求唯一标识
     */
    public static final String T_REQUEST_ID = "T_REQUEST_ID";


    /**
     * get请求标头
     *
     * @param request 请求
     * @return {@link Map }<{@link String }, {@link String }>
     * @Author wxl
     * @Date 2023-08-27
     **/
    private Map<String, String> getRequestHeaders(HttpServletRequest request) {
        Map<String, String> map = new HashMap<>(16);
        Enumeration headerNames = request.getHeaderNames();
        while (headerNames.hasMoreElements()) {
            String key = (String) headerNames.nextElement();
            String value = request.getHeader(key);
            map.put(key, value);
        }
        return map;
    }

    @Override
    public void apply(RequestTemplate requestTemplate) {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (null != attributes) {
            HttpServletRequest request = attributes.getRequest();
            Map<String, String> headers = getRequestHeaders(request);

            // 传递所有请求头,防止部分丢失
            for (Map.Entry<String, String> entry : headers.entrySet()) {
                requestTemplate.header(entry.getKey(), entry.getValue());
            }

            // 微服务之间传递的唯一标识,区分大小写所以通过httpServletRequest获取
            if (request.getHeader(T_REQUEST_ID) == null) {
                String sid = String.valueOf(UUID.randomUUID());
                requestTemplate.header(T_REQUEST_ID, sid);
            }

        }
    }


}

  1. 自定义CommonResponseResult并实现ResponseBodyAdvice​​
/**
 * 如果引入了swagger或knife4j的文档生成组件,这里需要仅扫描自己项目的包,否则文档无法正常生成
 *
 * @RestControllerAdvice(basePackages = "com.wxl52d41")
 * @ClassName: CommonResponseResult
 * @Description: controller返回结果统一封装
 * @Author wxl
 * @Date 2023-08-27
 * @Version 1.0.0
 **/
@RestControllerAdvice
public class CommonResponseResult implements ResponseBodyAdvice<Object> {
    /**
     * 支持注解@ResponseNotIntercept,使某些方法无需使用Result封装
     *
     * @param returnType    返回类型
     * @param converterType 选择的转换器类型
     * @return true 时会执行beforeBodyWrite方法,false时直接返回给前端
     */
    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        if (returnType.getDeclaringClass().isAnnotationPresent(ResponseNotIntercept.class)) {
            //若在类中加了@ResponseNotIntercept 则该类中的方法不用做统一的拦截
            return false;
        }
        if (returnType.getMethod().isAnnotationPresent(ResponseNotIntercept.class)) {
            //若方法上加了@ResponseNotIntercept 则该方法不用做统一的拦截
            return false;
        }
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                  Class<? extends HttpMessageConverter<?>> selectedConverterType,
                                  ServerHttpRequest request, ServerHttpResponse response) {

        if (request.getHeaders().containsKey(OpenFeignConfig.T_REQUEST_ID)) {
            //Feign请求时通过拦截器设置请求头,如果是Feign请求则直接返回实体对象
            return body;
        }
        if (body instanceof Result) {
            // 提供一定的灵活度,如果body已经被包装了,就不进行包装
            return body;
        }
        if (body instanceof String) {
            //解决返回值为字符串时,不能正常包装
            return JSON.toJSONString(Result.success(body));
        }
        return Result.success(body);
    }
}

  1. 修改provider后端接口返回对象以及feign接口
    如果为Feign请求,则不做转换,否则通过Result进行包装。
    /**
     * 对象返回值测试,是否能正常封装返回体
     */
    @GetMapping("getOne")
    public TestVO getOne() {
        TestVO testVO = new TestVO("1", "测试标题", "无内容", "小明");
        return testVO;
    }

Feign在实际项目中使用详解_第6张图片

  1. 修改consumer模块中feign调用逻辑
    不需要在接口上返回封装体ResultData,经由ResponseBodyAdvice实现自动增强。
   @GetMapping("getOne")
    public TestVO getOne() {
        TestVO one = commentRestApi.getOne();
        return one;
    }

  1. 测试
    在消费者端调用。发现控制台中调用feign接口返回的方法并没有被统一封装。
    Feign在实际项目中使用详解_第7张图片

直接通过postman调用provider层方法。发现方法被统一封装了。

Feign在实际项目中使用详解_第8张图片

在​正常情况下​达到了我们优化目标,通过Feign调用直接返回实体对象,通过网关调用返回统一包装体。看上去很完美,但是实际很糟糕,这又导致了第三个问题,Feign如何处理异常?

三 Feign如何抓取业务生产端的业务异常?

3.1.分析

生产者对于提供的接口方法会进行业务规则校验,对于不符合业务规则的调用请求会抛出业务异常​​BusinessException​​,而正常情况下项目上会有个全局异常处理器,他会捕获业务异常BusinessException,并将其封装成统一包装体返回给调用方,现在让我们来模拟这种业务场景:

  1. 生产者抛出业务异常
    模拟业务中名称为空
    /**
     * 对象返回值测试,是否能正常封装返回体
     */
    @GetMapping("getOne")
    public TestVO getOne() {
        TestVO testVO = new TestVO("1", "测试标题", "无内容", "小明");
        if (true) {
            throw new BusinessException(ResultEnum.VALIDATE_FAILED.getCode(), "名称为空");
        }
        return testVO;
    }

  1. 全局异常拦截器捕获业务异常
   /**
     * 捕获 自定 异常
     */
    @ExceptionHandler({BusinessException.class}
    public Result<?> handleBusinessException(BusinessException ex) {
        log.error(ex.getMessage(), ex);
        return Result.failed(ex.getCode(),ex.getMessage());
    }
  1. 消费者端调用异常的feign接口
    @Resource
    CommentRestApi commentRestApi;


    @GetMapping("getOne")
    public TestVO getOne() {
        TestVO one = commentRestApi.getOne();
        System.out.println("one = " + one);
        return one;
    }

3.2.Feign捕获不到异常

  1. 观察结果
    调用consumer中getOne()方法发现返回的信息中并没有异常,data中对象字段全部设置为null,如下:
    Feign在实际项目中使用详解_第9张图片
    查看provider端日志确实抛出了自定义异常:
    Feign在实际项目中使用详解_第10张图片
    将Feign的日志级别设置为FULL查看返回结果:
    @Bean
    Logger.Level feginLoggerLevel(){
        return Logger.Level.FULL;
    }

Feign在实际项目中使用详解_第11张图片
通过日志可以看到Feign其实获取到了全局异常处理器转换后的统一对象Result,并且响应码为200,正常响应。而消费者接受对象为TestVO,属性无法转换,全部当作NULL值处理。

很显然,这不符合我们正常业务逻辑,我们应该要直接返回生产者抛出的异常,​那如何处理呢?​

很简单,我们只需要给全局异常拦截器中​业务异常设置一个非200的响应码​即可,如:
在这里插入图片描述

    /**
     * 捕获 自定 异常
     */
    @ExceptionHandler({BusinessException.class})
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<?> handleBusinessException(BusinessException ex) {
        log.error(ex.getMessage(), ex);
        return Result.failed(ex.getCode(),ex.getMessage());
    }

这样消费者就可以正常捕获到生产者抛出的业务异常,如下图所示:Feign在实际项目中使用详解_第12张图片

3.3.异常被额外封装

虽然能获取到异常,但是Feign捕获到异常后又在业务异常的基础上再进行了一次封装。

原因是​当feign调用结果为非200的响应码时就触发了Feign的异常解析,Feign的异常解析器会将其包装成FeignException,即在我们业务异常的基础上再包装一次​。

可以在​​feign.codec.ErrorDecoder#decode()​​方法上打上断点观察执行结果,如下:
Feign在实际项目中使用详解_第13张图片
很显然,这个包装后的异常我们并不需要,我们应该直接将捕获到的生产者的业务异常直接抛给前端,那这又该如何解决呢?

3.4.解决方案

很简单,​我们只需要重写Feign的异常解析器,重新实现decode逻辑,返回正常的BusinessException即可,而后全局异常拦截器又会捕获BusinessException!​(感觉有点无限套娃的感觉)

代码如下:

  1. 重写Feign异常解析器
/**
 * @ClassName: OpenFeignErrorDecoder
 * @Description: 解决Feign的异常包装,统一返回结果
 * @Author wxl
 * @Date 2023-08-26
 * @Version 1.0.0
 **/
@Configuration
public class OpenFeignErrorDecoder implements ErrorDecoder {
    /**
     * Feign异常解析
     *
     * @param methodKey 方法名
     * @param response  响应体
     * @return {@link Exception }
     * @Author wxl
     * @Date 2023-08-26
     **/
    @SneakyThrows
    @Override
    public Exception decode(String methodKey, Response response) {
        //获取数据
        String body = Util.toString(response.body().asReader(Charset.defaultCharset()));
        Result<?> result = JSON.parseObject(body, Result.class);
        if (!result.isSuccess()) {
            return new BusinessException(result.getStatus(), result.getMessage());
        }
        return new BusinessException(500, "Feign client 调用异常");
    }
    
}

  1. 再次调用
    provider层抛出的异常信息能够被consumer层捕获,并通过自定义的异常解析器处理成自定义异常,不再被默认的feign异常包装;抛出的自定义异常被统一返回封装处理。Feign在实际项目中使用详解_第14张图片Feign在实际项目中使用详解_第15张图片
    Feign在实际项目中使用详解_第16张图片

案例源码

案例源码传送带

你可能感兴趣的:(#,SpringCloud,java)