OkHttp3原始碼分析[快取策略]

yangxi_001發表於2017-06-19

OkHttp系列文章如下

本文專門分析OkHttp的快取策略,應該是okhttp分析中最簡單的一篇了


HTTP快取基礎知識

在分析原始碼之前,我們先回顧一下http的快取Header的含義

1. Expires

表示到期時間,一般用在response報文中,當超過此事件後響應將被認為是無效的而需要網路連線,反之而是直接使用快取

Expires: Thu, 12 Jan 2017 11:01:33 GMT
2. Cache-Control

相對值,單位是秒,指定某個檔案被續多少秒的時間,從而避免額外的網路請求。比expired更好的選擇,它不用要求伺服器與客戶端的時間同步,也不用伺服器時刻同步修改配置Expired中的絕對時間,而且它的優先順序比Expires更高。比如簡書靜態資源有如下的header,表示可以續31536000秒,也就是一年。

Cache-Control: max-age=31536000, public
3. 修訂檔名(Reving Filenames)

如果我們通過設定header保證了客戶端可以快取的,而此時遠端伺服器更新了檔案如何解決呢?我們這時可以通過修改url中的檔名版本字尾進行快取,比如下文是又拍雲的公共CDN就提供了多個版本的JQuery

upcdn.b0.upaiyun.com/libs/jquery/jquery-2.0.3.min.js
4. 條件GET請求(Conditional GET Requests)與304

如快取果過期或者強制放棄快取,在此情況下,快取策略全部交給伺服器判斷,客戶端只用傳送條件get請求即可,如果快取是有效的,則返回304 Not Modifiled,否則直接返回body。

請求的方式有兩種:

4.1. Last-Modified-Date:

客戶端第一次網路請求時,伺服器返回了

Last-Modified: Tue, 12 Jan 2016 09:31:27 GMT

客戶端再次請求時,通過傳送

If-Modified-Since: Tue, 12 Jan 2016 09:31:27 GMT

交給伺服器進行判斷,如果仍然可以快取使用,伺服器就返回304

4.2. ETag

ETag是對資原始檔的一種摘要,客戶端並不需要了解實現細節。當客戶端第一請求時,伺服器返回了

ETag: "5694c7ef-24dc"

客戶端再次請求時,通過傳送

If-None-Match:"5694c7ef-24dc"

交給伺服器進行判斷,如果仍然可以快取使用,伺服器就返回304

如果 ETag 和 Last-Modified 都有,則必須一次性都發給伺服器,它們沒有優先順序之分,反正這裡客戶端沒有任何判斷的邏輯。

5. 其它標籤
  • no-cache/no-store: 不使用快取,no-cache指令的目的是防止從快取中返回過期的資源。客戶端傳送的請求中如果包含no-cache指令的話,表示客戶端將不會接受快取過的相應,於是快取伺服器必須把客戶端請求轉發給源伺服器。伺服器端返回的相應中包含no-cache指令的話那麼快取伺服器不能對資源進行快取。
  • only-if-cached: 只使用快取
  • Date: The date and time that the message was sent
  • Age: The Age response-header field conveys the sender's estimate of the amount of time since the response (or its revalidation) was generated at the origin server. 說人話就是CDN反代伺服器到原始伺服器獲取資料延時的快取時間

"only-if-cached"標籤非常具有誘導性,它只在請求中使用,表示無論是否有網完全只使用快取(如果命中還好說,否則返回503錯誤/網路錯誤),這個標籤比較危險。

全部的標籤,可以到這裡看

以上內容是作為一個伺服器開發或者客戶端的常識,下圖是網上找的總結,注意圖中的 ETag 和 Last-Modified 可能有優先順序的歧義,你只需要記住它們是沒有優先順序的。


圖源: 瀏覽器快取機制 - 吳秦(Tyler)

2. 原始碼分析

OkHttp中使用了CacheStrategy實現了上文的流程圖,它根據之前的快取結果與當前將要傳送Request的header進行策略分析,並得出是否進行請求的結論。

2.1. 總體請求流程分析

CacheStrategy類似一個mapping操作,將兩個值輸入,再將兩個值輸出

Input request, cacheCandidate
CacheStrategy 處理,判斷Header資訊
Output networkRequest, cacheResponse

Request:
開發者手動編寫並在Interceptor中遞迴加工而成的物件(如果讀者需要除錯分析的話,可以用logging-interceptor進行log操作),我們只需要知道了目前傳入的Request中並沒有任何關於快取的Header

cacheCandidate:
也就是上次與伺服器互動快取的Response,可能為null。這裡的快取全部是基於檔案系統的Map,key是請求中url的md5,value是在檔案中查詢到的快取,頁面置換基於LRU演算法,我們現在只需要知道它是一個可以讀取快取Header的Response即可。

當被CacheStrategy加工輸出後,輸出networkRequestcacheResponse,根據是否為空執行不同的請求

networkRequest cacheResponse result
null null only-if-cached(表明不進行網路請求,且快取不存在或者過期,一定會返回503錯誤)
null non-null 不進行網路請求,而且快取可以使用,直接返回快取,不用請求網路
non-null null 需要進行網路請求,而且快取不存在或者過期,直接訪問網路
non-null non-null Header中含有ETag/Last-Modified標籤,需要在條件請求下使用,還是需要訪問網路

以上是對networkRequest/cacheResponse進行findusage查詢獲得出的結論

基本上與上文的圖片完全一致,以上就是OkHttp的快取策略

