面試還沒搞懂OkHttp原理?看完這篇文章全明白了


最近打算做網絡相關的優化工作,不免需要重新熟悉一下網絡框架,在Android領域網絡框架的龍頭老大非OkHttp莫屬,借此機會對OkHttp的一些內部實現進行深入的剖析,同時這些問題也是面試時的常客,相信一定對你有幫助。

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

  • 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

 

 
 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM