Android 網絡全棧攻略(七)—— 從 OkHttp 攔截器來看 HTTP 協議二

Android 網絡全棧攻略系列文章:
Android 網絡全棧攻略(一)—— HTTP 協議基礎
Android 網絡全棧攻略(二)—— 編碼、加密、哈希、序列化與字符集
Android 網絡全棧攻略(三)—— 登錄與授權
Android 網絡全棧攻略(四)—— TCPIP 協議族與 HTTPS 協議
Android 網絡全棧攻略(五)—— 從 OkHttp 配置來看 HTTP 協議
Android 網絡全棧攻略(六)—— 從 OkHttp 攔截器來看 HTTP 協議一
Android 網絡全棧攻略(七)—— 從 OkHttp 攔截器來看 HTTP 協議二

上一篇我們介紹了 OkHttp 的責任鏈以及第一個內置攔截器 —— 重試與重定向攔截器。本篇我們將剩余四個攔截器的解析做完。

1、橋接攔截器

BridgeInterceptor 作為請求準備和實際發送之間的橋梁,自動處理 HTTP 請求頭等繁瑣工作。比如設置請求內容長度,編碼,gzip 壓縮,Cookie 等,獲取響應后保存 Cookie 等。它的設計目的是為了解決開發者手動處理 HTTP 協議細節的麻煩,特別是那些必須做但很繁瑣或難以實現的工作。

它的攔截代碼 intercept() 如下:

class BridgeInterceptor(private val cookieJar: CookieJar) : Interceptor {@Throws(IOException::class)override fun intercept(chain: Interceptor.Chain): Response {// 1.前置工作:從責任鏈上獲取請求,添加相關請求頭val userRequest = chain.request()val requestBuilder = userRequest.newBuilder()val body = userRequest.bodyif (body != null) {val contentType = body.contentType()if (contentType != null) {requestBuilder.header("Content-Type", contentType.toString())}// 請求體內容長度如果不是 -1 意味著使用 Content-Length 這個請求頭展示內容大小,// 否則就是要使用 Transfer-Encoding: chunked 分塊傳輸的方式。這兩個頭互斥val contentLength = body.contentLength()if (contentLength != -1L) {requestBuilder.header("Content-Length", contentLength.toString())requestBuilder.removeHeader("Transfer-Encoding")} else {requestBuilder.header("Transfer-Encoding", "chunked")requestBuilder.removeHeader("Content-Length")}}if (userRequest.header("Host") == null) {requestBuilder.header("Host", userRequest.url.toHostHeader())}// 如果請求頭中沒有配置 Connection,框架會自動為我們申請一個長連接,如果服務器同意// 長連接,那么會返回一個 Connection:Keep-Alive,否則返回 Connection:closeif (userRequest.header("Connection") == null) {requestBuilder.header("Connection", "Keep-Alive")}// 在沒有 Accept-Encoding 與 Range 這兩個請求頭的情況下,自動添加 gzip 壓縮數據var transparentGzip = falseif (userRequest.header("Accept-Encoding") == null && userRequest.header("Range") == null) {transparentGzip = truerequestBuilder.header("Accept-Encoding", "gzip")}// 使用構造函數上傳入的 cookieJar 補全 Cookie 請求頭val cookies = cookieJar.loadForRequest(userRequest.url)if (cookies.isNotEmpty()) {requestBuilder.header("Cookie", cookieHeader(cookies))}// 補全請求頭中的 User-Agent 字段,即請求者的用戶信息,如操作系統、瀏覽器等if (userRequest.header("User-Agent") == null) {requestBuilder.header("User-Agent", userAgent)}// 2.中置工作:啟動責任鏈的下一個節點,做接力棒交接val networkResponse = chain.proceed(requestBuilder.build())// 3.后置工作:修改響應cookieJar.receiveHeaders(userRequest.url, networkResponse.headers)val responseBuilder = networkResponse.newBuilder().request(userRequest)// 如果在第 1 步中使用了 gzip 壓縮,那么這里在拿到響應 networkResponse 后,需要將響應體// responseBody 解壓后放到新的響應體 responseBuilder.body() 中if (transparentGzip &&"gzip".equals(networkResponse.header("Content-Encoding"), ignoreCase = true) &&networkResponse.promisesBody()) {val responseBody = networkResponse.bodyif (responseBody != null) {val gzipSource = GzipSource(responseBody.source())val strippedHeaders = networkResponse.headers.newBuilder().removeAll("Content-Encoding").removeAll("Content-Length").build()responseBuilder.headers(strippedHeaders)val contentType = networkResponse.header("Content-Type")// RealResponseBody 內存放解壓后的響應體responseBuilder.body(RealResponseBody(contentType, -1L, gzipSource.buffer()))}}return responseBuilder.build()}
}

橋接攔截器的攔截邏輯還是很清晰的,三步走:

  1. 前置工作為請求添加請求頭。當請求體長度 contentLength 不為 -1 時,添加 Content-Length 請求頭填入請求體的完整長度;否則意味著要使用分塊傳輸,添加 Transfer-Encoding: chunked 請求頭。這兩個頭互斥,只能存在一個
  2. 中置工作啟動下一個責任鏈節點,進而觸發緩存攔截器
  3. 后置工作就一項,如果在前置工作中啟動了 gzip 數據壓縮,那么在拿到響應后,要把響應體解壓放到新的響應中

前置工作中添加的請求頭基本上在系列的前幾篇文章中已經介紹過了,因此這里就沒多啰嗦,就一個框架默認添加的壓縮與解壓機制值得一談。

2、緩存攔截器

CacheInterceptor 基于 HTTP 緩存頭信息(如 Expires、Last-Modified 等)實現請求緩存機制,減少重復網絡請求,這樣可以少流量消耗,同時加快響應速度。

CacheInterceptor 通過緩存策略 CacheStrategy 來決定緩存是否可用,它影響了整個 CacheInterceptor 的攔截邏輯,因此我們先了解緩存策略后再看整個攔截邏輯。

2.1 緩存策略

緩存策略 CacheStrategy 有兩個成員 networkRequest 和 cacheResponse:

class CacheStrategy internal constructor(/** 需發送的網絡請求:若為 null,表示禁止網絡請求,直接使用緩存。 */val networkRequest: Request?,/** 可用的緩存響應:若為 null,表示無有效緩存,必須發送網絡請求。 */val cacheResponse: Response?
)

這兩個成員共同決定了采用哪種緩存策略:

networkRequestcacheResponse說明
NullNot Null直接使用緩存
NullNull請求失敗,OkHttp 框架會返回 504
Not NullNull向服務器發起請求
Not NullNot Null發起請求,若得到響應為 304(無修改),則更新緩存響應并返回

可以概括為:若 networkRequest 存在則優先發起網絡請求,否則使用 cacheResponse 緩存,若都不存在則請求失敗!

CacheStrategy 采用工廠模式,由 CacheStrategy.Factory 負責生產 CacheStrategy 對象,因此需要對這個工廠有所了解。

2.1.1 CacheStrategy.Factory 初始化

工廠初始化主要是在緩存的響應 cacheResponse 不為空時將請求發送時間、響應接收時間以及一些響應頭數據保存為成員屬性:

  class Factory(private val nowMillis: Long,internal val request: Request,private val cacheResponse: Response?) {init {if (cacheResponse != null) {// 請求發出的本地時間以及接收到這個響應的本地時間this.sentRequestMillis = cacheResponse.sentRequestAtMillisthis.receivedResponseMillis = cacheResponse.receivedResponseAtMillis// 保存 cacheResponse 中的部分響應頭val headers = cacheResponse.headersfor (i in 0 until headers.size) {val fieldName = headers.name(i)val value = headers.value(i)when {fieldName.equals("Date", ignoreCase = true) -> {servedDate = value.toHttpDateOrNull()servedDateString = value}fieldName.equals("Expires", ignoreCase = true) -> {expires = value.toHttpDateOrNull()}fieldName.equals("Last-Modified", ignoreCase = true) -> {lastModified = value.toHttpDateOrNull()lastModifiedString = value}fieldName.equals("ETag", ignoreCase = true) -> {etag = value}fieldName.equals("Age", ignoreCase = true) -> {ageSeconds = value.toNonNegativeInt(-1)}}}}}}

對上面涉及到的響應頭及其作用稍作解釋:

響應頭說明示例
Date響應生成的服務器時間(GMT 格式)。用于計算緩存的年齡(Age)。Date: Sat, 18 Nov 2028 06:17:41 GMT
Expires指定響應的絕對過期時間(GMT 格式)。若存在,表示在此時間前緩存有效。Expires: Sat, 18 Nov 2028 06:17:41 GMT
Last-Modified資源最后一次修改的時間(GMT 格式)。用于條件請求(If-Modified-Since)。Last-Modified: Fri, 22 Jul 2016 02:57:17 GMT
ETag資源在服務器的唯一標識符(實體標簽)。用于條件請求(If-None-Match)。ETag: “16df0-5383097a03d40”
Age響應在代理緩存中已存儲的時間(秒)。用于校正 Date 的實際年齡。Age: 3825683

2.1.2 生產 CacheStrategy

Factory 的 compute() 會根據 RFC 規范計算緩存是否可用,返回的 CacheStrategy 包含 networkRequest(需發送的請求)和 cacheResponse(可用的緩存):

