OkHttp 原始碼剖析系列(三)——快取機制

N0tExpectErr0r發表於2020-01-03

你好,我是 N0tExpectErr0r,一名熱愛技術的 Android 開發

我的個人部落格:blog.N0tExpectErr0r.cn

OkHttp 原始碼剖析系列文章目錄:

OkHttp 原始碼剖析系列(一)——請求的發起及攔截器機制概述

OkHttp 原始碼剖析系列(二)——攔截器整體流程分析

OkHttp 原始碼剖析系列(三)——快取機制

OkHttp 原始碼剖析系列(四)——連線建立概述

OkHttp 原始碼剖析系列(五)——代理路由選擇

OkHttp 原始碼剖析系列(六)——連線複用機制及連線的建立

OkHttp 原始碼剖析系列(七)——請求的發起及響應的讀取

CacheInterceptor 中實現了對 Response 的快取功能,CacheInterceptor 的具體邏輯在前面的部落格已經分析過,但裡面對快取機制的詳細實現沒有進行介紹。這篇文章中我們將對 OkHttp 的快取機制的具體實現進行詳細的介紹。

HTTP 中的快取

我們先來了解一下 HTTP 協議中與快取相關的知識。

Expires

Expires 是 HTTP/1.0 中的 Header,它的作用類似於 Cache-Control:max-age,它告訴瀏覽器快取的過期時間,是一個具體格式的時間字串。

由於它記錄的是一個具體的時間,瀏覽器之類的客戶端應用會根據本地的時間與該具體時間對比從而判定是否過期。那麼如果我們對本地的時間進行了修改,則 Expires 的功能顯然會受到影響。

Cache-Control

HTTP/1.1 中引入了CacheControlCache-Control 相信大家都接觸過,它是一個位於 RequestResponse 的 Headers 中的一個欄位,對於請求的指令及響應的指令,它有如下不同的取值:

請求快取指令

  • max-age=<seconds>:設定快取儲存的最大週期,超過這個的時間快取被認為過期,時間是相對於請求的時間。
  • max-stale[=<seconds>]:表明客戶端願意接收一個已經過期的資源。可以設定一個可選的秒數,表示響應不能已經過時超過該給定的時間。
  • min-fresh=<seconds>:表示客戶端希望獲取一個能在指定的秒數內保持其最新狀態的響應。
  • no-cache :在釋出快取副本之前,強制要求快取把請求提交給原始伺服器進行驗證。
  • no-store:快取不應儲存有關客戶端請求的任何內容。
  • no-transform:不得對資源進行轉換或轉變,Content-EncodingContent-RangeContent-Type等 Header 不能由代理修改。
  • only-if-cached:表明客戶端只接受已快取的響應,並且不向原始伺服器檢查是否有更新的資料。

響應快取指令

  • must-revalidate:一旦資源過期(比如已經超過max-age),在成功向原始伺服器驗證之前,快取不能用該資源響應後續請求。
  • no-cache:在釋出快取副本之前,強制要求快取把請求提交給原始伺服器進行驗證
  • no-store:快取不應儲存有關伺服器響應的任何內容。
  • no-transform:不得對資源進行轉換或轉變,Content-EncodingContent-RangeContent-Type等 Header 不能由代理修改。
  • public:表明響應可以被任何物件(包括:傳送請求的客戶端,代理伺服器,等等)快取,即使是通常不可快取的內容(例如,該響應沒有 max-age 指令或 Expires 訊息頭)。
  • private:表明響應只能被單個使用者快取,不能作為共享快取(即代理伺服器不能快取它),私有快取可以快取響應內容。
  • proxy-revalidate:與 must-revalidate 作用相同,但它僅適用於共享快取(如代理),並被私有快取忽略。
  • max-age=<seconds>:設定快取儲存的最大週期,超過這個的時間快取被認為過期,時間是相對於請求的時間。
  • s-maxage=<seconds>:覆蓋 max-age 或者 Expires 頭,但它僅適用於共享快取(如代理),並被私有快取忽略。

其中我們常用的就是加粗的幾個欄位(max-agemax-staleno-cache)。

Last-Modified / If-Modified-Since

