HttpClient整理資料
HttpClient是Apache中的一個開源的項目。它實現了HTTP標準中Client端的所有功能,使用它能夠很容易地進行HTTP信息的傳輸。它的各個版本的使用方式都不太一樣,我使用的版本是4.3.5的,網上比較多的資源是3.+版本的,目前最新已經有4.4+版本了,感興趣的都可以看一下。
HttpCLient最關鍵的方法是執行HTTP請求的方法execute。只要把HTTP請求傳入,就可以得到HTTP響應。
使用HttpClient請求一個Http請求的步驟為:
(1)創建一個HttpClient對象
(2)創建一個Request對象
(3)使用HttpClient來執行Request請求,得到對方的response
(4)處理response
(5)關閉HttpClient
下面就針對這幾個步驟進行展開。
目前最新版的HttpClient的實現類為CloseableHttpClient。創建CloseableHttpClient實例有兩種方式:
(1)使用CloseableHttpClient的工廠類HttpClients的方法來創建實例。HttpClients提供了根據各種默認配置來創建CloseableHttpClient實例的快捷方法。最簡單的實例化方式是調用HttpClients.createDefault()。
(2)使用CloseableHttpClient的builder類HttpClientBuilder,先對一些屬性進行配置(采用裝飾者模式,不斷的.setxxxxx().setxxxxxxxx()就行了),再調用build方法來創建實例。上面的HttpClients.createDefault()實際上調用的也就是HttpClientBuilder.create().build()。
build()方法最終是根據各種配置來new一個InternalHttpClient實例(CloseableHttpClient實現類)。IternalHttpClient的定義如下:(忽略方法部分)
classInternalHttpClientextendsCloseableHttpClient{privatefinalLoglog =LogFactory.getLog(getClass());privatefinalClientExecChainexecChain;privatefinalHttpClientConnectionManagerconnManager;privatefinalHttpRoutePlannerroutePlanner;privatefinalLookup cookieSpecRegistry;privatefinalLookup authSchemeRegistry;privatefinalCookieStorecookieStore;privatefinalCredentialsProvidercredentialsProvider;privatefinalRequestConfigdefaultConfig;privatefinalList closeables;}
其中需要注意的有HttpCLientConnectionManager、HttpRoutePlanner和RequestConfig。
(1)HttpClientConnectionManager
HttpClientConnectionManager是一個HTTP連接管理器。它負責新HTTP連接的創建、管理連接的生命周期還有保證一個HTTP連接在某一時刻只被一個線程使用。在內部實現的時候,manager使用一個ManagedHttpClientConnection的實例來作為一個實際connection的代理,負責管理connection的狀態以及執行實際的I/O操作。如果一個被監管的connection被釋放或者被明確關閉,盡管此時manager仍持有該連接的代理,但是這個connection的狀態不會被改變也不能再執行任何的I/O操作。
HttpClientConnectionManager有兩種具體實現:
a、BasicHttpClientConnectionManager
BasicHttpClientConnectionManager每次只管理一個connection。不過,雖然它是thread-safe的,但由于它只管理一個連接,所以只能被一個線程使用。它在管理連接的時候如果發現有相同route的請求,會復用之前已經創建的連接,如果新來的請求不能復用之前的連接,它會關閉現有的連接并重新打開它來響應新的請求。
b、PoolingHttpClientConnectionManager
PoolingHttpClientConnectionManager與BasicHttpClientConnectionManager不同,它管理著一個連接池(連接池管理部分在第7部分有詳細介紹)。它可以同時為多個線程服務。每次新來一個請求,如果在連接池中已經存在route相同并且可用的connection,連接池就會直接復用這個connection;當不存在route相同的connection,就新建一個connection為之服務;如果連接池已滿,則請求會等待直到被服務或者超時。
默認不對HttpClientBuilder進行配置的話,new出來的CloeableHttpClient實例使用的是PoolingHttpClientConnectionManager,這種情況下HttpClientBuilder創建出的HttpClient實例就可以被多個連接&多個線程共用,在應用容器起來的時候實例化一次,在整個應用結束的時候再調用httpClient.close()就行了。在PoolingHttpClientConnectionManager的配置中有兩個最大連接數量,分別控制著總的最大連接數量和每個route的最大連接數量。如果沒有顯式設置,默認每個route只允許最多2個connection,總的connection數量不超過20。這個值對于很多并發度高的應用來說是不夠的,必須根據實際的情況設置合適的值,思路和線程池的大小設置方式是類似的,如果所有的連接請求都是到同一個url,那可以把MaxPerRoute的值設置成和MaxTotal一致,這樣就能更高效地復用連接。HttpClient 4.3.5的設置方法如下:
privatefinalstaticPoolingHttpClientConnectionManager poolingHttpClientConnectionManager =newPoolingHttpClientConnectionManager();poolingHttpClientConnectionManager.setMaxTotal(MAX_CONNECTION);poolingHttpClientConnectionManager.setDefaultMaxPerRoute(MAX_CONNECTION);CloseableHttpClient httpClient = HttpClientBuilder.create().setConnectionManager(poolingHttpClientConnectionManager).build();
HttpClient不僅支持簡單的直連、復雜的路由策略以及代理。HttpRoutePlanner是基于http上下文情況下,客戶端到服務器的路由計算策略,一般沒有代理的話,就不用設置這個東西。這里有一個很關鍵的概念—Route:在HttpClient中,一個Route指運行環境機器->目標機器host的一條線路,也就是如果目標url的host是同一個,那么它們的route也是一樣的。
RequestConfig是對request的一些配置。里面比較重要的有三個超時時間,默認的情況下這三個超時時間都為0(如果不設置request的Config,會在execute的過程中使用HttpClientParamConfig的getRequestConfig中用默認參數進行設置),這也就意味著無限等待,很容易導致所有的請求阻塞在這個地方無限期等待。這三個超時時間為:
a、connectionRequestTimeout—從連接池中取連接的超時時間
這個時間定義的是從ConnectionManager管理的連接池中取出連接的超時時間, 如果連接池中沒有可用的連接,則request會被阻塞,最長等待connectionRequestTimeout的時間,如果還沒有被服務,則拋出ConnectionPoolTimeoutException異常,不繼續等待。
b、connectTimeout—連接超時時間
這個時間定義了通過網絡與服務器建立連接的超時時間,也就是取得了連接池中的某個連接之后到接通目標url的連接等待時間。發生超時,會拋出ConnectionTimeoutException異常。
c、socketTimeout—請求超時時間
這個時間定義了socket讀數據的超時時間,也就是連接到服務器之后到從服務器獲取響應數據需要等待的時間,或者說是連接上一個url之后到獲取response的返回等待時間。發生超時,會拋出SocketTimeoutException異常。
注意,4.3.5版本超時設置方法和之前的版本不同,下面是一個設置各個超時時間的例子。注意,這樣設置的是該HttpClientc處理的所有request的默認配置,如果在構造request實例的時候不特別設置,則會使用默認配置。
RequestConfig requestConfig = RequestConfig.custom().setConnectionRequestTimeout(CON_RST_TIME_OUT).setConnectTimeout(CON_TIME_OUT).setSocketTimeout(SOCKET_TIME_OUT).build();CloseableHttpClient httpClient = HttpClientBuilder.create().setDefaultRequestConfig(requestConfig).build();
HttpClient支持所有的HTTP1.1中的所有定義的請求類型:GET、HEAD、POST、PUT、DELETE、TRACE和OPTIONS。對使用的類為HttpGet、HttpHead、HttpPost、HttpPut、HttpDelete、HttpTrace和HttpOptions。Request的對象建立很簡單,一般用目標url來構造就好了。下面是一個HttpPost的創建代碼:
HttpPost httpPost = new HttpPost(someGwUrl);
一個Request還可以addHeader、setEntity、setConfig等,一般這三個用的比較多。
RequestConfig這個類比較關鍵,就是request的配置,除了上面說到的三個超時時間外,還有一些可能有助于理解處理過程的配置:
staleConnectionCheckEnabled:這個配置默認為true,HttpClient的execute方法中有下面的代碼,也就是說如果這個設置為true的話,是會自動關閉那些狀態為stale的managed connection所管理的connection和socket(和remote ip)。(這里有個問題,在第7部分中再說)
if(config.isStaleConnectionCheckEnabled()) {// validate connectionif(managedConn.isOpen()) {this.log.debug("Stale connection check");if(managedConn.isStale()) {this.log.debug("Stale connection detected");? ? ? ? ? ? ? ? ? ? managedConn.close();? ? ? ? ? ? ? ? }? ? ? ? ? ? }? ? ? ? }
執行Request請求就是調用HttpClient的execute方法。最簡單的使用方法是調用execute(final HttpUriRequest request)。
HttpClient允許http連接在特定的Http上下文中執行,HttpContext是跟一個連接相關聯的,所以它也只能屬于一個線程,如果沒有特別設定,在execute的過程中,HttpClient會自動為每一個connectionnew一個HttpClientHttpContext。
HttpClientContext localcontext = HttpClientContext.adapt(context!=null?context:newBasicHttpContext());
整個execute執行的常規流程為:
new一個http context
|
取出Request和URL
|
根據HttpRoute的配置看是否需要重寫URL
|
根據URL的host、port和scheme設置target
|
在發送前用http協議攔截器處理request的各個部分
|
取得驗證狀態、user token來驗證身份
|
從連接池中取一個可用的連接
|
根據request的各種配置參數以及取得的connection構造一個connManaged
|
打開managed的connection(包括創建route、dns解析、綁定socket、socket連接等)
|
請求數據(包括發送請求和接收response兩個階段)
|
查看keepAlive策略,判斷連接是否要復用,并設置相應標識
|
返回response
|
用http協議攔截器處理response的各個部分
HttpReaponse是將服務端發回的Http響應解析后的對象。CloseableHttpClient的execute方法返回的response都是CloseableHttpResponse類型。可以getFirstHeader(String)、getLastHeader(String)、headerIterator(String)取得某個Header name對應的迭代器、getAllHeaders()、getEntity、getStatus等,一般這幾個方法比較常用。
在這個部分中,對于entity的處理需要特別注意一下。
一般來說一個response中的entity只能被使用一次,它是一個流,這個流被處理完就不再存在了。
先response.getEntity()再使用HttpEntity#getContent()來得到一個java.io.InputStream,然后再對內容進行相應的處理。
有一點非常重要,想要復用一個connection就必須要讓它占有的系統資源得到正確釋放。釋放資源有兩種方法:
a、關閉和entity相關的content stream
如果是使用outputStream就要保證整個entity都被write out,如果是inputStream,則再最后要記得調用inputStream.close()。或者使用EntityUtils.consume(entity)或EntityUtils.consumeQuietly(entity)來讓entity被完全耗盡(后者不拋異常)來做這一工作。EntityUtils中有個toString方法也很方便的(調用這個方法最后也會自動把inputStream close掉的),不過只有在可以確定收到的entity不是特別大的情況下才能使用。
做過實驗,如果沒有讓整個entity被fully consumed,則該連接是不能被復用的,很快就會因為在連接池中取不到可用的連接超時或者阻塞在這里(因為該連接的狀態將會一直是leased的,即正在被使用的狀態)。所以如果想要復用connection,一定一定要記得把entity fully consume掉,只要檢測到stream的eof,是會自動調用ConnectionHolder的releaseConnection方法進行處理的(注意,ConnectionHolder并不是一個public class,雖然里面有一些跟釋放連接相關的重要操作,但是卻無法直接調用)。
b、關閉response
執行response.close()雖然會正確釋放掉該connection占用的所有資源,但是這是一種比較暴力的方式,采用這種方式之后,這個connection就不能被重復使用了。
從源代碼中可以看出,response.close()調用了connectionHolder的abortConnection方法,它會close底層的socket,并且release當前的connection,并把reuse的時間設為0。這種情況下的connection稱為expired connection,也就是client端單方面把連接關閉。還要等待closeExpiredConnections方法將它從連接池中清除掉(從連接池中清除掉的含義是把它所對應的連接池的entry置為無效,并且關掉對應的connection,shutdown對應socket的輸入和輸出流。這個方法的調用時間是需要設置的)。
關閉stream和response的區別在于前者會嘗試保持底層的連接alive,而后者會直接shut down并且丟棄connection。
socket是和ip以及port綁定的,但是host相同的請求會盡量復用連接池里已經存在的connection(因為在連接池里會另外維護一個route的子連接池,這個子連接池中每個connection的狀態有三種:leased、available和pending,只有available狀態的connection才能被使用,而fully consume entity就可以讓該連接變為available狀態),如果host地址一樣,則優先使用該connection。
如果希望重復讀取entity中的內容,就需要把entity緩存下來。最簡單的方式是用entity來new一個BufferedHttpEntity,這一操作會把內容拷貝到內存中,之后使用這個BufferedHttpEntity就可以了。
調用httpClient.close()會先shut down connection manager,然后再釋放該HttpClient所占用的所有資源,關閉所有在使用或者空閑的connection包括底層socket。由于這里把它所使用的connection manager關閉了,所以在下次還要進行http請求的時候,要重新new一個connection manager來build一個HttpClient(也就是在需要關閉和新建Client的情況下,connection manager不能是單例的)。
在HttpClient.execute得到response之后的相關代碼中,它會先取出response的keep-alive頭來設置connection是否resuable以及存活的時間。如果服務器返回的響應中包含了Connection:Keep-Alive(默認有的),但沒有包含Keep-Alive時長的頭消息,HttpClient認為這個連接可以永遠保持。
不過,很多服務器都會在不通知客戶端的情況下,關閉一定時間內不活動的連接,來節省服務器資源。在這種情況下默認的策略顯得太樂觀,我們可能需要自定義連接存活策略,也就是在創建HttpClient的實例的時候用下面的代碼。(xxx為自己寫的保活策略)
ClosableHttpClientclient =HttpClients.custom().setKeepAliveStrategy(xxx).build();
前面也有說到關于從連接池中取可用連接的部分邏輯。完整的邏輯是:在每收到一個route請求后,連接池都會建立一個以這個route為key的子連接池,當有一個新的連接請求到來的時候,它會優先匹配已經存在的子連接池們,如果之前已經有過以這個route為key的子連接池,那么就會去試圖取這個子連接池中狀態為available的連接,如果此時有可用的連接,則將取得的available連接狀態改為leased的,取連接成功。如果此時子連接池沒有可用連接,那再看是否達到了所設置的最大連接數和每個route所允許的最大連接數的上限,如果還有余量則new一個新的連接,或者取得lastUsedConnection,關閉這個連接、把連接從原來所在的子連接池刪除,再lease取連接成功。如果此時的情況不允許再new一個新的連接,就把這個請求連接的請求放入一個queue中排隊等待,直到得到一個連接或者超時才會從queue中刪去。
一個連接被release之后,會從等待連接的queue中喚醒等待連接的服務進行處理。
當連接被管理器收回后,這個連接仍然存活,但是卻無法監控socket的狀態,也無法對I/O事件做出反饋。如果連接被服務器端關閉了,客戶端監測不到連接的狀態變化(也就無法根據連接狀態的變化,關閉本地的socket)。
HttpClient為了緩解這一問題造成的影響,會在使用某個連接前,監測這個連接是否已經過時,如果服務器端關閉了連接,那么連接就會失效。前面提到的RequestConfig中的staleConnectionCheckEnabled就是用來控制是否進行上述操作,相關代碼:
if(config.isStaleConnectionCheckEnabled()) {// validate connectionif(managedConn.isOpen()) {this.log.debug("Stale connection check");if(managedConn.isStale()) {this.log.debug("Stale connection detected");? ? ? ? ? ? ? ? ? ? managedConn.close();? ? ? ? ? ? ? ? }? ? ? ? ? ? }? ? ? ? }
其中的managedConn.isStale()就是檢查取出的連接是否失效,需要注意的是這種過時檢查并不是100%有效,并且會給每個請求增加10到30毫秒額外開銷。isStale()有一點比較奇怪的是,如果拋出SocketTimeoutException的時候會返回false,即意味著此managedConn并不是失效的(如果此managedConn是長連接的,那么沒失效是可理解的,但為什么會拋SocketTimeoutException異常就不懂了)。而這里SocketTimeoutException的發生與我們前面設置的RequestConfig.sotimeout是沒有關系的,它實現的機制是先設置1ms的超時時間,看在這1ms內是否能從inputBuffer里面讀到數據,如果讀到的數據長度為-1(即沒有數據),說明此連接失效。但是很經常隨機會發生SocketTimeoutException,這時會返回false,并且此時managedConn是open的狀態,這樣就會跳過后面的dns解析及socket重新建立和綁定的過程,直接再次重用之前的connection以及它綁定的socket。
在這里遇到的一個很糾結的問題:
Http1.1默認進行的長連接并不適用于我們的應用場景,我們的httpClient是用在服務端代替客戶端sdk ?去請求另一個應用的服務端,并且調用量非常大,在這種情況下,如果使用默認的長連接就會一直只去請求對方的某一臺服務器,不管怎么說,雖然調用的確實是相同host的主機對功能來說是沒有問題的,但萬一對方服務器被這樣弄掛了呢?并且這種情況下要是使用了dns負載均衡技術,那么dns的負載均衡將不能被執行到!這顯然不是我們所希望的。
并且通過測試發現,只要是長連接的connection,在代碼中調用各種close或者release方法都不能把connection真正關掉,除非把整個httpClient.close。
對于這個問題查了一些資料,里面提到的一個可行的解決辦法,是建立一個監控線程,來專門回收由于長時間不活動而被判定為失效的連接。這個監控線程可以周期性的調用ClientConnectionManager類的closeExpiredConnections()方法來關閉過期的連接,回收連接池中被關閉的連接。它也可以選擇性的調用ClientConnectionManager類的closeIdleConnections()方法來關閉一段時間內不活動的連接。由于這個解決方案對于我們的應用來說太復雜了,所以這個方案的有效性沒有驗證過。
我原先采用的解決方式是:在每次連接請求到來的時候都build一個新的HttpClient對象,并且使用BasicHttpClientConnectionManager作為connectionManager。然后在處理完http response之后 close掉這個HttpClient。目前本地自測來看,這種做法不會出現上面的奇怪問題。但是很憂傷的是,新建一個HttpClient的邏輯很重,并且連接不能復用,會浪費很多時間。
由于這個日常需求本身做的就是優化性質的工作,加上每個請求都新建HttpClient這一大坨代碼,心里總是有點難受。繼續找解決辦法。
在嘗試了改系統的各種tcp配置參數還有其他的socket、系統配置無果后,最終找到的解決方式卻異常簡單。簡單來說,其實我們的應用場景下需要的是短連接,這樣只要在request中添加Connection:close的頭部,就可以保證這個鏈接在這次請求完成之后就被關掉,只用一次。同時發現,如果頭中既有Connection:Keep-Alive又有Connection:close的話,Connection:close并不會有更高的優先級,依舊會保持長連。
使用HttpClient的時候特別需要注意的有下面幾個地方:
(1)連接池最大連接數,不配置為20
(2)同個route的最大連接數,不配置為2
(3)去連接池中取連接的超時時間,不配置則無限期等待
(4)與目標服務器建立連接的超時時間,不配置則無限期等待
(5)去目標服務器取數據的超時時間,不配置則無限期等待
(6)要fully consumed entity,才能正確釋放底層資源
(7)同個host但ip有多個的情況,請謹慎使用單例的HttpClient和連接池
(8)HTTP1.1默認支持的是長連接,如果想使用短連接,要在request上加Connection:close的header,不然長連接是不可能自動被關掉的!
一定要結合實際情況來看是否需要設置,不然可能導致嚴重的問題。
HttpClient的內容遠不止我上面說到的這些,還包括Cookie管理,Fluent API等內容,由于沒有實際使用,理解的并不透徹,后續繼續學習后再來補充。
下面是回復里提到的一個問題:
連接池里的連接是長連接嗎?還是說調用方拿到這個連接還要與server三次握手?
TCP的三次握手是發生在socket的connect方法被調用的時候,從代碼里看,這部分的調用鏈路是MainClientExec#execute->(條件if (!managedConn.isOpen()) )MainClientExec#establishRoute->PoolingHttpClientConnectionManager#connect->HttpClientConnectionOperator#connect->PlainConnectionSocketFactory#connectSocket->Socket#connect。也就是文中第四點講的“打開managed的connection(包括創建route、dns解析、綁定socket、socket連接等)”這部分實現。
如果某個連接在response的header中帶了keep-alive,那么它是以長連接的形式存在的,下次有相同目標host的請求,它會優先取得這個連接(包括底層socket的ip和post),如果底層的socket依然可用,那么就用它直接進行通信,不會再進行三次握手的過程。
關于如何讓一個放回pool的connection以長連接存在,這是在MainClientExec#execute中有if (reuseStrategy.keepAlive(response, context)) 里的相關邏輯給connection打上reusable的標并設置有效時間。然后在response.entity被fully consumed之后,會自動調用EofSensorInputStream#close,這個方法中惠對connection進行release操作,最終會調用到ConnectionHolder#releaseConnection(),在這個方法中對是否reusable的連接進行不同的release操作,對于reusable的類型,并不會去close底層的socket。所以它就一直保持長連接。
不過為什么會出現明明是長連接,間隔時間較長的話調用isStable()卻返回true,然后把socket關掉呢?個人猜測有可能是由于鏈接空閑了一段時間對方把長連接關掉了,這種情況下是會重新進行三次握手的。
當然短連接的情況下,socket也是關掉了的。