OkHttp原始碼分析(三)

語言: CN / TW / HK

theme: cyanosis


開啟掘金成長之旅!這是我參與「掘金日新計劃 · 12 月更文挑戰」的第37天,點選檢視活動詳情

文章中原始碼的OkHttp版本為4.10.0

implementation 'com.squareup.okhttp3:okhttp:4.10.0'

OkHttp原始碼分析(一)中主要分析了使用、如何建立,再到發起請求;

OkHttp原始碼分析(二)中主要分析了OkHttp的攔截器鏈;

這篇文章來分析OkHttp的快取策略。

1.OkHttp的快取策略

OkHttp的快取是基於HTTP網路協議的,所以這裡需要先來來了解一下HTTP的快取策略。HTTP的快取策略是根據請求和響應頭來標識快取是否可用,快取是否可用則是基於有效性和有效期的。

在HTTP1.0時代快取標識是根據Expires頭來決定的,它用來表示絕對時間,例如:Expires:Thu,31 Dec 2020 23:59:59 GMT,當客戶端再次發起請求時會將當前時間與上次請求的時間Expires進行對比,對比結果表示快取是否有效但是這種方式存在一定問題,比如說客戶端修改了它本地的時間,這樣對比結果就會出現問題。這個問題在HTTP1.1進行了改善,在HTTP1.1中引入了Cache-Control標識用來表示快取狀態,並且它的優先順序高於Expires。Cache-Control常見的取值為下面的一個或者多個:

  • private:預設值,用於標識一些私有的業務資料,只有客戶端可以快取;
  • public:用於標識通用的業務資料,客戶端和服務端都可以快取;
  • max-age-xx:快取有效期,單位為秒;
  • no-cache:需要使用對比快取驗證快取有效性;
  • no-store:所有內容都不快取,強制快取、對比快取都不會觸發。

HTTP的快取分為強制快取對比快取兩種:

  • 強制快取:當客戶端需要資料時先從快取中查詢是否有資料,如果有則返回沒有則向伺服器請求,拿到響應結果後將結果存進快取中,而強制快取最大的問題就是資料更新不及時,當伺服器資料有了更新時,如果快取有效期還沒有結束並且客戶端主動請求時沒有新增no-store頭那麼客戶端是無法獲取到最新資料的。

  • 對比快取:由伺服器決定是否使用快取,客戶端第一次請求時服務端會返回標識(Last-Modified/If-Modified-Since與ETag/If-None-Match)與資料,客戶端將這兩個值都存入快取中,當客戶端向伺服器請求資料時會把快取的這兩個資料都提交到服務端,伺服器根據標識決定返回200還是304,返回200則表示需要重新請求獲取資料,返回304表示可以直接使用快取中的資料
    • Last-Modified:客戶端第一次請求時服務端返回的上次資源修改的時間,單位為秒;
    • If-Modified-Since:客戶端第再次請求時將伺服器返回的Last-Modified的值放入If-Modified-Since頭傳遞給伺服器,伺服器收到後判斷快取是否有效;
    • ETag:這是一種優先順序高於Last-Modified的標識,返回的是一個資原始檔的標識碼,客戶端第一次請求時將其返回,生成的方式由伺服器決定,決定因素包括檔案修改的時間,問津的大小,檔案的編號等等;
    • If-None-Match:客戶端再次請求時會將ETag的資原始檔標識碼放入Header提交。

對比快取提供了兩種標識,那麼有什麼區別呢:

    • Last-Modified的單位是秒,如果某些檔案在一秒內被修改則並不能準確的標識修改時間;
    • 資源的修改依據不應該只用時間來表示,因為有些資料只是時間有了變化內容並沒有變化。

  • OkHttp的快取策略就是按照HTTP的方式實現的,okio最終實現了輸入輸出流,OKHttp的快取是根據伺服器端的Header自動完成的,開啟快取需要在OkHttpClient建立時設定一個Cache物件,並指定快取目錄和快取大小,快取系統內部使用LRU作為快取的淘汰演算法。

