第二章 连接管理


2.1. 连接持久性

两台服务器间建立连接的过程十分复杂,由于涉及到两个端点间多次数据包的交换,这一过程十分耗时.连接握手阶段的代价很高,尤其对于发送小的HTTP报文的时候.如果开放的连接可以被复用以执行多次请求,则接收方可获得更高的吞吐量. HTTP/1.1 规定HTTP连接默认可以重复用于多个请求.还有符合HTTP/1.0协议的端点也可以通过一种传递首选项的机制,来保持连接的活动状态并将其用于多个请求.HTTP代理同样也可以保持连接存活一段时间,以防后续连接到相同主机的请求需要重新发起.这种保持连接的技术通常被称为连接持久化,HttpClient全面支持该技术.

2.2. HTTP连接路由

HttpClient能够直接或通过涉及多个中间连接(也称为中继)的路由建立到目标主机的连接。HttpClient将路由的连接区分为普通,隧道和分层。使用若干个代理作为通路与目标主机的链接被称之为代理链接. 普通路由的建立是通过连接到指定目标或首个且仅有的代理来完成的.隧道路由是通过连接到首个或一连串代理来建立隧道.没有代理的路由不能被隧道传输.分层路由通过在现有连接上分层协议来建立。 协议只能在通向目标的隧道上进行分层,或者通过不走代理的直接连接进行分层。

2.2.1. 路由计算

RouteInfo接口表示关于涉及一个或多个中间步骤或跳跃的目标主机的确定路由的信息。 HttpRoute是RouteInfo的具体实现类,它不能被改变(是不可变的类)。 HttpTracker是一个可变的RouteInfo实现类,由HttpClient在内部用来跟踪剩余的跳转到最终路由目标。 在向路由目标成功执行下一跳之后,可以更新HttpTracker。 HttpRouteDirector是一个辅助类,可用于计算路由中的下一步。 这个类被HttpClient内部使用。

2.2.2. HTTP安全连接

如果在两个连接端点之间传输的信息无法被未经授权的第三方读取或篡改,则HTTP连接可被视为安全。 SSL/TLS协议是确保HTTP传输安全性的最广泛使用的技术。但是,也可以采用其他加密技术。 通常,HTTP传输构建在SSL/TLS加密连接层上。

2.3 HTTP连接管理器

2.3.1. 管理连接和连接管理器

HTTP连接是复杂的,有状态的,线程不安全的对象,需要妥善管理才能正常工作。 HTTP连接一次只能由一个执行线程使用。 HttpClient使用一个特殊的实体来管理对HTTP连接的访问​​,称为HTTP连接管理器,并HttpClientConnectionManager接口表示。 HTTP连接管理器的目的是作为新的HTTP连接的工厂,管理持久连接的生命周期,并同步对持久连接的访问​​,以确保一次只有一个线程可以访问连接。内部HTTP连接管理器与ManagedHttpClientConnection实例一起工作,作为管理连接状态和控制I/O操作执行的真实连接的代理。如果托管连接被释放或被其消费者明确关闭,则底层连接将从其代理中分离出来并返回给管理器。即使服务使用者仍然持有对代理实例的引用,它不再有意或无意地执行任何I / O操作或更改真实连接的状态。 以下是从连接管理器获取连接的示例

  1. HttpClientContext context = HttpClientContext.create();
  2. HttpClientConnectionManager connMrg = new BasicHttpClientConnectionManager();
  3. HttpRoute route = new HttpRoute(new HttpHost("localhost", 80));
  4. // 请求新连接,该过程耗时
  5. ConnectionRequest connRequest = connMrg.requestConnection(route, null);
  6. // Wait for connection up to 10 sec
  7. HttpClientConnection conn = connRequest.get(10, TimeUnit.SECONDS);
  8. try {
  9. // If not open
  10. if (!conn.isOpen()) {
  11. // establish connection based on its route info
  12. connMrg.connect(conn, route, 1000, context);
  13. // and mark it as route complete
  14. connMrg.routeComplete(conn, route, context);
  15. }
  16. // Do useful things with the connection.
  17. } finally {
  18. connMrg.releaseConnection(conn, null, 1, TimeUnit.MINUTES);
  19. }

