HttpClient 详解

作者:小白豆豆5
链接:https://www.jianshu.com/p/14c005e9287c
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

1.HTTP 请求创建流程

使用 HttpClient 执行一个 Http 请求的步骤为:

(1)创建一个 HttpClient 对象

(2)创建一个 HttpRequest 对象

(3)使用 HttpClient 来执行 HttpRequest请求,得到对方的 HttpResponse

(4)处理 HttpResponse

(5)关闭此次请求连接

2.创建一个 HttpClient 对象

目前最新版的 HttpClient 的实现类为 CloseableHttpClient。创建 CloseableHttpClient 实例有两种方式:

  1. 使用 CloseableHttpClient 的工厂类 HttpClients 的方法来创建实例。HttpClients 提供了根据各种默认配置来创建 CloseableHttpClient 实例的快捷方法。最简单的实例化方式是调用HttpClients.createDefault()。
  2. 使用 CloseableHttpClient 的 builder 类 HttpClientBuilder,先对一些属性进行配置(采用装饰者模式,不断的.setxxxxx().setxxxxxxxx()就行了),再调用 build() 方法来创建实例。上面的HttpClients.createDefault() 实际上调用的也就是HttpClientBuilder.create().build()。

build() 方法最终是根据各种配置来 new 一个 InternalHttpClient 实例(CloseableHttpClient 实现类)。

IternalHttpClient 类的实现如下:(忽略方法部分)

class InternalHttpClient extends CloseableHttpClient implements Configurable {
    private final Log log = LogFactory.getLog(this.getClass());
    private final ClientExecChain execChain;
    private final HttpClientConnectionManager connManager;
    private final HttpRoutePlanner routePlanner;
    private final Lookup<CookieSpecProvider> cookieSpecRegistry;
    private final Lookup<AuthSchemeProvider> authSchemeRegistry;
    private final CookieStore cookieStore;
    private final CredentialsProvider credentialsProvider;
    private final RequestConfig defaultConfig;
    private final List<Closeable> closeables;

    public InternalHttpClient(ClientExecChain execChain, HttpClientConnectionManager connManager, HttpRoutePlanner routePlanner, Lookup<CookieSpecProvider> cookieSpecRegistry, Lookup<AuthSchemeProvider> authSchemeRegistry, CookieStore cookieStore, CredentialsProvider credentialsProvider, RequestConfig defaultConfig, List<Closeable> closeables) {
        Args.notNull(execChain, "HTTP client exec chain");
        Args.notNull(connManager, "HTTP connection manager");
        Args.notNull(routePlanner, "HTTP route planner");
        this.execChain = execChain;
        this.connManager = connManager;
        this.routePlanner = routePlanner;
        this.cookieSpecRegistry = cookieSpecRegistry;
        this.authSchemeRegistry = authSchemeRegistry;
        this.cookieStore = cookieStore;
        this.credentialsProvider = credentialsProvider;
        this.defaultConfig = defaultConfig;
        this.closeables = closeables;
    }
...
}

其中需要注意的配置字段包括: HttpClientConnectionManager、HttpRoutePlanner 和 RequestConfig:

    1)HttpClientConnectionManager

            HttpClientConnectionManager 是一个 HTTP 连接管理器。它负责新 HTTP 连接的创建、管理连接的生命周期还有保证一个 HTTP 连接在某一时刻只被一个线程使用。在内部实现的时候,manager 使用一个 ManagedHttpClientConnection 的实例来作为一个实际 connection 的代理,负责管理 connection 的状态以及执行实际的 I/O 操作。如果一个被监管的 connection 被释放或者被明确关闭,尽管此时 manager 仍持有该连接的代理,但是这个 connection 的状态不会被改变也不能再执行任何的 I/O 操作。

