Feign在实际项目中使用详解
- 简介
- 一 Feign客户端应该如何提供?
- 二 Feign调用的接口要不要进行包装?
- 2.1.问题描述
- 2.2.问题解决
- 三 Feign如何抓取业务生产端的业务异常?
- 3.1.分析
- 3.2.Feign捕获不到异常
- 3.3.异常被额外封装
- 3.4.解决方案
- 案例源码
简介
我们在平时学习中简单知道调用feign接口或者做服务降级;但是在企业级项目中使用feign时会面临以下几个问题:
- Feign客户端应该如何提供?
- Feign调用的接口要不要进行包装?
- Feign如何抓取业务生产端的业务异常?
一 Feign客户端应该如何提供?
feign接口到底改如何对外提供?
分析:
-
消费者端需要引用到这些feign接口,那么feign接口直接写在消费者项目中的话,那如果另外一个也需要feign接口那是不是又得写一遍!自然而然的就会考虑到将feign接口独立出来。谁需要feign接口谁添加相应的依赖即可。
-
feign接口中包含实体对象。那这些实体一般情况下我们都是在provider中,通过feign接口改造时我们需要将controller中用到的实体类进行提取。可以进行如下两种方式处理
方式一将实体类提取出来放在独立模块中,provider和feign接口分别依赖实体类模块;
方式二将实体类放在feign接口的模块中,provider依赖这个feign模块;
项目中按照方式一来处理的情况比较多,这样不会造成依赖到不需要使用的代码;
二 Feign调用的接口要不要进行包装?
2.1.问题描述
平前后端分离项目中,后端给前端返回接口数据时一般会统一返回格式;我们的Controller基本上会是这样的:
@GetMapping("getTest")
public Result<TestVO> getTest() {
TestVO testVO = new TestVO("1", "测试标题", "无内容", "小明");
return Result.success(testVO);
而Feign的接口定义需要跟实现类保持一致;
所以我们在使用这个方法的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没法公用,如一个接口user/get/{id}既可以通过feign调用也可以通过网关直接调用,采用这种方法就需要写2个不同路径的接口。 -
基于Feign拦截器实现
对于Feign的调用,在Feign拦截器上加上特殊标识,在转换对象时如果发现是feign调用就直接返回对象。
第二种方式具体实现步骤:
- 在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);
}
}
}
}
- 自定义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);
}
}
- 修改provider后端接口返回对象以及feign接口
如果为Feign请求,则不做转换,否则通过Result进行包装。
/**
* 对象返回值测试,是否能正常封装返回体
*/
@GetMapping("getOne")
public TestVO getOne() {
TestVO testVO = new TestVO("1", "测试标题", "无内容", "小明");
return testVO;
}
- 修改consumer模块中feign调用逻辑
不需要在接口上返回封装体ResultData,经由ResponseBodyAdvice实现自动增强。
@GetMapping("getOne")
public TestVO getOne() {
TestVO one = commentRestApi.getOne();
return one;
}
- 测试
在消费者端调用。发现控制台中调用feign接口返回的方法并没有被统一封装。
直接通过postman调用provider层方法。发现方法被统一封装了。
在正常情况下达到了我们优化目标,通过Feign调用直接返回实体对象,通过网关调用返回统一包装体。看上去很完美,但是实际很糟糕,这又导致了第三个问题,Feign如何处理异常?
三 Feign如何抓取业务生产端的业务异常?
3.1.分析
生产者对于提供的接口方法会进行业务规则校验,对于不符合业务规则的调用请求会抛出业务异常BusinessException,而正常情况下项目上会有个全局异常处理器,他会捕获业务异常BusinessException,并将其封装成统一包装体返回给调用方,现在让我们来模拟这种业务场景:
- 生产者抛出业务异常
模拟业务中名称为空
/**
* 对象返回值测试,是否能正常封装返回体
*/
@GetMapping("getOne")
public TestVO getOne() {
TestVO testVO = new TestVO("1", "测试标题", "无内容", "小明");
if (true) {
throw new BusinessException(ResultEnum.VALIDATE_FAILED.getCode(), "名称为空");
}
return testVO;
}
- 全局异常拦截器捕获业务异常
/**
* 捕获 自定 异常
*/
@ExceptionHandler({BusinessException.class}
public Result<?> handleBusinessException(BusinessException ex) {
log.error(ex.getMessage(), ex);
return Result.failed(ex.getCode(),ex.getMessage());
}
- 消费者端调用异常的feign接口
@Resource
CommentRestApi commentRestApi;
@GetMapping("getOne")
public TestVO getOne() {
TestVO one = commentRestApi.getOne();
System.out.println("one = " + one);
return one;
}
3.2.Feign捕获不到异常
- 观察结果
调用consumer中getOne()方法发现返回的信息中并没有异常,data中对象字段全部设置为null,如下:
查看provider端日志确实抛出了自定义异常:
将Feign的日志级别设置为FULL查看返回结果:
@Bean
Logger.Level feginLoggerLevel(){
return Logger.Level.FULL;
}
通过日志可以看到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());
}
这样消费者就可以正常捕获到生产者抛出的业务异常,如下图所示:
3.3.异常被额外封装
虽然能获取到异常,但是Feign捕获到异常后又在业务异常的基础上再进行了一次封装。
原因是当feign调用结果为非200的响应码时就触发了Feign的异常解析,Feign的异常解析器会将其包装成FeignException,即在我们业务异常的基础上再包装一次。
可以在feign.codec.ErrorDecoder#decode()方法上打上断点观察执行结果,如下:
很显然,这个包装后的异常我们并不需要,我们应该直接将捕获到的生产者的业务异常直接抛给前端,那这又该如何解决呢?
3.4.解决方案
很简单,我们只需要重写Feign的异常解析器,重新实现decode逻辑,返回正常的BusinessException即可,而后全局异常拦截器又会捕获BusinessException!(感觉有点无限套娃的感觉)
代码如下:
- 重写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 调用异常");
}
}
- 再次调用
provider层抛出的异常信息能够被consumer层捕获,并通过自定义的异常解析器处理成自定义异常,不再被默认的feign异常包装;抛出的自定义异常被统一返回封装处理。
案例源码
案例源码传送带