如果需要,可以通过调用ConnectionRequest#cancel()来提早结束连接请求。如果执行这一操作的话,将解除阻塞在ConnectionRequest#get()方法中阻塞的线程。

2.3.2. 简单连接管理器

BasicHttpClientConnectionManager是一种简单的连接管理器,它在同一时间只能维护一个连接。即时该类是线程安全的,但它只能在一个线程中执行。BasicHttpClientConnectionManager尽最大努力为具有相同路由的后续的请求重用连接。但是,如果持久连接的路由与连接请求的路由不匹配,它将关闭现有连接并为给定路由重新打开它。如果连接已分配,则会引发java.lang.IllegalStateException异常。 此连接管理器实现可能在 EJB 容器内使用。

2.3.3. 连接池管理器

PoolingHttpClientConnectionManager 是一个更复杂的实现, 它管理一个客户端连接池, 并且能够在多个执行线程中服务连接请求。链接以每条路由分组汇集成连接池。当连接管理器连接池中已经有一个持久连接时,对于路由请求,将通过复用池中的连接而不是创建新连接的方式来提供服务。 PoolingHttpClientConnectionManager 在每条路由和总数上维持一个最大限度的连接数。默认情况下,实现将创建不超过2个并发连接,且总数上不超过20个。对于实际的应用场景,这些限制被证明过于苛刻。特别是,当使用HTTP作为服务的传输协议时。 本示例演示如何调整连接池参数:

  1. PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
  2. // 增加最大连接数到200
  3. cm.setMaxTotal(200);
  4. // 增加最大默认连接数/路由到20
  5. cm.setDefaultMaxPerRoute(20);
  6. // 为 localhost:80 增加最大连接数到50
  7. HttpHost localhost = new HttpHost("locahost", 80);
  8. cm.setMaxPerRoute(new HttpRoute(localhost), 50);
  9. CloseableHttpClient httpClient = HttpClients.custom()
  10. .setConnectionManager(cm)
  11. .build();

2.3.4 连接管理器的关闭

当HttpClient实例超出作用域且不再需要的情况下, 关闭其连接管理器以确保活动连接被关闭,并释放这些链接所分配的系统资源,这些是非常重要的。

  1. CloseableHttpClient httpClient = <...>
  2. httpClient.close();

2.4. 多线程请求执行

HttpClient使用诸如PoolingClientConnectionManager这样带有连接池的管理器,以实现在多线程中服务多个请求。PoolingClientConnectionManager 根据配置分配连接。如果指定路由上所有连接已被占用,连接请求会阻塞,直到连接被释放回池。但可以通过设置参数’http.conn-manager.timeout’ 为正数,以此确保无限期的阻塞连接请求。如果在指定时间范围内都无法服务连接请求,则会抛出ConnectionPoolTimeoutException异常。

  1. PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
  2. CloseableHttpClient httpClient = HttpClients.custom()
  3. .setConnectionManager(cm)
  4. .build();
  5. // URIs to perform GETs on
  6. String[] urisToGet = {
  7. "http://www.domain1.com/",
  8. "http://www.domain2.com/",
  9. "http://www.domain3.com/",
  10. "http://www.domain4.com/"
  11. };
  12. // 为每个URI Get请求创建线程
  13. GetThread[] threads = new GetThread[urisToGet.length];
  14. for (int i = 0; i < threads.length; i++) {
  15. HttpGet httpget = new HttpGet(urisToGet[i]);
  16. threads[i] = new GetThread(httpClient, httpget);
  17. }
  18. // 启动线程
  19. for (int j = 0; j < threads.length; j++) {
  20. threads[j].start();
  21. }
  22. // 同步线程
  23. for (int j = 0; j < threads.length; j++) {
  24. threads[j].join();
  25. }