關於此部分的分析,讀者可以在HttpEngine物件中通過對userResponse進行findUsage分析得出,原始碼都是一大堆的if判斷

2.2. CacheStrategy的加工過程

CacheStrategy使用Factory模式進行構造,引數如下

InternalCache responseCache = Internal.instance.internalCache(client);
//cacheCandidate從disklurcache中獲取
//request的url被md5序列化為key,進行快取查詢
Response cacheCandidate = responseCache != null ? responseCache.get(request) : null;
//請求與快取
factory = new CacheStrategy.Factory(now, request, cacheCandidate);
cacheStrategy = factory.get();
//輸出結果
networkRequest = cacheStrategy.networkRequest;
cacheResponse = cacheStrategy.cacheResponse;
//進行一大堆的if判斷,內容同上表格
.....

可以看出Factory.get()是最關鍵的快取策略的判斷,我們點入get()方法,可以發現是對getCandidate()的一個封裝,我們接著點開getCandidate(),全是if與數學計算,詳細程式碼如下

private CacheStrategy getCandidate() {
  //如果快取沒有命中(即null),網路請求也不需要加快取Header了
  if (cacheResponse == null) {
    //`沒有快取的網路請求,查上文的表可知是直接訪問
    return new CacheStrategy(request, null);
  }

  // 如果快取的TLS握手資訊丟失,返回進行直接連線
  if (request.isHttps() && cacheResponse.handshake() == null) {
    //直接訪問
    return new CacheStrategy(request, null);
  }

  //檢測response的狀態碼,Expired時間,是否有no-cache標籤
  if (!isCacheable(cacheResponse, request)) {
    //直接訪問
    return new CacheStrategy(request, null);
  }

  CacheControl requestCaching = request.cacheControl();
  //如果請求報文使用了`no-cache`標籤(這個只可能是開發者故意新增的)
  //或者有ETag/Since標籤(也就是條件GET請求)
  if (requestCaching.noCache() || hasConditions(request)) {
    //直接連線,把快取判斷交給伺服器
    return new CacheStrategy(request, null);
  }
  //根據RFC協議計算
  //計算當前age的時間戳
  //now - sent + age (s)
  long ageMillis = cacheResponseAge();
  //大部分情況伺服器設定為max-age
  long freshMillis = computeFreshnessLifetime();

  if (requestCaching.maxAgeSeconds() != -1) {
    //大部分情況下是取max-age
    freshMillis = Math.min(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds()));
  }

  long minFreshMillis = 0;
  if (requestCaching.minFreshSeconds() != -1) {
    //大部分情況下設定是0
    minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds());
  }

  long maxStaleMillis = 0;
  //ParseHeader中的快取控制資訊
  CacheControl responseCaching = cacheResponse.cacheControl();
  if (!responseCaching.mustRevalidate() && requestCaching.maxStaleSeconds() != -1) {
    //設定最大過期時間,一般設定為0
    maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds());
  }

  //快取在過期時間內,可以使用
  //大部分情況下是進行如下判斷
  //now - sent + age + 0 < max-age + 0
  if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
    //返回上次的快取
    Response.Builder builder = cacheResponse.newBuilder();
    return new CacheStrategy(null, builder.build());
  }

  //快取失效, 如果有etag等資訊
  //進行傳送`conditional`請求,交給伺服器處理
  Request.Builder conditionalRequestBuilder = request.newBuilder();

  if (etag != null) {
    conditionalRequestBuilder.header("If-None-Match", etag);
  } else if (lastModified != null) {
    conditionalRequestBuilder.header("If-Modified-Since", lastModifiedString);
  } else if (servedDate != null) {
    conditionalRequestBuilder.header("If-Modified-Since", servedDateString);
  }
  //下面請求實質還說網路請求
  Request conditionalRequest = conditionalRequestBuilder.build();
  return hasConditions(conditionalRequest) ? new CacheStrategy(conditionalRequest,
      cacheResponse) : new CacheStrategy(conditionalRequest, null);
}

太長不看的話,大多數常見的情況可以用這個估算

now - sent + age < max-age

這裡有個技巧,對建構函式進行findUsage查詢,就可以看出各個輸出是否為空的結果,然後各個擊破分析


new CacheStrategy()

3. 結論

通過上面的分析,我們可以發現,okhttp實現的快取策略實質上就是大量的if判斷集合,這些是根據RFC標準文件寫死的,並沒有相當難的技巧。

  1. Okhttp的快取是自動完成的,完全由伺服器Header決定的,自己沒有必要進行控制。網上熱傳的文章在Interceptor中手工新增快取程式碼控制,它固然有用,但是屬於Hack式的利用,違反了RFC文件標準,不建議使用,OkHttp的官方快取控制在註釋中。如果讀者的需求是物件持久化,建議用檔案儲存或者資料庫即可(比如realm)。
  2. 伺服器的配置非常重要,如果你需要減小請求次數,建議直接找對接人員對max-age等標頭檔案進行優化;伺服器的時鐘需要嚴格NTP同步
  3. 充分利用Idea的findUsage的功能,原始碼的各個跳轉條件可以很快分析完成
  4. 使用CMD + Y可以快速預覽某個函式,類似於forcetouch功能


    Idea quick preview
  5. 使用CMD + 左鍵可以新增標籤,方便跳轉程式碼,如圖


    Idea Favorite Bookmarks

最後,感謝大家的觀看

相關文章