Spring Cloud OpenFeign 一种实践方式
OpenFeign是Spring Cloud全家桶中最重要的一个RPC工具,本文想归纳一下自己两年多来使用Feign的一些实践经验,希望本文能对读者有所指引和帮助。
一、问题的提出
作为项目构建者,我们需要思考项目和开发者分别需要什么样的一种RPC,也就是我们面对的技术需求。
站在项目的角度:
1. 请求失败可以自动重试;
2. 重试的次数和间隔可以配置;
3. 失败之后可以有日志记录,可以获取到请求参数和返回值;
站在开发者的角度:
4. 开发者不想为每一个接口的每一个方法写一堆类似的fallback或fallbackFactory;
5. 在1的基础上,开发者希望调用Feign失败时没有异常,无需try catch,但是可以通过返回值判断调用是否成功以及具体原因。
对于大部分项目来讲,请求的资源(URL)没有backup,所以也就无法进行有效的fallback。fallback或者fallbackFactory里面大概率只是返回了空,保证不报异常,所以本文不讨论熔断降级此类需求。
6. 引入最少的第三方组件和配置。
二、阅读官方手册
(1) Decoder用于解析http的返回值,具体来讲是解析feign.Response对象,Spring Cloud提供了默认bean ResponseEntityDecoder,它可以将Json格式返回值反序列化为对象;
(2) Encoder用于请求参数到RequestBody之前的转换,默认为SpringEncoder;
(3) Logger是日志组件,默认为Slf4jLogger;
(4) Contract用于处理annotation,默认是SpringMvcContract;
(5) Feign.Builder用于构建FeignClient组件,默认是HystrixFeign.Builder;
(6) Client,客户端负载,环境中同时提供了spring-cloud-starter-netflix-ribbon和spring-cloud-starter-loadbalancer两种负载,但是均未启用,默认是FeignClient。
(7) Logger.Level是日志级别,包括:
(a) NONE,默认,不记录日志;
(b) BASIC,记录Request method,URL,Response Status code和执行时间;
(c) HEADERS,记录基本信息外加请求和相应头信息;
(d) FULL,记录请求和相应全部信息,包括header,body和metadata。
(8) Retryer,重试器,提供了默认实现Retryer.Default,但并未注入到环境中;
(9) ErrorDecoder,错误解码器,当返回状态码不属于2xx号段时,该实现将会被调用。同Retryer一样,它也有默认实现ErrorDecoder.Default,但并未注入到环境中;
(10) Request.Options,Feign的配置项;
(11) Collection<RequestInterceptor>,拦截器列表,可以定义统一的拦截器;
(12) SetterFactory,用于控制hystrix命令;
(13) QueryMapEncoder,将POJO或Map对象转为Get参数的解析器。
标记橙色底色的项目,是本文需要用到的项目。
三、解决方案
1. 解决问题1,我们可以自己实现Retryer并在其中自定义重试的算法和规则,我们也可以直接使用Feign提供的默认Retryer。
先来看看Feign提供的默认Retryer:
默认实现有三个构造函数参数,分别是period(重试间隔),maxPeriod(最大重试间隔)和maxAttempts(重试次数)。它的核心方法是continueOrPropagate,它决定是否继续重试,注意它的参数类型RetryableException,凡是类型为RetryableException的异常才是值得重试的异常。
continueOrPropagate的逻辑是第一次失败后等待period开始重试,再失败后等待period*(1.5的N次幂),其中N=重试次数-1,重试间隔小于maxPeriod;最多重试maxAttempts次。
由此可见Feign提供的默认实现Feign.Default完全能满足我们的需求,所以注入到环境中:
@Bean @ConditionalOnBean(name = "feignOptions") public feign.Retryer retryer(@Qualifier("feignOptions") Properties feignOptions) { long period = PropertiesUtils.getValue(feignOptions, "period", Long.class); long maxPeriod = PropertiesUtils.getValue(feignOptions, "maxPeriod", Long.class); int maxAttempts = PropertiesUtils.getValue(feignOptions, "maxAttempts", Integer.class); return new Retryer.Default(period, maxPeriod, maxAttempts); }
2. feignOptions这个bean便是为解决问题2而来。
首先在application.properties中定义配置:
feign.custom.config.enabled=true feign.custom.config.period=2000 feign.custom.config.maxPeriod=4000 feign.custom.config.maxAttempts=5
读取配置并注入到环境中,注意开关:
@Bean @ConditionalOnProperty(name = "feign.custom.config.enabled", havingValue = "true") @ConfigurationProperties("feign.custom.config") public Properties feignOptions() { return new Properties(); }
3. 解决问题3、4和5:
(1) 常见的RPC异常分为两种:
(a) 有响应的异常,出现这种异常时,客户端到服务器的链接已经建立,客户端接到了服务端而且有返回状态码,例如404,5xx类型的失败;对于这种类型的失败,我们可以实现ErrorDecoder。需要注意的是,所有进入到ErrorDecoder的请求都是有http响应的,所以对于无法解析的域名,Feign不会走到这一步。
(b) 无响应的异常,比如java.net.UnknownHostException,即域名无法解析。因为没有服务端的响应,这种类型的异常不会交由ErrorDecoder处理,我选择用Spring AOP切面拦截此类异常,对返回值进行统一类型封装。在K8S环境中,由于DNS的解析出现问题,我们的确遇到过临时性的UnknownHostException异常。
(2) 自定义ErrorDecoder,里面要解决的一个重要问题是定义哪些异常需要重试,对其封装成RetryableException。 在下面的代码里,我将404状态列为可重试的异常:
public class CustomErrorDecoder implements ErrorDecoder { private final ErrorDecoder defaultErrorDecoder = new Default(); @Override public Exception decode(String methodKey, Response response) { System.out.printf("CustomErrorDecoder, methodKey: %s, request url: %s\n", methodKey, response.request().url()); Exception exception = defaultErrorDecoder.decode(methodKey, response); System.out.printf("CustomErrorDecoder, status: %d, exception: %s\n", response.status(), exception.getClass().getName()); if (exception instanceof RetryableException) { return exception; } String message = String.format("CustomErrorDecoder, %d error!", response.status()); if (response.status() == 404) { return new RetryableException(response.status(), message, response.request().httpMethod(), null, response.request()); } return exception; } }
注入自定义的ErrorDecoder:
@Bean public ErrorDecoder errorDecoder() { return new CustomErrorDecoder(); }
(3) 解决完有响应的异常之后,我们再来思考如何应对无响应的异常。我用Spring AOP来拦截来自FeignClient所在的包的所有异常,然后对其进行统一封装,既然要统一封装,那么我们就得先定义一个统一的返回值类型:
public class BaseResponse<T> { private boolean succeeded; private String message; private T data; private int code; public BaseResponse(boolean succeeded, String message, T data, int code) { this.setSucceeded(succeeded); this.setMessage(message); this.setData(data); this.setCode(code); } public BaseResponse(T data) { this(true, null, data, 0); } public BaseResponse() { this.setSucceeded(true); } public void setErrorMessage(String message) { this.setSucceeded(false); this.setMessage(message); } public boolean isSucceeded() { return succeeded; } public void setSucceeded(boolean succeeded) { this.succeeded = succeeded; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public T getData() { return data; } public void setData(T data) { this.data = data; } public int getCode() { return code; } public void setCode(int code) { this.code = code; } }
该类型的一个重要特点是,无论请求是否成功,我们都可以使用这个类型返回,通过isSucceeded()来判断请求是否成功,通过getData()来获取请求的结果,通过getMessage()来获取异常信息。但是有几个问题需要考虑:
(a) 如果我们调用的API是自己人提供的,我们可以让自己人修改,以便统一返回类型;
(b) 如果我们调用的API是别人或者第三方提供的,我们无法统一其返回类型,此时如何是好?
换句话说,我们遇到的场景很可能是,有的API直接返回了BaseResponse格式的Json数据,有的API则是返回了非BaseResponse格式的业务数据。对于前者(我们称这种接口为普通接口),如果调用期间出现Exception,我们可以在切面中将异常信息封装到BaseResponse对象并返回,开发者可在Service或Controller中判断调用结果,进而做相应处理;对于后者(我们称这种接口为其他接口),如果调用期间出现Exception,我们无法在切面中将其封装到BaseResponse对象并返回,这会导致和FeignClient接口的返回类型不一致,我们该如何处置?
为了解决这个问题,这就需要自定义Decoder接口实现,我们首先需要区分哪些接口直接返回了BaseResponse格式的Json数据,哪些接口返回了自己的业务数据。对于前者,交由Feign默认的Decoder进行解析;对于后者,Feign的返回类型需要定制,我们需要使用Feign默认的Decoder解析业务数据,然后封装到定制类型对象中。
面向普通接口的统一返回类型:
public class ApiResponse<T> extends BaseResponse<T> { private int status; @JsonIgnore private HttpHeaders httpHeaders; @JsonIgnore private Throwable throwable; public ApiResponse() { this.setStatus(HttpStatus.OK.value()); } public ApiResponse(T data) { this(); super.setData(data); } public ApiResponse(ResponseEntity<T> responseEntity) { this(responseEntity.getBody()); setHttpHeaders(responseEntity.getHeaders()); } public ApiResponse(Throwable throwable) { super.setErrorMessage(throwable.getMessage()); this.setThrowable(throwable); if (throwable instanceof UnknownHostException || throwable.getCause() instanceof UnknownHostException) { setStatus(HttpCode.UNKNOWHOST.getValue()); } } public int getStatus() { return status; } public void setStatus(int status) { this.status = status; } public HttpHeaders getHttpHeaders() { return httpHeaders; } public void setHttpHeaders(HttpHeaders httpHeaders) { this.httpHeaders = httpHeaders; } public Throwable getThrowable() { return throwable; } public void setThrowable(Throwable throwable) { this.throwable = throwable; } }
面向其他接口的统一返回类型:
public class OtherApiResponse<T> extends ApiResponse<T> { public OtherApiResponse(T data) { super(data); } public OtherApiResponse(Throwable throwable) { super(throwable); } public OtherApiResponse() { super(); } }
定义切面,切入feign接口所在的包,拦截异常,根据接口返回类型分类封装并返回:
@Aspect @Component public class RemoteAspect { @Around("devutility.test.springcloud.feign.aspect.Pointcuts.pointcutForRemote()") public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { Signature signature = proceedingJoinPoint.getSignature(); Class<?> returnType = ((MethodSignature) signature).getReturnType(); try { return proceedingJoinPoint.proceed(); } catch (Throwable e) { if (OtherApiResponse.class.equals(returnType)) { return new OtherApiResponse<>(e); } if (ApiResponse.class.equals(returnType)) { return new ApiResponse<>(e); } if (BaseResponse.class.isAssignableFrom(returnType)) { BaseResponse<Object> response = new BaseResponse<>(); response.setErrorMessage("Failed!"); response.setData(e); return response; } throw e; } } }
(4) 自定义Decoder
在自定义之前,我们先来看看Feign自己的默认Decoder实现:
由此可见,Feign默认使用了三个Decoder实现,OptionalDecoder,ResponseEntityDecoder,SpringDecoder,并在SpringDecoder中使用了Spring Boot默认的messageConverters。
自定义我们自己的Decoder:
public class CustomDecoder implements Decoder { private ObjectFactory<HttpMessageConverters> messageConverters; private final Decoder delegate; public CustomDecoder(ObjectFactory<HttpMessageConverters> messageConverters) { this.messageConverters = messageConverters; Objects.requireNonNull(this.messageConverters, "Message converters must not be null. "); this.delegate = new OptionalDecoder(new ResponseEntityDecoder(new SpringDecoder(this.messageConverters))); } @Override public Object decode(Response response, Type type) throws IOException, DecodeException, FeignException { if (type instanceof ParameterizedType) { ParameterizedType parameterizedType = (ParameterizedType) type; Type rawType = parameterizedType.getRawType(); if (rawType instanceof Class) { @SuppressWarnings("rawtypes") Class rawClass = (Class) rawType; if (rawClass.equals(OtherApiResponse.class)) { Type genericType = GenericTypeUtils.getGenericType(parameterizedType); Object data = delegate.decode(response, genericType); return new OtherApiResponse<Object>(data); } } } return delegate.decode(response, type); } }
4. 注意
先看三段源码
如果FeignClient里的方法的返回类型定义成Response,且请求的URL有返回值(404也算),则Feign会直接返回封装好的Response,这属于一个正常响应,所以它也不会进行重试,即便它的status code != 200。
所以,FeignClient并不适合进行下载请求(返回类型必须是Response),或者你可以通过重写Feign.Builder来实现返回类型是Response时的重试。
四、日志
1. 首先我们可以打开Feign的日志记录。在application.properties中添加:
feign.client.config.default.logger-level=basic
2. 上文已经提到Feign默认使用Slf4jLogger作为日志组件,所以我们可以更改其实现,按需将日志持久化。
3. 对于一些特定情况下的日志,比如我们只希望记录请求失败的日志,可以在RemoteAspect的catch里面捕获并异步处理。
五、示例代码