2.1 连接持久性
建立从一个主机到另一个主机的连接的过程非常复杂,并且涉及两个端点之间的多个分组交换,这可能会非常耗时。连接握手的开销可能很大,尤其是对于小型 HTTP 消息。如果可以复用打开的连接去执行多个请求,就可以实现更高的数据吞吐量。
HTTP/1.1 声明 HTTP 连接可以在默认情况下重复用于多个请求。符合 HTTP/1.0 标准的端点还可以使用一种机制来显式传达其首选项,以保持连接活动并将其用于多个请求。如果后续请求需要连接到同一目标主机,HTTP 代理还可以使空闲连接保持一段时间。保持连接活动的能力通常被称为连接持久性。HttpClient 完整地支持了连接持久性。
2.2 HTTP 连接路由
HttpClient 能够直接或通过可能涉及多个中间连接的路由建立到目标主机的连接 - 也称为跳。HttpClient 将路由的连接区分为普通,隧道和分层。使用多个中间代理来隧道连接到目标主机称为代理链。
- 通过连接到目标或第一个也是唯一的代理来建立普通路由。
- 隧道路由是通过连接到第一个隧道并通过代理链到目标的隧道来建立的。没有代理的路由无法进行隧道传输。
- 通过在现有连接上分层协议来建立分层路由。协议只能通过隧道分层到目标,或通过没有代理的直接连接。
2.2.1 路由计算
RouteInfo 接口表示关于到目标主机的确定路由的信息,涉及一个或多个中间步骤或跳。HttpRoute 是一个 RouteInfo 具体的实现,它不能被改变(是不可变的)。HttpTracker 是一个 HttpClient 内部使用的 RouteInfo 的可变实现,它用于跟踪剩余的跳转到最终路径目标。 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 连接的工厂,管理持久连接的生命周期以及同步对持久连接的访问,确保一次只有一个线程可以访问连接。内部 HTTP 连接管理器使用 ManagedHttpClientConnection 类的实例充当实际连接的代理来管理连接状态并控制 I/O 操作的执行。如果托管连接被释放或由其使用者显式关闭,则底层连接将从其代理中分离并返回给管理器。即使服务使用者仍然持有对代理实例的引用,它也不再能够有意或无意地执行任何 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 多线程请求执行
当 HttpClient 拥有类似 PoolingClientConnectionManager
类这样的池连接管理器,他就能够使用多线程来并发执行多个请求。
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 通过测试连接是否为 “state”,希望能够缓解使用连接来执行 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 Socket 连接工厂
HTTP 连接在内部使用 java.net.Socket
类的对象来处理数据在链路上的传输。然而,他们依赖 ConnectionSocketFactory
接口来创建、初始化和连接 Socket。HttpClient 的使用者能够在运行时,提供应用程序特定的 Socket 初始化代码。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 附带了实现 SSL/TLS 分层的 SSLSocketFactory 类。请注意 HttpClient 没有使用任何自定义的加密功能,它完全依赖于标准的 Java Cryptography(JCE)和 Secure Sockets(JSSE)扩展。
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 协议的概念有一定程度的了解,其详细说明不在本文档的范围内。请点击 Java™ Secure Socket Extension (JSSE) Reference Guide
来获取 javax.net.ssl.SSLContext
接口的详细说明和相关工具。
2.7.4 主机名验证
除了在 SSL/TLS 协议级别上进行信任验证和客户端身份验证之外,一旦建立了连接,HttpClient 可以选择性地验证目标主机名是否与存储在服务器的 X.509 证书中的名称匹配。该验证可以提供对服务器信任材料的真实性的额外保证。javax.net.ssl.HostnameVerifier
接口代表主机名验证策略。HttpClient 附带了 javax.net.ssl.HostnameVerifier
接口的两个实现类。请注意:主机名验证和 SSL 信任验证这两者不应该混淆。
-
DefaultHostnameVerifier
:HttpClient 使用的默认实现类,它应该兼容 RFC 2818。主机名必须匹配证书指定的任何别名,或在证书持有者没有为别名给出最明确的证书通用名(CN)的情况下。在证书通用名(CN),以及任何 subject-alts 中都可以出现通配符。 -
NoopHostnameVerifier
:作为主机名验证工具,实际上关闭了主机名验证,他接受任何有效的 SSL 会话并匹配到目标主机。
HttpClient 默认使用DefaultHostnameVerifier
类来实现。如果需要,可以指定其他的主机名验证器来实现。
SSLContext sslContext = SSLContexts.createSystemDefault();
SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(
sslContext,
NoopHostnameVerifier.INSTANCE);
自版本 4.4 之后 HttpClient 使用由 Mozilla 基金维护的公共后缀名列表,以确保 SSL 证书中的通配符不会被滥用于申请拥有公共顶级域名的多个域。HttpClient 附带了在释放时回收的列表的副本。这个 URL 能够获取最近的版本号列表,强烈建议制作这个列表的本地副本,并每天从其原始位置下载不超过一次。
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();
}
}