使用到的工具:
idea,apifox,wireshark
当前项目使用到依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.12</version>
</parent>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2020.0.6</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2021.1</version>
</dependency>
复制代码
实验代码:
su-feign-model · coder97/su-service-parent - 码云 - 开源中国 (gitee.com)
为什么要升级 okhttpclient
项目中使用的 Feign 作为远程调用工具,Feign 在默认情况下使用的是 JDK 原生的 URLConnection 发送 HTTP 请求,没有连接池,但是对每个地址会保持一个长连接,即利用 HTTP 的 persistence connection。
HTTP 是目前比较通用的网络请求方式,用来访问请求交换数据,有效地使用 HTTP 可以使应用访问速度变得更快,更节省带宽。okhttp 是一个很棒的 HTTP 客户端,具有以下功能和特性。
怎么判断当前项目中 Feign 使用的是什么客户端?
查看 @EnableFeignClients,Spring 会将 FeignClientsRegistrar 导入容器内
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(FeignClientsRegistrar.class)
public @interface EnableFeignClients {}
复制代码
查看 FeignClientsRegistrar,它实现了ImportBeanDefinitionRegistrar
所以 Spring 会服务启动过程中回调 register 方法。register 方法主要做的就是扫描包,将指定包下面标明 @FeignClient 的类转换为 FeignClientFactoryBean,并用 BeanDefinition 形式提供出去,当该 BeanDefinition 构造实例时会回掉 FeignClientFactoryBean#getTarget()方法
<T> T getTarget() {
// 根据beanFactory的存在与否,从Spring容器中获取Feign上下文
FeignContext context = beanFactory != null ? beanFactory.getBean(FeignContext.class)
: applicationContext.getBean(FeignContext.class);
// 创建Feign.Builder实例
Feign.Builder builder = feign(context);
// 如果url为空,说明没有直接指定服务的URL
if (!StringUtils.hasText(url)) {
// 如果日志记录是启用的,记录一条信息级别的日志
if (LOG.isInfoEnabled()) {
LOG.info("For '" + name + "' URL not provided. Will try picking an instance via load-balancing.");
}
// 如果name不以http开头,则添加http前缀
if (!name.startsWith("http")) {
url = "http://" + name;
} else {
// 否则直接使用name作为url
url = name;
}
// 清理路径,比如去除尾随斜线等
url += cleanPath();
// 使用loadBalance方法获取目标对象,并将构建好的Feign.Builder和Feign上下文传递过去
return (T) loadBalance(builder, context, new HardCodedTarget<>(type, name, url));
}
// 如果url不为空但也不以http开头,则添加http前缀
if (StringUtils.hasText(url) && !url.startsWith("http")) {
url = "http://" + url;
}
// 将当前url与清理后的路径拼接
String url = this.url + cleanPath();
// 从Feign上下文中获取可选的Client实例
Client client = getOptional(context, Client.class);
// 如果client不为空
if (client != null) {
// 如果client是FeignBlockingLoadBalancerClient类型的实例
if (client instanceof FeignBlockingLoadBalancerClient) {
// 因为我们已经有了url,不需要负载均衡,所以获取原始的客户端代理
client = ((FeignBlockingLoadBalancerClient) client).getDelegate();
}
// 如果client是RetryableFeignBlockingLoadBalancerClient类型的实例
if (client instanceof RetryableFeignBlockingLoadBalancerClient) {
// 同样,不需要负载均衡,获取原始的客户端代理
client = ((RetryableFeignBlockingLoadBalancerClient) client).getDelegate();
}
// 将获取到的client设置到Feign.Builder中
builder.client(client);
}
// 从Feign上下文中获取Targeter实例
Targeter targeter = get(context, Targeter.class);
// 使用Targeter.target方法获取目标对象,传入当前对象、Feign.Builder、Feign上下文和目标信息
return (T) targeter.target(this, builder, context, new HardCodedTarget<>(type, name, url));
}
复制代码
通过断点查看feign.Client
类型就知道使用的是哪些客户端了!!
httpClient 分类
feign.Client
使用快捷键 ctrl alt b ,查看子类就知道 feign 有哪些客户端了。
default
okhttpclient
FeignBlockingLoadBalancerClient
RetryableFeignBlockingLoadBalancerClient
FeignBlockingLoadBalancerClient、RetryableFeignBlockingLoadBalancerClient 是支持负载均衡的 client。使用它需要引入 loadbalance 依赖。其实他就是对 default、okhttpclient 进行了包装再此不过多陈述
值的注意的是feign.Client
有一个很重要的方法就是
Response execute(Request request, Options options) throws IOException;
复制代码
通过查看不同子类的 execute 便知道底层使用的 http 客户端是哪些了。
不同的 Client 是如何注入进来的?
查看:org.springframework.cloud.openfeign.FeignAutoConfiguration
如果项目引入 loadbalancer 依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
复制代码
查看:
org.springframework.cloud.openfeign.loadbalancer
包下的实现
升级的步骤
引入依赖:
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>
复制代码
修改配置项:
feign:
httpclient:
enabled: false
okhttp:
enabled: true
复制代码
如此便完成了配置
升级导致的问题
请求本地服务接口提示 feign 调用 404,本地接口为:http://127.0.0.1:9115/dynamicTable/tableList
该接口内部主要步骤:
通过 feign 请求dg-data-service
。注意该服务是部署在 k8s 中的。
@FeignClient(
name = "DgDictTableClient",
url = "${dg.data.service.url:http://dg-data-service:31001}",
path = "dgDictTable"
)
public interface DgDictTableClient extends BaseDBClient<DgDictTable> {
@PostMapping({"/query"})
Response<PageResponse<T>> query(@ApiParam(value = "查询对象",required = true) @RequestBody DBQuery query) throws Exception;
}
复制代码
如果将配置项把 okhttp 禁用,该接口正常,这种现象非常诡异
抓包排查问题原因
我当时在想同样都是 http 调用,为什么换一个客户端就不行了。于是打开 wireshark 看下调用时,http 协议内的内容有什么区别:
禁用 okhttp,该接口的协议内容如下:
启用 okhttp,该接口协议内容如下:
通过比较,发现请求内容一样。
但是请求头中的 host 一个是 dg-data-service:31001,一个是 127.0.0.1:9115。
为什么 host 不一样会导致响应结果不一样呢?
因为该服务是部署在 k8s 内,我们请求过去 k8s 的主节点是需要根据 host 来做负载均衡到具体的节点的服务内的。所以当我们携带的 host 是 127.0.0.1 也就是当前 master 节点。当前 master 节点没有定义义/dgDictTable/query 接口自然是 404 了
为什么会出现原因以及如何解决问题
为什么会将原请求中 host 带到 feign 内呢?思前想后发现之前为了解决各个子服务鉴权 token 的问题,所以定义了 feign 的拦截器会将 web 上下文源请求的 header 信息透传给 requestTemplate 内,从而解决了 token 续传问题。
后续 requestTemplate 会生成 feign.Request,feign.Request 在通过不同的 client 构造不同的请求信息
public class FeignRequestInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
Enumeration<String> headerNames = request.getHeaderNames();
Optional.ofNullable(headerNames).ifPresent(headers -> {
while (headers.hasMoreElements()) {
String name = headers.nextElement();
String value = request.getHeader(name);
// 跳过 host
//if (name.equalsIgnoreCase("host")) {
// continue;
//}
requestTemplate.header(name, value);
}
});
}
}
}
复制代码
怎么解决呢?在拦截器中如果 header 中的 key 为 host 不进行赋值即可,打开上面的注释代码,无论是 httpclient 还是 okhttpclient 都会根据请求的 url 自动生成请求头中 host。
为什么默认的 httpclient 不会受当前拦截器的影响呢?而 okhttpclient 会受影响呢?
我们先讨论前者:
找到feign.Client
默认实现,具体方法上面已经讨论过。查看他的执行方法
@Override
public Response execute(Request request, Options options) throws IOException {
// request 是用 RequestTemplate.request() 生成的
HttpURLConnection connection = convertAndSend(request, options);
return convertResponse(connection, request);
}
复制代码
进入convertAndSend
方法,以下是主要核心逻辑,重点关注 connection.addRequestProperty 方法。他会将 header 中的数据添加到请求属性内
HttpURLConnection convertAndSend(Request request, Options options) throws IOException {
final URL url = new URL(request.url());
final HttpURLConnection connection = this.getConnection(url);
for (String field : request.headers().keySet()) {
if (field.equalsIgnoreCase("Accept")) {
hasAcceptHeader = true;
}
for (String value : request.headers().get(field)) {
if (field.equals(CONTENT_LENGTH)) {
if (!gzipEncodedRequest && !deflateEncodedRequest) {
contentLength = Integer.valueOf(value);
connection.addRequestProperty(field, value);
}
} else {
//
connection.addRequestProperty(field, value);
}
}
}
OutputStream out = connection.getOutputStream();
return connection;
}
复制代码
进入该方法内:
public synchronized void addRequestProperty(String var1, String var2) {
if (!this.connected && !this.connecting) {
if (var1 == null) {
throw new NullPointerException("key is null");
} else {
// 是否允许外部消息头
if (this.isExternalMessageHeaderAllowed(var1, var2)) {
this.requests.add(var1, var2);
if (!var1.equalsIgnoreCase("Content-Type")) {
this.userHeaders.add(var1, var2);
}
}
}
} else {
throw new IllegalStateException("Already connected");
}
}
复制代码
查看当前方法 只要在这个数组内 key 都不能 填充进来,所以我们拦截器的传递的 host 并没有污染到 httpclient 组装的 header 信息。
String[] restrictedHeaders = new String[]{"Access-Control-Request-Headers", "Access-Control-Request-Method", "Connection", "Content-Length", "Content-Transfer-Encoding", "Host", "Keep-Alive", "Origin", "Trailer", "Transfer-Encoding", "Upgrade", "Via"}
复制代码
我们在看下,httpclient 是怎么组装 host 信息的,回到 feign.Client.Default#convertAndSend。进入
connection.getOutputStream()
复制代码
顺着往下找到这个方法
sun.net.www.protocol.http.HttpURLConnection#writeRequests
复制代码
当看到 Host 生成逻辑时,一切云开雾散!!使用 url 生成的 host 放到了请求头中
private void writeRequests() throws IOException {
int var1 = this.url.getPort();
String var2 = this.url.getHost();
if (var1 != -1 && var1 != this.url.getDefaultPort()) {
var2 = var2 + ":" + var1;
}
String var3 = this.requests.findValue("Host");
if (var3 == null || !var3.equalsIgnoreCase(var2) && !this.checkSetHost()) {
this.requests.set("Host", var2);
}
}
复制代码
在讨论后者
进入到 feign.okhttp.OkHttpClient
@Override
public feign.Response execute(feign.Request input, feign.Request.Options options)
throws IOException {
okhttp3.OkHttpClient requestScoped;
if (delegate.connectTimeoutMillis() != options.connectTimeoutMillis()
|| delegate.readTimeoutMillis() != options.readTimeoutMillis()
|| delegate.followRedirects() != options.isFollowRedirects()) {
requestScoped = delegate.newBuilder()
.connectTimeout(options.connectTimeoutMillis(), TimeUnit.MILLISECONDS)
.readTimeout(options.readTimeoutMillis(), TimeUnit.MILLISECONDS)
.followRedirects(options.isFollowRedirects())
.build();
} else {
requestScoped = delegate;
}
Request request = toOkHttpRequest(input);
Response response = requestScoped.newCall(request).execute();
return toFeignResponse(response, input).toBuilder().request(input).build();
}
复制代码
看下 toOkHttpRequest 方法:
static Request toOkHttpRequest(feign.Request input) {
Request.Builder requestBuilder = new Request.Builder();
requestBuilder.url(input.url());
MediaType mediaType = null;
boolean hasAcceptHeader = false;
for (String field : input.headers().keySet()) {
for (String value : input.headers().get(field)) {
// 这一行代码会将拦截器的传递的header放到okhttp的request内,并没有过滤
requestBuilder.addHeader(field, value);
if (field.equalsIgnoreCase("Content-Type")) {
mediaType = MediaType.parse(value);
if (input.charset() != null) {
mediaType.charset(input.charset());
}
}
}
}
// ....
return requestBuilder.build();
}
复制代码
但是放到 header 中后,那通过请求 url 生成的 host 是否会使用呢?咨询一下月之暗面,以下是我获取的信息,详细代码等待有缘人探索吧~
---
在Request.Builder
类中,header
方法用于添加或覆盖请求头。如果添加的是Host
头,OkHttp 会检查是否已经存在Host
头。如果用户没有手动设置Host
头,build
方法会从HttpUrl
对象中自动添加正确的Host
头。
这里需要注意的是,如果用户在构建请求时手动添加了Host
头,即使它是错误的,OkHttp 也会使用这个用户指定的值,而不是从 URL 解析得到的值。这是因为 OkHttp 允许用户完全控制发出的 HTTP 请求,包括自定义头部字段。
在build
方法中,如果用户没有设置Host
头,OkHttp 会根据提供的 URL 自动添加一个正确的Host
头。如果用户设置了Host
头,即使它与 URL 中的主机名不一致,OkHttp 也会使用用户设置的值。
---
收获
通过抓包是最能显而易见的看到请求的区别的,对于我找问题有很大的帮助
feign 中不同的 client 的实现,不同 client 注入以及执行的流程比较清晰了
评论