Android面試:大廠必問之OkHttp相關問題全解析

augfun發表於2020-11-17

先來一發靈魂拷問四連擊:

  • addInterceptor與addNetworkInterceptor有什麼區別?
  • 網路快取如何實現的?
  • 網路連線怎麼實現複用?
  • OkHttp如何做網路監控?

是不是既熟悉又陌生,實際上就是因為網路框架已經為我們實現了這些基本功能,所以很容易被我們忽略。為了完整的分析上面的問題,我們需要先複習一下OkHttp的基礎原理:

OkHttp基本實現原理

OkHttp的內部實現通過一個責任鏈模式完成,將網路請求的各個階段封裝到各個鏈條中,實現了各層的解耦。

文內原始碼基於OkHttp最新版本4.2.2,從4.0.0版本開始,OkHttp使用全Kotlin語言開發,沒上車的小夥伴要抓緊了,要不原始碼都快看不懂了 [捂臉],學習Kotlin可參考舊文 Kotlin學習系列文章Overview

我們從發起一次請求的呼叫開始,熟悉一下OkHttp執行的流程。

//建立OkHttpClient
val client = OkHttpClient.Builder().build();

//建立請求
val request = Request.Builder()
           .url("https://wanandroid.com/wxarticle/list/408/1/json")
           .build()

//同步任務開啟新執行緒執行
Thread {
    //發起網路請求
    val response = client.newCall(request).execute()
    if (!response.isSuccessful) throw IOException("Unexpected code $response")
    Log.d("okhttp_test", "response:  ${response.body?.string()}")
}.start()

所以核心的程式碼邏輯是通過OkHttpClient的newCall方法建立了一個Call物件,並呼叫其execute方法;Call代表一個網路請求的介面,實現類只有一個RealCall。execute表示同步發起網路請求,與之對應還有一個enqueue方法,表示發起一個非同步請求,因此同時需要傳入callback。

我們來看RealCall的execute方法:

# RealCall
override fun execute(): Response {
    ...
    //開始計時超時、發請求開始回撥
    transmitter.timeoutEnter()
    transmitter.callStart()
    try {
      client.dispatcher.executed(this)//第1步
      return getResponseWithInterceptorChain()//第2步
    } finally {
      client.dispatcher.finished(this)//第3步
    }
}

把大象裝冰箱,統共也只需要三步。

第一步

呼叫Dispatcher的execute方法,那Dispatcher是什麼呢?從名字來看它是一個排程器,排程什麼呢?就是所有網路請求,也就是RealCall物件。網路請求支援同步執行和非同步執行,非同步執行就需要執行緒池、併發閾值這些東西,如果超過閾值需要將超過的部分儲存起來,這樣一分析Dispatcher的功能就可以總結如下:

  • 記錄同步任務、非同步任務及等待執行的非同步任務。
  • 執行緒池管理非同步任務。
  • 發起/取消網路請求API:execute、enqueue、cancel。

OkHttp設定了預設的最大併發請求量 maxRequests = 64 和單個host支援的最大併發量 maxRequestsPerHost = 5。

同時用三個雙端佇列儲存這些請求:

# Dispatcher
//非同步任務等待佇列
private val readyAsyncCalls = ArrayDeque<AsyncCall>()
//非同步任務佇列
private val runningAsyncCalls = ArrayDeque<AsyncCall>()
//同步任務佇列
private val runningSyncCalls = ArrayDeque<RealCall>()

為什麼要使用雙端佇列?很簡單因為網路請求執行順序跟排隊一樣,講究先來後到,新來的請求放隊尾,執行請求從對頭部取。

說到這LinkedList表示不服,我們知道LinkedList同樣也實現了Deque介面,內部是用連結串列實現的雙端佇列,那為什麼不用LinkedList呢?

實際上這與readyAsyncCalls向runningAsyncCalls轉換有關,當執行完一個請求或呼叫enqueue方法入隊新的請求時,會對readyAsyncCalls進行一次遍歷,將那些符合條件的等待請求轉移到runningAsyncCalls佇列中並交給執行緒池執行。儘管二者都能完成這項任務,但是由於連結串列的資料結構致使元素離散的分佈在記憶體的各個位置,CPU快取無法帶來太多的便利,另外在垃圾回收時,使用陣列結構的效率要優於連結串列。

回到主題,上述的核心邏輯在promoteAndExecute方法中:

