2.1 连接持久化
在两个主机之间建立连接的过程复杂并且可能相当耗时,这一过程涉及到多个数据包交换,,连接(特别是短连接)握手的开销会非常的大,我们可以通过多个request重用HTTP 连接来达到高吞吐数据量避免这一问题。
HTTP/1.1 默认HTTP连接可以被多个请求重用。HTTP/1.0标准的终端可以使用某些机制去显示的表达他们要想重用连接的意图。HTTP代理也能够保持一段时间内的空闲连接从而提供给同一主机的连续请求使用。保持连接的能力通常叫做连接持久化,HttpClient对连接持久化提供了全面的支持。
2.2 HTTP 路由
HttpClient可以直接跟目标主机建立连接,也可以通过路由来建立多个连接--我们称之为“跳”。HttpClient将连接划分为plain(直连),tunneled(隧道),layered(分层连接),多个中间代理的隧道连接被称之为代理链。
直接到目标或者第一个并且是唯一一个代理的连接称之为直连,与目标之间的多个中间代理建立的连接称之为隧道路由,隧道路由不能脱离代理而存在,通过已有连接的分层协议的路由则称之为分层路由,分层协议只能基于隧道或者直连。
2.2.1 路由计算
RouteInfo接口代表了到目标主机的一系列明确的路由信息,HttpRoute是RouteInfo的一个具体实现,该类是不可修改的。
HttpTracker是一个可变的RouteInfo的实现,用于HttpClient内部来追踪到最终目标主机的剩余路由,HttpTracker可以在进行完一个成功的路由之后被更新。
HttpRouteDirector则是用于计算到下一步路由的帮助类,该类也是在HttpClient内部使用。
HttpRoutePlanner基于执行上下文来计算到目标主机的路由策略,HttpClient附带两个默认的HttpRoutePlanner实现,
SystemDefaultRoutePlanner基于java.net.ProxySelector,它默认从JVM,系统属性或者浏览器中提取代理配置。
DefaultProxyRoutePlanner总是通过默认的策略来计算路由。
2.2.2 HTTP安全连接
如果两个主机之间传递的信息不能被未授权的第三方读取或者篡改,那么这个连接就认为是安全的,SSL/TLS是使用最广泛的HTTP安全传输协议,虽然其他加密技术也可以很好的实现安全传输,但是HTTP传输则普遍使用SSL/TLS。
2.3 HTTP 连接管理器
2.3.1 连接管理与连接管理器
HTTP连接是复杂的,无状态的,非线程安全的,需要妥善的管理以保证正确工作,一个HTTP连接同一时间只能被一个线程执行,HttpClient使用HttpClientConnectionManager接口作为连接管理器去管理HTTP连接,HTTP连接管理器的目标是
1 成为创建HTTP新连接的工厂
2 管理持久化连接的生命周期
3 同步持久化连接的访问,保证同一时间只有一个线程可以访问同一个连接。
内部的HTTP连接管理器与ManagedHttpClientConnection实例协同工作,能够实现代理连接,管理连接状态,控制执行I/O操作。如果一个连接被释放或者被消费端明确的关闭了,底层连接将会从其代理分离出来交还连接管理器,即便是消费端仍然持有代理实例的引用,它也没办法做任何I/O操作或者是改变真实连接的状态。
下面是从连接管理器获取HTTP连接的一个例子:
HttpClientContext context = HttpClientContext.create(); HttpClientConnectionManager connMrg = new BasicHttpClientConnectionManager(); HttpRoute route = new HttpRoute(new HttpHost("localhost", 80)); // Request new connection. This can be a long process ConnectionRequest connRequest = connMrg.requestConnection(route, null); // Wait for connection up to 10 sec HttpClientConnection conn = connRequest.get(10, TimeUnit.SECONDS); try { // If not open if (!conn.isOpen()) { // establish connection based on its route info connMrg.connect(conn, route, 1000, context); // and mark it as route complete connMrg.routeComplete(conn, route, context); } // Do useful things with the connection. } finally { connMrg.releaseConnection(conn, null, 1, TimeUnit.MINUTES); }
连接可以通过调用ConnectionRequest#cancel()提前终止,这会将ConnectionRequest#get()方法中阻塞的线程解除。
2.3.2 简单连接管理器
BasicHttpClientConnectionManager是一个简单连接管理器,一次只保持一条连接,即便这个类是线程安全的,它也只应该被一个执行线程使用,BasicHttpClientConnectionManager对相同路由的连续请求将重用连接,如果新的连接跟已经持久化保持的连接不同,那么它会关闭已有的连接,根据所给的路由重新开启一个新的连接来使用,如果连接已经被分配出去了,那么java.lang.IllegalStateException异常会被抛出。
该连接管理器的实现应该在EJB容器内使用。
2.3.3 连接池管理器
PoolingHttpClientConnectionManager是一个更加复杂的实现,其管理了一个连接池,能够为多个执行线程提供连接,连接依据路由归类放入到池中,当一个请求在连接池中有对应路由的连接时,连接管理器会从池中租借出一个持久化连接而不是创建一个带有标记的连接。
PoolingHttpClientConnectionManager在总的和每条路由上都会保持最大数量限制的连接,默认该实现会为每个路由保持2个并行连接,总的数量上不超过20个连接,在现实使用中,这是限制可能太过于苛刻,尤其对于那些将HTTP作为传输协议的服务来说。
下面是一个如何调整连接池参数的例子:
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); // Increase max total connection to 200 cm.setMaxTotal(200); // Increase default max connection per route to 20 cm.setDefaultMaxPerRoute(20); // Increase max connections for localhost:80 to 50 HttpHost localhost = new HttpHost("locahost", 80); cm.setMaxPerRoute(new HttpRoute(localhost), 50); CloseableHttpClient httpClient = HttpClients.custom() .setConnectionManager(cm) .build();
2.3.4 连接池关闭
当HttpClient实例不再使用并且将要离开作用域时,需要关闭连接管理器,确保所有的连接都被关闭,系统资源被正确释放,这一点非常重要。
CloseableHttpClient httpClient = <...> httpClient.close();
2.4 多线程request执行
当使用如PoolingClientConnectionManager的连接池管理器时,HttpClient可以通过多个执行线程同时执行多个请求。
PoolingClientConnectionManager将会根据其配置分配连接,如果某个路由的所有连接都已经被分配出去了,新进来的请求将会阻塞直到某个连接被释放回连接池,你可以通过配置http.conn-manager.timeout 这个参数来配置新的请求进来时阻塞的超时时间,从而避免无限期等待,如果在给定时间内连接没有被获取到,那么将会抛出ConnectionPoolTimeoutException异常。
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); CloseableHttpClient httpClient = HttpClients.custom() .setConnectionManager(cm) .build(); // URIs to perform GETs on String[] urisToGet = { "http://www.domain1.com/", "http://www.domain2.com/", "http://www.domain3.com/", "http://www.domain4.com/" }; // create a thread for each URI GetThread[] threads = new GetThread[urisToGet.length]; for (int i = 0; i < threads.length; i++) { HttpGet httpget = new HttpGet(urisToGet[i]); threads[i] = new GetThread(httpClient, httpget); } // start the threads for (int j = 0; j < threads.length; j++) { threads[j].start(); } // join the threads for (int j = 0; j < threads.length; j++) { threads[j].join(); }
由于HttpClient实例是线程安全的能够在多个执行线程之间共享,强烈建议每个线程维持其自己的专有HttpContext实例。
static class GetThread extends Thread { private final CloseableHttpClient httpClient; private final HttpContext context; private final HttpGet httpget; public GetThread(CloseableHttpClient httpClient, HttpGet httpget) { this.httpClient = httpClient; this.context = HttpClientContext.create(); this.httpget = httpget; } @Override public void run() { try { CloseableHttpResponse response = httpClient.execute( httpget, context); try { HttpEntity entity = response.getEntity(); } finally { response.close(); } } catch (ClientProtocolException ex) { // Handle protocol errors } catch (IOException ex) { // Handle I/O errors } } }
2.5 连接回收策略
经典阻塞I/O模型的一个主要缺点就是网络socket只有在I/O操作阻塞的情况下才会对I/O事件作出反应。当连接释放回管理器时,它虽然能够保持存活,但是它无法监控socket的状态也无法对任何I/O事件作出反应。如果连接在Server端被关闭,client端连接无法侦测到连接状态的改变并且作出适当的回应。
HttpClient尝试通过测试连接是否'stale'来缓解这个问题,stale的意思是连接不再有效因为在执行Http请求之前其已经被服务端关闭。过期连接检查不是100%可靠的,唯一可行解决方案是提供一个专用监控线程用于回收那些长时间内不活动的连接,而该解决方案不会影响到一个socket一个线程的空闲连接模型。监控线程可以定期调用 ClientConnectionManager#closeExpiredConnections()方法来关闭所有过期的连接,同时从连接池中回收已经被关闭的连接,也可以有选择性的调用 ClientConnectionManager#closeIdleConnection()方法去关闭那些在给定时间范围内空闲的连接。
public static class IdleConnectionMonitorThread extends Thread { private final HttpClientConnectionManager connMgr; private volatile boolean shutdown; public IdleConnectionMonitorThread(HttpClientConnectionManager connMgr) { super(); this.connMgr = connMgr; } @Override public void run() { try { while (!shutdown) { synchronized (this) { wait(5000); // Close expired connections connMgr.closeExpiredConnections(); // Optionally, close connections // that have been idle longer than 30 sec connMgr.closeIdleConnections(30, TimeUnit.SECONDS); } } } catch (InterruptedException ex) { // terminate } } public void shutdown() { shutdown = true; synchronized (this) { notifyAll(); } } }
2.6连接保活策略
HTTP规范没有明确指出一个连接应该被持久化保持多长时间,一些HTTP 服务器使用非标准的Keep-Aliveheader去同客户端沟通他们想要在server端保持多久的连接,HttpClient也会利用这个信息,如果Keep-Alive消息头在response中不存在,HttpClient假设连接可以被无限期的保持存活,但是很多HTTP服务端通常配置为在持久化连接一段时间内不活动时就丢弃以节省系统资源,而丢弃时往往不通知客户端,在这种情况下,默认的策略会变得过于乐观,你可能想要提供一个自定义keep-alive策略。
ConnectionKeepAliveStrategy myStrategy = new ConnectionKeepAliveStrategy() { public long getKeepAliveDuration(HttpResponse response, HttpContext context) { // Honor 'keep-alive' header HeaderElementIterator it = new BasicHeaderElementIterator( response.headerIterator(HTTP.CONN_KEEP_ALIVE)); while (it.hasNext()) { HeaderElement he = it.nextElement(); String param = he.getName(); String value = he.getValue(); if (value != null && param.equalsIgnoreCase("timeout")) { try { return Long.parseLong(value) * 1000; } catch(NumberFormatException ignore) { } } } HttpHost target = (HttpHost) context.getAttribute( HttpClientContext.HTTP_TARGET_HOST); if ("www.naughty-server.com".equalsIgnoreCase(target.getHostName())) { // Keep alive for 5 seconds only return 5 * 1000; } else { // otherwise keep alive for 30 seconds return 30 * 1000; } } }; CloseableHttpClient client = HttpClients.custom() .setKeepAliveStrategy(myStrategy) .build();
2.7 连接套接字工厂
HTTP连接内部使用java.net.Socket对象来处理线路上的数据传输,但是他们依赖于ConnectionSocketFactory接口来创建,初始化和连接socket,这使得HttpClient的用户可以在运行时提供应用程序特定的socket初始化代码。PlainConnectionSocketFactory是创建和初始化普通socket(非加密)的默认工厂类。
创建socket和连接socket的过程是分离的,这样socket在连接操作阻塞时就可以被关闭掉。
HttpClientContext clientContext = HttpClientContext.create(); PlainConnectionSocketFactory sf = PlainConnectionSocketFactory.getSocketFactory(); Socket socket = sf.createSocket(clientContext); int timeout = 1000; //ms HttpHost target = new HttpHost("localhost"); InetSocketAddress remoteAddress = new InetSocketAddress( InetAddress.getByAddress(new byte[] {127,0,0,1}), 80); sf.connectSocket(timeout, socket, target, remoteAddress, null, clientContext);
2.7.1 安全套接字层
LayeredConnectionSocketFactory是ConnectionSocketFactory接口的一个扩展,分层套接字工厂能够在已存在的普通socket上创建分层socket,Socket分层主要用于通过代理创建安全socket,HttpClient附带的SSLSocketFactory实现了SSL/TLS层协议,请注意HttpClient不会使用任何定制的加密功能,它完全依赖JCE(标准JAVA加密)和JSEE(安全套接字)扩展。
2.7.2 同连接管理器整合
自定义连接套接字工厂可以跟特定的协议(如HTTP,HTTPS)关联起来用于创建自定义的连接管理器。
ConnectionSocketFactory plainsf = <...> LayeredConnectionSocketFactory sslsf = <...> Registry<ConnectionSocketFactory> r = RegistryBuilder.<ConnectionSocketFactory>create() .register("http", plainsf) .register("https", sslsf) .build(); HttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(r); HttpClients.custom() .setConnectionManager(cm) .build();
2.7.3 SSL/TLS 定制
HttpClient使用SSLConnectionSocketFactory来创建SSL连接,SSLConnectionSocketFactory允许高度定制化,它可以通过 javax.net.ssl.SSLContext 参数来创建定制的SSL连接。
KeyStore myTrustStore = <...> SSLContext sslContext = SSLContexts.custom() .loadTrustMaterial(myTrustStore) .build(); SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(sslContext);
定制SSLConnectionSocketFactory意味着对SSL/TLS协议有一定程度的了解,其详细说明已经超出本文档的范围,请查看http://docs.oracle.com/javase/6/docs/technotes/guides/security/jsse/JSSERefGuide.html
来获取javax.net.ssl.SSLContext和其相关工具的详细说明。
2.7.4 主机名验证
除了信任验证和客户端身份验证在SSL/TLS协议层进行之外,HttpClient可以有选择的验证目标主机名是否跟服务端存储在X.509认证里的一致,一旦连接已经建立,这种验证可以为服务器认证提供额外的保障,javax.net.ssl.HostnameVerifier 接口代表了主机名验证的一种策略,HttpClient附带了两中javax.net.ssl.HostnameVerifier的实现,注意:不要把主机名验证跟SSL信任验证混淆
DefaultHostnameVerifier: HttpClient使用的默认实现,与RFC2818兼容,主机名必须匹配证书指定的任何可替换的名称,或者没有可替换名称下证书主体中指定的具体的CN,CN和可替换名称中都可能有通配符。
NoopHostnameVerifier: 这个主机名验证器基本上就是把主机名验证关闭了,它接受任何有效的SSL会话来匹配目标主机。
默认HttpClient使用DefaultHostnameVerifier实现,如果有需要的话你可以指定一个不同的主机名验证器
SSLContext sslContext = SSLContexts.createSystemDefault(); SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory( sslContext, NoopHostnameVerifier.INSTANCE);
HttpClient4.4使用Mozilla基金会维护的公共后缀列表去确保SSL证书的通配符不会被多个通用顶级域名误用,HttpClient会附带一个该列表的最新的拷贝,最新的修正版在https://publicsuffix.org/list/,强烈建议从源数据每天更新一次并且保持一份本地拷贝。
PublicSuffixMatcher publicSuffixMatcher = PublicSuffixMatcherLoader.load( PublicSuffixMatcher.class.getResource("my-copy-effective_tld_names.dat")); DefaultHostnameVerifier hostnameVerifier = new DefaultHostnameVerifier(publicSuffixMatcher);
你可以通过使用null匹配来关闭公共后缀列表验证
DefaultHostnameVerifier hostnameVerifier = new DefaultHostnameVerifier(null);
2.8 HttpClient 代理配置
尽管HttpClient已经有复杂路由计算和代理量,他也只支持简单的直连和单跳代理连接。
让HttpClient通过代理连接目标主机最简单的方式是配置默认的代理参数:
HttpHost proxy = new HttpHost("someproxy", 8080); DefaultProxyRoutePlanner routePlanner = new DefaultProxyRoutePlanner(proxy); CloseableHttpClient httpclient = HttpClients.custom() .setRoutePlanner(routePlanner) .build();
你也可以让HttpClient使用标准JRE代理选择器来获取代理配置:
SystemDefaultRoutePlanner routePlanner = new SystemDefaultRoutePlanner( ProxySelector.getDefault()); CloseableHttpClient httpclient = HttpClients.custom() .setRoutePlanner(routePlanner) .build();
或者,你可以提供定制的RoutePlanner实现,来实现HTTP路由的复杂计算:
HttpRoutePlanner routePlanner = new HttpRoutePlanner() { public HttpRoute determineRoute( HttpHost target, HttpRequest request, HttpContext context) throws HttpException { return new HttpRoute(target, null, new HttpHost("someproxy", 8080), "https".equalsIgnoreCase(target.getSchemeName())); } }; CloseableHttpClient httpclient = HttpClients.custom() .setRoutePlanner(routePlanner) .build(); } }