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里面捕獲並異步處理。
五、示例代碼