虽然HttpClient实例是线程安全的,可以在多线程中共享。但还是推荐每个线程维护自己的HttpContext的专有实例。

  1. static class GetThread extends Thread {
  2. private final CloseableHttpClient httpClient;
  3. private final HttpContext context;
  4. private final HttpGet httpget;
  5. public GetThread(CloseableHttpClient httpClient, HttpGet httpget) {
  6. this.httpClient = httpClient;
  7. this.context = HttpClientContext.create();
  8. this.httpget = httpget;
  9. }
  10. @Override
  11. public void run() {
  12. try {
  13. CloseableHttpResponse response = httpClient.execute(
  14. httpget, context);
  15. try {
  16. HttpEntity entity = response.getEntity();
  17. } finally {
  18. response.close();
  19. }
  20. } catch (ClientProtocolException ex) {
  21. // Handle protocol errors
  22. } catch (IOException ex) {
  23. // Handle I/O errors
  24. }
  25. }
  26. }

2.5. 连接回收策略

经典的阻塞I/O模型主要缺点之一是只能在I/O操作阻塞时才能对I/O事件作出响应。当链接释放并放回管理器中,它可以保持活动状态,但不能监视套接字的状态并对任何I/O事件作出响应。如果服务端关闭连接,客户端无法探测到连接状态的改变(并通过关闭套接字进行回应)。 HttpClient 试图通过测试连接是否为 “陈旧” 来缓解问题, 因为在执行 HTTP 请求之前, 它在服务器端已关闭, 因此不再有效。陈旧的连接检查不是100% 可靠的。唯一可行的解决方案是一个专有的监视线程, 存在与每个套接字模型中的空闲连接线程,用于回收由于长时间不活动而被视为过期的连接。监视线程可以定期调用ClientConnectionManager # closeExpiredConnections () 方法来关闭所有过期的连接, 并从池中回收关闭的连接。它还可以选择调用ClientConnectionManager # closeIdleConnections () 方法来关闭在指定时间段内闲置的所有连接。

  1. public static class IdleConnectionMonitorThread extends Thread {
  2. private final HttpClientConnectionManager connMgr;
  3. private volatile boolean shutdown;
  4. public IdleConnectionMonitorThread(HttpClientConnectionManager connMgr) {
  5. super();
  6. this.connMgr = connMgr;
  7. }
  8. @Override
  9. public void run() {
  10. try {
  11. while (!shutdown) {
  12. synchronized (this) {
  13. wait(5000);
  14. // Close expired connections
  15. connMgr.closeExpiredConnections();
  16. // Optionally, close connections
  17. // that have been idle longer than 30 sec
  18. connMgr.closeIdleConnections(30, TimeUnit.SECONDS);
  19. }
  20. }
  21. } catch (InterruptedException ex) {
  22. // terminate
  23. }
  24. }
  25. public void shutdown() {
  26. shutdown = true;
  27. synchronized (this) {
  28. notifyAll();
  29. }
  30. }
  31. }

2.6. 连接KeepAlive策略

