OkHttp源碼學習系列三:緩存總結(jié)

本文為本人原創(chuàng),轉(zhuǎn)載請注明作者和出處。

緩存在面試中經(jīng)常被問到,在這一章我會單獨講解有關(guān)于http緩存以及我們okhttp實現(xiàn)緩存的方方面面。

系列文章索引:
OkHttp源碼學習系列一:總流程和Dispatcher分析
OkHttp源碼學習系列二:攔截鏈分析
OkHttp源碼學習系列三:緩存總結(jié)

零、http緩存規(guī)則

試想一下如果我們使用了緩存,那我們應(yīng)該在何時使用緩存呢,或者說我們?nèi)绾尾拍苤牢覀兊木彺鏇]有過期,可以使用。這就需要我們先學習http的緩存規(guī)則。服務(wù)端和客戶端都必須遵守這套規(guī)則才能夠使得我們的緩存可以在合適的時機被使用。其實這套規(guī)則還是稍微有點復雜的,筆者也是花了不少時間才完全明白這一套規(guī)則是怎么回事。為了全面搞清楚這套流程,我畫了如下這張圖,一起來看下吧:


http緩存規(guī)則

從客戶端發(fā)起請求開始,客戶端先去緩存文件中根據(jù)url查找是否有緩存,取到緩存的http報文后,首先要做的事是判斷是否過期。判斷過期的標志是根據(jù)報文的兩個頭域,Expires和Cache-Control。

Expires規(guī)定了緩存的報文的過期時間,Cache-Control規(guī)定了緩存的有效期。前者是http1.0的標準,基本已被棄用,后者是http1.1的標準,兩者同時存在的話以Cache-Control為準。Cache-Control設(shè)定過期時間一般是Cache-Control: max-age=3600這樣的形式,3600代表3600秒,即一小時后過期。如果判斷后沒有過期的話,則客戶端直接使用緩存,不會向服務(wù)器發(fā)起請求。

看到這里你會不會有疑問?那這樣緩存策略豈不是很簡單,過期就重新請求,沒過期就直接用,怎么那張圖后面還有那么多流程?是的,如果我們要請求的資源對即時性要求不高,我們確實可以這樣,服務(wù)器規(guī)定個過期時間,過期就重新請求,沒過期就直接使用緩存。但是但是,我們大部分請求都不會使用這種過期策略,即使是對即時性要求不高的。為什么?舉個例子,假如運維小哥在首頁中不小心加入了些不和諧內(nèi)容,如果使用過期策略,那即使運維小哥秒刪,用戶也得等緩存過期后才會重現(xiàn)請求,這樣是不是很囧?

那么又有疑問了,既然絕大多數(shù)請求都不會設(shè)定一個緩存有效期,或者說我們的緩存永遠都是過期的,那緩存還有什么用?其實,上圖中后面的流程也很簡單,就是服務(wù)器會把我們將要使用的緩存與服務(wù)器實時數(shù)據(jù)進行對比,如果服務(wù)器的更新則緩存不可用,需要使用服務(wù)器返回的最新數(shù)據(jù)。假如服務(wù)器對比發(fā)現(xiàn)客戶端的緩存已經(jīng)說最新的了,那么就會返回304響應(yīng)碼,不返回資源內(nèi)容,客戶端接受到304響應(yīng)碼后直接使用緩存。

那么服務(wù)器是如何對比呢?有兩種情況,一種是ETag。服務(wù)器會對資源做一個ETag算法,生成一個字符串。這個字符串實際上代表了資源在服務(wù)器的版本。服務(wù)器在響應(yīng)頭中會加入這個ETag給客戶端。客戶端緩存后下次繼續(xù)請求的時候把緩存中的ETag取出來加入到請求頭。服務(wù)器取出請求頭中的ETag,與當前服務(wù)器該資源實時的ETag作對比。如果不一致則表明服務(wù)器有更新資源,則返回200響應(yīng)碼,并返回資源。如果客戶端的ETag和服務(wù)端的相同,代表服務(wù)端沒有更新數(shù)據(jù),則返回304響應(yīng)碼,并不返回資源。這樣就達到了使用緩存去節(jié)省時間和流量的目的。另一種是Last-Modified,它代表的是資源在服務(wù)器更新的時間,和ETag類似客戶端從緩存中取出這個時間加入到請求頭,服務(wù)器根據(jù)最近的一次更新時間與之對比,決定是返回資源還是返回304。