#Dispatcher
private fun promoteAndExecute(): Boolean {
    val executableCalls = mutableListOf<AsyncCall>()
    val isRunning: Boolean
    synchronized(this) {
      val i = readyAsyncCalls.iterator()
      //遍歷readyAsyncCalls
      while (i.hasNext()) {
        val asyncCall = i.next()
        //閾值校驗
        if (runningAsyncCalls.size >= this.maxRequests) break // Max capacity.
        if (asyncCall.callsPerHost().get() >= this.maxRequestsPerHost) continue // Host max capacity.
        //符合條件 從readyAsyncCalls列表中刪除
        i.remove()
        //per host 計數加1
        asyncCall.callsPerHost().incrementAndGet()
        executableCalls.add(asyncCall)
        //移入runningAsyncCalls列表
        runningAsyncCalls.add(asyncCall)
      }
      isRunning = runningCallsCount() > 0
    }

    for (i in 0 until executableCalls.size) {
      val asyncCall = executableCalls[i]
      //提交任務到執行緒池
      asyncCall.executeOn(executorService)
    }

    return isRunning
}

這個方法在enqueue和finish方法中都會呼叫,即當有新的請求入隊和當前請求完成後,需要重新提交一遍任務到執行緒池。

講了半天執行緒池,那OkHttp內部到底用的什麼執行緒池呢?

#Dispatcher 
@get:JvmName("executorService") val executorService: ExecutorService
get() {
  if (executorServiceOrNull == null) {
    executorServiceOrNull = ThreadPoolExecutor(0, Int.MAX_VALUE, 60, TimeUnit.SECONDS,
        SynchronousQueue(), threadFactory("OkHttp Dispatcher", false))
  }
  return executorServiceOrNull!!
}

這不是一個newCachedThreadPool嗎?沒錯,除了最後一個threadFactory引數之外與newCachedThreadPool一毛一樣,只不過是設定了執行緒名字而已,用於排查問題。

阻塞佇列用的SynchronousQueue,它的特點是不儲存資料,當新增一個元素時,必須等待一個消費執行緒取出它,否則一直阻塞,如果當前有空閒執行緒則直接在這個空閒執行緒執行,如果沒有則新啟動一個執行緒執行任務。通常用於需要快速響應任務的場景,在網路請求要求低延遲的大背景下比較合適,詳見舊文 Java執行緒池工作原理淺析

繼續回到主線,第二步比較複雜我們先跳過,來看第三步。

第三步

呼叫Dispatcher的finished方法

//非同步任務執行結束
internal fun finished(call: AsyncCall) {
    call.callsPerHost().decrementAndGet()
    finished(runningAsyncCalls, call)
}

//同步任務執行結束
internal fun finished(call: RealCall) {
    finished(runningSyncCalls, call)
}

//同步非同步任務 統一彙總到這裡
private fun <T> finished(calls: Deque<T>, call: T) {
    val idleCallback: Runnable?
    synchronized(this) {
      //將完成的任務從佇列中刪除
      if (!calls.remove(call)) throw AssertionError("Call wasn't in-flight!")
      idleCallback = this.idleCallback
    }
    //這個方法在第一步中已經分析,用於將等待佇列中的請求移入非同步佇列,並交由執行緒池執行。
    val isRunning = promoteAndExecute()

    //如果沒有請求需要執行,回撥閒置callback
    if (!isRunning && idleCallback != null) {
      idleCallback.run()
    }
}

第二步

現在我們回過頭來看最複雜的第二步,呼叫getResponseWithInterceptorChain方法,這也是整個OkHttp實現責任鏈模式的核心。

#RealCall
fun getResponseWithInterceptorChain(): Response {
    //建立攔截器陣列
    val interceptors = mutableListOf<Interceptor>()
    //新增應用攔截器
    interceptors += client.interceptors
    //新增重試和重定向攔截器
    interceptors += RetryAndFollowUpInterceptor(client)
    //新增橋接攔截器
    interceptors += BridgeInterceptor(client.cookieJar)
    //新增快取攔截器
    interceptors += CacheInterceptor(client.cache)
    //新增連線攔截器
    interceptors += ConnectInterceptor
    if (!forWebSocket) {
      //新增網路攔截器
      interceptors += client.networkInterceptors
    }
    //新增請求攔截器
    interceptors += CallServerInterceptor(forWebSocket)

    //建立責任鏈
    val chain = RealInterceptorChain(interceptors, transmitter, null, 0, originalRequest, this,
        client.connectTimeoutMillis, client.readTimeoutMillis, client.writeTimeoutMillis)
    ...
    try {
      //啟動責任鏈
      val response = chain.proceed(originalRequest)
      ...
      return response
    } catch (e: IOException) {
      ...
    }
  }

