写点什么

FeignClient 从默认的 httpClient 升级为 okhttpclient 踩坑记录

  • 2024-04-20
    北京
  • 本文字数:6700 字

    阅读完需:约 22 分钟

使用到的工具:

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 客户端,具有以下功能和特性。


  • 支持 SPDY,可以合并多个到同一个主机的请求。

  • 使用连接池技术减少请求的延迟(如果 SPDY 是可用的话)。

  • 使用 GZIP 压缩减少传输的数据量。

  • 缓存响应避免重复的网络请求。

怎么判断当前项目中 Feign 使用的是什么客户端?

  1. 查看 @EnableFeignClients,Spring 会将 FeignClientsRegistrar 导入容器内


@Retention(RetentionPolicy.RUNTIME)  @Target(ElementType.TYPE)  @Documented  @Import(FeignClientsRegistrar.class)  public @interface EnableFeignClients {}
复制代码


  1. 查看 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));  }
复制代码


  1. 通过断点查看feign.Client类型就知道使用的是哪些客户端了!!


httpClient 分类

feign.Client 使用快捷键 ctrl alt b ,查看子类就知道 feign 有哪些客户端了。


  1. default

  2. okhttpclient

  3. FeignBlockingLoadBalancerClient

  4. 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包下的实现

升级的步骤

  1. 引入依赖:


<dependency>      <groupId>io.github.openfeign</groupId>      <artifactId>feign-okhttp</artifactId>  </dependency>
复制代码


  1. 修改配置项:


feign:    httpclient:      enabled: false    okhttp:      enabled: true
复制代码


如此便完成了配置

升级导致的问题

请求本地服务接口提示 feign 调用 404,本地接口为:http://127.0.0.1:9115/dynamicTable/tableList


该接口内部主要步骤:


  1. 通过 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 也会使用用户设置的值。

---


收获


  1. 通过抓包是最能显而易见的看到请求的区别的,对于我找问题有很大的帮助

  2. feign 中不同的 client 的实现,不同 client 注入以及执行的流程比较清晰了


发布于: 刚刚阅读数: 3
用户头像

还未添加个人签名 2021-01-10 加入

还未添加个人简介

评论

发布
暂无评论
FeignClient从默认的httpClient升级为okhttpclient踩坑记录_k8s_追随月光的战士_InfoQ写作社区