``` //給定一個請求和快取的響應,它將確定是使用網路、快取還是兩者都使用。 class CacheStrategy internal constructor( //傳送的網路請求 val networkRequest: Request?,

//快取響應,基於DiskLruCache實現的檔案快取,key為請求的url的MD5值,value為檔案中查詢到的快取
//如果呼叫不使用快取,則為null
val cacheResponse: Response?

) {

init {
    //這裡初始化,根據傳遞的cacheResponse判斷是否快取
    if (cacheResponse != null) {
        this.sentRequestMillis = cacheResponse.sentRequestAtMillis
        this.receivedResponseMillis = cacheResponse.receivedResponseAtMillis
        val headers = cacheResponse.headers
        for (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)
                }
            }
        }
    }
}

fun compute(): CacheStrategy {
    val candidate = computeCandidate()

    //被禁止使用網路並且快取不足,
    if (candidate.networkRequest != null && request.cacheControl.onlyIfCached) {
        //返回networkRequest=null和cacheResponse=null的CacheStrategy
        //在快取攔截器中最終會丟擲504的異常
        return CacheStrategy(null, null)
    }

    return candidate
}

/** 
 * 假設請求可以使用網路,則返回要使用的策略 
 */
private fun computeCandidate(): CacheStrategy {
    // 無快取的響應
    if (cacheResponse == null) {
        return CacheStrategy(request, null)
    }

    // 如果快取的響應缺少必要的握手,則丟棄它。
    if (request.isHttps && cacheResponse.handshake == null) {
        return CacheStrategy(request, null)
    }

    // 如果不應該儲存此響應,則絕不應該將其用作響應源。
    // 只要永續性儲存是良好的並且規則是不變的那麼這個檢查就是多餘的
    if (!isCacheable(cacheResponse, request)) {
        return CacheStrategy(request, null)
    }

    val requestCaching = request.cacheControl
    //沒有快取 || 如果請求包含條件,使伺服器不必傳送客戶機在本地的響應,則返回true
    if (requestCaching.noCache || hasConditions(request)) {
        return CacheStrategy(request, null)
    }

    //返回cacheResponse的快取控制指令。即使這個響應不包含Cache-Control頭,它也不會為空。
    val responseCaching = cacheResponse.cacheControl

    //返回response的有效期,以毫秒為單位。
    val ageMillis = cacheResponseAge()
    //獲取從送達時間開始返回響應重新整理的毫秒數
    var freshMillis = computeFreshnessLifetime()

    if (requestCaching.maxAgeSeconds != -1) {
        //從最新響應的持續時間和響應後的服務期限的持續時間中取出最小值
        freshMillis = minOf(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds.toLong()))
    }

    var minFreshMillis: Long = 0
    if (requestCaching.minFreshSeconds != -1) {
        //從requestCaching中獲取最新的時間並轉為毫秒
        minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds.toLong())
    }

    var maxStaleMillis: Long = 0
    if (!responseCaching.mustRevalidate && requestCaching.maxStaleSeconds != -1) {
        //從requestCaching中獲取過期的時間並轉為毫秒
        maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds.toLong())
    }

    //
    if (!responseCaching.noCache && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
        val builder = cacheResponse.newBuilder()
        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())
    }

    // 找到要新增到請求中的條件。如果滿足條件,則不傳輸響應體。
    val conditionName: String
    val 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) 
    }

    val conditionalRequestHeaders = request.headers.newBuilder()
    conditionalRequestHeaders.addLenient(conditionName, conditionValue!!)

    val conditionalRequest = request.newBuilder()
        .headers(conditionalRequestHeaders.build())
        .build()
    return CacheStrategy(conditionalRequest, cacheResponse)
}

/**
 * 從送達日期開始返回響應重新整理的毫秒數
 */
private fun computeFreshnessLifetime(): Long {
    val responseCaching = cacheResponse!!.cacheControl
    if (responseCaching.maxAgeSeconds != -1) {
        return SECONDS.toMillis(responseCaching.maxAgeSeconds.toLong())
    }

    val expires = this.expires
    if (expires != null) {
        val servedMillis = servedDate?.time ?: receivedResponseMillis
        val delta = expires.time - servedMillis
        return if (delta > 0L) delta else 0L
    }

    if (lastModified != null && cacheResponse.request.url.query == null) {
        //正如HTTP RFC所推薦並在Firefox中實現的那樣,
        //檔案的最大過期時間應該被預設為檔案被送達時過期時間的10%
        //預設過期日期不用於包含查詢的uri。
        val servedMillis = servedDate?.time ?: sentRequestMillis
        val delta = servedMillis - lastModified!!.time
        return if (delta > 0L) delta / 10 else 0L
    }

    return 0L
}

/**
 * 返回response的有效期,以毫秒為單位。
 */
private fun cacheResponseAge(): Long {
    val servedDate = this.servedDate
    val apparentReceivedAge = if (servedDate != null) {
        maxOf(0, receivedResponseMillis - servedDate.time)
    } else {
        0
    }

    val receivedAge = if (ageSeconds != -1) {
        maxOf(apparentReceivedAge, SECONDS.toMillis(ageSeconds.toLong()))
    } else {
        apparentReceivedAge
    }

    val responseDuration = receivedResponseMillis - sentRequestMillis
    val residentDuration = nowMillis - receivedResponseMillis
    return receivedAge + responseDuration + residentDuration
}

...

} ```

上面的程式碼就是對HTTP的對比快取和強制快取的一種實現:

    • 拿到響應頭後根據頭資訊決定是否進行快取;
    • 獲取資料時判斷快取是否有效,對比快取後決定是否發出請求還是獲取本地快取;
  • OkHttp快取的請求只有GET,其他的請求方式也不是不可以但是收益很低,這本質上是由各個method的使用場景決定的。

``` internal fun put(response: Response): CacheRequest? { val requestMethod = response.request.method

if (HttpMethod.invalidatesCache(response.request.method)) {
try {
    remove(response.request)
} catch (_: IOException) {
    // 無法寫入快取。
}
    return null
}

if (requestMethod != "GET") {
    // 不要快取非get響應。技術上我們允許快取HEAD請求和一些
    //POST請求,但是這樣做的複雜性很高,收益很低。
    return null
}
...

} ```

  • 完整流程圖如下