HTTP规范没有指定持久连接keeyAlive保持时间,某些HTTP服务器使用非标准的Keep-Alive头与客户端通讯,以在秒为单位的时间周期内,保持连接的存活状态。如果该选项可以用,HttpClient充分利用这些信息。如果在返回包中没有给出Keep-Alive选项,HttpClient将假定无限期的保持连接。然而,许多服务器一般都被配置为在连接静默一段时间后丢弃连接的特性,以节省系统资源,并一般不会通知到客户端。如果认为默认策略过于理想乐观的话,可以自定义keep-alive策略。

  1. ConnectionKeepAliveStrategy myStrategy = new ConnectionKeepAliveStrategy() {
  2. public long getKeepAliveDuration(HttpResponse response, HttpContext context) {
  3. // Honor 'keep-alive' header
  4. HeaderElementIterator it = new BasicHeaderElementIterator(
  5. response.headerIterator(HTTP.CONN_KEEP_ALIVE));
  6. while (it.hasNext()) {
  7. HeaderElement he = it.nextElement();
  8. String param = he.getName();
  9. String value = he.getValue();
  10. if (value != null && param.equalsIgnoreCase("timeout")) {
  11. try {
  12. return Long.parseLong(value) * 1000;
  13. } catch(NumberFormatException ignore) {
  14. }
  15. }
  16. }
  17. HttpHost target = (HttpHost) context.getAttribute(
  18. HttpClientContext.HTTP_TARGET_HOST);
  19. if ("www.naughty-server.com".equalsIgnoreCase(target.getHostName())) {
  20. // Keep alive for 5 seconds only
  21. return 5 * 1000;
  22. } else {
  23. // otherwise keep alive for 30 seconds
  24. return 30 * 1000;
  25. }
  26. }
  27. };
  28. CloseableHttpClient client = HttpClients.custom()
  29. .setKeepAliveStrategy(myStrategy)
  30. .build();

2.7. 连接套接字工厂

HTTP连接实现在内部使用java.net.Socket对象来处理数据传输。但是创建、初始化和连接套接字的话都需要依赖ConnectionSocketFactory工厂类。这使得使用HttpClient的用户给特定应用带来在运行时初始化socket的功能。 PlainConnectionSocketFactory 是创建和初始化普通 (未加密) 套接字的默认工厂。 将创建套接字和连接到服务器的过程解耦,以便在操作阻塞时关闭套接字。

  1. HttpClientContext clientContext = HttpClientContext.create();
  2. PlainConnectionSocketFactory sf = PlainConnectionSocketFactory.getSocketFactory();
  3. Socket socket = sf.createSocket(clientContext);
  4. int timeout = 1000; //ms
  5. HttpHost target = new HttpHost("localhost");
  6. InetSocketAddress remoteAddress = new InetSocketAddress(
  7. InetAddress.getByAddress(new byte[] {127,0,0,1}), 80);
  8. sf.connectSocket(timeout, socket, target, remoteAddress, null, clientContext);

2.7.1. 安全套接字层(SSL)

LayeredConnectionSocketFactory 是 ConnectionSocketFactory 接口的扩展。分层套接字工厂类可以在现有的普通套接字上创建套接字。套接字分层主要用于通过代理创建安全套接字。HttpClient连同SSLSocketFactory实现了 SSL/TLS 分层功能。需要注意的是 HttpClient 不提供任何自定义加密功能。这完全依赖于标准的 Java 加密 (JCE) 和安全套接字 (JSEE) 扩展。

2.7.2. 集成连接管理器

自定义连接套接字工厂类与特定协议相关联, 如 HTTP 或 HTTPS, 然后用于创建自定义连接管理器。

  1. ConnectionSocketFactory plainsf = <...>
  2. LayeredConnectionSocketFactory sslsf = <...>
  3. Registry<ConnectionSocketFactory> r = RegistryBuilder.<ConnectionSocketFactory>create()
  4. .register("http", plainsf)
  5. .register("https", sslsf)
  6. .build();
  7. HttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(r);
  8. HttpClients.custom()
  9. .setConnectionManager(cm)
  10. .build();

2.7.3. SSL/TLS 定制

HttpClient 使用 SSLConnectionSocketFactory 创建 SSL 连接。SSLConnectionSocketFactory 可被深度定制。可以将 javax.net.ssl.SSLContext 的实例作为参数, 并用它来创建自定义配置的 ssl 连接。

  1. KeyStore myTrustStore = <...>
  2. SSLContext sslContext = SSLContexts.custom()
  3. .loadTrustMaterial(myTrustStore)
  4. .build();
  5. SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(sslContext);

