OkHttp 原始碼分析(二)—— 快取機制

空帆船發表於2019-03-19

上一篇文章我們主要介紹了 OkHttp 的請求流程,這篇文章講解一下 OkHttp 的快取機制。

建議將 OkHttp 的原始碼下載下來,使用 IDEA 編輯器可以直接開啟閱讀。我這邊也將最新版的原始碼下載下來,進行了註釋說明,有需要的可以直接從 這裡 下載檢視。

在網路請求的過程中,一般都會使用到快取,快取的意義在於,對於客戶端來說,使用快取資料能夠縮短頁面展示資料的時間,優化使用者體驗,同時降低請求網路資料的頻率,避免流量浪費。對於服務端來說,使用快取能夠分解一部分服務端的壓力。

在講解 OkHttp 的快取機制之前,先了解下 Http 的快取理論知識,這是實現 OkHttp 快取的基礎。

Http 快取

Http 的快取機制如下圖:

OkHttp 原始碼分析(二)—— 快取機制

Http 的快取分為兩種:強制快取和對比快取。強制快取優先於對比快取。

強制快取

客戶端第一次請求資料時,服務端返回快取的過期時間(通過欄位 Expires 與 Cache-Control 標識),後續如果快取沒有過期就直接使用快取,無需請求服務端;否則向服務端請求資料。

Expires

服務端返回的到期時間。下一次請求時,請求時間小於 Expires 的值,直接使用快取資料。

由於到期時間是服務端生成,客戶端和服務端的時間可能存在誤差,導致快取命中的誤差。

Cache-Control

Http1.1 中採用了 Cache-Control 代替了 Expires,常見 Cache-Control 的取值有:

  • private: 客戶端可以快取
  • public: 客戶端和代理伺服器都可快取
  • max-age=xxx: 快取的內容將在 xxx 秒後失效
  • no-cache: 需要使用對比快取來驗證快取資料,並不是字面意思
  • no-store: 所有內容都不會快取,強制快取,對比快取都不會觸發

對比快取

對比快取每次請求都需要與伺服器互動,由服務端判斷是否可以使用快取。

客戶端第一次請求資料時,伺服器會將快取標識(Last-Modified/If-Modified-Since 與 Etag/If-None-Match)與資料一起返回給客戶端,客戶端將兩者備份到快取資料庫中。

當再次請求資料時,客戶端將備份的快取標識傳送給伺服器,伺服器根據快取標識進行判斷,返回 304 狀態碼,通知客戶端可以使用快取資料,服務端不需要將報文主體返回給客戶端。

Last-Modified/If-Modified-Since

Last-Modified 表示資源上次修改的時間,在第一次請求時服務端返回給客戶端。

客戶端再次請求時,會在 header 裡攜帶 If-Modified-Since ,將資源修改時間傳給服務端。

服務端發現有 If-Modified-Since 欄位,則與被請求資源的最後修改時間對比,如果資源的最後修改時間大於 If-Modified-Since,說明資源被改動了,則響應所有資源內容,返回狀態碼 200;否則說明資源無更新修改,則響應狀態碼 304,告知客戶端繼續使用所儲存的快取。

Etag/If-None-Match

優先於 Last-Modified/If-Modified-Since。

Etag 是當前資源在伺服器的唯一標識,生成規則由伺服器決定。當客戶端第一次請求時,服務端會返回該標識。

當客戶端再次請求資料時,在 header 中新增 If-None-Match 標識。

服務端發現有 If-None-Match 標識,則會與被請求資源對比,如果不同,說明資源被修改,返回 200;如果相同,說明資源無更新,響應 304,告知客戶端繼續使用快取。

OkHttp 快取

為了節省流量和提高響應速度,OkHttp 有自己的一套快取機制,CacheInterceptor 就是用來負責讀取快取以及更新快取的。

我們來看 CacheInterceptor 的關鍵程式碼:

