2.1 鏈接持久化
主機之間建立鏈接的過程是相當複雜的,並且涉及到多個數據包的交換,因此這一過程相當耗時。鏈接時握手的開銷非常大,尤其是對於那些小的HTTP消息。如果打開的鏈接能夠被重用執行多次請求,那麼就會獲得很高的數據吞吐量。
HTTP/1.1聲明,默認情況下HTTP鏈接能夠被重用執行多次請求。HTTP/1.0兼容的端點也可以使用一種機制明確交流他們的偏好用於保持鏈接狀態和執行多次請求。HTTP代理也能在一個確切的時間保持一個理想的鏈接狀態以防後來的請求鏈接到相同的目標主機。
保持鏈接狀態的能力通常被稱爲鏈接持久化,HttpClient完全支持鏈接持久化。
2.2 HTTP鏈接路由
HttpClient可以直接或者通過多箇中間媒介的跳躍建立到目標主機鏈接。HttpClient區別普通的路由、通道路由和分層的路由,使用多箇中間代理建立到目標服務器的鏈接被稱作代理鏈。
普通路由通過第一個且唯一一個代理建立到目標服務器鏈接。通道路由通過代理鏈建立到目標服務器的鏈接。分層路由通過在一個已存在的鏈接上進行分層協議來建立到目標服務器的鏈接。協議僅僅可以在一個建立到目標服務器的通道上或者在一個沒有代理的直接連接上進行分層。
2.2.1 路由計算
RouteInfo接口表示一個經過了一個或多箇中介跳躍路由到目標主機的信息,HttpRoute是HouteInfo接口的一個基具體實現,它是不可變的。HttpTracker是個可變的HouteInfo接口的實現類,此類在HttpClient內部被用作追蹤到最終路由目標的剩餘路由路程。HttpTracker可以在下一個到路由目標跳躍成功執行後被修改。HttpRouteDirector是一個用作HttpClient內部的輔助類,它可以用來在路由中計算下一個跳躍。
HttpRoutePlanner是一個代表基於執行上下文計算一個到指定目標的完整路由的策略,HttpClient提供兩個默認的HttpRoutePlanner的實現類,基於java.net.ProxySelector的SystemDefaultRoutePlanner,默認情況下,它從系統屬性或者運行應用的瀏覽器讀取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鏈接管理器用ManagerHttpClientConnection的實例管理鏈接,此實例作爲管理鏈接狀態和控制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 多線程請求的執行
當使用例如PoolingClientConnectionManager的連接池管理器時,HttpClient可以使用多線程同時執行多個請求。
PoolingClientConnectionManager會基於它的配置創建鏈接,如果鏈接池中所有的鏈接都被使用那麼請求將會阻塞,直到有鏈接被釋放至連接池。可以通過設置http.comm-mananger.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試圖通過測試鏈接是否過時來緩解這一問題,因爲服務器端已經關閉了鏈接所以之前使用的請求鏈接就不再有效。過時鏈接檢查也不是百分之百可靠的。唯一可行的且不必每一個套接字模型都涉及到一個線程的解決方案是使用一個專用的監視器線程釋放那些因爲長時間不活躍而被認爲是過時的鏈接。監視器線程可以定期地調用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使用實現於SSL/TLS分層的SSLSocketFactory,請注意HttpClient不使用任何定製的加密措施。他完全依賴標準JCE(Java Cryptography)和安全套接字擴展。
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描述和相關工具請參考JSSE說明指南(http://docs.oracle.com/javase/6/docs/technotes/guides/security/jsse/JSSERefGuide.html)
2.7.4 主機名驗證
一旦鏈接建立,除了在SSL/TLS協議水平上進行的信任驗證和客戶端的身份認證外,HttpClient還能夠可選地驗證目標主機名稱是否匹配存儲在服務器X.509證書內的名稱。驗證可以提供額外的服務器信任材料的驗證保證。javax.net.ssl.HostnameVerifier接口表示主機名稱驗證策略。HttpClient提供兩個javax.net.ssl.HostnameVerifier實現類。重要的主機名稱驗證不應該與SSL信任驗證混淆。
DefaultHostnameVerifier:HttpClient使用的默認實現類期望符合RFC2818標準。主機名稱必須匹配任何被證書指定的可替代的名稱,或者在沒有可替代名稱的情況下給出最詳細的指導。也可以使用通配符。
NoopHostnameVerifier:此主機名稱驗證器本質上關閉了逐漸名稱驗證。它接受任何SSL回話作爲有效的並且匹配目標主機。
HttpClient默認使用DefaultHostnameVerifier實現類。如果願意也可以指定一個不同的主機名稱驗證實現類;
SSLContext sslContext = SSLContexts.createSystemDefault(); SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
隨着4.4版本的HttpClient使用被Mozilla基金會維護的公共後綴清單確保SSL證書內的通配符不會被濫用並且適用於多個普通頂級域。HttpClient在發行時自帶了清單拷貝。最新版本的清單拷貝可以在https://publicsuffix.org/list/ [https://publicsuffix.org/list/effective_tld_names.dat].上找到。強烈建議保存一份列表清單到本地而不是每次都從原始位置下載。
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();
作爲另一種選擇,爲了完全控制HTTP路由計算的過程還可以提供一個定製的RoutePlanter的實現類:
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(); } }