總結(jié)下,如果服務(wù)器通過Cache-Control頭規(guī)定了過期時間,沒過期的話可以直接使用緩存。否則,使用Etag或者Last-Modified加入請求體,服務(wù)器接收后與最新的數(shù)據(jù)進行比對,決定直接返回最新數(shù)據(jù)還是304響應(yīng)碼。是不是其實一點都不復雜呢?

一、Okhttp的緩存策略的實現(xiàn)

http協(xié)議規(guī)定了緩存的使用規(guī)則,那么Okhttp是如何實現(xiàn)這些規(guī)則的呢?
其實我寫到這塊有點無從起筆了,在上一章講解CacheIntercept的時候已經(jīng)將Okhttp使用緩存的過程講的差不多了,沒有看過的同學可以先翻去看上一章。此時我們可以不著急看源碼,在學習完http緩存規(guī)則后,我們可以思考下,我們的客戶端也就是OkHttpClient在緩存這塊要做哪些事呢?首先,需要取緩存,并確定緩存有無過期。如果過期,看看有沒有Etag或Last-Modified可以加到請求體。最后如果服務(wù)器返回304,我們要直接使用緩存,如果返回200,我們需要更新緩存。按照這個順序,我們來在源碼中找一找,這些操作是如何實現(xiàn)的。

在上一章中,CacheIntercept先取緩存,再通過緩存策略類CacheStrategy獲取處理后的緩存response,一起來回顧下這段代碼:

    Response cacheCandidate = cache != null
        ? cache.get(chain.request())
        : null;

    long now = System.currentTimeMillis();

    CacheStrategy strategy = new CacheStrategy.Factory(now, chain.request(), cacheCandidate).get();
    Request networkRequest = strategy.networkRequest;
    Response cacheResponse = strategy.cacheResponse;

首先從cache對象中根據(jù)request取到緩存的response,然后調(diào)用CacheStrategy的構(gòu)建方法并獲取實例對象,從這個CacheStrategy中獲取處理后的response。現(xiàn)在我們就來看下CacheStrategy到底怎么處理的cacheResponse,先從factory方法開始吧:

public Factory(long nowMillis, Request request, Response cacheResponse) {
  this.nowMillis = nowMillis;
  this.request = request;
  this.cacheResponse = cacheResponse;

  if (cacheResponse != null) {
    this.sentRequestMillis = cacheResponse.sentRequestAtMillis();
    this.receivedResponseMillis = cacheResponse.receivedResponseAtMillis();
    Headers headers = cacheResponse.headers();
    for (int i = 0, size = headers.size(); i < size; i++) {
      String fieldName = headers.name(i);
      String value = headers.value(i);
      if ("Date".equalsIgnoreCase(fieldName)) {
        servedDate = HttpDate.parse(value);
        servedDateString = value;
      } else if ("Expires".equalsIgnoreCase(fieldName)) {
        expires = HttpDate.parse(value);
      } else if ("Last-Modified".equalsIgnoreCase(fieldName)) {
        lastModified = HttpDate.parse(value);
        lastModifiedString = value;
      } else if ("ETag".equalsIgnoreCase(fieldName)) {
        etag = value;
      } else if ("Age".equalsIgnoreCase(fieldName)) {
        ageSeconds = HttpHeaders.parseSeconds(value, -1);
      }
    }
  }
}

這個構(gòu)造方法做的事情很簡單,就是記錄下這個緩存的response的請求發(fā)送時間,接收response的時間,以及Expires、Last-Modified、ETag等與緩存有關(guān)的響應(yīng)頭,為后續(xù)的判斷做準備。繼續(xù)來看get方法:

public CacheStrategy get() {
  CacheStrategy candidate = getCandidate();

  if (candidate.networkRequest != null && request.cacheControl().onlyIfCached()) {
    // We're forbidden from using the network and the cache is insufficient.
    return new CacheStrategy(null, null);
  }

  return candidate;
}