@Override
public Response intercept(Chain chain) throws IOException {
    // 1、如果此次網路請求有快取資料,取出快取資料作為候選
    Response cacheCandidate = cache != null
        ? cache.get(chain.request())
        : null;

    long now = System.currentTimeMillis();

    // 2、根據cache獲取快取策略
    CacheStrategy strategy = new CacheStrategy.Factory(now, chain.request(), cacheCandidate).get();
    // 通過快取策略計算的網路請求
    Request networkRequest = strategy.networkRequest;
    // 通過快取策略處理得到的快取響應資料
    Response cacheResponse = strategy.cacheResponse;

    if (cache != null) {
        cache.trackResponse(strategy);
    }

    // 快取資料不能使用,清理此快取資料
    if (cacheCandidate != null && cacheResponse == null) {
        closeQuietly(cacheCandidate.body());
    }

    // 3、不進行網路請求,而且沒有快取資料,則返回網路請求錯誤的結果
    if (networkRequest == null && cacheResponse == null) {
        return new Response.Builder()
            .request(chain.request())
            .protocol(Protocol.HTTP_1_1)
            .code(504)
            .message("Unsatisfiable Request (only-if-cached)")
            .body(Util.EMPTY_RESPONSE)
            .sentRequestAtMillis(-1L)
            .receivedResponseAtMillis(System.currentTimeMillis())
            .build();
    }

    // 4、如果不進行網路請求,快取資料可用,則直接返回快取資料.
    if (networkRequest == null) {
        return cacheResponse.newBuilder()
            .cacheResponse(stripBody(cacheResponse))
            .build();
    }

    // 5、快取無效,則繼續執行網路請求。
    Response networkResponse = null;
    try {
        networkResponse = chain.proceed(networkRequest);
    } finally {
        // If we're crashing on I/O or otherwise, don't leak the cache body.
        if (networkResponse == null && cacheCandidate != null) {
            closeQuietly(cacheCandidate.body());
        }
    }

    if (cacheResponse != null) {
        // 6、通過服務端校驗後,快取資料可以使用(返回304),則直接返回快取資料,並且更新快取
        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());
        }
    }

    // 7、讀取網路結果,構造response
    Response response = networkResponse.newBuilder()
        .cacheResponse(stripBody(cacheResponse))
        .networkResponse(stripBody(networkResponse))
        .build();

    // 對資料進行快取
    if (cache != null) {
        if (HttpHeaders.hasBody(response) && CacheStrategy.isCacheable(response, networkRequest)) {
            // Offer this request to the cache.
            CacheRequest cacheRequest = cache.put(response);
            return cacheWritingResponse(cacheRequest, response);
        }

        if (HttpMethod.invalidatesCache(networkRequest.method())) {
            try {
                cache.remove(networkRequest);
            } catch (IOException ignored) {
                // The cache cannot be written.
            }
        }
    }

    return response;
}
複製程式碼

整個方法的流程如下:

  • 1、讀取候選快取。
  • 2、根據候選快取建立快取策略。
  • 3、根據快取策略,如果不進行網路請求,而且沒有快取資料時,報錯返回錯誤碼 504。
  • 4、根據快取策略,如果不進行網路請求,快取資料可用,則直接返回快取資料。
  • 5、快取無效,則繼續執行網路請求。
  • 6、通過服務端校驗後,快取資料可以使用(返回 304),則直接返回快取資料,並且更新快取。
  • 7、讀取網路結果,構造 response,對資料進行快取。

OkHttp 通過 CacheStrategy 獲取快取策略,CacheStrategy 根據之前快取結果與當前將要發生的 request 的Header 計算快取策略。規則如下:

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

CacheStrategy 通過工廠模式構造,CacheStrategy.Factory 物件構建以後,呼叫它的 get 方法即可獲得具體的CacheStrategy,CacheStrategy.Factory 的 get方法內部呼叫的是 CacheStrategy.Factory 的 getCandidate 方法,它是核心的實現。