HttpClientConnectionManager 有两种具体实现:

  • BasicHttpClientConnectionManager

  BasicHttpClientConnectionManager 每次只管理一个 connection。不过,虽然它是 thread-safe 的,但由于它只管理一个连接,所以只能被一个线程使用。它在管理连接的时候如果发现有相同route 的请求,会复用之前已经创建的连接,如果新来的请求不能复用之前的连接,它会关闭现有的连接并重新打开它来响应新的请求。

  • PoolingHttpClientConnectionManager

  PoolingHttpClientConnectionManager 与 BasicHttpClientConnectionManager 不同,它管理着一个连接池(连接池管理部分在第7部分有详细介绍)。它可以同时为多个线程服务。每次新来一个请求,如果在连接池中已经存在 route 相同并且可用的 connection,连接池就会直接复用这个 connection;当不存在 route 相同的 connection,就新建一个 connection 为之服务;如果连接池已满,则请求会等待直到被服务或者超时(Timeout waiting for connection from pool)。

  默认不对 HttpClientBuilder 进行配置的话,new 出来的 CloeableHttpClient 实例使用的是 PoolingHttpClientConnectionManager,这种情况下 HttpClientBuilder 创建出的 HttpClient 实例就可以被多个连接和多个线程共用,在应用容器起来的时候实例化一次,在整个应用结束的时候再调用 httpClient.close() 就行了。在 PoolingHttpClientConnectionManager 的配置中有两个最大连接数量,分别控制着总的最大连接数量(MaxTotal)和每个 route 的最大连接数量(DefaultMaxPerRoute)。如果没有显式设置,默认每个 route 只允许最多2个connection,总的 connection 数量不超过 20。这个值对于很多并发度高的应用来说是不够的,必须根据实际的情况设置合适的值,思路和线程池的大小设置方式是类似的,如果所有的连接请求都是到同一个url,那可以把 MaxPerRoute 的值设置成和 MaxTotal 一致,这样就能更高效地复用连接。HttpClient 4.3.5的设置方法如下:

private final static PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager();
poolingHttpClientConnectionManager.setMaxTotal(MAX_CONNECTION);
poolingHttpClientConnectionManager.setDefaultMaxPerRoute(MAX_CONNECTION);
CloseableHttpClient httpClient = HttpClientBuilder.create().setConnectionManager(poolingHttpClientConnectionManager).build();

    2)HttpRoutePlanner

        HttpClient 不仅支持简单的直连、复杂的路由策略以及代理。HttpRoutePlanner 是基于 http 上下文情况下,客户端到服务器的路由计算策略,一般没有代理的话,就不用设置这个东西。这里有一个很关键的概念—route:在 HttpClient 中,一个 route 指运行环境机器->目标机器 host 的一条线路,也就是如果目标 url 的 host 是同一个,那么它们的 route 也是一样的。

    3)RequestConfig

    RequestConfig 是对 request 的一些配置。里面比较重要的有三个超时时间,默认的情况下这三个超时时间都为-1(如果不设置request的Config,会在execute的过程中使用HttpClientParamConfig 的 getRequestConfig 中用默认参数进行设置),这也就意味着无限等待,很容易导致所有的请求阻塞在这个地方无限期等待。这三个超时时间为:

    (1)connectionRequestTimeout——从连接池中取连接的超时时间

      这个时间定义的是从 ConnectionManager 管理的连接池中取出连接的超时时间, 如果连接池中没有可用的连接,则 request 会被阻塞,最长等待 connectionRequestTimeout 的时间,如果还没有被服务,则抛出 ConnectionPoolTimeoutException 异常,不继续等待。

    (2)connectTimeout——连接超时时间

      这个时间定义了通过网络与服务器建立连接的超时时间,也就是取得了连接池中的某个连接之后到接通目标 url 的连接等待时间。发生超时,会抛出ConnectionTimeoutException异常。

    (3)socketTimeout——请求超时时间

      这个时间定义了 socket 读数据的超时时间,也就是连接到服务器之后到从服务器获取响应数据需要等待的时间,或者说是连接上一个 url 之后到获取 response 的返回等待时间。发生超时会抛出SocketTimeoutException异常。

注意,4.3.5版本超时设置方法和之前的版本不同,下面是一个设置各个超时时间的例子。注意,这样设置的是该 HttpClient 处理的所有 request 的默认配置,如果在构造 request 实例的时候不特别设置,则会使用默认配置。

RequestConfig requestConfig = RequestConfig.custom().setConnectionRequestTimeout(CON_RST_TIME_OUT).setConnectTimeout(CON_TIME_OUT).setSocketTimeout(SOCKET_TIME_OUT).build();
HttpEntityEnclosingRequestBase httpRequest = new HttpEntityEnclosingRequestBase() {
    @Override
public String getMethod() {
return method;
}
};
httpRequest.setConfig(requestConfig);

3.创建一个 Request 对象

