上一篇我們詳解了 OkHttp 的眾多配置,本篇來看 OkHttp 是如何通過責任鏈上的內置攔截器完成 HTTP 請求與響應的,目的是更好地深入理解 HTTP 協議。這仍然是一篇偏向于協議實現向的文章,重點在于 HTTP 協議的實現方法與細節,關于責任鏈模式這些設計模式相關的內容,由于與 HTTP 協議關聯不大,因此只是有所提及但不會著重講解。
1、責任鏈與內置攔截器
不論是同步還是異步執行網絡請求,任務的執行最終都會落到 RealCall 的 getResponseWithInterceptorChain() 中:
@Throws(IOException::class)internal fun getResponseWithInterceptorChain(): Response {// 1.構建完整的攔截器堆棧,按添加順序形成鏈式處理流程val interceptors = mutableListOf<Interceptor>()// 1.1 先添加 OkHttpClient 配置的 interceptorsinterceptors += client.interceptors// 1.2 再添加 OkHttp 內置的攔截器interceptors += RetryAndFollowUpInterceptor(client)interceptors += BridgeInterceptor(client.cookieJar)interceptors += CacheInterceptor(client.cache)interceptors += ConnectInterceptor// forWebSocket 用于區分是普通 HTTP 請求還是 WebSocket 握手請求。如果是普通// HTTP 請求,這里要插入 OkHttpClient 配置的網絡攔截器列表 networkInterceptorsif (!forWebSocket) {interceptors += client.networkInterceptors}interceptors += CallServerInterceptor(forWebSocket)// 2.創建責任鏈的初始對象val chain = RealInterceptorChain(// RealCall call = this,// 攔截器列表interceptors = interceptors,// 索引,表示當前處理的是 interceptors 中的哪一個攔截器,責任鏈的開始傳 0index = 0,exchange = null,// 請求,責任鏈的開始傳原始請求request = originalRequest,connectTimeoutMillis = client.connectTimeoutMillis,readTimeoutMillis = client.readTimeoutMillis,writeTimeoutMillis = client.writeTimeoutMillis)// 3.開啟責任鏈的執行var calledNoMoreExchanges = falsetry {// proceed() 觸發責任鏈val response = chain.proceed(originalRequest)if (isCanceled()) {response.closeQuietly()throw IOException("Canceled")}return response} catch (e: IOException) {calledNoMoreExchanges = truethrow noMoreExchanges(e) as Throwable} finally {if (!calledNoMoreExchanges) {noMoreExchanges(null)}}}
由于各個攔截器我們馬上就要逐個詳解,因此這里就簡單聊聊責任鏈的構成與執行。
被實例化的責任鏈對象 RealInterceptorChain 實現了 Interceptor.Chain 接口,該接口最重要的方法就是用于執行責任鏈的 proceed():
class RealInterceptorChain(// 實際的網絡請求對象internal val call: RealCall,// 攔截器列表private val interceptors: List<Interceptor>,// 索引,表示當前處理的是 interceptors 中的哪一個攔截器,責任鏈的開始傳 0private val index: Int,// 負責傳輸單個 HTTP 請求與響應對。在 [ExchangeCodec](處理實際 I/O 操作)的基礎上,// 實現了連接管理和事件通知的分層邏輯。internal val exchange: Exchange?,// 請求internal val request: Request,// 連接超時時間internal val connectTimeoutMillis: Int,// 讀超時internal val readTimeoutMillis: Int,// 寫超時internal val writeTimeoutMillis: Int
) : Interceptor.Chain {@Throws(IOException::class)override fun proceed(request: Request): Response {// 生成下一個責任鏈節點對象,參數 index 告知下一個責任鏈節點應該取出// interceptors[index + 1] 這個攔截器進行處理val next = copy(index = index + 1, request = request)// 取構造函傳入的 index 對應的攔截器val interceptor = interceptors[index]// 執行攔截器的處理邏輯并得到響應結果 responseval response = interceptor.intercept(next) ?: throw NullPointerException("interceptor $interceptor returned null")return response}
}
精簡后的 proceed() 代碼就是兩點:
- 生成責任鏈的下一個節點,在攔截器執行攔截邏輯的 intercept() 時作為參數傳入
- 執行攔截器的攔截邏輯 intercept() 得到響應結果
攔截器的攔截邏輯 intercept() 大致可以分為三部分:
override fun intercept(chain: Interceptor.Chain): Response {// 1.獲取 Request 并根據自身功能對 Request 做出修改...Request request = chain.request();...// 2.執行參數傳入的責任鏈上下一個責任鏈節點對象 chain 的 proceed(),交接接力棒到下一個節點val networkResponse = chain.proceed(requestBuilder.build())// 3.對第 2 步得到的響應 networkResponse 根據自身功能做出修改...return networkResponse}
由于每個攔截器的功能不同,因此 1、3 兩步是否存在要看具體的攔截器功能。比如重試與重定向攔截器 RetryAndFollowUpInterceptor 就沒有第 1 步修改 Request 的需求。但所有攔截器一定都有第 2 步接力棒交接的動作,如果沒有,責任鏈就斷了,后面的攔截器就無法工作。
通過 RealInterceptorChain 的構造函數能看到,雖然它保存了所有攔截器的列表 interceptors: List<Interceptor>
,但是在進行處理時,每個 RealInterceptorChain 都是根據 index 取出 interceptors 中的一個攔截器進行攔截操作的,而攔截器的攔截邏輯又會通過 proceed() 執行下一個責任鏈節點。因此可以將這種責任鏈模式看成如下結構:
因此,先添加到攔截器列表中的攔截器,就越先獲取并修改 Request,但越靠后拿到響應的 Response。
接下來我們會按照 getResponseWithInterceptorChain() 內向 interceptors 列表添加攔截器的順序逐一介紹這些攔截器。
2、重試與重定向攔截器
RetryAndFollowUpInterceptor 顧名思義就是要做重試與重定向的:
@Throws(IOException::class)override fun intercept(chain: Interceptor.Chain): Response {// 通過參數傳入的 chain 獲取 HTTP 請求 Request 請求以及請求任務 RealCallval realChain = chain as RealInterceptorChainvar request = chain.requestval call = realChain.call// 重定向次數var followUpCount = 0// 上一次請求的響應,用于在重定向返回結果時,把上一次請求的響應體放到本次// 也就是重定向的響應體 Response 中var priorResponse: Response? = null// 是否是新的 ExchangeFinder,當不是重試且允許新路由時為 truevar newExchangeFinder = true// 可以重試的錯誤列表var recoveredFailures = listOf<IOException>()// 在滿足重試或重定向的條件時會一直循環while (true) {// 1.準備工作call.enterNetworkInterceptorExchange(request, newExchangeFinder)var response: Responsevar closeActiveExchange = truetry {...try {// 2.中置工作:責任鏈交接棒response = realChain.proceed(request)newExchangeFinder = true} catch (e: RouteException) { // 3.請求出錯了,進行重試判斷// 3.1 路由異常,連接失敗,請求還沒有發出去,用 recover() 判斷是否滿足重試條件if (!recover(e.lastConnectException, call, request, requestSendStarted = false)) {throw e.firstConnectException.withSuppressed(recoveredFailures)} else {// if 未命中說明滿足重試條件,那么就把這個異常放到 recoveredFailures 里面recoveredFailures += e.firstConnectException}// 由于滿足重試條件要進行重試了,因此要把 newExchangeFinder 置為 false 了newExchangeFinder = false// 由于本次循環失敗但滿足重試條件,因此跳出本次 while 循環,重試進入下一次循環continue} catch (e: IOException) {// 3.2 請求可能已經發出,但與服務器通信失敗,還是用 recover() 判斷能否重試if (!recover(e, call, request, requestSendStarted = e !is ConnectionShutdownException)) {throw e.withSuppressed(recoveredFailures)} else {recoveredFailures += e}newExchangeFinder = falsecontinue}// 4.代碼能執行到這里說明前面沒出錯,不用進行重試,那么就開始做后置工作,// 根據返回的 Response 響應判斷是否需要做重定向工作了// 4.1 如果之前做過重定向,那么 priorResponse 就不為空,將其放到本次響應體中if (priorResponse != null) {response = response.newBuilder().priorResponse(priorResponse.newBuilder().body(null).build()).build()}// 4.2 檢查是否需要進行重定向,如果 followUp 為空則不需要val exchange = call.interceptorScopedExchangeval followUp = followUpRequest(response, exchange)if (followUp == null) {if (exchange != null && exchange.isDuplex) {call.timeoutEarlyExit()}closeActiveExchange = falsereturn response}// 4.3 需要重定向的情況val followUpBody = followUp.body// 如果請求的 Request 有請求體,并且請求體中配置了只允許傳輸一次,那就不做重定向直接返回if (followUpBody != null && followUpBody.isOneShot()) {closeActiveExchange = falsereturn response}response.body?.closeQuietly()// 重定向次數超過 MAX_FOLLOW_UPS(默認 20),也不做重定向if (++followUpCount > MAX_FOLLOW_UPS) {throw ProtocolException("Too many follow-up requests: $followUpCount")}// 將重定向請求賦值給 request 作為下一次循環的請求,從而實現重定向請求// response 賦值給 priorResponse 作為下一次請求的前置請求request = followUppriorResponse = response} finally {// 5、與 1 的準備工作相對應,現在本次請求結束了,要做收尾工作call.exitNetworkInterceptorExchange(closeActiveExchange)}}}
RetryAndFollowUpInterceptor 的 intercept() 如果細致劃分的話,可以像注釋中標記的那樣分成 5 步。
第 1 步準備工作與第 5 步收尾工作可以放在一起看,調用的都是 RealCall 的方法:
/*** 為可能遍歷所有網絡攔截器的流程做準備。此操作將嘗試找到一個 Exchange 來承載請求。* 如果請求已被緩存滿足,則不需要 Exchange。** @param newExchangeFinder 如果這不是一次重試且允許執行新路由,則為 true。*/fun enterNetworkInterceptorExchange(request: Request, newExchangeFinder: Boolean) {// 確保當前沒有已綁定的 Exchangecheck(interceptorScopedExchange == null)// 如果上一個請求體與響應體未關閉則拋異常,確保前一次請求的資源已釋放,防止資源泄漏synchronized(this) {check(!responseBodyOpen) {"cannot make a new request because the previous response is still open: " +"please call response.close()"}check(!requestBodyOpen)}// 創建新的路由查找器(如非重試場景)if (newExchangeFinder) {this.exchangeFinder = ExchangeFinder(connectionPool,createAddress(request.url),this,eventListener)}}/*** @param closeExchange 是否應關閉當前 Exchange(通常因異常或重試導致不再使用)*/internal fun exitNetworkInterceptorExchange(closeExchange: Boolean) {// 線程安全校驗:確保當前調用未被釋放synchronized(this) {check(expectMoreExchanges) { "released" }}// 強制關閉當前 Exchange(如發生異常需要重試)if (closeExchange) {exchange?.detachWithViolence()}// 清理當前攔截器作用域下的 ExchangeinterceptorScopedExchange = null}
第 2 步執行下一個責任鏈,前面已經分析過。比較重要的是第 3 步重試與第 4 步重定向,我們詳細介紹一下。
2.1 重試
從注釋的 3.1 與 3.2 兩步可以看出,允許進行重試判斷的情況有兩種:
- 拋出 RouteException,路由異常,連接失敗,請求未能發出
- 拋出 IOException,請求可能已經發出,但與服務器通信失敗
只有在這兩種情況下才能使用 recover() 做進一步的細分判斷是否能重試:
/*** 報告并嘗試從與服務器通信的失敗中恢復。若異常 `e` 可恢復則返回 true,否則返回 false。* 注意:帶請求體的請求僅在以下情況可恢復:* 1. 請求體已被緩沖;* 2. 失敗發生在請求發送前。*/private fun recover(e: IOException,call: RealCall,userRequest: Request,requestSendStarted: Boolean): Boolean {// 情況 1:應用層禁止重試(通過 OkHttpClient 配置)if (!client.retryOnConnectionFailure) return false// 情況 2:請求已發送且為一次性請求體(無法重試發送 Body)if (requestSendStarted && requestIsOneShot(e, userRequest)) return false// 情況 3:異常不可恢復(如 SSL 證書錯誤)if (!isRecoverable(e, requestSendStarted)) return false// 情況 4:無更多可用路由(如所有 IP 嘗試失敗)if (!call.retryAfterFailure()) return false// 所有條件通過,允許恢復并重試return true}
下面看每種情況的具體內容。
客戶端是否允許重試
取決于 OkHttpClient 的 retryOnConnectionFailure 屬性,默認值為 true,可以通過 Builder 模式配置該屬性決定是否允許由該 OkHttpClient 發送的所有 Request 進行重試:
@get:JvmName("retryOnConnectionFailure") val retryOnConnectionFailure: Boolean =builder.retryOnConnectionFailure
單個 Request 是否允許重試
對于已經發送的一次性請求體是無法重試的,它取決于 requestSendStarted 與 requestIsOneShot() 兩部分。
對于 requestSendStarted,我們看注釋 3.1 與 3.2 傳的是不一樣的:
- 當拋出 RouteException 時,這時由于路由問題并沒有連接到服務器,可以斷定請求一定沒有被發送,所以直接給 requestSendStarted 傳了 false
- 當拋出 IOException 時,只有在這個異常的具體類型是 ConnectionShutdownException 時才認為由于連接中斷沒有發出請求,其他的情況都認為請求已發出。由于只有 HTTP2 才會拋出這個異常,所以對于 HTTP1 而言,requestSendStarted 一定是 true
再看 requestIsOneShot():
private fun requestIsOneShot(e: IOException, userRequest: Request): Boolean {val requestBody = userRequest.bodyreturn (requestBody != null && requestBody.isOneShot()) ||e is FileNotFoundException}
兩個判斷條件:
- 如果有請求體,且是一次性請求體
- 發生 FileNotFoundException,即請求體依賴文件,但該文件不存在
第一個條件,RequestBody 的 isOneShot() 是一個默認值為 false 的 open 方法,也就是說默認情況下,請求不是一次性的。但如果需要配置為 OneShot 的話,可以通過重寫該函數實現。
第二個條件,假設一個 HTTP 請求的 Body 是基于本地文件的(比如上傳文件):
val file = File("image.jpg")
val request = Request.Builder().url("https://api.example.com/upload").post(file.asRequestBody("image/jpeg".toMediaType())).build()
如果因為文件不存在導致 FileNotFoundException,重試也還是相同的結果,因此這種情況也不能重試。
是否為可重試的異常類型
某些異常發生時,是不允許重試的,因為這些異常是真的因為客戶端或服務端的代碼有問題,即便重試無數次得到的也都是錯誤結果;而有些異常可能是由于網絡波動導致異常發生,換個路線重試可能就會解決問題。因此需要 isRecoverable() 判斷,哪些異常可以重試,哪些不可以:
private fun isRecoverable(e: IOException, requestSendStarted: Boolean): Boolean {// 1.協議異常,不重試if (e is ProtocolException) {return false}// 2.一般的 IO 中斷異常不會重試,但是如果是 Socket 超時異常,有可能是網絡波動造成的,// 可以嘗試其它路線(如果有)if (e is InterruptedIOException) {return e is SocketTimeoutException && !requestSendStarted}// 3.SSL 握手異常,如果是證書出現問題,不能重試if (e is SSLHandshakeException) {// 由 X509TrustManager 拋出的 CertificateException,不重試if (e.cause is CertificateException) {return false}}// 4.SSL 未授權異常(證書校驗失敗,不匹配或過期等問題)不重試 if (e is SSLPeerUnverifiedException) {// 這里框架作者舉的例子是 CertificatePinning 的錯誤,我們在上一篇詳細講過// e.g. a certificate pinning error.return false}return true}
四個判斷條件中,后兩條是證書驗證相關的,這部分在上一篇講 OkHttpClient 的配置時有講過,比如 CertificatePinning 的使用、X509 證書驗證等等,所以不再贅述。
第 2 條也比較好理解,如果是網絡波動造成 Socket 連接超時,重試時可能會躲過網絡波動,這時允許重試是合理的。
需要稍作解釋的是第 1 條 —— 協議異常。
ProtocolException 是一種 IOException,比如 OkHttp 的 CallServerInterceptor 在其處理責任鏈的 intercept() 內就拋出了這種異常:
@Throws(IOException::class)override fun intercept(chain: Interceptor.Chain): Response {try {...var code = response.code...if ((code == 204 || code == 205) && response.body?.contentLength() ?: -1L > 0L) {throw ProtocolException("HTTP $code had non-zero Content-Length: ${response.body?.contentLength()}")}return response}}
看條件,是狀態碼為 204 或 205 且響應體內容長度大于 0 時會拋出 ProtocolException。兩個狀態碼的含義分別為:
- 204:No Content,表示無內容。服務器成功處理,但未返回內容。在未更新網頁的情況下,可確保瀏覽器繼續顯示當前文檔
- 205:Reset Content,表示重置內容。服務器處理成功,用戶終端(例如:瀏覽器)應重置文檔視圖。可通過此返回碼清除瀏覽器的表單域
也就是說,狀態碼告訴我們服務器沒有返回內容,即沒有響應體,但是我們通過代碼拿到響應體的內容長度大于 0,這兩個條件前后矛盾,有可能是服務器響應本身就存在問題,就算重試也還是得到一個錯誤結果,于是拋出 ProtocolException 不進行重試。
是否有其他可用路由
我們在配置 OkHttpClient 的時候可能會為客戶端配置多個代理,而服務器端的同一個域名也可能會有多個 IP 地址,當某條路線通信失敗后,如果存在更多路線,就會換個路線嘗試。比如說,restapi.amap.com 解析為 IP1 和 IP2,如果 IP1 通信失敗會重試 IP2。
最后我們用一張圖來總結一下重試的流程:
2.2 重定向
在進行重定向判斷時,框架是將響應 Response 與 Exchange 對象傳入 followUpRequest() 得到了一個新的 Request 請求 followUp。在 followUp 不為空且其 body 不為空、followUp 不是一次性 Request 以及重定向次數未超過閾值的情況下,才會進行重定向。那么 followUpRequest() 就是一個很關鍵的方法:
/*** 根據接收到的 [userResponse] 確定要進行的 HTTP 請求。這將添加身份驗證頭、跟隨重定向或處理* 客戶端請求超時。如果后續請求不必要或不適用,則返回 null。*/@Throws(IOException::class)private fun followUpRequest(userResponse: Response, exchange: Exchange?): Request? {// 獲取服務器響應的狀態碼val route = exchange?.connection?.route()val responseCode = userResponse.codeval method = userResponse.request.methodwhen (responseCode) {// 407 代理認證:客戶端使用了 HTTP 代理,需要在請求頭中添加【Proxy-Authorization】,// 讓代理服務器授權HTTP_PROXY_AUTH -> {val selectedProxy = route!!.proxyif (selectedProxy.type() != Proxy.Type.HTTP) {throw ProtocolException("Received HTTP_PROXY_AUTH (407) code while not using proxy")}// 調用 OkHttpClient 代理的鑒權接口return client.proxyAuthenticator.authenticate(route, userResponse)}// 401 未授權:有些服務器接口需要驗證使用者身份,要在請求頭中添加【Authorization】HTTP_UNAUTHORIZED -> return client.authenticator.authenticate(route, userResponse)// HTTP_PERM_REDIRECT:308 永久重定向、HTTP_TEMP_REDIRECT:307 臨時重定向、// HTTP_MULT_CHOICE:300 多種選擇、HTTP_MOVED_PERM:301 永久移動、// HTTP_MOVED_TEMP:302 臨時移動、HTTP_SEE_OTHER:303:查看其它地址,這些情況構建重定向請求HTTP_PERM_REDIRECT, HTTP_TEMP_REDIRECT, HTTP_MULT_CHOICE, HTTP_MOVED_PERM, HTTP_MOVED_TEMP, HTTP_SEE_OTHER -> {return buildRedirectRequest(userResponse, method)}// 408:服務器等待客戶端發送的請求時間過長,超時。實際中用的很少,像 HAProxy 這種服務器// 會用這個狀態碼,不需要修改請求,只需再申請一次即可。HTTP_CLIENT_TIMEOUT -> {// 假如應用層(指 OkHttpClient)配置了不允許重試就返回 nullif (!client.retryOnConnectionFailure) {return null}// 如果是一次性 RequestBody,不重試val requestBody = userResponse.request.bodyif (requestBody != null && requestBody.isOneShot()) {return null}// 如果上一次響應的狀態碼就是 408,不重試val priorResponse = userResponse.priorResponseif (priorResponse != null && priorResponse.code == HTTP_CLIENT_TIMEOUT) {// We attempted to retry and got another timeout. Give up.return null}// 如果服務器告訴我們需要在多久后重試,那框架就不管了if (retryAfter(userResponse, 0) > 0) {return null}// 返回原來的請求,沒有修改,也就是重試了return userResponse.request}// 503:服務不可用。由于超載或系統維護,服務器暫時的無法處理客戶端的請求。// 延時的長度可包含在服務器的 Retry-After 頭信息中HTTP_UNAVAILABLE -> {val priorResponse = userResponse.priorResponse// 如果上一次響應的狀態碼也是 503 則不重試if (priorResponse != null && priorResponse.code == HTTP_UNAVAILABLE) {return null}// 如果服務器返回的 Retry-After 是 0,也就是立即重試的意思,框架才重新請求if (retryAfter(userResponse, Integer.MAX_VALUE) == 0) {return userResponse.request}return null}// 421:錯誤連接。HTTP/2 允許不同域名的請求共享連接(看 RealConnection.isEligible()),// 但若服務器返回 421,需斷開合并連接。HTTP_MISDIRECTED_REQUEST -> {val requestBody = userResponse.request.body// 一次性請求體不重試if (requestBody != null && requestBody.isOneShot()) {return null}// 無關聯的 Exchange 對象(表示無實際網絡交互)或者當前連接未啟用合并機制,不重試if (exchange == null || !exchange.isCoalescedConnection) {return null}// 標記連接禁止合并,禁止后續請求復用該連接進行合并(將 noCoalescedConnections 置位)exchange.connection.noCoalescedConnections()// 返回原請求重試return userResponse.request}else -> return null}}
可以看出,followUpRequest() 會根據不同的響應狀態碼做出判斷是否進行重定向,如需要進行重定向就返回 Request 否則返回 null。
下面我們按照狀態碼分類介紹上述狀態碼的處理過程。
3xx
followUpRequest() 將所有 3xx 狀態碼都放在一個 case 中處理了,就是用 buildRedirectRequest() 創建一個重定向請求:
private fun buildRedirectRequest(userResponse: Response, method: String): Request? {// 如果 OkHttpClient 配置了不允許重定向則返回 nullif (!client.followRedirects) return null// 檢查響應頭中是否有 Location 字段,該字段的值能否解析成 URL,// 可以則解析并保存 URL,否則返回 null 不進行重定向val location = userResponse.header("Location") ?: return nullval url = userResponse.request.url.resolve(location) ?: return null// 檢查重需要定向的 URL 與此前請求的 URL 的 scheme 是否相同。scheme 在 OkHttp 這個// 框架中就只有 http 或 https 兩個值。假如 OkHttpClient 配置 followSslRedirects 為// false,那就不允許在 SSL(HTTPS)與非 SSL(HTTP)協議之間重定向val sameScheme = url.scheme == userResponse.request.url.schemeif (!sameScheme && !client.followSslRedirects) return null// 大多數重定向請求都沒有請求體val requestBuilder = userResponse.request.newBuilder()// 不是 GET 或 HEAD 方法的話,是允許有請求體的if (HttpMethod.permitsRequestBody(method)) {val responseCode = userResponse.code// 如果請求方法是 PROPFIND 或者響應狀態碼為 308、307,需要保持請求體val maintainBody = HttpMethod.redirectsWithBody(method) ||responseCode == HTTP_PERM_REDIRECT ||responseCode == HTTP_TEMP_REDIRECT// 如果請求方法不是 PROPFIND 且響應狀態碼不是 308、307,重定向方法要使用 GETif (HttpMethod.redirectsToGet(method) && responseCode != HTTP_PERM_REDIRECT && responseCode != HTTP_TEMP_REDIRECT) {requestBuilder.method("GET", null)} else {// 如果請求方法是 PROPFIND 或者響應狀態碼是 307、308 之一,則使用原請求方法與請求體val requestBody = if (maintainBody) userResponse.request.body else nullrequestBuilder.method(method, requestBody)}// 如果不需要保持請求體就移除以下請求頭if (!maintainBody) {requestBuilder.removeHeader("Transfer-Encoding")requestBuilder.removeHeader("Content-Length")requestBuilder.removeHeader("Content-Type")}}// 當重定向到不同主機時,移除 Authorization 頭if (!userResponse.request.url.canReuseConnectionFor(url)) {requestBuilder.removeHeader("Authorization")}return requestBuilder.url(url).build()}
buildRedirectRequest() 可以大致分為兩部分:
- 檢查是否滿足重定向條件,不滿足則返回 null,涉及到的不滿足情況包括:
- OkHttpClient 的重定向配置 followRedirects 設置為 false
- 返回的響應體是 3xx 要求重定向,但響應頭中沒有 Location 或者 Location 的值無法解析為 URL
- 原請求的 URL 與原請求對應的響應頭中 Location 指定的 URL 發生了 SSL 協議切換(對于 OkHttp 框架而言,就是一個 URL 是 HTTP,另一個 URL 是 HTTPS),但 OkHttpClient 又關閉了跨 SSL 重定向的配置 followSslRedirects
- 滿足重定向條件,就構造重定向的請求頭與請求體:
- 對于沒有請求體的 GET 與 HEAD 方法而言,只需要額外再判斷一個跨主機重定向時去掉 Authorization 請求頭即可
- 對于其他可以有請求體的方法,先判斷是否保持原請求的請求體 maintainBody,對于明確需要保持的方法 PROPFIND(目前 redirectsWithBody() 只有傳入 PROPFIND 才返回 true)或者狀態碼為 307、308 時,maintainBody 為 true
- 隨后決定重定向請求使用哪個方法以及請求體。如果原請求方法不是 PROPFIND(目前 redirectsToGet() 除了 PROPFIND 以外其余都是 true),且狀態碼不是 307 與 308,那么重定向請求的方法就是 GET 且沒有方法體;否則重定向方法就是 PROPFIND 且請求體為原請求的請求體
為什么 307、308 以及 PROPFIND 需要做出特殊的判斷與處理,強制保留原請求的方法和請求體?
其中 307、308 是 HTTP 重定向規范(RFC 7231 (HTTP/1.1) 和 RFC 7538 (HTTP/308))中規定的,要求客戶端在重定向時保持原始請求方法和請求體。
至于 PROPFIND 是 WebDAV 協議的擴展方法,行為類似于標準 HTTP 的 GET,但需要攜帶 XML 請求體以描述查詢條件。這個方法本身就需要保持請求體,因為服務器可能依賴請求體中的 XML 內容處理查詢。
因此關于重定向的處理思路可以總結為如下表格:
條件 | 操作 | 示例場景 |
---|---|---|
狀態碼為307/308 | 強制保留方法和請求體 | 服務器要求保持原始請求 |
方法為PROPFIND/POST/PUT | 保留請求體(需配合307/308) | WebDAV查詢或表單提交 |
其他狀態碼(301/302) | 改為GET并移除請求體 | 傳統重定向邏輯 |
4xx
我們先說兩個比較相近的:HTTP_UNAUTHORIZED(401 未授權)與 HTTP_PROXY_AUTH(407 代理認證)。
實際上這兩個狀態碼我們在上一篇講 OkHttp 配置時已經講過:當你要訪問的服務器資源需要授權才能訪問,而你在請求中又沒有攜帶授權信息相關的請求頭時,服務器會返回 401 狀態碼;407 是類似的,當你使用代理服務器又沒有提供鑒權請求頭時服務器就會返回 407。
清楚原因后,對于兩種狀態碼的處理方案也就呼之欲出了,就是在請求時帶上授權信息唄。按照 OkHttp 框架給出的,對于 401 調用 OkHttpClient 的 authenticator 的 authenticate() 進行授權,對于 407 則調用 OkHttpClient 的 proxyAuthenticator 的 authenticate() 授權。但 authenticator 與 proxyAuthenticator 的默認值都是 Authenticator 接口的默認實現 Authenticator.NONE:
fun interface Authenticator {@Throws(IOException::class)fun authenticate(route: Route?, response: Response): Request?companion object {/** An authenticator that knows no credentials and makes no attempt to authenticate. */@JvmFieldval NONE: Authenticator = AuthenticatorNone()private class AuthenticatorNone : Authenticator {override fun authenticate(route: Route?, response: Response): Request? = null}}
}
因此通過這種方式需要自己實現 Authenticator 接口:
val client = OkHttpClient.Builder().authenticator(object : Authenticator {override fun authenticate(route: Route?, response: Response): Request? {// 添加 Authorization 與 Proxy-Authorization 請求頭return response.request.newBuilder().header("Authorization", "Bearer xxxxx....").header("Proxy-Authorization", "xxxxx....").build()}})
然后說一下 HTTP_CLIENT_TIMEOUT(408),是服務器等待客戶端發送超時。這個實際上跟重定向沒啥關系,屬于超時重試的邏輯,在刨除不滿足重試的條件后,無需修改請求進行重試即可。我們來看看它這種情況下用到的 retryAfter():
private fun retryAfter(userResponse: Response, defaultDelay: Int): Int {val header = userResponse.header("Retry-After") ?: return defaultDelayif (header.matches("\\d+".toRegex())) {return Integer.valueOf(header)}return Integer.MAX_VALUE}
根據響應頭中的 Retry-After 字段,返回當前距離下一次重試需要的時間。當前會忽略 HTTP 的日期,并且假設任何 int 型的非 0 值都是延遲。
實際上就是返回一個延遲的值,表示過多長時間之后再重試。
5xx
在 followUpRequest() 中只有 HTTP_UNAVAILABLE(503 服務不可用)一個 5 開頭的狀態碼,它是指由于超載或系統維護,服務器暫時的無法處理客戶端的請求。需要在上一次響應不是 HTTP_UNAVAILABLE 的情況下通過 retryAfter() 判斷是否可以立即進行重試,如果服務器返回的 Retry-After 響應頭的值不為 0,那么就放棄重試。