這里調(diào)用了一個最為重要的getCandidate()方法,返回的仍然是一個CacheStrategy對象,后面的那個判斷是在Cache-Control響應(yīng)頭為only-if-cached且需要進行網(wǎng)絡(luò)請求的情況,遇到這種情況返回的CacheStrategy對象networkRequest和cacheResponse為空,在CacheIntercept中針對這種情況會返回504錯誤碼。這種情況其實就是服務(wù)器要求使用緩存,而我們沒有緩存或緩存過期了,只能作為錯誤情況處理。在CacheStrategy源碼的時候要記得,networkRequest其實就是傳進來的我們的request,只是如果不需要進行網(wǎng)絡(luò)請求的話CacheStrategy為把它置為null;cacheResponse則是我們的緩存response,只是如果緩存不可用會將它置為null。現(xiàn)在我們來重點看下這個getCandidate()方法,該方法非常的長,我們還是一段一段地看:

  if (cacheResponse == null) {
    return new CacheStrategy(request, null);
  }

  // Drop the cached response if it's missing a required handshake.
  if (request.isHttps() && cacheResponse.handshake() == null) {
    return new CacheStrategy(request, null);
  }

  if (!isCacheable(cacheResponse, request)) {
    return new CacheStrategy(request, null);
  }

  CacheControl requestCaching = request.cacheControl();
  if (requestCaching.noCache() || hasConditions(request)) {
    return new CacheStrategy(request, null);
  }

這四種情況最終的結(jié)果都一樣,networkRequest不為null,cacheResponse為null,代表我們的緩存不可用,需要請求網(wǎng)絡(luò)。第一種是我們的緩存為空,即我們沒取到緩存;第二種是連接上https,而緩存response沒有握手信息,這時候緩存也用不了;第三種調(diào)用了isCacheable方法,這個方法就不貼了,大致就是判斷響應(yīng)碼和Cache-Control是否符合緩存要求,不符合的緩存也不能使用;最后一種是判斷請求頭中的Cache-Control是否允許緩存,不允許的話也不能使用緩存。

在這里額外說一下,一開始我也對請求頭中的Cache-Control很困惑。后來了解到客戶端請求頭中確實是可以有Cache-Control用于讓客戶端來控制緩存策略。但由于這樣對服務(wù)器來說有安全隱患,因此幾乎很少有服務(wù)器會實現(xiàn)客戶端的緩存策略。

long ageMillis = cacheResponseAge();
long freshMillis = computeFreshnessLifetime();

//略過requestCacheing部分
      
long maxStaleMillis = 0;
CacheControl responseCaching = cacheResponse.cacheControl();
if (!responseCaching.mustRevalidate() && requestCaching.maxStaleSeconds() != -1) {
maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds());
}
    
if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
    Response.Builder builder = cacheResponse.newBuilder();
    if (ageMillis + minFreshMillis >= freshMillis) {
    builder.addHeader("Warning", "110 HttpURLConnection \"Response is stale\"");
    }
    long oneDayMillis = 24 * 60 * 60 * 1000L;
    if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
    builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\"");
    }
    return new CacheStrategy(null, builder.build());
}

這一段的話就是計算過期時間的代碼了。這里講下ageMillis就是緩存的已存在的時間(當前時間減去緩存當時的時間),freshMillis就是緩存的過期時間。然后下面的一大段都是requestCaching里的緩存策略就不看了,原因我上面說了很少有服務(wù)器會實現(xiàn)客戶端的緩存策略。然后通過response中的Cache-Control獲取緩存的最大允許過期時間maxStaleMillis。接下來有個計算ageMillis + minFreshMillis < freshMillis + maxStaleMillis,也就是緩存在允許過期的時間范圍內(nèi)。在這種情況緩存是可以使用的,因此后續(xù)return的CacheStrategy的networkRequest為null,cacheResponse不為null,代表我們客戶端直接用緩存,不需要進行網(wǎng)絡(luò)請求。但后續(xù)還有兩個判斷,第一個是在緩存過期的情況(雖然過期但是在允許的過期范圍內(nèi)),加入一個warning響應(yīng)頭;第二個是緩存存在超過一天且是否過期存在模糊不清的情況下,也加一個warning響應(yīng)頭作為提醒。

  String conditionName;
  String conditionValue;
  if (etag != null) {
    conditionName = "If-None-Match";
    conditionValue = etag;
  } else if (lastModified != null) {
    conditionName = "If-Modified-Since";
    conditionValue = lastModifiedString;
  } else if (servedDate != null) {
    conditionName = "If-Modified-Since";
    conditionValue = servedDateString;
  } else {
    return new CacheStrategy(request, null); 
  }

  Headers.Builder conditionalRequestHeaders = request.headers().newBuilder();
  Internal.instance.addLenient(conditionalRequestHeaders, conditionName, conditionValue);

  Request conditionalRequest = request.newBuilder()
      .headers(conditionalRequestHeaders.build())
      .build();
  return new CacheStrategy(conditionalRequest, cacheResponse);