HttpClient 支持所有的 HTTP1.1 中的所有定义的请求类型:GET、HEAD、POST、PUT、DELETE、TRACE 和 OPTIONS。对使用的类为 HttpGet、HttpHead、HttpPost、HttpPut、HttpDelete、HttpTrace 和 HttpOptions。Request的对象建立很简单,一般用目标url来构造就好了。下面是一个HttpPost的创建代码:

HttpPost httpPost = new HttpPost(someGwUrl);

一个 Request 还可以 addHeader、setEntity、setConfig 等,一般这三个用的比较多。

当然,你也可以通过创建一个 HttpEntityEnclosingRequestBase 对象作为 Request 对象,配置代码如下:

HttpEntityEnclosingRequestBase httpRequest = new HttpEntityEnclosingRequestBase() {
    @Override
    public String getMethod() {
        return method;     // 对应的GET,POST,DELETE等
    }
};
httpRequest.setURI();
httpRequest.setEntity();

4.执行 Request 请求

执行 Request 请求就是调用 HttpClient 的execute方法。最简单的使用方法是调用 execute(final HttpUriRequest request)。

HttpClient 允许 http 连接在特定的 Http 上下文中执行,HttpContext 是跟一个连接相关联的,所以它也只能属于一个线程,如果没有特别设定,在 execute 的过程中,HttpClient 会自动为每一个connection new 一个 HttpClientHttpContext。

HttpClientContext localcontext = HttpClientContext.adapt(context != null ? context : newBasicHttpContext());

整个 execute 执行的常规流程为:

  1. new一个 http context
  2. 取出 Request 和URL
  3. 根据 HttpRoute 的配置看是否需要重写URL
  4. 根据 URL 的host、port和scheme设置target
  5. 在发送前用 http 协议拦截器处理 request 的各个部分
  6. 取得验证状态、user token来验证身份
  7. 从连接池中取一个可用的连接
  8. 根据request的各种配置参数以及取得的connection构造一个connManaged
  9. 打开managed的connection(包括创建route、dns解析、绑定socket、socket连接等)
  10. 请求数据(包括发送请求和接收response两个阶段)
  11. 查看keepAlive策略,判断连接是否要复用,并设置相应标识
  12. 返回response
  13. 用http协议拦截器处理response的各个部分

5.处理 response

HttpReaponse 是将服务端发回的 Http 响应解析后的对象。CloseableHttpClient 的 execute 方法返回的 response 都是 CloseableHttpResponse 类型。可以 getFirstHeader(String)、getLastHeader(String)、headerIterator(String)取得某个Header name对应的迭代器、getAllHeaders()、getEntity、getStatus等,一般这几个方法比较常用。在这个部分中,对于 entity 的处理需要特别注意一下。一般来说一个 response 中的 entity 只能被使用一次,它是一个流,这个流被处理完就不再存在了。先 response.getEntity() 再使用 HttpEntity.getContent()来得到一个java.io.InputStream,然后再对内容进行相应的处理。

有一点非常重要,想要复用一个 connection 就必须要让它占有的系统资源得到正确释放。释放资源有两种方法:

  1)关闭和 entity 相关的 content stream

  如果是使用 outputStream 就要保证整个 entity 都被 write out,如果是 inputStream,则在最后要记得调用 inputStream.close()。或者使用 EntityUtils.consume(entity) 或EntityUtils.consumeQuietly(entity) 来让 entity 被完全耗尽(后者不抛异常)来做这一工作。EntityUtils 中有个 toString 方法也很方便的(调用这个方法最后也会自动把 inputStream close掉的),不过只有在可以确定收到的 entity 不是特别大的情况下才能使用。

做过实验,如果没有让整个 entity 被 fully consumed,则该连接是不能被复用的,很快就会因为在连接池中取不到可用的连接超时或者阻塞在这里(因为该连接的状态将会一直是 leased 的,即正在被使用的状态)。所以如果想要复用 connection,一定一定要记得把 entity fully consume 掉,只要检测到 stream 的 eof,才会自动调用 ConnectionHolder 的 releaseConnection 方法进行处理(注意,ConnectionHolder 并不是一个public class,虽然里面有一些跟释放连接相关的重要操作,但是却无法直接调用)。