我們先不關心每個攔截器具體做了什麼,主流程最終走到chain.proceed(originalRequest)。我們看一下這個procceed方法:

  # RealInterceptorChain
  override fun proceed(request: Request): Response {
    return proceed(request, transmitter, exchange)
  }

  @Throws(IOException::class)
  fun proceed(request: Request, transmitter: Transmitter, exchange: Exchange?): Response {
    if (index >= interceptors.size) throw AssertionError()
    // 統計當前攔截器呼叫proceed方法的次數
    calls++

    // exchage是對請求流的封裝,在執行ConnectInterceptor前為空,連線和流已經建立但此時此連線不再支援當前url
    // 說明之前的網路攔截器對url或埠進行了修改,這是不允許的!!
    check(this.exchange == null || this.exchange.connection()!!.supportsUrl(request.url)) {
      "network interceptor ${interceptors[index - 1]} must retain the same host and port"
    }

    // 這裡是對攔截器呼叫proceed方法的限制,在ConnectInterceptor及其之後的攔截器最多隻能呼叫一次proceed!!
    check(this.exchange == null || calls <= 1) {
      "network interceptor ${interceptors[index - 1]} must call proceed() exactly once"
    }

    // 建立下一層責任鏈 注意index + 1
    val next = RealInterceptorChain(interceptors, transmitter, exchange,
        index + 1, request, call, connectTimeout, readTimeout, writeTimeout)

    //取出下標為index的攔截器,並呼叫其intercept方法,將新建的鏈傳入。
    val interceptor = interceptors[index]
    val response = interceptor.intercept(next) 

    // 保證在ConnectInterceptor及其之後的攔截器至少呼叫一次proceed!!
    check(exchange == null || index + 1 >= interceptors.size || next.calls == 1) {
      "network interceptor $interceptor must call proceed() exactly once"
    }

    return response
  }

程式碼中的註釋已經寫得比較清楚了,總結起來就是建立下一級責任鏈,然後取出當前攔截器,呼叫其intercept方法並傳入建立的責任鏈。++為保證責任鏈能依次進行下去,必須保證除最後一個攔截器(CallServerInterceptor)外,其他所有攔截器intercept方法內部必須呼叫一次chain.proceed()方法++,如此一來整個責任鏈就執行起來了。

比如ConnectInterceptor原始碼中:

# ConnectInterceptor 這裡使用單例
object ConnectInterceptor : Interceptor {

  @Throws(IOException::class)
  override fun intercept(chain: Interceptor.Chain): Response {
    val realChain = chain as RealInterceptorChain
    val request = realChain.request()
    val transmitter = realChain.transmitter()

    val doExtensiveHealthChecks = request.method != "GET"
    //建立連線和流
    val exchange = transmitter.newExchange(chain, doExtensiveHealthChecks)
    //執行下一級責任鏈
    return realChain.proceed(request, transmitter, exchange)
  }
}

除此之外在責任鏈不同節點對於proceed的呼叫次數有不同的限制,ConnectInterceptor攔截器及其之後的攔截器能且只能呼叫一次,因為網路握手、連線、傳送請求的工作發生在這些攔截器內,表示正式發出了一次網路請求;而在這之前的攔截器可以執行多次proceed,比如錯誤重試。

經過責任鏈一級一級的遞推下去,最終會執行到CallServerInterceptor的intercept方法,此方法會將網路響應的結果封裝成一個Response物件並return。之後沿著責任鏈一級一級的回溯,最終就回到getResponseWithInterceptorChain方法的返回。

攔截器分類

現在我們需要先大致總結一下責任鏈的各個節點攔截器的作用:

攔截器作用
應用攔截器拿到的是原始請求,可以新增一些自定義header、通用引數、引數加密、閘道器接入等等。
RetryAndFollowUpInterceptor處理錯誤重試和重定向
BridgeInterceptor應用層和網路層的橋接攔截器,主要工作是為請求新增cookie、新增固定的header,比如Host、Content-Length、Content-Type、User-Agent等等,然後儲存響應結果的cookie,如果響應使用gzip壓縮過,則還需要進行解壓。
CacheInterceptor快取攔截器,如果命中快取則不會發起網路請求。
ConnectInterceptor連線攔截器,內部會維護一個連線池,負責連線複用、建立連線(三次握手等等)、釋放連線以及建立連線上的socket流。
networkInterceptors(網路攔截器)使用者自定義攔截器,通常用於監控網路層的資料傳輸。
CallServerInterceptor請求攔截器,在前置準備工作完成後,真正發起了網路請求。