這兩個欄位需要配合 Cache-Control 來使用

  • Last-Modified:該響應資源最後的修改時間,伺服器在響應請求的時候可以填入該欄位。
  • If-Modified-Since:客戶端快取過期時(max-age 到達),發現該資源具有 Last-Modified 欄位,可以在 Header 中填入 If-Modified-Since 欄位,表示當前請求時間。伺服器收到該時間後會與該資源的最後修改時間進行比較,若最後修改的時間更新一些,則會對整個資源響應,否則說明該資源在訪問時未被修改,響應 code 304,告知客戶端使用快取的資源,這也就是為什麼之前看到 CacheInterceptor 中對 304 做了特殊處理。

Etag / If-None-Match

這兩個欄位同樣需要配合 Cache-Control 使用

  • Etag:請求的資源在伺服器中的唯一標識,規則由伺服器決定
  • If-None-Match:若客戶端在快取過期時(max-age 到達),發現該資源具有 Etag 欄位,就可以在 Header 中填入 If-None-Match 欄位,它的值就是 Etag 中的值,之後伺服器就會根據這個唯一標識來尋找對應的資源,根據其更新與否情況返回給客戶端 200 或 304。

同時,這兩個欄位的優先順序是比 Last-ModifiedIf-Modified-Since 兩個欄位的優先順序要高的。

OkHttp 中的快取機制

瞭解完 HTTP 協議的快取相關 Header 之後,我們來學習一下 OkHttp 對快取相關的實現。

InternalCache

首先我們通過之前的文章可以知道,CacheInterceptor 中通過 cache 這個 InternalCache 物件進行對快取的 CRUD 操作。這裡 InternalCache 只是一個介面,它定義了對 HTTP 請求的快取的 CRUD 介面。讓我們看看它的定義:

/**
 * OkHttp's internal cache interface. Applications shouldn't implement this: instead use {@link
 * okhttp3.Cache}.
 */
public interface InternalCache {
    @Nullable
    Response get(Request request) throws IOException;
    @Nullable
    CacheRequest put(Response response) throws IOException;
    /**
     * Remove any cache entries for the supplied {@code request}. This is invoked when the client
     * invalidates the cache, such as when making POST requests.
     */
    void remove(Request request) throws IOException;
    /**
     * Handles a conditional request hit by updating the stored cache response with the headers from
     * {@code network}. The cached response body is not updated. If the stored response has changed
     * since {@code cached} was returned, this does nothing.
     */
    void update(Response cached, Response network);
    /**
     * Track an conditional GET that was satisfied by this cache.
     */
    void trackConditionalCacheHit();
    /**
     * Track an HTTP response being satisfied with {@code cacheStrategy}.
     */
    void trackResponse(CacheStrategy cacheStrategy);
}
複製程式碼

看到該介面的 JavaDoc 可以知道,官方禁止使用者實現這個介面,而是使用 Cache 這個類。

Cache

那麼 Cache 難道是 InternalCache 的實現類麼?讓我們去看看 Cache 類。

程式碼非常多這裡就不全部貼出來了,Cache 類並沒有實現 InternalCache 這個類,而是在內部持有了一個實現了 InternalCache 的內部物件 internalCache

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

這裡轉調到了 Cache 類中的 CRUD 相關實現,這裡採用了組合的方式,提高了設計的靈活性。

同時,在 Cache 類中,還可以看到一個熟悉的身影——DiskLruCache(關於它的原理這裡不再進行詳細分析,具體原理分析可以看我之前的部落格 【Android】LRU 快取——記憶體快取與磁碟快取),看來 OkHttp 的快取的實現是基於 DiskLruCache 實現的。

現在可以大概猜測,Cache 中的 CRUD 操作都是在對 DiskLruCache 物件進行操作。

構建

而我們的 Cache 物件是何時構建的呢?其實是在 OkHttpClient 建立時構建並傳入的:

File cacheFile = new File(cachePath);	// 快取路徑
int cacheSize = 10 * 1024 * 1024;		// 快取大小10MB
Cache cache = new Cache(cacheFile, cacheSize);
OkHttpClient client = new OkHttpClient.Builder()
			// ...
			.cache(cache)
			.build();