    fun compute(): CacheStrategy {// 1.生成初步候選策略(基于緩存有效性、過期時間、驗證頭等)val candidate = computeCandidate()// 2.處理 only-if-cached 約束if (candidate.networkRequest != null && request.cacheControl.onlyIfCached) {// 如果強制禁用網絡且無可用緩存 → 返回雙 null 策略(觸發 504 錯誤)return CacheStrategy(null, null)}return candidate}

computeCandidate() 會綜合各種情況生成初步的候選策略。由于該方法內代碼細節還是比較多的,一次貼出所有代碼閱讀體驗不佳,因此分段講解該方法內容。

檢查緩存響應

當被緩存的響應由于某些情況無效時,需要發起網絡請求,此時只返回 CacheStrategy(request, null)

    private fun computeCandidate(): CacheStrategy {// 1.緩存的響應為空:必須發起網絡請求,因此返回只有網絡請求的策略if (cacheResponse == null) {return CacheStrategy(request, null)}// 2.是 HTTPS 請求但緩存缺少握手信息:意味著緩存不安全或不完整,因此忽略緩存,直接發起網絡請求if (request.isHttps && cacheResponse.handshake == null) {return CacheStrategy(request, null)}// 3.緩存不可用:調用 isCacheable() 檢查緩存是否有效。如果無效,同樣返回需要網絡請求的策略。// 如果這個響應不應該被存儲,則永遠不應作為響應源使用。只要持久化存儲表現良好且規則保持不變,// 這個檢查應該是多余的。if (!isCacheable(cacheResponse, request)) {return CacheStrategy(request, null)}...}

這一段主要看 isCacheable() 是如何檢查 cacheResponse 是否可以被緩存的:

	fun isCacheable(response: Response, request: Request): Boolean {// 對于不可緩存的響應代碼(RFC 7231 第 6.1 節),始終訪問網絡。此實現不支持緩存部分內容。when (response.code) {HTTP_OK, // 200HTTP_NOT_AUTHORITATIVE, // 203HTTP_NO_CONTENT, // 204HTTP_MULT_CHOICE, // 300HTTP_MOVED_PERM, // 301HTTP_NOT_FOUND, // 404HTTP_BAD_METHOD, // 405HTTP_GONE, // 410HTTP_REQ_TOO_LONG, // 414HTTP_NOT_IMPLEMENTED, // 501StatusLine.HTTP_PERM_REDIRECT -> { // 308// 以上狀態碼可以緩存,除非被最后的請求頭/響應頭中的 cache-control:nostore 禁止了}HTTP_MOVED_TEMP, // 302StatusLine.HTTP_TEMP_REDIRECT -> { // 307// 對于 302 和 307,只有響應頭正確時才可被緩存。由于 OkHttp 是私有緩存因此沒有檢查 s-maxage:// http://tools.ietf.org/html/rfc7234#section-3if (response.header("Expires") == null &&response.cacheControl.maxAgeSeconds == -1 &&!response.cacheControl.isPublic &&!response.cacheControl.isPrivate) {return false}}else -> {// All other codes cannot be cached.return false}}// 請求或響應上的 'no-store' 指令會阻止響應被緩存return !response.cacheControl.noStore && !request.cacheControl.noStore}

檢查思路可以分為兩級:

  1. 先檢查狀態碼:
    • 可以緩存的狀態碼:200、203、204、300、301、404、405、410、414、501、308
    • 只有響應頭正確才可緩存的狀態碼:302、307,當響應中沒有 Expires 響應頭、響應的 Cache-Control 響應頭沒有設置資源有效期 max-age、且既不是 public 也不是 private 時,不能緩存
    • 其余狀態碼不能緩存
  2. 再檢查請求或響應上是否有 Cache-Control: no-store 明確禁止使用緩存,這個判斷級別要高于狀態碼判斷結果

需要說一下 response.cacheControl,它表示 Cache-Control 頭,是控制緩存機制的核心工具,允許客戶端和服務器定義緩存策略,優化性能并確保數據的新鮮度。它可同時用于請求頭(客戶端指令)和響應頭(服務器指令),多個指令以逗號分隔,如Cache-Control: max-age=3600, public

響應頭指令包括:

指令作用
public允許任何緩存(共享或私有)存儲響應,即使默認不可緩存(如帶Authorization的響應)。
private僅允許用戶私有緩存(如瀏覽器)存儲,禁止 CDN 等共享緩存存儲。
no-store禁止緩存存儲響應內容,每次請求必須從服務器獲取。
no-cache緩存必須向服務器驗證有效性后,才能使用緩存副本(即使未過期)。
max-age=<秒>資源有效期(相對時間),如max-age=3600表示 1 小時內有效。
s-maxage=<秒>覆蓋max-age,但僅作用于共享緩存(如 CDN),優先級高于max-age
must-revalidate緩存過期后,必須向服務器驗證有效性,不得直接使用過期資源。
immutable資源永不變更(如帶哈希的靜態文件),客戶端可無限期使用緩存。
proxy-revalidate類似must-revalidate,但僅針對共享緩存。
no-transform禁止代理修改資源(如壓縮圖片或轉換格式)。

請求頭指令包括:

指令作用
no-cache強制服務器返回最新內容,緩存需驗證(發送If-Modified-Since等頭)。
no-store要求中間緩存不存儲任何響應,用于敏感數據請求。
max-age=<秒>只接受緩存時間不超過指定秒數的資源(如max-age=0需最新內容)。
max-stale=<秒>允許接受過期但不超過指定秒數的緩存(如max-stale=300接受過期5分鐘內)。
min-fresh=<秒>要求資源至少保持指定秒數的新鮮度(如min-fresh=60需至少1分鐘有效)。
only-if-cached僅返回緩存內容,若緩存無效則返回504(不發起網絡請求)。

可以在不進行驗證的情況下提供的響應服務日期后的持續時間。

檢查請求頭

接下來檢查請求頭:

    private fun computeCandidate(): CacheStrategy {// 請求的緩存控制val requestCaching = request.cacheControl// 如果請求頭包含 noCache 或者請求有條件頭 If-Modified-Since、// If-None-Match 二者之一,則忽略緩存直接發起網絡請求if (requestCaching.noCache || hasConditions(request)) {return CacheStrategy(request, null)}}

如果請求頭的 Cache-Control 設置了 no-cache,或者包含 If-Modified-SinceIf-None-Match 請求頭則不可緩存,需發起網絡請求:

	private fun hasConditions(request: Request): Boolean =request.header("If-Modified-Since") != null || request.header("If-None-Match") != null

這兩個請求頭的含義:

請求頭說明
If-Modified-Since:[Time]值一般為 Date 或 lastModified,如果服務器沒有在指定的時間后修改請求對應資源,會返回 304(無修改)
If-None-Match:[Tag]值一般為 Etag,將其與請求對應資源的 Etag 值進行比較;如果匹配,則返回 304
檢查響應的緩存有效期

響應緩存只是在一定時間內有效,并不是永久有效,判定緩存是否在有效期的公式:

緩存存活時間 < 緩存新鮮度 - 緩存最小新鮮度 + 過期后繼續使用時長

在緩存有效期判斷上,需要先計算緩存的新鮮度,再調整緩存新鮮生存期,判斷緩存是否新鮮可用。如果可用則可以返回緩存而無需發起網絡請求,否則需要構造條件請求頭,發起條件請求。具體代碼如下:

    private fun computeCandidate(): CacheStrategy {// 響應的緩存控制指令val responseCaching = cacheResponse.cacheControl// 1.計算緩存新鮮度// 1.1 緩存年齡。cacheResponseAge() 計算緩存已經存在了多久val ageMillis = cacheResponseAge()// 1.2 新鮮生存期。computeFreshnessLifetime() 根據 Cache-Control 計算緩存應該保持新鮮的時間var freshMillis = computeFreshnessLifetime()// 2.調整緩存新鮮生存期// 2.1 如果請求設置了 max-age 頭,則取 maxAge 和原新鮮生存期的較小值if (requestCaching.maxAgeSeconds != -1) {freshMillis = minOf(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds.toLong()))}// 2.2 minFreshMillis 表示客戶端希望緩存至少在接下來的多少秒內保持新鮮var minFreshMillis: Long = 0if (requestCaching.minFreshSeconds != -1) {minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds.toLong())}// 2.3 maxStaleMillis 允許使用已過期的緩存,但不能超過指定的時間var maxStaleMillis: Long = 0if (!responseCaching.mustRevalidate && requestCaching.maxStaleSeconds != -1) {maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds.toLong())}// 3.判斷緩存是否新鮮可用:如果緩存年齡加上 minFresh 小于新鮮生存期加上 maxStale,// 說明緩存仍然有效,可以返回緩存而不發起請求if (!responseCaching.noCache && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {val builder = cacheResponse.newBuilder()// 添加警告頭:緩存已過期但還在允許的 maxStale 時間內if (ageMillis + minFreshMillis >= freshMillis) {builder.addHeader("Warning", "110 HttpURLConnection \"Response is stale\"")}val oneDayMillis = 24 * 60 * 60 * 1000L// 添加警告頭:啟發式過期if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\"")}// 使用緩存return CacheStrategy(null, builder.build())}// 4.構造條件請求頭:根據緩存中的 ETag、Last-Modified 或 Date 頭,添加對應的條件頭 If-None-Match// 或 If-Modified-Since,發起條件請求。如果服務器返回 304,則使用緩存,否則下載新內容val conditionName: Stringval conditionValue: String?when {etag != null -> {conditionName = "If-None-Match"conditionValue = etag}lastModified != null -> {conditionName = "If-Modified-Since"conditionValue = lastModifiedString}servedDate != null -> {conditionName = "If-Modified-Since"conditionValue = servedDateString}else -> return CacheStrategy(request, null) // No condition! Make a regular request.}val conditionalRequestHeaders = request.headers.newBuilder()conditionalRequestHeaders.addLenient(conditionName, conditionValue!!)val conditionalRequest = request.newBuilder().headers(conditionalRequestHeaders.build()).build()return CacheStrategy(conditionalRequest, cacheResponse)}

2.2 攔截邏輯

弄清了緩存策略后,來看 CacheInterceptor 完整的攔截邏輯:

// cache 成員實際傳入的是 OkHttpClient 的 cache 屬性
class CacheInterceptor(internal val cache: Cache?) : Interceptor {@Throws(IOException::class)override fun intercept(chain: Interceptor.Chain): Response {// 1. 初始化及緩存策略計算val call = chain.call()// 1.1 根據當前請求的 Key(這里是 Request 對象)查找緩存響應val cacheCandidate = cache?.get(chain.request())val now = System.currentTimeMillis()// 1.2 計算緩存策略,決定是發送網絡請求還是使用緩存val strategy = CacheStrategy.Factory(now, chain.request(), cacheCandidate).compute()val networkRequest = strategy.networkRequestval cacheResponse = strategy.cacheResponse// 2.清理無效緩存if (cacheCandidate != null && cacheResponse == null) {// 關閉不可用的緩存響應 BodycacheCandidate.body?.closeQuietly()}// 3. 處理強制僅緩存(only-if-cached)且無可用緩存// 請求頭包含 Cache-Control: only-if-cached 但無有效緩存,返回 504 錯誤,表示無法滿足請求if (networkRequest == null && cacheResponse == null) {return Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(HTTP_GATEWAY_TIMEOUT) // 504.message("Unsatisfiable Request (only-if-cached)").body(EMPTY_RESPONSE).sentRequestAtMillis(-1L).receivedResponseAtMillis(System.currentTimeMillis()).build().also {listener.satisfactionFailure(call, it)}}// 4. 直接使用緩存。策略判定無需網絡請求,緩存有效,因此直接使用緩存if (networkRequest == null) {return cacheResponse!!.newBuilder()// stripBody() 避免后續操作修改原始緩存的 Body.cacheResponse(stripBody(cacheResponse)).build().also {// 觸發 cacheHit 事件,通知監聽器listener.cacheHit(call, it)}}// 5.處理條件請求或緩存未命中if (cacheResponse != null) {// 條件請求命中:存在緩存但需驗證(如發送 If-None-Match)listener.cacheConditionalHit(call, cacheResponse)} else if (cache != null) {// 完全未命中:無任何可用緩存,完全依賴網絡listener.cacheMiss(call)}var networkResponse: Response? = nulltry {// 6.中置工作,交給下一個責任鏈處理networkResponse = chain.proceed(networkRequest)} finally { // 網絡請求異常時(如 IO 異常或其他異常),清理舊緩存 Bodyif (networkResponse == null && cacheCandidate != null) {cacheCandidate.body?.closeQuietly()}}// 7. 處理 304 響應(緩存仍有效)if (cacheResponse != null) {// 服務器返回 304,那就使用緩存作為本次請求的響應,但是需要更新時間等數據if (networkResponse?.code == HTTP_NOT_MODIFIED) {// 更新 cacheResponse 的發送、接收時間等數據,但是響應體并沒有動,還用原來的val response = cacheResponse.newBuilder()// 合并緩存與 304 響應的頭信息(304 通常只包含更新的頭,如 Date,需合并到原緩存響應中).headers(combine(cacheResponse.headers, networkResponse.headers)).sentRequestAtMillis(networkResponse.sentRequestAtMillis).receivedResponseAtMillis(networkResponse.receivedResponseAtMillis).cacheResponse(stripBody(cacheResponse)).networkResponse(stripBody(networkResponse)).build() // 關閉 304 響應的 BodynetworkResponse.body!!.close()// Update the cache after combining headers but before stripping the// Content-Encoding header (as performed by initContentStream()).cache!!.trackConditionalCacheHit()// 更新緩存頭cache.update(cacheResponse, response)// 更新緩存后返回新響應,避免重復驗證return response.also {// 觸發緩存命中(更新后)listener.cacheHit(call, it)}} else {// 關閉失效的緩存 BodycacheResponse.body?.closeQuietly()}}// 8.處理非 304 網絡響應:代碼走到這里說明緩存不可用,緩存已過期或服務器返回新內容,構建最終響應val response = networkResponse!!.newBuilder().cacheResponse(stripBody(cacheResponse)) // 關聯原始緩存(用于日志).networkResponse(stripBody(networkResponse)) // 關聯網絡響應.build()// 9.緩存新響應(如可緩存)if (cache != null) {if (response.promisesBody() && CacheStrategy.isCacheable(response, networkRequest)) {// 寫入新緩存val cacheRequest = cache.put(response)// 寫入并返回響應return cacheWritingResponse(cacheRequest, response).also {if (cacheResponse != null) {// This will log a conditional cache miss only.listener.cacheMiss(call)}}}// 10. 處理破壞性請求(如 POST),非冪等方法(如 POST、PUT)可能修改資源,需清除舊緩存。// 因此需要通過 invalidatesCache() 檢查方法是否需要清除緩存if (HttpMethod.invalidatesCache(networkRequest.method)) {try {// 移除相關緩存cache.remove(networkRequest)} catch (_: IOException) {// The cache cannot be written.}}}return response}
}

總結:

  • 緩存策略優先級:遵循 HTTP RFC 規范,優先使用 Cache-Control 指令。
  • 資源管理:確保所有 Response Body 正確關閉,防止內存泄漏。
  • 事件通知:通過 EventListener 提供詳細的緩存命中/未命中跟蹤。
  • 條件請求優化:通過 304 響應減少數據傳輸,提升性能。

該攔截器通過精細的條件分支和資源管理,實現了高效且符合規范的 HTTP 緩存機制。

3、連接攔截器

連接攔截器的作用是建立與目標服務器的連接,為后續請求提供網絡通道。它看似簡單,整個類只有 9 行代碼,只有前置與中置工作,但內部實現復雜:

object ConnectInterceptor : Interceptor {@Throws(IOException::class)override fun intercept(chain: Interceptor.Chain): Response {// 1.前置工作:創建連接val realChain = chain as RealInterceptorChainval exchange = realChain.call.initExchange(chain)val connectedChain = realChain.copy(exchange = exchange)// 2.中置工作:執行下一個責任鏈return connectedChain.proceed(realChain.request)}
}

ConnectInterceptor 的前置工作就是通過 initExchange() 找到一個 Exchange 對象并更新到責任鏈對象中;中置工作仍然是啟動下一個責任鏈;理論上還應該有一個后置工作 —— 斷開連接,這項工作由連接池自動處理了,因此 ConnectInterceptor 沒有后置工作,所以主要就是看 initExchange() 都干了啥。

3.1 初始化 Exchange

Exchange 可以理解為“請求交換”,指代請求發送和響應接收的完整交互過程。它的作用是傳輸單個 HTTP 請求與響應對,比如寫請求頭、請求體,讀取響應頭、響應體的工作是由 Exchange 主導的:

class Exchange(internal val call: RealCall,internal val eventListener: EventListener,internal val finder: ExchangeFinder,private val codec: ExchangeCodec // Exchange 編解碼器 
) {@Throws(IOException::class)fun writeRequestHeaders(request: Request) {...codec.writeRequestHeaders(request)...}@Throws(IOException::class)fun createRequestBody(request: Request, duplex: Boolean): Sink {...val rawRequestBody = codec.createRequestBody(request, contentLength)...}@Throws(IOException::class)fun readResponseHeaders(expectContinue: Boolean): Response.Builder? {...codec.readResponseHeaders(expectContinue)...}@Throws(IOException::class)fun openResponseBody(response: Response): ResponseBody {...codec.openResponseBodySource(response)...}
}

通過精簡的代碼能看出,Exchange 在生成請求與讀取響應這方面是對 ExchangeCodec 這個編解碼器做了一個封裝,ExchangeCodec 才是真正執行請求的生成與響應讀取的類。舉個例子,Exchange.writeRequestHeaders() 是要寫請求頭,交給 ExchangeCodec.writeRequestHeaders():

  // ExchangeCodec 是接口,這里舉得是 Http1ExchangeCodec 的實現override fun writeRequestHeaders(request: Request) {val requestLine = RequestLine.get(request, connection.route().proxy.type())writeRequest(request.headers, requestLine)}fun writeRequest(headers: Headers, requestLine: String) {check(state == STATE_IDLE) { "state: $state" }sink.writeUtf8(requestLine).writeUtf8("\r\n")for (i in 0 until headers.size) {sink.writeUtf8(headers.name(i)).writeUtf8(": ").writeUtf8(headers.value(i)).writeUtf8("\r\n")}sink.writeUtf8("\r\n")state = STATE_OPEN_REQUEST_BODY}

最終請求頭的字符串都是由編解碼器寫的,所以在初始化 Exchange 之前,必須先找到合適的 ExchangeCodec 才行。因此就有了 initExchange() 創建 Exchange 的邏輯:

  // 獲取新連接或復用連接池中的連接,以承載后續的請求和響應internal fun initExchange(chain: RealInterceptorChain): Exchange {...val exchangeFinder = this.exchangeFinder!!// 1.找到發送請求與處理響應的編解碼器val codec = exchangeFinder.find(client, chain)// 2.用編解碼器等參數創建 Exchange 對象val result = Exchange(this, eventListener, exchangeFinder, codec)this.interceptorScopedExchange = resultthis.exchange = result...if (canceled) throw IOException("Canceled")return result}

接下來要關注如何獲取 Exchange 編解碼器對象。

3.2 獲取 ExchangeCodec

ExchangeFinder 的 find() 會根據傳入的 OkHttpClient 以及責任鏈 RealInterceptorChain 查找到一個健康連接,并返回該連接的編解碼器:

  fun find(client: OkHttpClient,chain: RealInterceptorChain): ExchangeCodec {try {// 1.查找健康連接val resultConnection = findHealthyConnection(connectTimeout = chain.connectTimeoutMillis,readTimeout = chain.readTimeoutMillis,writeTimeout = chain.writeTimeoutMillis,pingIntervalMillis = client.pingIntervalMillis,connectionRetryEnabled = client.retryOnConnectionFailure,doExtensiveHealthChecks = chain.request.method != "GET")// 2.生成健康連接的編解碼器并返回return resultConnection.newCodec(client, chain)} catch (e: RouteException) {trackFailure(e.lastConnectException)throw e} catch (e: IOException) {trackFailure(e)throw RouteException(e)}}

主要工作是第 1 步如何查找到一個健康連接,代碼層次很深,后面主要就是介紹它。所以我們先看第 2 步,拿到一個健康連接后,如何生成它的編解碼器:

  // RealConnection 根據 HTTP 連接類型生成對應的編解碼器@Throws(SocketException::class)internal fun newCodec(client: OkHttpClient, chain: RealInterceptorChain): ExchangeCodec {val socket = this.socket!!val source = this.source!!val sink = this.sink!!val http2Connection = this.http2Connectionreturn if (http2Connection != null) {Http2ExchangeCodec(client, this, chain, http2Connection)} else {socket.soTimeout = chain.readTimeoutMillis()source.timeout().timeout(chain.readTimeoutMillis.toLong(), MILLISECONDS)sink.timeout().timeout(chain.writeTimeoutMillis.toLong(), MILLISECONDS)Http1ExchangeCodec(client, this, source, sink)}}

由于 HTTP1 與 HTTP2 的編解碼方式是不同的,因此 ExchangeCodec 被抽象成一個接口,當 RealConnection 的 http2Connection 不為空時,說明它是一個 HTTP2 連接,所以此時會返回 HTTP2 的編解碼器 Http2ExchangeCodec,否則視為 HTTP1 連接返回 Http1ExchangeCodec。

3.3 查找健康連接

3.2 中的第 1 步通過 findHealthyConnection() 返回一個健康連接:

  @Throws(IOException::class)private fun findHealthyConnection(connectTimeout: Int,readTimeout: Int,writeTimeout: Int,pingIntervalMillis: Int,connectionRetryEnabled: Boolean,doExtensiveHealthChecks: Boolean): RealConnection {while (true) {// 1.查找候選連接val candidate = findConnection(connectTimeout = connectTimeout,readTimeout = readTimeout,writeTimeout = writeTimeout,pingIntervalMillis = pingIntervalMillis,connectionRetryEnabled = connectionRetryEnabled)// 2.檢查候選連接是否健康if (candidate.isHealthy(doExtensiveHealthChecks)) {return candidate}// 如果連接不健康,則將 noNewExchanges 標記置位,連接池會移除該連接candidate.noNewExchanges()// 3.確保我們還有可以嘗試的路由。一種可能耗盡所有路由的情況是:// 當新建連接后立即被檢測為不健康時,需要檢查是否還有其他可用路由if (nextRouteToTry != null) continue// 當前路由選擇器中還有未嘗試的路由,繼續重試val routesLeft = routeSelection?.hasNext() ?: trueif (routesLeft) continue// 存在其他路由選擇器(如備用代理組),繼續重試val routesSelectionLeft = routeSelector?.hasNext() ?: trueif (routesSelectionLeft) continuethrow IOException("exhausted all routes")}}

在一個死循環內不斷做三件事:

  • 查找候選連接
  • 檢查連接是否健康,如健康則作為結果返回,否則要將該連接的 noNewExchanges 置位
  • 檢查是否還有可用路由(線路),如有則繼續循環,否則意味著所有路由都被嘗試完也未找到健康連接,拋出 IO 異常

由于第 1 步通過 findConnection() 查找候選連接的內容非常多,還是放到下一節介紹,這里先看找到連接的后續工作。

檢查連接是否健康

  /** Returns true if this connection is ready to host new streams. */fun isHealthy(doExtensiveChecks: Boolean): Boolean {assertThreadDoesntHoldLock()val nowNs = System.nanoTime()// 底層 TCP Socketval rawSocket = this.rawSocket!!// 應用層 Socket,如果是 HTTPS 協議通信的話,就是在 rawSocket 之上的// SSLSocket,否則就是 rawSocket 本身val socket = this.socket!!val source = this.source!!// 1.底層與應用層的 Socket 均為關閉或停止if (rawSocket.isClosed || socket.isClosed || socket.isInputShutdown ||socket.isOutputShutdown) {return false}// 2.如果是 HTTP2 連接的話,做保活/心跳相關檢查:如果當前時間超過 pong 響應截止時間,// 且如果已發送的降級 ping 數 > 已接收的降級 pong 數,判定為不健康val http2Connection = this.http2Connectionif (http2Connection != null) {return http2Connection.isHealthy(nowNs)}// 3.擴展檢查:當連接空閑時間超過健康閾值且需要深度檢查時,檢查應用層 socket 是否健康val idleDurationNs = synchronized(this) { nowNs - idleAtNs }if (idleDurationNs >= IDLE_CONNECTION_HEALTHY_NS && doExtensiveChecks) {return socket.isHealthy(source)}return true}

這一步分多個層級檢查連接是否健康,如果不健康,需要通過 noNewExchanges() 將 noNewExchanges 標記置位:

  /*** 如果為 true,則不能在此連接上創建新的數據交換(exchange)。當從連接池中移除連接時* 必須設為 true,否則在競爭條件下,調用方可能本不應該獲取到此連接卻從連接池中獲取到了。* 對稱地,在從連接池返回連接前必須始終檢查此標志。* 一旦為 true 將始終保持為 true。由 this 對象(當前連接實例)的同步鎖進行保護。*/var noNewExchanges = false@Synchronized internal fun noNewExchanges() {noNewExchanges = true}

檢查是否還有可用連接

3.4 查找候選連接

這節我們來看 3.3 中的第 1 步,findConnection() 是如何查找到一個連接的:

  /*** 獲取承載新數據流的連接,優先級順序:復用現有連接、連接池中的連接、建立全新連接。* 每個阻塞操作前都會檢查是否已取消請求。*/@Throws(IOException::class)private fun findConnection(connectTimeout: Int,readTimeout: Int,writeTimeout: Int,pingIntervalMillis: Int,connectionRetryEnabled: Boolean): RealConnection {// 1.連接復用檢查階段// 1.1 檢查請求是否已取消if (call.isCanceled()) throw IOException("Canceled")// 1.2 驗證現有連接是否可用val callConnection = call.connection // This may be mutated by releaseConnectionNoEvents()!if (callConnection != null) {// 應該關閉的 Socketvar toClose: Socket? = nullsynchronized(callConnection) {// 1.2.1 若連接被標記為不可用(noNewExchanges)或主機、端口不匹配,則關閉該連接if (callConnection.noNewExchanges || !sameHostAndPort(callConnection.route().address.url)) {// 關閉連接,會將 call.connection 置為 nulltoClose = call.releaseConnectionNoEvents()}}// 1.2.2 call.connection 還存在的話就復用它,直接返回if (call.connection != null) {check(toClose == null)return callConnection}// 靜默關閉 SockettoClose?.closeQuietly()eventListener.connectionReleased(call, callConnection)}// 2.連接池獲取階段// 2.1 由于需要一個新的連接,因此重置相關數據refusedStreamCount = 0connectionShutdownCount = 0otherFailureCount = 0// 2.2 嘗試從連接池獲取一個連接if (connectionPool.callAcquirePooledConnection(address, call, null, false)) {val result = call.connection!!eventListener.connectionAcquired(call, result)return result}// 3.路由選擇階段val routes: List<Route>? // 可能的路由列表val route: Route // 最終選擇的路由// 3.1 三級路由獲取策略if (nextRouteToTry != null) { // 3.1.1 預置路由// Use a route from a preceding coalesced connection.routes = nullroute = nextRouteToTry!!nextRouteToTry = null} else if (routeSelection != null && routeSelection!!.hasNext()) { // 3.1.2 現有路由// Use a route from an existing route selection.routes = nullroute = routeSelection!!.next()} else {// 3.1.3 新建路由選擇器(是一個阻塞操作)var localRouteSelector = routeSelectorif (localRouteSelector == null) {localRouteSelector = RouteSelector(address, call.client.routeDatabase, call, eventListener)this.routeSelector = localRouteSelector}val localRouteSelection = localRouteSelector.next()routeSelection = localRouteSelectionroutes = localRouteSelection.routesif (call.isCanceled()) throw IOException("Canceled")// 3.2 獲取一組 IP 地址后,再次嘗試從連接池獲取連接(連接合并提高了匹配的可能性)if (connectionPool.callAcquirePooledConnection(address, call, routes, false)) {val result = call.connection!!eventListener.connectionAcquired(call, result)return result}route = localRouteSelection.next()}// 4.新建連接階段// Connect. Tell the call about the connecting call so async cancels work.// 4.1 創建 RealConnection 實例val newConnection = RealConnection(connectionPool, route)// 4.2 設置可取消標記call.connectionToCancel = newConnection// 4.3 執行 TCP/TLS 握手try {newConnection.connect(connectTimeout,readTimeout,writeTimeout,pingIntervalMillis,connectionRetryEnabled,call,eventListener)} finally {call.connectionToCancel = null}// 4.4 更新路由數據庫call.client.routeDatabase.connected(newConnection.route())// 5.連接合并優化// If we raced another call connecting to this host, coalesce the connections. This makes for 3// different lookups in the connection pool!// 5.1 最終檢查連接池是否有合并機會if (connectionPool.callAcquirePooledConnection(address, call, routes, true)) {val result = call.connection!!nextRouteToTry = route// 5.2 若合并成功則關閉新建連接newConnection.socket().closeQuietly()eventListener.connectionAcquired(call, result)return result}// 5.3 合并失敗,將新連接加入連接池synchronized(newConnection) {connectionPool.put(newConnection)call.acquireConnectionNoEvents(newConnection)}eventListener.connectionAcquired(call, newConnection)return newConnection}

按照注釋標注的 5 步序號逐一來看。

連接復用檢查

首先看 RealCall 自身保存的 connection 是否可以復用,主要判斷條件:

  • noNewExchanges 若為 true 表示該連接不能再接收更多任務了,此時不可復用
  • sameHostAndPort() 的判斷不成立,即主機域名與端口號不同時不可復用

如果不可復用,需要以無事件方式關閉連接:

  /*** 連接的資源分配列表(calls)中移除此任務(RealCall)。返回調用方應當關閉的 socket。*/internal fun releaseConnectionNoEvents(): Socket? {val connection = this.connection!!connection.assertThreadHoldsLock()// 這個連接承載的請求,是一個 MutableList<Reference<RealCall>>val calls = connection.callsval index = calls.indexOfFirst { it.get() == this@RealCall }check(index != -1)// 從請求集合中移除當前 RealCall 并將連接 connection 置為 nullcalls.removeAt(index)this.connection = null// 如果這個連接沒有承載任何請求,那么它就成為了一個閑置連接,如果它的 noNewExchanges// 被置位或者連接池允許的最大限制連接數量為 0,就需要關閉這個連接,此時返回該連接的 Socketif (calls.isEmpty()) {connection.idleAtNs = System.nanoTime()if (connectionPool.connectionBecameIdle(connection)) {return connection.socket()}}return null}

關閉連接需要將當前 RealCall 任務從 connection 承載的任務列表中移除,并將 connection 置為 null。如果當前連接沒有承載任何任務便成為空閑連接,如果它自身的 noNewExchanges 被置為 true 或者連接池不允許有空閑連接,需要關閉該連接,此時要返回 connection 的 Socket。

如果在 1.2.2 中檢查連接不為 null 說明滿足復用 1.2.1 的復用條件,直接返回,否則就要關閉 releaseConnectionNoEvents() 返回的 Socket 對象。

檢查連接池

如果 RealCall 自身的連接不可復用,嘗試從連接池中找一個可復用的連接。主要是通過 2.2 的 callAcquirePooledConnection() 查找滿足復用條件的連接:

  /*** 嘗試從連接池獲取可復用的連接,用于服務指定[address]的[call]請求。當成功獲取連接時返回 true*/fun callAcquirePooledConnection(address: Address,call: RealCall,routes: List<Route>?,requireMultiplexed: Boolean): Boolean {// 遍歷連接池中的所有連接for (connection in connections) {synchronized(connection) {// 1.多路復用要求檢查。由于只有 HTTP2 支持多路復用,因此這是一項針對 HTTP2 的檢查if (requireMultiplexed && !connection.isMultiplexed) return@synchronized// 2.檢查是否有復用資格,主要是對地址路由的匹配檢查if (!connection.isEligible(address, routes)) return@synchronized// 3.檢查通過以無事件方式獲取連接call.acquireConnectionNoEvents(connection)return true}}return false}

首先是多路復用檢查,如果參數 requireMultiplexed 要求強制使用多路復用,但 connection 不是 HTTP2 連接不支持多路復用時,該 connection 不能復用:

  /*** RealConnection 的 isMultiplexed 屬性會在該 RealConnection 是 HTTP2 連接時* 返回 true,這些連接可同時用于多個 HTTP 請求*/internal val isMultiplexed: Booleanget() = http2Connection != nullprivate var http2Connection: Http2Connection? = null

然后是復用資格檢查,主要是地址路由相關檢查:

  /*** 判斷當前連接是否可用于承載目標地址的流分配。若 routes 參數非空,則表示該連接已解析的具體路由信息*/internal fun isEligible(address: Address, routes: List<Route>?): Boolean {assertThreadHoldsLock()// 如果這個連接所承載的請求(RealCall)已經到達上限,或者該連接不能創建新交換,視為不可用// HTTP1 只允許 1 個請求,而 HTTP2 最大允許 4 個if (calls.size >= allocationLimit || noNewExchanges) return false// 地址的非主機字段(DNS、代理、端口等等)對比if (!this.route.address.equalsNonHost(address)) return false// 如果主機匹配則連接可以承載地址請求,直接返回if (address.url.host == this.route().address.url.host) {return true // This connection is a perfect match.}// 到這里主機沒匹配,但如果滿足我們的連接合并(connection coalescing)要求,// 仍然可以繼續處理請求,實際上就是判斷 HTTP2 連接。更多信息查看:// https://hpbn.co/optimizing-application-delivery/#eliminate-domain-sharding// https://daniel.haxx.se/blog/2016/08/18/http2-connection-coalescing/// 1. This connection must be HTTP/2.if (http2Connection == null) return false// 2. The routes must share an IP address.if (routes == null || !routeMatchesAny(routes)) return false// 3. This connection's server certificate's must cover the new host.if (address.hostnameVerifier !== OkHostnameVerifier) return falseif (!supportsUrl(address.url)) return false// 4. Certificate pinning must match the host.try {address.certificatePinner!!.check(address.url.host, handshake()!!.peerCertificates)} catch (_: SSLPeerUnverifiedException) {return false}return true // The caller's address can be carried by this connection.}

在連接可以承載的 RealCall 未到上限且可以接收更多 Exchanges 的情況下:

  • 如果非主機字段與主機都相同,可以直接復用該連接
  • 如果非主機字段相同但主機不同,可以進一步檢查是否滿足 HTTP2 多路復用條件:
    • 連接必須是 HTTP2 的連接
    • 有可用路由且滿足匹配條件:直連且 IP 地址相同
    • 連接的服務器證書必須能覆蓋新的主機
    • 證書鎖定(Certificate Pinning)必須與主機匹配

先看 equalsNonHost() 都檢查了哪些非主機配置:

  internal fun equalsNonHost(that: Address): Boolean {return this.dns == that.dns &&this.proxyAuthenticator == that.proxyAuthenticator &&this.protocols == that.protocols &&this.connectionSpecs == that.connectionSpecs &&this.proxySelector == that.proxySelector &&this.proxy == that.proxy &&this.sslSocketFactory == that.sslSocketFactory &&this.hostnameVerifier == that.hostnameVerifier &&this.certificatePinner == that.certificatePinner &&this.url.port == that.url.port}

這些配置我們在前面講 OkHttp 配置時基本都已經解釋過,所以這里就不多說了。

舉個例子,原本有一個請求的 URL 是 http://test.com/1,那么后續的新請求:

  • http://test.com/2 可以有復用 http://test.com/1 的連接的資格,因為域名一樣,http 協議端口又都是 80(說可以有資格是因為除了域名和端口,還有其他條件)
  • https://test.com/1 就一定不滿足復用條件,因為 https 協議的端口號是 443

然后再看 HTTP2 多路復用的條件,它適用于不同域名的主機配置了相同 IP 的情況。比如兩個網站,主機名分別為 https://dabendan.com 與 https://xiaobendan.com,它們在同一個虛擬主機上,被配置到同一個 IP 地址 123.123.123.123。這種情況下,只是域名不同,但 IP、端口以及其他配置(equalsNonHost() 中檢查的)都一樣,也是可以使用同一個 HTTP2 連接的。只不過,為了驗證 https://dabendan.com 與 https://xiaobendan.com 確實是同一個網站,需要額外再進行證書驗證。

看具體代碼。routes 是一個路由列表,表名可用的路由,routeMatchesAny() 會從 routes 中檢查是否有任意一個 IP 地址匹配的路由:

  /*** 檢查當前連接的路由地址是否與候選列表中的任意路由匹配。注意:* 1.要求雙方主機都已完成 DNS 解析(需在路由規劃之后)* 2.代理連接不可合并,因為代理會隱藏原始服務器 IP 地址* 當存在完全匹配的路由時返回 true*/private fun routeMatchesAny(candidates: List<Route>): Boolean {return candidates.any {// 必須都要是直連(不是代理)且地址相同it.proxy.type() == Proxy.Type.DIRECT &&route.proxy.type() == Proxy.Type.DIRECT &&route.socketAddress == it.socketAddress}}

需要注意的是,代理類型需要為直連 DIRECT(實際上就是沒有使用代理),否則原始服務器 IP 會被隱藏,不知道原始服務器 IP 就不能合并連接。

接下來需要驗證連接的證書是否能覆蓋到新的主機,首先需要地址的主機驗證器是否為 OkHostnameVerifier。因為 HostnameVerifier 是 Java 原生 javax 包下的一個接口,有多種實現,在需要驗證地址時,需要符合 OkHttp 的規范。

然后檢查 URL 是否滿足復用條件:

  private fun supportsUrl(url: HttpUrl): Boolean {...val routeUrl = route.address.url// 1.端口不同不可復用if (url.port != routeUrl.port) {return false // Port mismatch.}// 2.如果主機名和端口都相同,則可直接復用if (url.host == routeUrl.host) {return true}// 3.如果主機名不同,但是連接允許合并且存在 TLS 握手信息,證書也能驗證通過的話,也可復用// We have a host mismatch. But if the certificate matches, we're still good.return !noCoalescedConnections && handshake != null && certificateSupportHost(url, handshake!!)}private fun certificateSupportHost(url: HttpUrl, handshake: Handshake): Boolean {val peerCertificates = handshake.peerCertificates// 使用 OkHostnameVerifier 驗證服務器證書(前面說過,證書鏈的第一個證書是服務器證書)return peerCertificates.isNotEmpty() && OkHostnameVerifier.verify(url.host,peerCertificates[0] as X509Certificate)}

如果主機名與端口都相同,那就符合復用條件。如果端口相同,但主機不同,可以進一步看連接是否支持合并,如果支持的話,驗證這個簽名是否支持主機。

最后是證書固定:

  /*** 確認至少有一個為`hostname`預置的證書指紋存在于`peerCertificates`證書鏈中。* 若未設置該主機名的證書鎖定規則,則不執行任何操作。OkHttp 在成功完成 TLS 握手后、* 使用連接前調用此方法。*/@Throws(SSLPeerUnverifiedException::class)fun check(hostname: String, peerCertificates: List<Certificate>) {return check(hostname) {(certificateChainCleaner?.clean(peerCertificates, hostname) ?: peerCertificates).map { it as X509Certificate }}}

經過 isEligible() 的檢查,如果連接符合復用條件,則通過 acquireConnectionNoEvents() 復用連接:

  fun acquireConnectionNoEvents(connection: RealConnection) {...check(this.connection == null)this.connection = connectionconnection.calls.add(CallReference(this, callStackTrace))}

路由選擇

在看代碼之前我們先舉一個實例。比如要連接的服務器地址為 https://test.com,該域名可以有多個 IP 地址:

  • 1.2.3.4:443
  • 5.6.7.8:443

服務器可以有代理服務器 https://testproxy.com,代理服務器也可能有多個 IP 地址:

  • 9.10.11.12:443
  • 13.14.15.16:443

在解析的時候,URL 可以提供域名和端口號信息組成 Address:

class Address(// 域名和端口號來自于 URLuriHost: String,uriPort: Int,// 其余信息通過 OkHttpClient 獲取@get:JvmName("dns") val dns: Dns,@get:JvmName("socketFactory") val socketFactory: SocketFactory,@get:JvmName("sslSocketFactory") val sslSocketFactory: SSLSocketFactory?,@get:JvmName("hostnameVerifier") val hostnameVerifier: HostnameVerifier?,@get:JvmName("certificatePinner") val certificatePinner: CertificatePinner?,@get:JvmName("proxyAuthenticator") val proxyAuthenticator: Authenticator,@get:JvmName("proxy") val proxy: Proxy?,protocols: List<Protocol>,connectionSpecs: List<ConnectionSpec>,@get:JvmName("proxySelector") val proxySelector: ProxySelector
)

Address 又作為路由 Route 的成員:

class Route(@get:JvmName("address") val address: Address,@get:JvmName("proxy") val proxy: Proxy,@get:JvmName("socketAddress") val socketAddress: InetSocketAddress
)

路由選擇器 RouteSelector 在進行路由選擇時,會按組遍歷。比如直連的 IP 下的兩個 IP 地址 List<Route> 分到一個組中,這個組就是 RouteSelector.Selection,然后代理的 IP 又是另一個 Selection,每個 Selection 下面都有一個 List<Route>

現在再看路由選擇,它的最終目的是通過遍歷這些路由,再去連接池獲取一次連接。因為在上一步檢查連接池去獲取連接時,沒有傳 List<Route>,也就是在沒有指定 IP 地址的情況下去嘗試獲取可以復用的連接。那么在主機名不同的情況下,去檢查 HTTP2 多路復用的條件時,就會因為沒有具體的 IP 地址而無法復用已經存在的 HTTP2 連接。所以這一次傳入 List<Route> 是拓寬了連接池中連接的可選擇性,可能匹配到之前不能匹配的連接(得益于連接合并)。

具體到路由選擇的三級策略上:

  • 首先檢查前置連接 nextRouteToTry,它是之前合并連接時(下一小節要講合并連接)保存的路由,相同 IP、端口的 HTTPS 連接可以復用
  • 使用現有路由選擇器的路由 Selection,就是我們舉例的分組。比如直連的 Selection 中有 1.2.3.4 和 5.6.7.8 兩個 IP,先看這一組中的路由是否有可以復用的
  • 需要計算新的路由選擇。假如直連的兩個路由不能復用,那么就檢查下一個 Selection,也就是代理這一組內的路由是否有滿足

新建連接

  fun connect(connectTimeout: Int,readTimeout: Int,writeTimeout: Int,pingIntervalMillis: Int,connectionRetryEnabled: Boolean,call: Call,eventListener: EventListener) {check(protocol == null) { "already connected" }var routeException: RouteException? = nullval connectionSpecs = route.address.connectionSpecsval connectionSpecSelector = ConnectionSpecSelector(connectionSpecs)// 路由中的地址 Address 的 sslSocketFactory 為空,說明該地址不是 HTTPS 協議if (route.address.sslSocketFactory == null) {// 沒有開啟明文傳輸(不是 HTTPS 那就是 HTTP 了,HTTP 需要明文傳輸你又沒配置,拋異常)if (ConnectionSpec.CLEARTEXT !in connectionSpecs) {throw RouteException(UnknownServiceException("CLEARTEXT communication not enabled for client"))}val host = route.address.url.host// 網絡安全政策不允許明文傳輸,那 HTTP 沒法工作,也得拋異常if (!Platform.get().isCleartextTrafficPermitted(host)) {throw RouteException(UnknownServiceException("CLEARTEXT communication to $host not permitted by network security policy"))}} else {// H2_PRIOR_KNOWLEDGE 指客戶端在建立連接時就已經知道服務器支持 HTTP/2 協議,故而不先進行// 協商而直接發送 HTTP2 幀。從安全性考慮,OkHttp 不允許它與 HTTPS 一起使用if (Protocol.H2_PRIOR_KNOWLEDGE in route.address.protocols) {throw RouteException(UnknownServiceException("H2_PRIOR_KNOWLEDGE cannot be used with HTTPS"))}}while (true) {try {// 使用 HTTP 隧道連接if (route.requiresTunnel()) {connectTunnel(connectTimeout, readTimeout, writeTimeout, call, eventListener)if (rawSocket == null) {// We were unable to connect the tunnel but properly closed down our resources.break}} else {// 正常情況下無需使用 Tunnel,就正常建立一個 TCP 連接connectSocket(connectTimeout, readTimeout, call, eventListener)}// 建立 HTTP 與 HTTP2 連接establishProtocol(connectionSpecSelector, pingIntervalMillis, call, eventListener)eventListener.connectEnd(call, route.socketAddress, route.proxy, protocol)break} catch (e: IOException) {...}}if (route.requiresTunnel() && rawSocket == null) {throw RouteException(ProtocolException("Too many tunnel connections attempted: $MAX_TUNNEL_ATTEMPTS"))}idleAtNs = System.nanoTime()}

HTTP 隧道是標準的使用 HTTP 代理 HTTPS 的方式。

第五步

同時做請求,同時都創建一個新連接,先創建好的把連接放連接池里,后創建好的把連接扔掉用剛剛創建好的。

復用連接,非多路復用,可以多路復用(非多路復用 + 多路復用),自己創建,只拿多路復用連接

第一次是上面的流程,但如果是第二次,比如發生了重試或者重定向,那么第一部分判斷 supportUrl() 檢測就有可能不行了。比如重定向,原本是訪問 http://test.com,重定向的 URL 是 https://test.com,由于協議發生了切換,端口不一樣了,這樣不滿足 supportUrl() 的條件,所以需要把連接釋放掉。

4、請求服務攔截器

  @Throws(IOException::class)override fun intercept(chain: Interceptor.Chain): Response {val realChain = chain as RealInterceptorChainval exchange = realChain.exchange!!val request = realChain.requestval requestBody = request.bodyval sentRequestMillis = System.currentTimeMillis()var invokeStartEvent = truevar responseBuilder: Response.Builder? = nullvar sendRequestException: IOException? = nulltry {exchange.writeRequestHeaders(request)if (HttpMethod.permitsRequestBody(request.method) && requestBody != null) {// If there's a "Expect: 100-continue" header on the request, wait for a "HTTP/1.1 100// Continue" response before transmitting the request body. If we don't get that, return// what we did get (such as a 4xx response) without ever transmitting the request body.if ("100-continue".equals(request.header("Expect"), ignoreCase = true)) {exchange.flushRequest()responseBuilder = exchange.readResponseHeaders(expectContinue = true)exchange.responseHeadersStart()invokeStartEvent = false}if (responseBuilder == null) {if (requestBody.isDuplex()) {// Prepare a duplex body so that the application can send a request body later.exchange.flushRequest()val bufferedRequestBody = exchange.createRequestBody(request, true).buffer()requestBody.writeTo(bufferedRequestBody)} else {// Write the request body if the "Expect: 100-continue" expectation was met.val bufferedRequestBody = exchange.createRequestBody(request, false).buffer()requestBody.writeTo(bufferedRequestBody)bufferedRequestBody.close()}} else {exchange.noRequestBody()if (!exchange.connection.isMultiplexed) {// If the "Expect: 100-continue" expectation wasn't met, prevent the HTTP/1 connection// from being reused. Otherwise we're still obligated to transmit the request body to// leave the connection in a consistent state.exchange.noNewExchangesOnConnection()}}} else {exchange.noRequestBody()}if (requestBody == null || !requestBody.isDuplex()) {exchange.finishRequest()}} catch (e: IOException) {if (e is ConnectionShutdownException) {throw e // No request was sent so there's no response to read.}if (!exchange.hasFailure) {throw e // Don't attempt to read the response; we failed to send the request.}sendRequestException = e}try {if (responseBuilder == null) {responseBuilder = exchange.readResponseHeaders(expectContinue = false)!!if (invokeStartEvent) {exchange.responseHeadersStart()invokeStartEvent = false}}var response = responseBuilder.request(request).handshake(exchange.connection.handshake()).sentRequestAtMillis(sentRequestMillis).receivedResponseAtMillis(System.currentTimeMillis()).build()var code = response.codeif (shouldIgnoreAndWaitForRealResponse(code)) {responseBuilder = exchange.readResponseHeaders(expectContinue = false)!!if (invokeStartEvent) {exchange.responseHeadersStart()}response = responseBuilder.request(request).handshake(exchange.connection.handshake()).sentRequestAtMillis(sentRequestMillis).receivedResponseAtMillis(System.currentTimeMillis()).build()code = response.code}exchange.responseHeadersEnd(response)response = if (forWebSocket && code == 101) {// Connection is upgrading, but we need to ensure interceptors see a non-null response body.response.newBuilder().body(EMPTY_RESPONSE).build()} else {response.newBuilder().body(exchange.openResponseBody(response)).build()}if ("close".equals(response.request.header("Connection"), ignoreCase = true) ||"close".equals(response.header("Connection"), ignoreCase = true)) {exchange.noNewExchangesOnConnection()}if ((code == 204 || code == 205) && response.body?.contentLength() ?: -1L > 0L) {throw ProtocolException("HTTP $code had non-zero Content-Length: ${response.body?.contentLength()}")}return response} catch (e: IOException) {if (sendRequestException != null) {sendRequestException.addSuppressed(e)throw sendRequestException}throw e}}

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/web/87681.shtml
繁體地址,請注明出處:http://hk.pswp.cn/web/87681.shtml
英文地址,請注明出處:http://en.pswp.cn/web/87681.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

45-使用scale實現圖形縮放

45-使用scale實現圖形縮放_嗶哩嗶哩_bilibili45-使用scale實現圖形縮放是一次性學會 Canvas 動畫繪圖&#xff08;核心精講50個案例&#xff09;2023最新教程的第46集視頻&#xff0c;該合集共計53集&#xff0c;視頻收藏或關注UP主&#xff0c;及時了解更多相關視頻內容。http…

軟件開發早期階段,使用存儲過程的優勢探討:敏捷開發下的利器

在現代軟件開發中&#xff0c;隨著持續集成與敏捷開發的深入推進&#xff0c;開發團隊越來越重視快速響應需求變更、快速上線迭代。在這種背景下&#xff0c;傳統將業務邏輯全部放在應用層的方式在某些階段顯得笨重。本文將探討在軟件開發初期&#xff0c;特別是在需求尚不穩定…

『 C++入門到放棄 』- string

C 學習筆記 - string 一、什麼是string ? string 是 C 中標準函數庫中的一個類&#xff0c;其包含在 中 該類封裝了C語言中字符串操作&#xff0c;提供內存管理自動化與更多的操作 支持複製、比較、插入、刪除、查找等功能 二、常用接口整理 類別常用方法 / 說明建立與指…

ARM架構下C++程序堆溢出與棧堆碰撞問題深度解析

ARM架構下C程序堆溢出與棧堆碰撞問題深度解析 一、問題背景&#xff1a;從崩潰現象到內存異常 在嵌入式系統開發中&#xff0c;程序崩潰是常見但棘手的問題。特別是在ARM架構設備上&#xff0c;一種典型的崩潰場景如下&#xff1a;程序在執行聚類算法或大規模數據處理時突然終…

.NET9 實現排序算法(MergeSortTest 和 QuickSortTest)性能測試

在 .NET 9 平臺下&#xff0c;我們對兩種經典的排序算法 MergeSortTest&#xff08;歸并排序&#xff09;和 QuickSortTest&#xff08;快速排序&#xff09;進行了性能基準測試&#xff08;Benchmark&#xff09;&#xff0c;以評估它們在不同數據規模下的執行效率、內存分配及…

RabbitMQ - SpringAMQP及Work模型

一、概述RabbitMQ是一個流行的開源消息代理&#xff0c;支持多種消息傳遞協議。它通常用于實現異步通信、解耦系統組件和分布式任務處理。Spring AMQP是Spring框架下的一個子項目&#xff0c;提供了對RabbitMQ的便捷訪問和操作。本文將詳細介紹RabbitMQ的工作模型&#xff08;W…

微信小程序51~60

1.界面交互-loading提示框 loading提示框用于增加用戶體驗&#xff0c; 對應的API有兩個&#xff1a; wx.showLoading()顯示loading提示框wx.hideLoading()關閉loading提示框 Page({getData () {//顯示loading提示框wx.showLoading({//提示內容不會自動換行&#xff0c;多出來的…

SqueezeBERT:計算機視覺能為自然語言處理在高效神經網絡方面帶來哪些啟示?

摘要 人類每天閱讀和撰寫數千億條消息。得益于大規模數據集、高性能計算系統和更優的神經網絡模型&#xff0c;自然語言處理&#xff08;NLP&#xff09;技術在理解、校對和組織這些消息方面取得了顯著進展。因此&#xff0c;將 NLP 部署于各類應用中&#xff0c;以幫助網頁用…

Springboot開發常見注解一覽

注解用法常用參數Configuration用于標記類為配置類&#xff0c;其中通過Bean方法定義Spring管理的組件。它替代XML配置&#xff0c;用Java代碼聲明對象創建邏輯&#xff0c;并確保單例等容器特性生效。相當于給Spring提供一個“制造說明書”來組裝應用部件RestControllerRestCo…

Maven高級——分模塊設計與開發

目錄 ?編輯 分模塊設計與開發 拆分策略 繼承與聚合 版本鎖定 聚合 作用 實現 Maven中繼承與聚合的聯系與區別&#xff1f; 聯系 區別 私服 分模塊設計與開發 將一個大項目拆分成若干個子模塊&#xff0c;方便項目的管理維護&#xff0c;擴展&#xff0c;也方便模…

線程池的七個參數設計源于對高并發場景下資源管理、系統穩定性與性能平衡的深刻洞察

?? 一、核心參數設計目標與解決的問題 參數設計目標解決的核心問題典型取值策略corePoolSize&#xff08;核心線程數&#xff09;維持常備線程資源避免頻繁創建/銷毀線程的開銷&#xff0c;提高響應速度CPU密集型&#xff1a;N_cpu 1 IO密集型&#xff1a;2 N_cpu maximum…

少樣本學習在計算機視覺中的應用:原理、挑戰與最新突破

在深度學習的黃金時代&#xff0c;大量標注數據似乎成了算法性能的前提。然而在許多現實場景中&#xff0c;如醫療圖像分析、工業缺陷檢測、遙感識別、甚至個性化視覺服務中&#xff0c;高質量、成規模的標注數據往往昂貴、稀缺&#xff0c;甚至難以獲得。這種場景正是**少樣本…

github在線圖床

github做的圖床&#xff0c;原理是利用github API實現的在線上傳&#xff0c;就一個頁面&#xff0c;css和js都是集成在頁面&#xff0c;相關信息保存在瀏覽器緩存中&#xff0c;配置一下即可使用 效果演示&#xff1a; github在線圖床 打開網站填寫下列信息 github用戶名&a…

css-多條記錄,自動換行與自動并行布局及gap兼容

實現這樣的內容布局&#xff0c;當一段文案長度超過當前行的時候自動占據一行&#xff0c;其他相近的不超過一行自動放在一行間隔隔開 關鍵實現原理&#xff1a; 彈性布局容器&#xff1a; .history-container {display: flex;flex-wrap: wrap;gap: 12px; }使用flex-wrap: wr…

Redis 哨兵模式部署--docker版本

redis sentinel 簡介 Redis Sentinel 是 Redis 官方提供的高可用&#xff08;HA&#xff09;解決方案&#xff0c;用于監控主從架構中的故障并自動完成故障轉移。當主節點&#xff08;Master&#xff09;宕機時&#xff0c;Sentinel 能自動選舉新的主節點&#xff0c;通知從節…

Java線程中的守護線程

Java線程中的守護線程在Java中&#xff0c;守護線程&#xff08;Daemon Thread&#xff09;是一種特殊類型的線程&#xff0c;它在后臺運行&#xff0c;主要用于支持其他線程&#xff08;如用戶線程&#xff09;的工作。守護線程不會阻止JVM&#xff08;Java虛擬機&#xff09;…

Flink-狀態恢復-isRestore分析

isRestored 方法返回值依賴 restoredCheckpointId 是否為空&#xff1a;restoredCheckpointId 在算子狀態句柄&#xff08;StreamOperatorStateHandler&#xff09;中從 StreamOperatorStateContext 獲取并賦值給 StateInitializationContext&#xff08;該 context 就是 initi…

rk3128 emmc顯示剩余容量為0

機器emmc 容量顯示異常&#xff0c;顯示剩余容量為0&#xff0c;這時候做了一個讓 系統不檢測GPP分區部分的操作&#xff0c;此問題才得以解決&#xff0c;如下&#xff1a; system/vold/DirectVolume.cpp -33,6 33,8 #include "VolumeManager.h"#include "Re…

WebAssembly國際化多語種支持

icu linux數據裁剪 先linux編譯出所有的工具 mkdir build && cd build ../configure --prefix=$(pwd)/build_wasm/install --enable-static --disable-shared --with-data-packaging=static --enable-tools=yes --enable-extras=yes --e…

Ubuntu 安裝 etcd 與 etcd-cpp-apiv3

目錄 安裝 etcd 安裝 etcd-cpp-apiv3 安裝 etcd sudo apt update sudo apt install etcd-server sudo apt install -y etcd-client 在 /etc/default/etcd 配置文件中配置&#xff0c;下面示例是單個服務器內進程之間交換信息且只有一個etcd節點。 #節點名稱&#xff0c;默認為…