系列索引
本系列文章基于 OkHttp3.14
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-Encoding
、Content-Range
、Content-Type
等 Header 不能由代理修改。 -
only-if-cached
:表明客戶端只接受已緩存的響應,并且不向原始服務器檢查是否有更新的數據。
響應緩存指令
-
must-revalidate
:一旦資源過期(比如已經超過max-age
),在成功向原始服務器驗證之前,緩存不能用該資源響應后續請求。 -
no-cache
:在發布緩存副本之前,強制要求緩存把請求提交給原始服務器進行驗證 -
no-store
:緩存不應存儲有關服務器響應的任何內容。 -
no-transform
:不得對資源進行轉換或轉變,Content-Encoding
、Content-Range
、Content-Type
等 Header 不能由代理修改。 -
public
:表明響應可以被任何對象(包括:發送請求的客戶端,代理服務器,等等)緩存,即使是通常不可緩存的內容(例如,該響應沒有max-age
指令或Expires
消息頭)。 -
private
:表明響應只能被單個用戶緩存,不能作為共享緩存(即代理服務器不能緩存它),私有緩存可以緩存響應內容。 -
proxy-revalidate
:與must-revalidate
作用相同,但它僅適用于共享緩存(如代理),并被私有緩存忽略。 -
max-age=<seconds>
:設置緩存存儲的最大周期,超過這個的時間緩存被認為過期,時間是相對于請求的時間。 -
s-maxage=<seconds>
:覆蓋max-age
或者Expires
頭,但它僅適用于共享緩存(如代理),并被私有緩存忽略。
其中我們常用的就是加粗的幾個字段(max-age
、max-stale
、no-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-Modified
及 If-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
方法獲取到了對應的緩存策略
如果發現我們的請求中指定了禁止使用網絡,只使用緩存(指定 CacheControl
為 only-if-cached
),則創建一個 networkRequest
及 cacheResponse
均為 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);
}
在緩存策略的創建中,主要是以下幾步:
- 沒有緩存
response
,直接進行尋常網絡請求 - HTTPS 的
response
丟失了握手相關數據,丟棄緩存直接進行網絡請求 - 緩存的
response
的 code 不支持緩存,則忽略緩存,直接進行尋常網絡請求 - 對
Cache-Control
中的字段進行處理,主要是計算緩存是否還能夠使用(比如超過了max-age
就不能再使用) - 對
If-None-Match
、If-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-Modified
、Etag
等相關數據,結合request
及緩存中的response
-
request == null, response == null
:不允許使用網絡請求,且沒有緩存,在CacheInterceptor
中會構建一個 504 的response
總結
OkHttp 的緩存機制主要是基于 DiskLruCache 這個開源庫實現的,從而實現了緩存在磁盤中的 LRU 存儲。通過在 OkHttpClient
中對 Cache
類的配置,我們可以實現對緩存位置及緩存空間大小的配置,同時 OkHttp 提供了 CacheStrategy
類對 Cache-Control
中的值進行處理,從而支持 HTTP 協議的緩存相關 Header。