至此,OkHttp的核心執行流程就結束了,是不是有種豁然開朗的感覺?現在我們終於可以回答開篇的問題:

addInterceptor與addNetworkInterceptor的區別

二者通常的叫法為應用攔截器和網路攔截器,從整個責任鏈路來看,應用攔截器是最先執行的攔截器,也就是使用者自己設定request屬性後的原始請求,而網路攔截器位於ConnectInterceptor和CallServerInterceptor之間,此時網路鏈路已經準備好,只等待傳送請求資料。

  1. 首先,應用攔截器在RetryAndFollowUpInterceptor和CacheInterceptor之前,所以一旦發生錯誤重試或者網路重定向,網路攔截器可能執行多次,因為相當於進行了二次請求,但是應用攔截器永遠只會觸發一次。另外如果在CacheInterceptor中命中了快取就不需要走網路請求了,因此會存在短路網路攔截器的情況。

  2. 其次,如上文提到除了CallServerInterceptor,每個攔截器都應該至少呼叫一次realChain.proceed方法。實際上在應用攔截器這層可以多次呼叫proceed方法(本地異常重試)或者不呼叫proceed方法(中斷),但是網路攔截器這層連線已經準備好,可且僅可呼叫一次proceed方法。

  3. 最後,從使用場景看,應用攔截器因為只會呼叫一次,通常用於統計客戶端的網路請求發起情況;而網路攔截器一次呼叫代表了一定會發起一次網路通訊,因此通常可用於統計網路鏈路上傳輸的資料。

網路快取機制CacheInterceptor

這裡的快取是指基於Http網路協議的資料快取策略,側重點在客戶端快取,所以我們要先來複習一下Http協議如何根據請求和響應頭來標識快取的可用性。

提到快取,就必須要聊聊快取的有效性、有效期。

HTTP快取原理

在HTTP 1.0時代,響應使用Expires頭標識快取的有效期,其值是一個絕對時間,比如Expires:Thu,31 Dec 2020 23:59:59 GMT。當客戶端再次發出網路請求時可比較當前時間 和上次響應的expires時間進行比較,來決定是使用快取還是發起新的請求。

使用Expires頭最大的問題是它依賴客戶端的本地時間,如果使用者自己修改了本地時間,就會導致無法準確的判斷快取是否過期。

因此,從HTTP 1.1 開始使用Cache-Control頭表示快取狀態,它的優先順序高於Expires,常見的取值為下面的一個或多個。

  • private,預設值,標識那些私有的業務邏輯資料,比如根據使用者行為下發的推薦資料。該模式下網路鏈路中的代理伺服器等節點不應該快取這部分資料,因為沒有實際意義。
  • public 與private相反,public用於標識那些通用的業務資料,比如獲取新聞列表,所有人看到的都是同一份資料,因此客戶端、代理伺服器都可以快取。
  • no-cache 可進行快取,但在客戶端使用快取前必須要去服務端進行快取資源有效性的驗證,即下文的對比快取部分,我們稍後介紹。
  • max-age 表示快取時長單位為秒,指一個時間段,比如一年,通常用於不經常變化的靜態資源。
  • no-store 任何節點禁止使用快取。

強制快取

在上述快取頭規約基礎之上,強制快取是指網路請求響應header標識了Expires或Cache-Control帶了max-age資訊,而此時客戶端計算快取並未過期,則可以直接使用本地快取內容,而不用真正的發起一次網路請求。

協商快取

強制快取最大的問題是,一旦服務端資源有更新,直到快取時間截止前,客戶端無法獲取到最新的資源(除非請求時手動新增no-store頭),另外大部分情況下伺服器的資源無法直接確定快取失效時間,所以使用對比快取更靈活一些。

使用Last-Modify / If-Modify-Since頭實現協商快取,具體方法是服務端響應頭新增Last-Modify頭標識資源的最後修改時間,單位為秒,當客戶端再次發起請求時新增If-Modify-Since頭並賦值為上次請求拿到的Last-Modify頭的值。

服務端收到請求後自行判斷快取資源是否仍然有效,如果有效則返回狀態碼304同時body體為空,否則下發最新的資源資料。客戶端如果發現狀態碼是304,則取出本地的快取資料作為響應。

使用這套方案有一個問題,那就是資原始檔使用最後修改時間有一定的侷限性:

  1. Last-Modify單位為秒,如果某些檔案在一秒內被修改則並不能準確的標識修改時間。
  2. 資源修改時間並不能作為資源是否修改的唯一依據,比如資原始檔是Daily Build的,每天都會生成新的,但是其實際內容可能並未改變。

