原文链接
第2章:连接管理
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内部使用。
HttpRoutePlanner是一个接口,表示根据执行上下文计算到给定目标的完整路由的策略。 HttpClient附带两个默认的HttpRoutePlanner实现。 SystemDefaultRoutePlanner基于java.net.ProxySelector。默认情况下,它将从系统属性或运行应用程序的浏览器中获取JVM的代理设置。 DefaultProxyRoutePlanner实现不使用任何Java系统属性,也不使用任何系统或浏览器代理设置。它始终通过相同的默认代理计算路由。
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连接的工厂,管理持久连接的生命周期以及同步对持久连接的访问,确保一次只有一个线程可以访问连接。如果被管理的连接被释放或由其使用者显式关闭,则底层连接将从其代理中分离并返回给管理器。即使服务使用者仍然拥有对代理实例的引用,它也不再能够执行任何I/O操作或更改真实连接的状态。
这是从连接管理器获取连接的示例:
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. 多线程请求执行
当配备池化连接管理器(如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模型的主要缺点之一是网络套接字只有在I/O操作中被阻塞时才能对I/O事件作出反应。当连接释放回管理器时,它可以保持活动状态,但它无法监视套接字的状态并对任何I/O事件做出反应。如果连接在服务器端关闭,则客户端连接无法检测连接状态的变化(并通过关闭套接字来适当地做出反应)。
HttpClient尝试通过测试连接是否“陈旧”来缓解此问题,在使用连接执行HTTP请求之前不再有效,因为它在服务器端关闭。陈旧的连接检查不是100%可靠。唯一可行的解决方案是,每个套接字模型都有一个不涉及空闲连接的专用的监视器线程,用于驱逐由于长时间不活动而被视为过期的连接。监视器线程可以定期调用ClientConnectionManager#closeExpiredConnections()方法来关闭所有过期的连接并从池中驱逐关闭的连接。它还可以选择调用ClientConnectionManager#closeIdleConnections()方法来关闭在给定时间段内空闲的所有连接。
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-Alive标头与客户端通信他们打算在服务器端保持连接活动的时间段(以秒为单位)。 如果可用,HttpClient会使用此信息。 如果响应中不存在Keep-Alive标头,HttpClient会假定连接可以无限期保持活动状态。 但是,通常使用的许多HTTP服务器被配置为在一段不活动时间之后丢弃持久连接,以便节省系统资源,通常不通知客户端。 如果默认策略过于乐观,可能需要提供自定义保持活动策略。
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接口来创建,初始化和连接套接字。 这使HttpClient的用户能够在运行时提供特定于应用程序的套接字初始化代码。 PlainConnectionSocketFactory是创建和初始化普通(未加密)套接字的默认工厂。
创建套接字的过程以及将其连接到主机的过程是分离的,以便在连接操作中阻塞时可以关闭套接字。
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接口的扩展。 分层套接字工厂能够在现有的普通套接字上创建分层的套接字。 套接字分层主要用于通过代理创建安全套接字。 HttpClient附带SSLSocketFactory,可实现SSL / TLS分层。 请注意,HttpClient不使用任何自定义加密功能。 它完全依赖于标准Java加密(JCE)和安全套接字(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协议的概念有一定程度的熟悉,其详细解释超出了本文档的范围。 有关javax.net.ssl.SSLContext和相关工具的详细说明,请参阅Java安全套接字扩展(JSSE)参考指南。
2.7.4. 主机名验证
除了在SSL / TLS协议级别上执行的信任验证和客户端身份验证之外,一旦建立连接,HttpClient可以选择性地验证目标主机名是否与存储在服务器的X.509证书中的名称匹配。 此验证可以提供服务器信任材料的真实性的额外保证。 javax.net.ssl.HostnameVerifier接口表示主机名验证的策略。 HttpClient附带了两个javax.net.ssl.HostnameVerifier实现。 重要提示:不应将主机名验证与SSL信任验证混淆。
- DefaultHostnameVerifier: HttpClient使用的默认实现应符合RFC 2818。主机名必须与证书指定的任何替代名称匹配,或者如果没有替代名称,则给出证书主题的最具体CN。 通配符可以出现在CN和任何主题中。
- NoopHostnameVerifier:此主机名验证程序实质上关闭了主机名验证。 它接受任何SSL会话作为有效并匹配目标主机。
默认情况下,HttpClient使用DefaultHostnameVerifier实现。 如果需要,可以指定不同的主机名验证器实现。
SSLContext sslContext = SSLContexts.createSystemDefault();
SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(
sslContext,
NoopHostnameVerifier.INSTANCE);
从版本4.4开始,HttpClient使用由Mozilla Foundation友好维护的公共后缀列表,以确保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匹配器禁用对公共suffic列表的验证。
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();
}
}