剩下的情況不說大家也能猜到了,即緩存可用,但是需要服務(wù)器根據(jù)ETag或lastModified來比對是否過期。可以看到Etag的優(yōu)先級最高,如果這些判定條件都沒有則緩存不可用。之后把Etag或lastModified加入到請求頭中,讓后續(xù)的網(wǎng)絡(luò)請求發(fā)送給服務(wù)器,讓服務(wù)器來比對該緩存是否可用。

好了到這里,Okhttp判斷緩存是否過期的代碼已經(jīng)講完了,下面選擇性的看下如果服務(wù)器返回304,即我們的緩存可用,客戶端怎么處理的:

// If we have a cache response too, then we're doing a conditional get.
if (cacheResponse != null) {
  if (networkResponse.code() == HTTP_NOT_MODIFIED) {
    Response response = cacheResponse.newBuilder()
        .headers(combine(cacheResponse.headers(), networkResponse.headers()))
        .sentRequestAtMillis(networkResponse.sentRequestAtMillis())
        .receivedResponseAtMillis(networkResponse.receivedResponseAtMillis())
        .cacheResponse(stripBody(cacheResponse))
        .networkResponse(stripBody(networkResponse))
        .build();
    networkResponse.body().close();

    // Update the cache after combining headers but before stripping the
    // Content-Encoding header (as performed by initContentStream()).
    cache.trackConditionalCacheHit();
    cache.update(cacheResponse, response);
    return response;
  } else {
    closeQuietly(cacheResponse.body());
  }
}

首先當然是根據(jù)我們的緩存response封裝成一個最終的response,然后調(diào)用cache類的trackConditionalCacheHit()方法。這個方法是干嘛的呢?我們的緩存大小是有限制的,由于我們底層緩存算法用的Lru算法,即最近最少使用原則更新緩存,我們需要記錄緩存的命中次數(shù),在緩存超出限制后就可以根據(jù)算法清除最近最少使用的緩存。另外如果服務(wù)器沒有返回304,我們當然也會刷新緩存,用新的response替換舊的。

二、其它

Cache類

除了緩存策略類CacheStrategy,還有一個重要的類Cache沒講。這里簡單講下這個類就是負責存取緩存的。它里面存儲緩存的對象是DiskLruCache,而DiskLruCache內(nèi)部又是LinkedHashMap<String, Entry>,關(guān)于LinkedHashMap大家有可以查閱一下相關(guān)文章,它底層是一個哈希表和鏈表的組合,并且由于同時維護了一個可以給存儲對象排序的雙向鏈表,因此可以實現(xiàn)Lru算法。關(guān)于DiskLruCache是如何在內(nèi)存和文件之間來回讀寫的,這里就不展開講解了,也是常考的面試題,有興趣的同學可以自己看看。

無網(wǎng)情況下強制使用緩存

當沒有網(wǎng)絡(luò)的時候,我們無法發(fā)送給服務(wù)器進行比對是否過期。在某些場景,可能會要求我們在無網(wǎng)的時候直接使用緩存,這該怎么做呢?很簡單,我們只要自定義一個攔截器,在我們的請求頭中判斷沒有網(wǎng)絡(luò)可用時,緩存策略為強制使用緩存。

Interceptor interceptor = new Interceptor() {
    @Override
    public Response intercept(Chain chain) throws IOException {
        Request request = chain.request();
        if (!context.isNetworkReachable()) {
            request = request.newBuilder()
                    .cacheControl(CacheControl.FORCE_CACHE)
                    .build();
            Log.d("OkHttp", "網(wǎng)絡(luò)不可用請求攔截");
        } 

        Response response = chain.proceed(request);
        return response;
    }
};

httpClient.interceptors().add(interceptor);
httpClient.networkInterceptors().add(interceptor);

實際上就是當沒有網(wǎng)絡(luò)的時候,在請求頭上加入Cache-Control:only-if-cached,同時還會把允許過期時間改為無限大,這樣就可以強制使用緩存。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,362評論 6 544
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,577評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,486評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,852評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,600評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,944評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,944評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 43,108評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,652評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,385評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,616評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,111評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,798評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,205評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,537評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,334評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,570評論 2 379

推薦閱讀更多精彩內(nèi)容