private CacheStrategy getCandidate() {
    // 1、沒有快取,直接返回包含網路請求的策略結果
    if (cacheResponse == null) {
        return new CacheStrategy(request, null);
    }

    // 2、如果握手資訊丟失,則返返回包含網路請求的策略結果
    if (request.isHttps() && cacheResponse.handshake() == null) {
        return new CacheStrategy(request, null);
    }

    // 3、如果根據CacheControl引數有no-store,則不適用快取,直接返回包含網路請求的策略結果
    if (!isCacheable(cacheResponse, request)) {
        return new CacheStrategy(request, null);
    }

    // 4、如果快取資料的CacheControl有no-cache指令或者需要向伺服器端校驗後決定是否使用快取,則返回只包含網路請求的策略結果
    CacheControl requestCaching = request.cacheControl();
    if (requestCaching.noCache() || hasConditions(request)) {
        return new CacheStrategy(request, null);
    }

    CacheControl responseCaching = cacheResponse.cacheControl();

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

    if (requestCaching.maxAgeSeconds() != -1) {
        freshMillis = Math.min(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds()));
    }

    long minFreshMillis = 0;
    if (requestCaching.minFreshSeconds() != -1) {
        minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds());
    }

    long maxStaleMillis = 0;
    if (!responseCaching.mustRevalidate() && requestCaching.maxStaleSeconds() != -1) {
        maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds());
    }
    // 5. 如果快取在過期時間內則可以直接使用,則直接返回上次快取
    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());
    }

    //6. 如果快取過期,且有ETag等資訊,則傳送If-None-Match、If-Modified-Since等條件請求
    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); // No condition! Make a regular request.
    }

    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);
}
複製程式碼

整個函式的邏輯就是按照上面的 Http 快取策略流程圖來實現的,這裡不再贅述。

我們再簡單看下 OkHttp 是如何快取資料的。

OkHttp 具體的快取資料是利用 DiskLruCache 實現,用磁碟上的有限大小空間進行快取,按照 LRU 演算法進行快取淘汰。

Cache 類封裝了快取的實現,快取操作封裝在 InternalCache 介面中。

public interface InternalCache {
    // 獲取快取
    @Nullable
    Response get(Request request) throws IOException;

    // 存入快取
    @Nullable
    CacheRequest put(Response response) throws IOException;

    // 移除快取
    void remove(Request request) throws IOException;

    // 更新快取
    void update(Response cached, Response network);

    // 跟蹤一個滿足快取條件的GET請求
    void trackConditionalCacheHit();

    // 跟蹤滿足快取策略CacheStrategy的響應
    void trackResponse(CacheStrategy cacheStrategy);
}
複製程式碼

Cache 類在其內部實現了 InternalCache 的匿名內部類,內部類的方法呼叫 Cache 對應的方法。

public final class Cache implements Closeable, Flushable {
    
  final InternalCache internalCache = new InternalCache() {
    @Override public @Nullable Response get(Request request) throws IOException {
      return Cache.this.get(request);
    }

    @Override public @Nullable CacheRequest put(Response response) throws IOException {
      return Cache.this.put(response);
    }

    @Override public void remove(Request request) throws IOException {
      Cache.this.remove(request);
    }

    @Override public void update(Response cached, Response network) {
      Cache.this.update(cached, network);
    }

    @Override public void trackConditionalCacheHit() {
      Cache.this.trackConditionalCacheHit();
    }

    @Override public void trackResponse(CacheStrategy cacheStrategy) {
      Cache.this.trackResponse(cacheStrategy);
    }
  };
}
複製程式碼

總結

  • OkHttp 的快取機制是按照 Http 的快取機制實現。

  • OkHttp 具體的資料快取邏輯封裝在 Cache 類中,它利用 DiskLruCache 實現。

  • 預設情況下,OkHttp 不進行快取資料。

  • 可以在構造 OkHttpClient 時設定 Cache 物件,在其建構函式中指定快取目錄和快取大小。

  • 如果對 OkHttp 內建的 Cache 類不滿意,可以自行實現 InternalCache 介面,在構造 OkHttpClient 時進行設定,這樣就可以使用自定義的快取策略了。

參考

相關文章