複製程式碼

我們看到 Cache 的建構函式,它最後呼叫到了 Cache(directory, maxSize, fileSystem),而 fileSystem 傳入的是 FileSystem.SYSTEM

Cache(File directory, long maxSize, FileSystem fileSystem) {
    this.cache = DiskLruCache.create(fileSystem, directory, VERSION, ENTRY_COUNT, maxSize);
}
複製程式碼

在它的建構函式中構造了一個 DiskLruCache 物件。

put

接著讓我們看一下它的 put 方法是如何實現的:

@Nullable
CacheRequest put(Response response) {
    String requestMethod = response.request().method();
    // 對request的method進行校驗
    if (HttpMethod.invalidatesCache(response.request().method())) {
        try {
        	// 若method為POST PATCH PUT DELETE MOVE其中一個,刪除現有快取並結束
            remove(response.request());
        } catch (IOException ignored) {
            // The cache cannot be written.
        }
        return null;
    }
    if (!requestMethod.equals("GET")) {
        // 雖然技術上允許快取POST請求及HEAD請求,但這樣實現較為複雜且收益不高
        // 因此OkHttp只允許快取GET請求
        return null;
    }
    if (HttpHeaders.hasVaryAll(response)) {
        return null;
    }
    // 根據response建立entry
    Entry entry = new Entry(response);
    DiskLruCache.Editor editor = null;
    try {
    	// 嘗試獲取editer
        editor = cache.edit(key(response.request().url()));
        if (editor == null) {
            return null;
        }
        // 將entry寫入Editor
        entry.writeTo(editor);
        // 根據editor獲取CacheRequest物件
        return new CacheRequestImpl(editor);
    } catch (IOException e) {
        abortQuietly(editor);
        return null;
    }
}
複製程式碼

它主要的實現就是根據 Response 構建 Entry,之後將其寫入到 DiskLruCache.Editor 中,寫入的過程中呼叫了 key 方法根據 url 產生了其儲存的 key

同時從註釋中可以看出,OkHttp 的作者認為雖然能夠實現如 POST、HEAD 等請求的快取,但其實現會比較複雜,且收益不高,因此只允許快取 GET 請求的 Response

key 方法的實現如下:

public static String key(HttpUrl url) {
    return ByteString.encodeUtf8(url.toString()).md5().hex();
}
複製程式碼

其實就是將 url 轉變為 UTF-8 編碼後進行了 md5 加密。

接著我們看到 Entry 建構函式,看看它是如何儲存 Response 相關的資訊的:

Entry(Response response) {
    this.url = response.request().url().toString();
    this.varyHeaders = HttpHeaders.varyHeaders(response);
    this.requestMethod = response.request().method();
    this.protocol = response.protocol();
    this.code = response.code();
    this.message = response.message();
    this.responseHeaders = response.headers();
    this.handshake = response.handshake();
    this.sentRequestMillis = response.sentRequestAtMillis();
    this.receivedResponseMillis = response.receivedResponseAtMillis();
}
複製程式碼

主要是一些賦值操作,我們接著看到 Entry.writeTo 方法

public void writeTo(DiskLruCache.Editor editor) throws IOException {
    BufferedSink sink = Okio.buffer(editor.newSink(ENTRY_METADATA));
    sink.writeUtf8(url)
            .writeByte('\n');
    // ... 一些write操作
    if (isHttps()) {
        sink.writeByte('\n');
        sink.writeUtf8(handshake.cipherSuite().javaName())
                .writeByte('\n');
        writeCertList(sink, handshake.peerCertificates());
        writeCertList(sink, handshake.localCertificates());
        sink.writeUtf8(handshake.tlsVersion().javaName()).writeByte('\n');
    }
    sink.close();
}
複製程式碼

這裡主要是利用了 Okio 這個庫中的 BufferedSink 實現了寫入操作,將一些 Response 中的資訊寫入到 Editor。關於 Okio,會在後續文章中進行介紹

get

我們接著看到 get 方法的實現:

