使用到的工具:
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 注入以及执行的流程比较清晰了
评论