最近打算做網絡相關的優化工作,不免需要重新熟悉一下網絡框架,在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之間,此時網絡鏈路已經准備好,只等待發送請求數據。
-
首先,應用攔截器在RetryAndFollowUpInterceptor和CacheInterceptor之前,所以一旦發生錯誤重試或者網絡重定向,網絡攔截器可能執行多次,因為相當於進行了二次請求,但是應用攔截器永遠只會觸發一次。另外如果在CacheInterceptor中命中了緩存就不需要走網絡請求了,因此會存在短路網絡攔截器的情況。
-
其次,如上文提到除了CallServerInterceptor,每個攔截器都應該至少調用一次realChain.proceed方法。實際上在應用攔截器這層可以多次調用proceed方法(本地異常重試)或者不調用proceed方法(中斷),但是網絡攔截器這層連接已經准備好,可且僅可調用一次proceed方法。
-
最后,從使用場景看,應用攔截器因為只會調用一次,通常用於統計客戶端的網絡請求發起情況;而網絡攔截器一次調用代表了一定會發起一次網絡通信,因此通常可用於統計網絡鏈路上傳輸的數據。
網絡緩存機制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,則取出本地的緩存數據作為響應。
使用這套方案有一個問題,那就是資源文件使用最后修改時間有一定的局限性:
- Last-Modify單位為秒,如果某些文件在一秒內被修改則並不能准確的標識修改時間。
- 資源修改時間並不能作為資源是否修改的唯一依據,比如資源文件是Daily Build的,每天都會生成新的,但是其實際內容可能並未改變。
因此,HTTP 還提供了另外一組頭信息來處理緩存,ETag/If-None-Match。流程與Last-Modify一樣,只是把服務端響應的頭變成Last-Modify,客戶端發出的頭變成If-None-Match。ETag是資源的唯一標識符 ,服務端資源變化一定會導致ETag變化。具體的生成方式有服務端控制,場景的影響因素包括,文件最終修改時間、文件大小、文件編號等等。
OKHttp的緩存實現
上面講了這么多,實際上OKHttp就是將上述流程用代碼實現了一下,即:
- 第一次拿到響應后根據頭信息決定是否緩存。
- 下次請求時判斷是否存在本地緩存,是否需要使用對比緩存、封裝請求頭信息等等。
- 如果緩存失效或者需要對比緩存則發出網絡請求,否則使用本地緩存。
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