FeignOkHttpClientProperties

feign自动配置

package com.baiyz.common.infrastructure.httpclient;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.HashSet;
import java.util.Set;


/**
* okHttp feign 自动配置
*/
@ConfigurationProperties(prefix = "custom.okhttp")
@Data
public class FeignOkHttpClientProperties {

/**
* 是否开始okHttp
*/
private boolean enabled = false;

/**
* 最大链接数量
*/
private int poolMaxIdleConnections = 200;

/**
* 最大链接时间 ms
*/
private long poolKeepAliveDuration = 30000;

/**
* 是否自动重连
*/
private boolean retryConnection = true;

/**
* 连接超时时间 s
*/ private long connectTimeOut = 15;

/**
* 读取超时时间 s
*/ private long readTimeOut = 60;

/**
* 是否允许重定向
*/
private boolean redirectsAllow = true;

/**
* 开启请求日志
*/
private boolean logInterceptorEnable = true;

/**
* 开启请求日志拦截之后忽略的日志路径
*/
private Set<String> ignorLogPath = new HashSet<>();

/**
* ok http重试次数 0为关闭
*/
private int clientRetryNum = 3;

}

自动配置Feign客户端

在FeignAutoConfiguration加载之前配置,因为6.6 TaskRecordHeartBeater依赖TaskRecordClient名称为client的bean, 这个用的Resource注解导致ByName,但是启用feign非JDK自带客户端会优先注入一个类为其他客户端, 但是名字client的Bean导致启动失败

  
import com.baiyz.common.infrastructure.httpclient.interceptors.OkHttpLogInterceptor;
import com.baiyz.common.infrastructure.httpclient.interceptors.OkHttpRetryInterceptor;
import feign.Client;
import lombok.extern.slf4j.Slf4j;
import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.net.ssl.*;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.concurrent.TimeUnit;


