OkHttp 源碼剖析系列(三)——緩存機制

系列索引

本系列文章基于 OkHttp3.14

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

OkHttp 源碼剖析系列(二)——攔截器大體流程分析

OkHttp 源碼剖析系列(三)——緩存機制分析

OkHttp 源碼剖析系列(四)——連接的建立概述

OkHttp 源碼剖析系列(五)——路由選擇機制

OkHttp 源碼剖析系列(六)——連接復用機制及連接的建立

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

前言

我們知道,在 CacheInterceptor 中實現了 OkHttp 中對 Response 的緩存功能,CacheInterceptor 的具體邏輯在前面的博客已經分析過,但里面對緩存機制的詳細實現沒有進行介紹。這篇文章中我們將對 OkHttp 的緩存機制的具體實現進行詳細的介紹。

HTTP 中的緩存

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

Cache-Control

Cache-Control 相信大家都接觸過,它是一個處于 Request 以及 Response 的 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)。

Expires

Expires 頭是 HTTP1.0 中的內容,它的作用類似于 Cache-Control:max-age,它告訴瀏覽器緩存的過期時間,這段時間瀏覽器就可以不用直接再向服務器請求了。

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're 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之緩存配置詳解

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。