关闭response

  2)关闭response

  执行 response.close() 虽然会正确释放掉该 connection 占用的所有资源,但是这是一种比较暴力的方式,采用这种方式之后,这个 connection 就不能被重复使用了。从源代码中可以看出,response.close() 调用了 connectionHolder 的 abortConnection 方法,它会 close 底层的 socket,并且 release 当前的 connection,并把 reuse 的时间设为0。这种情况下的 connection 称为expired connection,也就是 client 端单方面把连接关闭。还要等待 closeExpiredConnections 方法将它从连接池中清除掉(从连接池中清除掉的含义是把它所对应的连接池的 entry 置为无效,并且关掉对应的 connection,shutdown 对应 socket 的输入和输出流,这个方法的调用时间是需要设置的)。

  关闭stream和response的区别在于前者会尝试保持底层的连接alive,而后者会直接shut down并且丢弃connection。

  socket是和ip以及port绑定的,但是host相同的请求会尽量复用连接池里已经存在的 connection(因为在连接池里会另外维护一个 route 的子连接池,这个子连接池中每个 connection 的状态有三种:leased、available 和 pending,只有 available 状态的 connection 才能被使用,而 fully consume entity 就可以让该连接变为available状态),如果 host 地址一样,则优先使用connection。如果希望重复读取 entity 中的内容,就需要把 entity 缓存下来。最简单的方式是用 entity 来 new 一个 BufferedHttpEntity,这一操作会把内容拷贝到内存中,之后使用这个BufferedHttpEntity就可以了。

6.关闭 httpClient

调用 httpClient.close() 会先 shut down connection manager,然后再释放该 HttpClient 所占用的所有资源,关闭所有在使用或者空闲的 connection 包括底层 socket。由于这里把它所使用的connection manager 关闭了,所以在下次还要进行 http 请求的时候,要重新 new 一个 connection manager 来 build 一个 HttpClient(也就是在需要关闭和新建 Client 的情况下,connection manager不能是单例的)。

7.其他一些东西

  (1)关于keep-alive

  在 HttpClient.execute 得到 response 之后的相关代码中,它会先取出 response 的 keep-alive 头来设置 connection 是否 resuable 以及存活的时间。如果服务器返回的响应中包含了Connection:Keep-Alive(默认有的),但没有包含 Keep-Alive 时长的头消息,HttpClient 认为这个连接可以永远保持。不过,很多服务器都会在不通知客户端的情况下,关闭一定时间内不活动的连接,来节省服务器资源。在这种情况下默认的策略显得太乐观,我们可能需要自定义连接存活策略,也就是在创建 HttpClient 的实例的时候用下面的代码。(xxx为自己写的保活策略)

ClosableHttpClientclient =HttpClients.custom().setKeepAliveStrategy(xxx).build();

  (2)连接池管理

  前面也有说到关于从连接池中取可用连接的部分逻辑。完整的逻辑是:在每收到一个 route 请求后,连接池都会建立一个以这个 route 为 key 的子连接池,当有一个新的连接请求到来的时候,它会优先匹配已经存在的子连接池们,如果之前已经有过以这个 route 为 key 的子连接池,那么就会去试图取这个子连接池中状态为 available 的连接,如果此时有可用的连接,则将取得的 available 连接状态改为 leased 的,取连接成功。如果此时子连接池没有可用连接,那再看是否达到了所设置的最大连接数和每个 route 所允许的最大连接数的上限,如果还有余量则 new 一个新的连接,或者取得 lastUsedConnection,关闭这个连接、把连接从原来所在的子连接池删除,再 lease 取连接成功。如果此时的情况不允许再new一个新的连接,就把这个请求连接的请求放入一个 queue 中排队等待,直到得到一个连接或者超时才会从 queue 中删去。一个连接被 release 之后,会从等待连接的 queue 中唤醒等待连接的服务进行处理。

  (3)连接回收策略

  当连接被管理器收回后,这个连接仍然存活,但是却无法监控 socket 的状态,也无法对 I/O 事件做出反馈。如果连接被服务器端关闭了,客户端监测不到连接的状态变化(也就无法根据连接状态的变化,关闭本地的socket)。HttpClient 为了缓解这一问题造成的影响,会在使用某个连接前,监测这个连接是否已经过时,如果服务器端关闭了连接,那么连接就会失效。前面提到的RequestConfig 中的 staleConnectionCheckEnabled 就是用来控制是否进行上述操作,相关代码:

if(config.isStaleConnectionCheckEnabled()) {
// validate connection
if(managedConn.isOpen()) {
  this.log.debug("Stale connection check");
    if(managedConn.isStale()) {
      this.log.debug("Stale connection detected");
      managedConn.close();
    }
  }
}