/**
* <b>配置Feign客户端</b>
* <p> * 在FeignAutoConfiguration加载之前配置,因为6.6 TaskRecordHeartBeater依赖TaskRecordClient名称为client的bean,<br>
* 这个用的Resource注解导致ByName,但是启用feign非JDK自带客户端会优先注入一个类为其他客户端,<br>
* 但是名字client的Bean导致启动失败<br>
* </p> */@Configuration
@EnableConfigurationProperties({FeignOkHttpClientProperties.class})
@ConditionalOnProperty("custom.okhttp.enabled")
@Slf4j
public class FeignOkHttpClientAutoConfiguration {

private final FeignOkHttpClientProperties feignOkHttpClientProperties;

public FeignOkHttpClientAutoConfiguration(FeignOkHttpClientProperties feignOkHttpClientProperties) {
this.feignOkHttpClientProperties = feignOkHttpClientProperties;
}

/**
* 忽略证书校验
*
* @return 证书信任管理器
*/
@Bean
public X509TrustManager x509TrustManager() {
return new X509TrustManager() {
@Override
public void checkClientTrusted(X509Certificate[] x509Certificates, String s) {

}
@Override
public void checkServerTrusted(X509Certificate[] x509Certificates, String s) {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
};
}

/**
* 信任所有 SSL 证书
*
* @return SSLSocketFactory
*/ @Bean
public SSLSocketFactory sslSocketFactory() {
try {
TrustManager[] trustManagers = new TrustManager[]{x509TrustManager()};
SSLContext sslContext = SSLContext.getInstance("SSL");
sslContext.init(null, trustManagers, new SecureRandom());
return sslContext.getSocketFactory();
} catch (NoSuchAlgorithmException | KeyManagementException e) {
log.warn("ok http client 出现 SSLSocketFactory 初始化 SSL 错误:", e);
}
return null;
}

/**
* 连接池配置
*
* @return 连接池
*/
@Bean
public ConnectionPool pool() {
// 最大连接数、连接存活时间、存活时间单位
return new ConnectionPool(
feignOkHttpClientProperties.getPoolMaxIdleConnections(),
feignOkHttpClientProperties.getPoolKeepAliveDuration(),
TimeUnit.MILLISECONDS);
}

/**
* OkHttp 客户端配置
*
* @return OkHttp 客户端配
*/
@Bean("okHttpClient")
public okhttp3.OkHttpClient okHttpClient() {
OkHttpClient.Builder builder = new OkHttpClient.Builder()
.sslSocketFactory(sslSocketFactory(), x509TrustManager())
.hostnameVerifier(hostnameVerifier())
// 重试
.retryOnConnectionFailure(feignOkHttpClientProperties.isRetryConnection())
//连接池
.connectionPool(pool())
// 连接超时时间
.connectTimeout(feignOkHttpClientProperties.getConnectTimeOut(), TimeUnit.SECONDS)
// 读取超时时间
.readTimeout(feignOkHttpClientProperties.getReadTimeOut(), TimeUnit.SECONDS)
// 是否允许重定向
.followRedirects(feignOkHttpClientProperties.isRedirectsAllow());
// 是否开启日志
if (feignOkHttpClientProperties.isLogInterceptorEnable()) {
builder.addInterceptor(new OkHttpLogInterceptor(feignOkHttpClientProperties.getIgnorLogPath()));
}

// 是否开启客户端重试
if (feignOkHttpClientProperties.getClientRetryNum() >= 0) {
builder.addInterceptor(new OkHttpRetryInterceptor(feignOkHttpClientProperties.getClientRetryNum()));
}
return builder.build();
}

/**
* 信任所有主机名
*
* @return 主机名校验
*/
@Bean
public HostnameVerifier hostnameVerifier() {
return (s, sslSession) -> true;
}

@Bean
public Client feignClient(@Qualifier(value = "okHttpClient") okhttp3.OkHttpClient client) {
return new feign.okhttp.OkHttpClient(client);
}

OkHttp3日志打印拦截器

package com.baiyz.common.infrastructure.httpclient.interceptors;  

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.date.TimeInterval;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import okio.Buffer;
import org.apache.commons.lang3.RegExUtils;
import org.jetbrains.annotations.NotNull;

import java.io.IOException;
import java.util.Set;

/**
* OkHttp3日志打印拦截器
*/
@Slf4j
@Data
public class OkHttpLogInterceptor implements Interceptor {

public static final String TEXT = "text";

public static final String XML = "xml";

public static final String JSON = "json";

public static final String HTM = "htm";

public static final String HTML = "html";

public static final String WEBVIEWHTML = "webviewhtml";

private final Set<String> ignorLogPath;

public OkHttpLogInterceptor(Set<String> ignorLogPath) {
this.ignorLogPath = ignorLogPath;
}

@Override
public @NotNull Response intercept(Chain chain) throws IOException {
Request request = chain.request();
String path = request.url().uri().getPath();

// 检查路径是否在忽略日志的列表中
boolean shouldLog = true;
for (String ignorePath : ignorLogPath) {
if (path.startsWith(ignorePath)) {
shouldLog = false;
break;
}
}

if (shouldLog) {
logForRequest(request);
TimeInterval timer = DateUtil.timer();
Response response = chain.proceed(request);
return logForResponse(response, timer);
}

return chain.proceed(request);
}

private Response logForResponse(Response response, TimeInterval timer) {
try (Response clone = response.newBuilder().build()) {
log.debug("响应: code={}, time={}, protocol={}",
clone.code(), timer.intervalMs() + "ms", clone.protocol());

ResponseBody body = clone.body();
if (body != null) {
MediaType mediaType = body.contentType();
if (mediaType != null && isText(mediaType)) {
String content = body.string();
log.debug("响应体: message={}, contentType={}, content={}",
clone.message(), mediaType, content);
body = ResponseBody.create(mediaType, content);
return response.newBuilder().body(body).build();
}
}
} catch (Exception e) {
log.warn("log response error", e);
}
return response;
}

private void logForRequest(Request request) {
String url = request.url().toString();
String method = request.method();
Headers headers = request.headers();
log.debug("请求: url={}, method={}, headers={}", url, method, headers);
RequestBody requestBody = request.body();
if (requestBody != null) {
MediaType mediaType = requestBody.contentType();
if (mediaType != null && isText(mediaType)) {
String bodyStr = RegExUtils.replaceAll(bodyToString(request),
"\"password\":\\s*\".*\"\\s*,", "\"password\":\"******\",");
log.debug("请求体: mediaType={}, bodyToString={}", mediaType, bodyStr);
}
}
}

private String bodyToString(final Request request) {
final Request copy = request.newBuilder().build();
final Buffer buffer = new Buffer();
try {
if (copy.body() != null) {
copy.body().writeTo(buffer);
}
} catch (Exception e) {
return "occurs error when show requestBody";
}
return buffer.readUtf8();
}

private boolean isText(MediaType mediaType) {
if (mediaType.type() != null && TEXT.equals(mediaType.type())) {
return true;
}
if (mediaType.subtype() != null) {
return JSON.equals(mediaType.subtype())
|| XML.equals(mediaType.subtype())
|| HTM.equals(mediaType.subtype())
|| HTML.equals(mediaType.subtype())
|| WEBVIEWHTML.equals(mediaType.subtype());
}
return false;
}

}

重试拦截器

package com.baiyz.common.infrastructure.httpclient.interceptors;  

import io.netty.handler.timeout.ReadTimeoutException;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
import org.jetbrains.annotations.NotNull;

import java.net.ConnectException;

@Slf4j
@Data
public class OkHttpRetryInterceptor implements Interceptor {

/**
* 最大重试次数
*/
private final int maxRetry;

public OkHttpRetryInterceptor(int maxRetry) {
this.maxRetry = maxRetry;
}

@NotNull
@Override public Response intercept(@NotNull Chain chain) {
return retry(chain, maxRetry);
}

private Response retry(Chain chain, int retryCet) {
Request request = chain.request();
Response response;
try {
response = chain.proceed(request);
} catch (ReadTimeoutException | ConnectException e) {
if (maxRetry > retryCet) {
log.error("==> 请求接口【{}】出现链接异常异常 开始重试 -> 当前重试次数:{}", request.url(), maxRetry, e);
return retry(chain, retryCet + 1);
}
log.error("==> 请求接口【{}】超过最大重试次数【{}】仍然失败 由于异常:", request.url(), maxRetry, e);
// interceptor 返回 null 会报 IllegalStateException 异常
return new Response.Builder().build();
} catch (Exception e2) {
if (maxRetry > retryCet) {
log.error("==> 请求接口【{}】出现系统异常 开始重试 -> 当前重试次数:{}", request.url(), maxRetry, e2);
return retry(chain, retryCet + 1);
}
log.error("==> 请求接口【{}】超过最大重试次数【{}】仍然失败 由于异常:", request.url(), maxRetry, e2);
// interceptor 返回 null 会报 IllegalStateException 异常
return new Response.Builder().build();
}
return response;
}
}

重试注解以及切面

注解

Backoff 回退策略

package com.baiyz.common.infrastructure.httpclient.anno;  

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Backoff {
long delay() default 1000L;

;
long maxDelay() default 0L;

double multiplier() default 0.0D;
}

重试注解

package com.baiyz.common.infrastructure.httpclient.anno;  

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface FeignRetry {

Backoff backoff() default @Backoff();

int maxAttempt() default 3;

Class<? extends Throwable>[] include() default {};
}

切面

package com.baiyz.common.infrastructure.httpclient.aspect;  

import com.baiyz.common.infrastructure.httpclient.anno.FeignRetry;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.retry.RetryException;
import org.springframework.retry.backoff.BackOffPolicy;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.backoff.FixedBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;

@Aspect
@Component
@Slf4j
public class FeignRetryAspect {

/**
* 定义切入点
*/
@Pointcut("@annotation(com.baiyz.common.infrastructure.httpclient.anno.FeignRetry)")
public void pointcut() {
}
@Around("pointcut()")
public Object retry(ProceedingJoinPoint joinPoint) throws Throwable {
Method method = getCurrentMethod(joinPoint);
FeignRetry feignRetry = method.getAnnotation(FeignRetry.class);

RetryTemplate retryTemplate = new RetryTemplate();
retryTemplate.setBackOffPolicy(prepareBackOffPolicy(feignRetry));
retryTemplate.setRetryPolicy(prepareSimpleRetryPolicy(feignRetry));

// 重试
return retryTemplate.execute(arg0 -> {
int retryCount = arg0.getRetryCount();
log.info("FeignRetryAspect feign重试器开始进行请求: Sending request method: {}, max attempt: {}, delay: {}, " +
"retryCount: " +
"{}",
method.getName(),
feignRetry.maxAttempt(),
feignRetry.backoff().delay(),
retryCount
);
return joinPoint.proceed(joinPoint.getArgs());
});
}

private BackOffPolicy prepareBackOffPolicy(FeignRetry feignRetry) {
if (feignRetry.backoff().multiplier() != 0) {
ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
backOffPolicy.setInitialInterval(feignRetry.backoff().delay());
backOffPolicy.setMaxInterval(feignRetry.backoff().maxDelay());
backOffPolicy.setMultiplier(feignRetry.backoff().multiplier());
return backOffPolicy;
} else {
FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy();
fixedBackOffPolicy.setBackOffPeriod(feignRetry.backoff().delay());
return fixedBackOffPolicy;
}
}


private SimpleRetryPolicy prepareSimpleRetryPolicy(FeignRetry feignRetry) {
Map<Class<? extends Throwable>, Boolean> policyMap = new HashMap<>();
policyMap.put(RetryException.class, true); // Connection refused or time out
for (Class<? extends Throwable> t : feignRetry.include()) {
policyMap.put(t, true);
}
return new SimpleRetryPolicy(feignRetry.maxAttempt(), policyMap, true);
}

private Method getCurrentMethod(JoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
return signature.getMethod();
}

}

使用方式

  1. 增加配置文件:
  2. 使用注解标注对应的重试feign接口
    @FeignClient(name = "test-web", contextId = "test-web", url = "${test-web-domain:http://test-web:8080}")  
    public interface CaWebServiceClient {

    @PostMapping({"api/auth/search"})
    @FeignRetry(maxAttempt = 4, backoff = @Backoff(delay = 500L, maxDelay = 10000L, multiplier = 1.5))
    Response<PaginationResponse<GetAuthorizeResponse>> getArchiveAuthToken(
    @RequestParam(value = "userId", required = false) String userId,
    @RequestBody AuthData auth);
    }

IDEA提示

spring-configuration-metadata.json [[IDEA Properties增加Idea提示]]

{
"groups": [
{
"name": "custom.okhttp",
"type": "com.baiyz.common.infrastructure.httpclient.FeignOkHttpClientProperties",
"sourceType": "com.baiyz.common.infrastructure.httpclient.FeignOkHttpClientProperties"
}
],
"properties": [
{
"name": "custom.okhttp.client-retry-num",
"type": "java.lang.Integer",
"description": "ok http重试次数 0为关闭",
"sourceType": "com.baiyz.common.infrastructure.httpclient.FeignOkHttpClientProperties",
"defaultValue": 3
},
{
"name": "custom.okhttp.connect-time-out",
"type": "java.lang.Long",
"description": "连接超时时间 s",
"sourceType": "com.baiyz.common.infrastructure.httpclient.FeignOkHttpClientProperties",
"defaultValue": 15
},
{
"name": "custom.okhttp.enabled",
"type": "java.lang.Boolean",
"description": "是否开始okHttp",
"sourceType": "com.baiyz.common.infrastructure.httpclient.FeignOkHttpClientProperties",
"defaultValue": false
},
{
"name": "custom.okhttp.ignor-log-path",
"type": "java.util.Set<java.lang.String>",
"description": "开启请求日志拦截之后忽略的日志路径",
"sourceType": "com.baiyz.common.infrastructure.httpclient.FeignOkHttpClientProperties"
},
{
"name": "custom.okhttp.log-interceptor-enable",
"type": "java.lang.Boolean",
"description": "开启请求日志",
"sourceType": "com.baiyz.common.infrastructure.httpclient.FeignOkHttpClientProperties",
"defaultValue": true
},
{
"name": "custom.okhttp.pool-keep-alive-duration",
"type": "java.lang.Long",
"description": "最大链接时间 ms",
"sourceType": "com.baiyz.common.infrastructure.httpclient.FeignOkHttpClientProperties",
"defaultValue": 30000
},
{
"name": "custom.okhttp.pool-max-idle-connections",
"type": "java.lang.Integer",
"description": "最大链接数量",
"sourceType": "com.baiyz.common.infrastructure.httpclient.FeignOkHttpClientProperties",
"defaultValue": 200
},
{
"name": "custom.okhttp.read-time-out",
"type": "java.lang.Long",
"description": "读取超时时间 s",
"sourceType": "com.baiyz.common.infrastructure.httpclient.FeignOkHttpClientProperties",
"defaultValue": 60
},
{
"name": "custom.okhttp.redirects-allow",
"type": "java.lang.Boolean",
"description": "是否允许重定向",
"sourceType": "com.baiyz.common.infrastructure.httpclient.FeignOkHttpClientProperties",
"defaultValue": true
},
{
"name": "custom.okhttp.retry-connection",
"type": "java.lang.Boolean",
"description": "是否自动重连",
"sourceType": "com.baiyz.common.infrastructure.httpclient.FeignOkHttpClientProperties",
"defaultValue": true
}
],
"hints": []
}