@Nullable
Response get(Request request) {
    String key = key(request.url());
    DiskLruCache.Snapshot snapshot;
    Entry entry;
    try {
        snapshot = cache.get(key);
        if (snapshot == null) {
            return null;
        }
    } catch (IOException e) {
        // Give up because the cache cannot be read.
        return null;
    }
    try {
        entry = new Entry(snapshot.getSource(ENTRY_METADATA));
    } catch (IOException e) {
        Util.closeQuietly(snapshot);
        return null;
    }
    Response response = entry.response(snapshot);
    if (!entry.matches(request, response)) {
        Util.closeQuietly(response.body());
        return null;
    }
    return response;
}
複製程式碼

這裡拿到了 DiskLruCache.Snapshot,之後通過它的 source 建立了 Entry,然後再通過 Entry 來獲取其 Response

我們看看通過 Snapshot.source 是如何建立 Entry 的:

Entry(Source in) throws IOException {
    try {
        BufferedSource source = Okio.buffer(in);
        url = source.readUtf8LineStrict();
        requestMethod = source.readUtf8LineStrict();
        Headers.Builder varyHeadersBuilder = new Headers.Builder();
        // 一些read操作
        responseHeaders = responseHeadersBuilder.build();
        if (isHttps()) {
            String blank = source.readUtf8LineStrict();
            if (blank.length() > 0) {
                throw new IOException("expected \"\" but was \"" + blank + "\"");
            }
            String cipherSuiteString = source.readUtf8LineStrict();
            CipherSuite cipherSuite = CipherSuite.forJavaName(cipherSuiteString);
            List<Certificate> peerCertificates = readCertificateList(source);
            List<Certificate> localCertificates = readCertificateList(source);
            TlsVersion tlsVersion = !source.exhausted()
                    ? TlsVersion.forJavaName(source.readUtf8LineStrict())
                    : TlsVersion.SSL_3_0;
            handshake = Handshake.get(tlsVersion, cipherSuite, peerCertificates, localCertificates);
        } else {
            handshake = null;
        }
    } finally {
        in.close();
    }
}
複製程式碼

可以看到,同樣是通過 Okio 進行了讀取,看來 OkHttp 中的大部分 I/O 操作都使用到了 Okio。我們接著看到 Entry.response 方法:

public Response response(DiskLruCache.Snapshot snapshot) {
    String contentType = responseHeaders.get("Content-Type");
    String contentLength = responseHeaders.get("Content-Length");
    Request cacheRequest = new Request.Builder()
            .url(url)
            .method(requestMethod, null)
            .headers(varyHeaders)
            .build();
    return new Response.Builder()
            .request(cacheRequest)
            .protocol(protocol)
            .code(code)
            .message(message)
            .headers(responseHeaders)
            .body(new CacheResponseBody(snapshot, contentType, contentLength))
            .handshake(handshake)
            .sentRequestAtMillis(sentRequestMillis)
            .receivedResponseAtMillis(receivedResponseMillis)
            .build();
}
複製程式碼

其實就是根據 response 的相關資訊重新構建了 Response 物件。

可以發現,寫入和讀取的過程都有用到 Entry 類,看來 Entry 類就是 OkHttp 中 Response 快取的橋樑了,這裡要注意的是,這裡的 Entry 與 DiskLruCache 中的 Entry 是不同的

remove

remove 的實現非常簡單,它直接呼叫了 DiskLruCache.remove

void remove(Request request) throws IOException {
    cache.remove(key(request.url()));
}
複製程式碼

update

update 的實現也十分簡單,這裡不再解釋,和 put 比較相似

void update(Response cached, Response network) {
    Entry entry = new Entry(network);
    DiskLruCache.Snapshot snapshot = ((CacheResponseBody) cached.body()).snapshot;
    DiskLruCache.Editor editor = null;
    try {
        editor = snapshot.edit(); // Returns null if snapshot is not current.
        if (editor != null) {
            entry.writeTo(editor);
            editor.commit();
        }
    } catch (IOException e) {
        abortQuietly(editor);
    }
}
複製程式碼

CacheStrategy

我們前面介紹了快取的使用,但還沒有介紹在 CacheInterceptor 中使用到的快取策略類 CacheStrategy。我們先看到 CacheStrategy.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);
            }
        }
    }
}
複製程式碼