其中的 managedConn.isStale() 就是检查取出的连接是否失效,需要注意的是这种过时检查并不是100%有效,并且会给每个请求增加10到30毫秒额外开销。isStale()有一点比较奇怪的是,如果抛出SocketTimeoutException 的时候会返回 false,即意味着此 managedConn 并不是失效的(如果此 managedConn 是长连接的,那么没失效是可理解的,但为什么会抛 SocketTimeoutException 异常就不懂了)。而这里 SocketTimeoutException 的发生与我们前面设置的 RequestConfig.socketTimeout 是没有关系的,它实现的机制是先设置 1ms 的超时时间,看在这 1ms 内是否能从inputBuffer 里面读到数据,如果读到的数据长度为 -1(即没有数据),说明此连接失效。但是很经常随机会发生 SocketTimeoutException,这时会返回 false,并且此时 managedConn 是 open 的状态,这样就会跳过后面的 dns 解析及 socket 重新建立和绑定的过程,直接再次重用之前的 connection 以及它绑定的 socket。

在这里遇到的一个很纠结的问题:

Http1.1 默认进行的长连接并不适用于我们的应用场景,我们的 httpClient 是用在服务端代替客户端 sdk 去请求另一个应用的服务端,并且调用量非常大,在这种情况下,如果使用默认的长连接就会一直只去请求对方的某一台服务器,不管怎么说,虽然调用的确实是相同 host 的主机对功能来说是没有问题的,但万一对方服务器被这样弄挂了呢?并且这种情况下要是使用了dns负载均衡技术,那么dns的负载均衡将不能被执行到!这显然不是我们所希望的。并且通过测试发现,只要是长连接的 connection,在代码中调用各种 close 或者 release 方法都不能把 connection 真正关掉,除非把整个 httpClient.close。

对于这个问题查了一些资料,里面提到的一个可行的解决办法,是建立一个监控线程,来专门回收由于长时间不活动而被判定为失效的连接。这个监控线程可以周期性的调用ClientConnectionManager 类的 closeExpiredConnections() 方法来关闭过期的连接,回收连接池中被关闭的连接。它也可以选择性的调用 ClientConnectionManager 类的 closeIdleConnections() 方法来关闭一段时间内不活动的连接。由于这个解决方案对于我们的应用来说太复杂了,所以这个方案的有效性没有验证过。

我原先采用的解决方式是:在每次连接请求到来的时候都 build 一个新的 HttpClient 对象,并且使用 BasicHttpClientConnectionManager 作为 connectionManager。然后在处理完 http response 之后 close掉这个 HttpClient。目前本地自测来看,这种做法不会出现上面的奇怪问题。但是很忧伤的是,新建一个 HttpClient 的逻辑很重,并且连接不能复用,会浪费很多时间。

由于这个日常需求本身做的就是优化性质的工作,加上每个请求都新建 HttpClient 这一大坨代码,心里总是有点难受。继续找解决办法。

在尝试了改系统的各种 tcp 配置参数还有其他的 socket、系统配置无果后,最终找到的解决方式却异常简单。简单来说,其实我们的应用场景下需要的是短连接,这样只要在 request 中添加Connection:close 的头部,就可以保证这个链接在这次请求完成之后就被关掉,只用一次。同时发现,如果头中既有 Connection:Keep-Alive 又有 Connection:close 的话,Connection:close 并不会有更高的优先级,依旧会保持长连。

 

7.总结

使用 HttpClient 的时候特别需要注意的有下面几个地方:

(1)连接池最大连接数,不配置,默认为20

(2)同个 route 的最大连接数,不配置,默认为2

(3)去连接池中取连接的超时时间,不配置则无限期等待

(4)与目标服务器建立连接的超时时间,不配置则无限期等待

(5)去目标服务器取数据的超时时间,不配置则无限期等待

(6)要 fully consumed entity,才能正确释放底层资源

(7)同个 host 但 ip 有多个的情况,请谨慎使用单例的 HttpClient 和连接池

(8)HTTP1.1 默认支持的是长连接,如果想使用短连接,要在 request 上加 Connection:close 的 header,不然长连接是不可能自动被关掉的!

一定要结合实际情况来看是否需要设置,不然可能导致严重的问题。

HttpClient 的内容远不止我上面说到的这些,还包括 Cookie 管理,Fluent API 等内容,由于没有实际使用,理解的并不透彻,后续继续学习后再来补充。

posted @ 2019-12-22 20:21  傍晚的羔羊  阅读(3264)  评论(0编辑  收藏  举报