作者:小白豆豆5
鏈接:https://www.jianshu.com/p/14c005e9287c
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
1.HTTP 請求創建流程
使用 HttpClient 執行一個 Http 請求的步驟為:
(1)創建一個 HttpClient 對象
(2)創建一個 HttpRequest 對象
(3)使用 HttpClient 來執行 HttpRequest請求,得到對方的 HttpResponse
(4)處理 HttpResponse
(5)關閉此次請求連接
2.創建一個 HttpClient 對象
目前最新版的 HttpClient 的實現類為 CloseableHttpClient。創建 CloseableHttpClient 實例有兩種方式:
- 使用 CloseableHttpClient 的工廠類 HttpClients 的方法來創建實例。HttpClients 提供了根據各種默認配置來創建 CloseableHttpClient 實例的快捷方法。最簡單的實例化方式是調用HttpClients.createDefault()。
- 使用 CloseableHttpClient 的 builder 類 HttpClientBuilder,先對一些屬性進行配置(采用裝飾者模式,不斷的.setxxxxx().setxxxxxxxx()就行了),再調用 build() 方法來創建實例。上面的HttpClients.createDefault() 實際上調用的也就是HttpClientBuilder.create().build()。
build() 方法最終是根據各種配置來 new 一個 InternalHttpClient 實例(CloseableHttpClient 實現類)。
IternalHttpClient 類的實現如下:(忽略方法部分)
class InternalHttpClient extends CloseableHttpClient implements Configurable { private final Log log = LogFactory.getLog(this.getClass()); private final ClientExecChain execChain; private final HttpClientConnectionManager connManager; private final HttpRoutePlanner routePlanner; private final Lookup<CookieSpecProvider> cookieSpecRegistry; private final Lookup<AuthSchemeProvider> authSchemeRegistry; private final CookieStore cookieStore; private final CredentialsProvider credentialsProvider; private final RequestConfig defaultConfig; private final List<Closeable> closeables; public InternalHttpClient(ClientExecChain execChain, HttpClientConnectionManager connManager, HttpRoutePlanner routePlanner, Lookup<CookieSpecProvider> cookieSpecRegistry, Lookup<AuthSchemeProvider> authSchemeRegistry, CookieStore cookieStore, CredentialsProvider credentialsProvider, RequestConfig defaultConfig, List<Closeable> closeables) { Args.notNull(execChain, "HTTP client exec chain"); Args.notNull(connManager, "HTTP connection manager"); Args.notNull(routePlanner, "HTTP route planner"); this.execChain = execChain; this.connManager = connManager; this.routePlanner = routePlanner; this.cookieSpecRegistry = cookieSpecRegistry; this.authSchemeRegistry = authSchemeRegistry; this.cookieStore = cookieStore; this.credentialsProvider = credentialsProvider; this.defaultConfig = defaultConfig; this.closeables = closeables; } ... }
其中需要注意的配置字段包括: HttpClientConnectionManager、HttpRoutePlanner 和 RequestConfig:
HttpClientConnectionManager 是一個 HTTP 連接管理器。它負責新 HTTP 連接的創建、管理連接的生命周期還有保證一個 HTTP 連接在某一時刻只被一個線程使用。在內部實現的時候,manager 使用一個 ManagedHttpClientConnection 的實例來作為一個實際 connection 的代理,負責管理 connection 的狀態以及執行實際的 I/O 操作。如果一個被監管的 connection 被釋放或者被明確關閉,盡管此時 manager 仍持有該連接的代理,但是這個 connection 的狀態不會被改變也不能再執行任何的 I/O 操作。
HttpClientConnectionManager 有兩種具體實現:
- BasicHttpClientConnectionManager
BasicHttpClientConnectionManager 每次只管理一個 connection。不過,雖然它是 thread-safe 的,但由於它只管理一個連接,所以只能被一個線程使用。它在管理連接的時候如果發現有相同route 的請求,會復用之前已經創建的連接,如果新來的請求不能復用之前的連接,它會關閉現有的連接並重新打開它來響應新的請求。
- PoolingHttpClientConnectionManager
PoolingHttpClientConnectionManager 與 BasicHttpClientConnectionManager 不同,它管理着一個連接池(連接池管理部分在第7部分有詳細介紹)。它可以同時為多個線程服務。每次新來一個請求,如果在連接池中已經存在 route 相同並且可用的 connection,連接池就會直接復用這個 connection;當不存在 route 相同的 connection,就新建一個 connection 為之服務;如果連接池已滿,則請求會等待直到被服務或者超時(Timeout waiting for connection from pool)。
默認不對 HttpClientBuilder 進行配置的話,new 出來的 CloeableHttpClient 實例使用的是 PoolingHttpClientConnectionManager,這種情況下 HttpClientBuilder 創建出的 HttpClient 實例就可以被多個連接和多個線程共用,在應用容器起來的時候實例化一次,在整個應用結束的時候再調用 httpClient.close() 就行了。在 PoolingHttpClientConnectionManager 的配置中有兩個最大連接數量,分別控制着總的最大連接數量(MaxTotal)和每個 route 的最大連接數量(DefaultMaxPerRoute)。如果沒有顯式設置,默認每個 route 只允許最多2個connection,總的 connection 數量不超過 20。這個值對於很多並發度高的應用來說是不夠的,必須根據實際的情況設置合適的值,思路和線程池的大小設置方式是類似的,如果所有的連接請求都是到同一個url,那可以把 MaxPerRoute 的值設置成和 MaxTotal 一致,這樣就能更高效地復用連接。HttpClient 4.3.5的設置方法如下:
private final static PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager();
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 的一些配置。里面比較重要的有三個超時時間,默認的情況下這三個超時時間都為-1(如果不設置request的Config,會在execute的過程中使用HttpClientParamConfig 的 getRequestConfig 中用默認參數進行設置),這也就意味着無限等待,很容易導致所有的請求阻塞在這個地方無限期等待。這三個超時時間為:
(1)connectionRequestTimeout——從連接池中取連接的超時時間
這個時間定義的是從 ConnectionManager 管理的連接池中取出連接的超時時間, 如果連接池中沒有可用的連接,則 request 會被阻塞,最長等待 connectionRequestTimeout 的時間,如果還沒有被服務,則拋出 ConnectionPoolTimeoutException 異常,不繼續等待。
(2)connectTimeout——連接超時時間
這個時間定義了通過網絡與服務器建立連接的超時時間,也就是取得了連接池中的某個連接之后到接通目標 url 的連接等待時間。發生超時,會拋出ConnectionTimeoutException異常。
(3)socketTimeout——請求超時時間
這個時間定義了 socket 讀數據的超時時間,也就是連接到服務器之后到從服務器獲取響應數據需要等待的時間,或者說是連接上一個 url 之后到獲取 response 的返回等待時間。發生超時會拋出SocketTimeoutException異常。
注意,4.3.5版本超時設置方法和之前的版本不同,下面是一個設置各個超時時間的例子。注意,這樣設置的是該 HttpClient 處理的所有 request 的默認配置,如果在構造 request 實例的時候不特別設置,則會使用默認配置。
RequestConfig requestConfig = RequestConfig.custom().setConnectionRequestTimeout(CON_RST_TIME_OUT).setConnectTimeout(CON_TIME_OUT).setSocketTimeout(SOCKET_TIME_OUT).build();
HttpEntityEnclosingRequestBase httpRequest = new HttpEntityEnclosingRequestBase() {
@Override
public String getMethod() {
return method;
}
};
httpRequest.setConfig(requestConfig);
3.創建一個 Request 對象
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 等,一般這三個用的比較多。
當然,你也可以通過創建一個 HttpEntityEnclosingRequestBase 對象作為 Request 對象,配置代碼如下:
HttpEntityEnclosingRequestBase httpRequest = new HttpEntityEnclosingRequestBase() { @Override public String getMethod() { return method; // 對應的GET,POST,DELETE等 } };
httpRequest.setURI();
httpRequest.setEntity();
4.執行 Request 請求
執行 Request 請求就是調用 HttpClient 的execute方法。最簡單的使用方法是調用 execute(final HttpUriRequest request)。
HttpClient 允許 http 連接在特定的 Http 上下文中執行,HttpContext 是跟一個連接相關聯的,所以它也只能屬於一個線程,如果沒有特別設定,在 execute 的過程中,HttpClient 會自動為每一個connection new 一個 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的各個部分
5.處理 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 就必須要讓它占有的系統資源得到正確釋放。釋放資源有兩種方法:
1)關閉和 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,雖然里面有一些跟釋放連接相關的重要操作,但是卻無法直接調用)。
關閉response
2)關閉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就可以了。
6.關閉 httpClient
7.其他一些東西
在 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 connection
if(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.socketTimeout 是沒有關系的,它實現的機制是先設置 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 並不會有更高的優先級,依舊會保持長連。
7.總結
使用 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 等內容,由於沒有實際使用,理解的並不透徹,后續繼續學習后再來補充。