這裡主要是對一些變數的初始化,接著我們看到 Factory.get 方法,之前通過該方法我們就獲得了 CacheStrategy 物件:

/**
 * Returns a strategy to satisfy {@code request} using the a cached response {@code response}.
 */
public CacheStrategy get() {
    CacheStrategy candidate = getCandidate();
    if (candidate.networkRequest != null && request.cacheControl().onlyIfCached()) {
        // We are forbidden from using the network and the cache is insufficient.
        return new CacheStrategy(null, null);
    }
    return candidate;
}
複製程式碼

這裡首先通過 getCandidate 方法獲取到了對應的快取策略

如果發現我們的請求中指定了禁止使用網路,只使用快取(指定 CacheControlonly-if-cached ),則建立一個 networkRequestcacheResponse 均為 null 的快取策略。

我們接著看到 getCandidate 方法:

/**
 * Returns a strategy to use assuming the request can use the network.
 */
private CacheStrategy getCandidate() {
    // 若沒有快取的response,則預設採用網路請求
    if (cacheResponse == null) {
        return new CacheStrategy(request, null);
    }
    // 如果HTTPS下快取的response丟失了需要的握手相關資料,忽略本地快取response
    if (request.isHttps() && cacheResponse.handshake() == null) {
        return new CacheStrategy(request, null);
    }
    // 對快取的response的狀態碼進行校驗,一些特殊的狀態碼不論怎樣都走網路請求
    if (!isCacheable(cacheResponse, request)) {
        return new CacheStrategy(request, null);
    }
    CacheControl requestCaching = request.cacheControl();
    // 如果請求的Cache-Control中指定了no-cache,則使用網路請求
    if (requestCaching.noCache() || hasConditions(request)) {
        return new CacheStrategy(request, null);
    }
    CacheControl responseCaching = cacheResponse.cacheControl();
   	// 計算當前快取的response的存活時間以及快取應當被重新整理的時間
    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());
    }
    // 對未超過時限的快取,直接採用快取資料策略
    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());
    }
    // 對If-None-Match、If-Modified-Since等Header進行處理
    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 {
    	// 若上述Header都不存在,則採用尋常網路請求
        return new CacheStrategy(request, null); 
    }
    // 若存在上述Header,則在原request中新增對應header,之後結合本地cacheResponse建立快取策略
    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);
}
複製程式碼

在快取策略的建立中,主要是以下幾步:

  1. 沒有快取 response,直接進行尋常網路請求
  2. HTTPS 的 response 丟失了握手相關資料,丟棄快取直接進行網路請求
  3. 快取的 response 的 code 不支援快取,則忽略快取,直接進行尋常網路請求
  4. Cache-Control 中的欄位進行處理,主要是計算快取是否還能夠使用(比如超過了 max-age 就不能再使用)
  5. If-None-MatchIf-Modified-Since 欄位進行處理,填入相應 Header(同時可以看出 Etag 確實比 Last-Modified 優先順序要高

我們可以發現,OkHttp 中實現了一個 CacheControl 類,用於以物件導向的形式表示 HTTP 協議中的 Cache-Control Header,從而支援獲取 Cache-Control 中的值。

同時可以看出,我們的快取策略主要存在以下幾種情況:

  • request != null, response == null:執行尋常網路請求,忽略快取
  • request == null, response != null:採用快取資料,忽略網路資料
  • request != null, response != null:存在 Last-ModifiedEtag 等相關資料,結合 request 及快取中的 response
  • request == null, response == null:不允許使用網路請求,且沒有快取,在 CacheInterceptor 中會構建一個 504 的 response

總結

OkHttp 的快取機制主要是基於 DiskLruCache 這個開源庫實現的,從而實現了快取在磁碟中的 LRU 儲存。通過在 OkHttpClient 中對 Cache 類的配置,我們可以實現對快取位置及快取空間大小的配置,同時 OkHttp 提供了 CacheStrategy 類對 Cache-Control 中的值進行處理,從而支援 HTTP 協議的快取相關 Header。

參考資料

OKHTTP之快取配置詳解

相關文章