因此,HTTP 還提供了另外一組頭資訊來處理快取,ETag/If-None-Match。流程與Last-Modify一樣,只是把服務端響應的頭變成Last-Modify,客戶端發出的頭變成If-None-Match。ETag是資源的唯一識別符號 ,服務端資源變化一定會導致ETag變化。具體的生成方式有服務端控制,場景的影響因素包括,檔案最終修改時間、檔案大小、檔案編號等等。

OKHttp的快取實現

上面講了這麼多,實際上OKHttp就是將上述流程用程式碼實現了一下,即:

  1. 第一次拿到響應後根據頭資訊決定是否快取。
  2. 下次請求時判斷是否存在本地快取,是否需要使用對比快取、封裝請求頭資訊等等。
  3. 如果快取失效或者需要對比快取則發出網路請求,否則使用本地快取。

OKHttp內部使用Okio來實現快取檔案的讀寫。

快取檔案分為CleanFiles和DirtyFiles,CleanFiles用於讀,DirtyFiles用於寫,他們都是陣列,長度為2,表示兩個檔案,即快取的請求頭和請求體;同時記錄了快取的操作日誌,記錄在journalFile中。

開啟快取需要在OkHttpClient建立時設定一個Cache物件,並指定快取目錄和快取大小,快取系統內部使用LRU作為快取的淘汰演算法。

## Cache.kt
class Cache internal constructor(
  directory: File,
  maxSize: Long,
  fileSystem: FileSystem
): Closeable, Flushable

OkHttp早期的版本有個一個InternalCache介面,支援自定義實現快取,但到了4.x的版本後刪減了InternalCache,Cache類又為final的,相當於關閉了擴充套件功能。

具體原始碼實現都在CacheInterceptor類中,大家可以自行查閱。

通過OkHttpClient設定快取是全域性狀態的,如果我們想對某個特定的request使用或禁用快取,可以通過CacheControl相關的API實現:

//禁用快取
Request request = new Request.Builder()
    .cacheControl(new CacheControl.Builder().noCache().build())
    .url("http://publicobject.com/helloworld.txt")
    .build();

OKHttp不支援的快取情況

最後需要注意的一點是,OKHttp預設只支援get請求的快取。

# okhttp3.Cache.java
@Nullable CacheRequest put(Response response) {
    String requestMethod = response.request().method();
    ...
    //快取僅支援GET請求
    if (!requestMethod.equals("GET")) {
      // Don't cache non-GET responses. We're technically allowed to cache
      // HEAD requests and some POST requests, but the complexity of doing
      // so is high and the benefit is low.
      return null;
    }

    //對於vary頭的值為*的情況,統一不快取
    if (HttpHeaders.hasVaryAll(response)) {
      return null;
    }
    ...
}

這是當網路請求響應後,準備進行快取時的邏輯程式碼,當返回null時表示不快取。從程式碼註釋中不難看出,我們從技術上可以快取method為HEAD和部分POST請求,但實現起來的複雜性很高而收益甚微。這本質上是由各個method的使用場景決定的。

我們先來看看常見的method型別及其用途。

  • GET 請求資源,引數都在URL中。
  • HEAD 與GET基本一致,只不過其不返回訊息體,通常用於速度或頻寬優先的場景,比如檢查資源有效性,可訪問性等等。
  • POST 提交表單,修改資料,引數在body中。
  • PUT 與POST基本一致,最大不同是冪等操作。
  • DELETE 刪除指定資源。

可以看到對於標準的RETful請求,GET就是用來獲取資料,最適合使用快取,而對於資料的其他操作快取意義不大或者根本不需要快取。

也是基於此在僅支援GET請求的條件下,OKHTTP使用request URL作為快取的key(當然還會經過一系列摘要演算法)。

最後上面程式碼中貼到,如果請求頭中包含vary:*這樣的頭資訊也不會被快取。vary頭用於提高多端請求時的快取命中率,比如兩個客戶端,一個支援gzip壓縮而另一個不支援,二者的請求URL都是一致的,但Accept-Encoding不同,這很容易導致快取環錯亂,我們可以宣告vary:Accept-Encoding防止這種情況發生。

而包含vary:*頭資訊,標識著此請求是唯一的,不應被快取,除非有意為之,一般不會這樣做來犧牲快取效能。

作者:wanderingguy
連結:https://juejin.im/post/5e69a4bf6fb9a07cd74f6ab8



作者:位元組走動_Android
連結:https://www.jianshu.com/p/b864c6abf71e
來源:簡書
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

相關文章