SSLConnectionSocketFactory 的自定义意味着需要对 SSL/TLS 协议的概念有一定程度的熟悉, 对该技术的详细解释超出了本文的范围。有关 javax.net.ssl.SSLContext 和相关工具的详细描述, 请参阅 Java™安全套接字扩展 (JSSE) 参考指南

2.7.4. 主机名验证

除了可以在 SSL/TLS 协议层级上执行的信任验证和客户端身份验证之外, HttpClient 还可以在链接建立时,选择性的验证目标主机名是否与存储在服务器 x509 证书中的名称匹配.此验证可以为服务器认证真实性提供额外的保证。javax.net.ssl.HostnameVerifier 接口代表主机名验证策略。HttpClient 自带有两个 javax.net.ssl.HostnameVerifier 实现。注意: 主机名验证需要区别与 SSL 信任验证。

  • DefaultHostnameVerifier HttpClient 的默认实现符合 RFC 2818规范。主机名必须与证书指定的任意别名匹配, 或者如果没有别名可以是证书中CN字段。通配符可以出现在 CN 中, 也可能发生在任意别名中。
  • NoopHostnameVerifier 该主机名验证程序实际上关闭了主机名验证。它接受任何 SSL 会话并认为有效且匹配目标主机。 每个 HttpClient 默认使用 DefaultHostnameVerifier 实现。如果需要, 可以指定一个不同的主机名验证程序实现。

    1. SSLContext sslContext = SSLContexts.createSystemDefault();
    2. SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(
    3. sslContext,
    4. NoopHostnameVerifier.INSTANCE);

    HttpClient v4.4开始,作为开发商,Mozilla基金会维护了一个域名后缀列表,以确保 SSL 证书中的通配符不会被误用,并正确应用到拥有公共顶级域的多个域。 HttpClient发布的时候就带有这份后缀列表的清单。最新的版本可在后缀清单检索到。在本地保留一份清单的副本并每条不超过一次的下载更新是明智的。

    1. PublicSuffixMatcher publicSuffixMatcher = PublicSuffixMatcherLoader.load(
    2. PublicSuffixMatcher.class.getResource("my-copy-effective_tld_names.dat"));
    3. DefaultHostnameVerifier hostnameVerifier = new DefaultHostnameVerifier(publicSuffixMatcher);

    可以使用null匹配器禁用公共后缀验证功能

    1. DefaultHostnameVerifier hostnameVerifier = new DefaultHostnameVerifier(null);

    2.8. HttpClient 代理配置

    即使HttpClient提出复杂路由和代理链接,但它只能支持简单直连或一个hop的代理连接。连到目标主机最简单的方式是通过代理,并配置默认参数。

    1. HttpHost proxy = new HttpHost("someproxy", 8080);
    2. DefaultProxyRoutePlanner routePlanner = new DefaultProxyRoutePlanner(proxy);
    3. CloseableHttpClient httpclient = HttpClients.custom()
    4. .setRoutePlanner(routePlanner)
    5. .build();

    还可以让 HttpClient 使用标准 JRE 代理选择器获取代理信息:

    1. SystemDefaultRoutePlanner routePlanner = new SystemDefaultRoutePlanner(
    2. ProxySelector.getDefault());
    3. CloseableHttpClient httpclient = HttpClients.custom()
    4. .setRoutePlanner(routePlanner)
    5. .build();

    另外, 可以提供自定义 RoutePlanner 实现, 以便完全控制 HTTP 路由计算过程: ``` HttpRoutePlanner routePlanner = new HttpRoutePlanner() {

    public HttpRoute determineRoute(

    1. HttpHost target,
    2. HttpRequest request,
    3. HttpContext context) throws HttpException {
    4. return new HttpRoute(target, null, new HttpHost("someproxy", 8080),
    5. "https".equalsIgnoreCase(target.getSchemeName()));

    }

}; CloseableHttpClient httpclient = HttpClients.custom() .setRoutePlanner(routePlanner) .build(); } } ```