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里面捕获并异步处理。

五、示例代码

亲测可用: https://github.com/eagle6688/devutility.test.springcloud/tree/master/devutility.test.springcloud.feign 

posted @ 2020-06-10 23:23  白马黑衣  阅读(4807)  评论(2编辑  收藏  举报