接主文okhttp 3.10詳細(xì)介紹okhttp的緩存機(jī)制,緩存代碼都在攔截器CacheInterceptor中實(shí)現(xiàn),在看代碼之前,先回顧http的緩存策略。
http緩存策略
http緩存中最常用的是下面幾個(gè):
- Expires
- Cache-control
- Last-Modified / If-Modified-Since
- Etag / If-None-Match
Expires和Cache-control
Expires和Cache-control看的是資源過期時(shí)間,如果在時(shí)間范圍內(nèi),緩存命中,直接使用緩存;否則需要向服務(wù)器發(fā)送請(qǐng)求,拿到完整的數(shù)據(jù)。
Expires:Mon, 30 Apr 2018 05:24:14 GMT
Cache-Control:public, max-age=31536000
上面是第一次訪問資源時(shí),response返回的Expires和Cache-control。
Expires寫死資源的過期時(shí)間,在時(shí)間范圍內(nèi),客戶端可以繼續(xù)使用緩存,不需要發(fā)送請(qǐng)求。Expires是http1時(shí)代的東西,缺陷很明顯,時(shí)間是服務(wù)器時(shí)間,和客戶端時(shí)間可能存在誤差。在http1.1,升級(jí)使用Cache-Control,同時(shí)存在Expires和Cache-control時(shí),以Cache-control為準(zhǔn)。
Cache-control常見的可選項(xiàng)有:
- private:客戶端可以緩存
- public:客戶端和代理服務(wù)器都可以緩存
- max-age=x-seconds:多少秒內(nèi)可以緩存
- no-cache:不能直接緩存,需要用后面介紹的緩存校驗(yàn)
- no-store:不能緩存
上面例子同時(shí)使用了public和max-age,max-age=31536000表示在365天內(nèi)都可以直接使用緩存。
緩存校驗(yàn)
資源過期后,需要向服務(wù)器發(fā)送請(qǐng)求,但資源可能在服務(wù)器上沒有修改過,沒有必要完整拿回整個(gè)資源,這個(gè)時(shí)候緩存校驗(yàn)就派上用場。
- Last-Modified / If-Modified-Since
- Etag / If-None-Match
上面兩組是緩存校驗(yàn)相關(guān)的字段,首先來看Last-Modified / If-Modified-Since。
第一次請(qǐng)求response
Last-Modified:Tue, 03 Apr 2018 10:26:36 GMT
第二次請(qǐng)求request
If-Modified-Since:Tue, 03 Apr 2018 10:26:36 GMT
第一次請(qǐng)求資源時(shí),服務(wù)器會(huì)在response中帶上資源最后修改時(shí)間,寫在Last-Modified。當(dāng)客戶端再次請(qǐng)求資源,request用If-Modified-Since帶上上次response中的Last-Modified,詢問該時(shí)間后資源是否修改過:
- 資源修改過,需要返回完整內(nèi)容,響應(yīng)200;
- 資源沒有修改過,只需要返回http頭,響應(yīng)304。
Last-Modified在時(shí)間上只到秒,Etag為資源生成唯一標(biāo)識(shí),更加精確。
第一次請(qǐng)求response
ETag:"2400-5437207ef2880"
第二次請(qǐng)求request
If-None-Match:"2400-5437207ef2880"
第一次請(qǐng)求資源時(shí),response在ETag返回資源在服務(wù)器的唯一標(biāo)識(shí)。當(dāng)客戶端再次請(qǐng)求資源時(shí),request在If-None-Match帶上上次的唯一標(biāo)識(shí),詢問資源是否修改過:
- 唯一標(biāo)識(shí)不同,資源修改過,需要返回完整內(nèi)容,響應(yīng)200;
- 唯一標(biāo)識(shí)相同,資源沒有修改過,只返回http頭,響應(yīng)304。
Last-Modified和ETag同時(shí)存在時(shí),當(dāng)然ETag優(yōu)先。
測試緩存效果
進(jìn)入正題,先來展示okhttp上使用緩存的效果。
Cache cache = new Cache(new File("/Users/heng/testCache"), 1024 * 1024);
OkHttpClient client = new OkHttpClient.Builder().cache(cache).build();
Request request = new Request.Builder().url("http://www.taobao.com/").
cacheControl(new CacheControl.Builder().maxStale(365, TimeUnit.DAYS).build()).
build();
Response response1 = client.newCall(request).execute();
response1.body().string();
System.out.println("response1.networkResponse:" + response1.networkResponse());
System.out.println("response1.cacheResponse:" + response1.cacheResponse());
System.out.println("");
Response response2 = client.newCall(request).execute();
response2.body().string();
System.out.println("response2.networkResponse:" + response2.networkResponse());
System.out.println("response2.cacheResponse:" + response2.cacheResponse());
// run result
response1.networkResponse:Response{protocol=http/1.1, code=200, message=OK, url=https://www.taobao.com/}
response1.cacheResponse:null
response2.networkResponse:null
response2.cacheResponse:Response{protocol=http/1.1, code=200, message=OK, url=https://www.taobao.com/}
創(chuàng)建一個(gè)Cache對(duì)象,參數(shù)是緩存在磁盤的路徑和大小,傳遞給OkHttpClient。請(qǐng)求淘寶主頁兩次,可以看到第一次請(qǐng)求是通過網(wǎng)絡(luò),第二次請(qǐng)求是通過緩存,networkResponse和cacheResponse分別表示請(qǐng)求從哪個(gè)途徑獲取數(shù)據(jù)。
查看磁盤,多了下面三個(gè)文件。
journal
bb35d9b59f4cc10d8fa23899f8cbb054.0
bb35d9b59f4cc10d8fa23899f8cbb054.1
journal是DiskLruCache日志文件,用DiskLruCache注釋里的例子學(xué)習(xí)寫入格式:
libcore.io.DiskLruCache
1
100
2
CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054
DIRTY 335c4c6028171cfddfbaae1a9c313c52
CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342
REMOVE 335c4c6028171cfddfbaae1a9c313c52
DIRTY 1ab96a171faeeee38496d8b330771a7a
CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234
READ 335c4c6028171cfddfbaae1a9c313c52
READ 3400330d1dfc7f3f7f4b8d4d803dfcf6
頭幾行每行是個(gè)字段,具體含義:
- 第一行:固定寫libcore.io.DiskLruCache;
- 第二行:緩存版本;
- 第三行:應(yīng)用版本;
- 第四行:指valueCount,后文會(huì)介紹。
接下來每一行是一次操作記錄,每次操作Cache都會(huì)產(chǎn)生一條。
- DIRTY:說明緩存數(shù)據(jù)正在創(chuàng)建或更新,每個(gè)成功的DIRTY都要對(duì)應(yīng)一個(gè)CLEAN或REMOVE,如果對(duì)不上,說明操作失敗,要清理;
- CLEAN:說明操作成功,每行后面記錄value的長度
- READ:一次讀取
- REMOVE:一次清除
Cache和文件
磁盤上的日志文件是如何關(guān)聯(lián)Cache并支持增刪改查的呢,我們從底層File開始,逐層解開okhttp對(duì)緩存數(shù)據(jù)的管理。
Cache內(nèi)部使用了DiskLruCache,這個(gè)DiskLruCache是okhttp自己實(shí)現(xiàn)的,使用okio作為輸入輸出。
第一步:FileSystem封裝File的操作
首先是FileSystem,封裝了File常用操作,沒有使用java.io的InputStream和OutputStream作為輸入輸出流,取而代之的是okio。FileSystem是個(gè)接口,直接在interface里提供了個(gè)實(shí)現(xiàn)類SYSTEM(我要參考)。
public interface FileSystem {
Source source(File file) throws FileNotFoundException;
Sink sink(File file) throws FileNotFoundException;
Sink appendingSink(File file) throws FileNotFoundException;
void delete(File file) throws IOException;
boolean exists(File file);
long size(File file);
void rename(File from, File to) throws IOException;
void deleteContents(File directory) throws IOException;
}
第二步:DiskLruCache.Entry和DiskLruCache.Editor
private final class Entry {
final String key;
final File[] cleanFiles;
final File[] dirtyFiles;
//...
}
DiskLruCache.Entry維護(hù)請(qǐng)求url對(duì)應(yīng)的緩存文件,url的md5作為key,value_count說明對(duì)應(yīng)幾多個(gè)文件,預(yù)設(shè)是2。cleanFiles和dirtyFiles就是對(duì)應(yīng)上面講的CLEAN和DIRTY,描述數(shù)據(jù)進(jìn)入修改和已經(jīng)穩(wěn)定兩種狀態(tài)。
看上面我們實(shí)操得到的兩個(gè)緩存文件,名字都是key,結(jié)尾不同:
- .0:記錄請(qǐng)求的內(nèi)容和握手信息;
- .1:真正緩存的內(nèi)容。
拒絕魔法數(shù)字,Cache上定義了0和1的常量:
private static final int ENTRY_METADATA = 0;
private static final int ENTRY_BODY = 1;
操作DiskLruCache.Entry的是DiskLruCache.Editor,它的構(gòu)造函數(shù)傳入DiskLruCache.Entry對(duì)象,里面有兩個(gè)方法:
public Source newSource(int index){}
public Sink newSink(int index){}
通過傳入的index定位,讀取cleanFiles,寫入dirtyFiles,對(duì)外暴露okio的Source和Sink。于是,我們可以通過DiskLruCache.Editor讀寫磁盤上的緩存文件了。
第三步:Snapshot封裝緩存結(jié)果
從DiskLruCache獲取緩存結(jié)果,不是返回DiskLruCache.Entry,而是緩存快照Snapshot。我們只關(guān)心當(dāng)前緩存的內(nèi)容,其他東西知道得越少越好。
public final class Snapshot implements Closeable {
private final String key;
private final Source[] sources;
}
Snapshot保存了key和sources,sources的來源通過FileSystem獲取cleanFiles的Source。
Snapshot snapshot() {
if (!Thread.holdsLock(DiskLruCache.this)) throw new AssertionError();
Source[] sources = new Source[valueCount];
long[] lengths = this.lengths.clone(); // Defensive copy since these can be zeroed out.
try {
for (int i = 0; i < valueCount; i++) {
sources[i] = fileSystem.source(cleanFiles[i]);
}
return new Snapshot(key, sequenceNumber, sources, lengths);
} catch (FileNotFoundException e) {
//
}
}
緩存增刪改查
Cache通過InternalCache供外部包調(diào)用,提供增刪改查的能力,實(shí)質(zhì)調(diào)用DiskLruCache對(duì)應(yīng)方法。
- get -> get
- put -> edit
- update -> edit
- remove -> remove
箭頭左邊是Cache的方法,右邊是DiskLruCache的方法。
DiskLruCache核心的數(shù)據(jù)結(jié)構(gòu)是LinkedHashMap,key是字符串,對(duì)應(yīng)一個(gè)Entry,要注意Cache的Entry和DiskLruCache的Entry不是同一回事。
final LinkedHashMap<String, Entry> lruEntries = new LinkedHashMap<>(0, 0.75f, true);
簡單幾句回顧LinkedHashMap的特點(diǎn),它在HashMap的基礎(chǔ)上,主要增加維護(hù)順序的雙向鏈表,元素Entry增加before和after描述前后指向的元素。
順序的控制有兩種,由標(biāo)志位accessOrder控制:
- 插入順序
- 訪問順序
如果使用LinkedHashMap實(shí)現(xiàn)LRU,accessOrder需要設(shè)置為true,按訪問排序,head后的第一個(gè)Entry就是最近最少使用的節(jié)點(diǎn)。
//...
Entry entry = new Entry(response);
DiskLruCache.Editor editor = null;
try {
editor = cache.edit(key(response.request().url()));
if (editor == null) {
return null;
}
entry.writeTo(editor);
return new CacheRequestImpl(editor);
} catch (IOException e) {
abortQuietly(editor);
return null;
}
上面代碼片段是Cache.put重要部分,首先將response封裝到Cache.Entry,然后獲取DiskLruCache.Editor。
synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
initialize();
checkNotClosed();
validateKey(key);
Entry entry = lruEntries.get(key);
if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null
|| entry.sequenceNumber != expectedSequenceNumber)) {
return null; // Snapshot is stale.
}
if (entry != null && entry.currentEditor != null) {
return null; // Another edit is in progress.
}
if (mostRecentTrimFailed || mostRecentRebuildFailed) {
// The OS has become our enemy! If the trim job failed, it means we are storing more data than
// requested by the user. Do not allow edits so we do not go over that limit any further. If
// the journal rebuild failed, the journal writer will not be active, meaning we will not be
// able to record the edit, causing file leaks. In both cases, we want to retry the clean up
// so we can get out of this state!
executor.execute(cleanupRunnable);
return null;
}
// Flush the journal before creating files to prevent file leaks.
journalWriter.writeUtf8(DIRTY).writeByte(' ').writeUtf8(key).writeByte('\n');
journalWriter.flush();
if (hasJournalErrors) {
return null; // Don't edit; the journal can't be written.
}
if (entry == null) {
entry = new Entry(key);
lruEntries.put(key, entry);
}
Editor editor = new Editor(entry);
entry.currentEditor = editor;
return editor;
}
通過key獲取editor,里面是一系列工作:
- initialize初始化,關(guān)聯(lián)journal文件并按格式讀??;
- journal寫入DIRTY行;
- 獲取或創(chuàng)建DiskLruCache.Entry;
- 創(chuàng)建Editor對(duì)象。
具體寫入文件有兩步,第一步調(diào)用entry.writeTo(editor),里面是一堆write操作,寫入目標(biāo)是ENTRY_METADATA,也就是上面說過以.0結(jié)尾的文件。
第二步調(diào)用new CacheRequestImpl(editor),返回一個(gè)CacheRequest。
CacheRequestImpl(final DiskLruCache.Editor editor) {
this.editor = editor;
this.cacheOut = editor.newSink(ENTRY_BODY);
this.body = new ForwardingSink(cacheOut) {
@Override public void close() throws IOException {
synchronized (Cache.this) {
if (done) {
return;
}
done = true;
writeSuccessCount++;
}
super.close();
editor.commit();
}
};
}
CacheRequestImpl在構(gòu)造函數(shù)里直接執(zhí)行邏輯,文件操作目標(biāo)是ENTRY_BODY(具體的緩存數(shù)據(jù))。Editor有commit和abort兩個(gè)重要操作,我們來看commit,里面繼續(xù)調(diào)用completeEdit:
synchronized void completeEdit(Editor editor, boolean success) throws IOException {
Entry entry = editor.entry;
//..
for (int i = 0; i < valueCount; i++) {
File dirty = entry.dirtyFiles[i];
if (success) {
if (fileSystem.exists(dirty)) {
File clean = entry.cleanFiles[i];
fileSystem.rename(dirty, clean);
long oldLength = entry.lengths[i];
long newLength = fileSystem.size(clean);
entry.lengths[i] = newLength;
size = size - oldLength + newLength;
}
} else {
fileSystem.delete(dirty);
}
}
redundantOpCount++;
entry.currentEditor = null;
if (entry.readable | success) {
entry.readable = true;
journalWriter.writeUtf8(CLEAN).writeByte(' ');
journalWriter.writeUtf8(entry.key);
entry.writeLengths(journalWriter);
journalWriter.writeByte('\n');
if (success) {
entry.sequenceNumber = nextSequenceNumber++;
}
} else {
lruEntries.remove(entry.key);
journalWriter.writeUtf8(REMOVE).writeByte(' ');
journalWriter.writeUtf8(entry.key);
journalWriter.writeByte('\n');
}
journalWriter.flush();
if (size > maxSize || journalRebuildRequired()) {
executor.execute(cleanupRunnable);
}
}
具體commit過程,是將DIRTY改為CLEAN,并寫入CLEAN行。
過了一遍最復(fù)雜的put,里面還有很多細(xì)節(jié)沒有寫出來,但足夠讓我們了解寫入journal和緩存文件的過程。
光速看完其他三個(gè)操作,update類似put,路過。
public synchronized Snapshot get(String key) throws IOException {
initialize();
checkNotClosed();
validateKey(key);
Entry entry = lruEntries.get(key);
if (entry == null || !entry.readable) return null;
Snapshot snapshot = entry.snapshot();
if (snapshot == null) return null;
redundantOpCount++;
journalWriter.writeUtf8(READ).writeByte(' ').writeUtf8(key).writeByte('\n');
if (journalRebuildRequired()) {
executor.execute(cleanupRunnable);
}
return snapshot;
}
get方法直接從lruEntries獲取到entry,轉(zhuǎn)為Snapshot返回,寫入一條READ行。最后會(huì)從ENTRY_METADATA再讀一次entry,比較確認(rèn)匹配。
boolean removeEntry(Entry entry) throws IOException {
if (entry.currentEditor != null) {
entry.currentEditor.detach(); // Prevent the edit from completing normally.
}
for (int i = 0; i < valueCount; i++) {
fileSystem.delete(entry.cleanFiles[i]);
size -= entry.lengths[i];
entry.lengths[i] = 0;
}
redundantOpCount++;
journalWriter.writeUtf8(REMOVE).writeByte(' ').writeUtf8(entry.key).writeByte('\n');
lruEntries.remove(entry.key);
if (journalRebuildRequired()) {
executor.execute(cleanupRunnable);
}
return true;
}
刪除就是刪除clean文件和寫入REMOVE行。
補(bǔ)充介紹日志的清理,當(dāng)滿足冗余日志超過日志本體或者超過2000(journalRebuildRequired),需要執(zhí)行清理。
執(zhí)行的線程池只有一條清理線程cleanupRunnable,直接新建journal去除冗余記錄。(ConnectionPool都是用線程池執(zhí)行清理線程,好像挺好用,記?。?/p>
CacheInterceptor
對(duì)日志的操作不感冒,為了學(xué)習(xí)的完整性,分析了一輪。其實(shí)緩存機(jī)制不外乎就是對(duì)上面操作的調(diào)用,磨刀不誤砍柴工。
首先需要弄懂的是CacheStrategy,顧名思義,定義了緩存的策略,基本就是http緩存協(xié)議的實(shí)現(xiàn)。
CacheStrategy提供了Factory,傳入原始request和當(dāng)前緩存response,從requst里讀取我們熟悉的"Expires"、"Last-Modified"、"ETag"幾個(gè)緩存相關(guān)字段。CacheStrategy的get方法調(diào)用了getCandidate方法,getCandidate代碼很長,而且是根據(jù)RFC標(biāo)準(zhǔn)文檔對(duì)http協(xié)議的實(shí)現(xiàn),很死板貼。最后創(chuàng)建了CacheStrategy對(duì)象,根據(jù)是否有緩存、是否開啟緩存配置、緩存是否失效等設(shè)置networkRequest和cacheResponse。
巴拉巴拉說了這么多,記住CacheStrategy的目標(biāo)就是得到networkRequest和cacheResponse,具體代碼自己看。
根據(jù)networkRequest和cacheResponse是否為空,兩兩組合有四種情況:
| networkRequest | cacheResponse | 結(jié)果 |
|---|---|---|
| null | null | 不需要網(wǎng)絡(luò),又無緩存,所以配置了only-if-cached,返回504 |
| null | non-null | 緩存有效,使用緩存,不需要網(wǎng)絡(luò) |
| non-null | null | 無緩存或者失效,直接網(wǎng)絡(luò) |
| non-null | non-null | 緩存校驗(yàn),需要網(wǎng)絡(luò) |
CacheInterceptor的實(shí)現(xiàn)就依據(jù)上面四種情況,我們逐段分析:
Response cacheCandidate = cache != null
? cache.get(chain.request())
: null;
long now = System.currentTimeMillis();
CacheStrategy strategy = new CacheStrategy.Factory(now, chain.request(), cacheCandidate).get();
Request networkRequest = strategy.networkRequest;
Response cacheResponse = strategy.cacheResponse;
if (cache != null) {
cache.trackResponse(strategy);
}
獲取緩存和緩存策略,上面已經(jīng)講完,trackResponse統(tǒng)計(jì)緩存命中率。
if (cacheCandidate != null && cacheResponse == null) {
closeQuietly(cacheCandidate.body()); // The cache candidate wasn‘t applicable. Close it.
}
// If we’re forbidden from using the network and the cache is insufficient, fail.
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();
}
networkRequest和cacheResponse同時(shí)為空,說明設(shè)置了只用緩存,但又沒有緩存,返回504。
// If we don't need the network, we're done.
if (networkRequest == null) {
return cacheResponse.newBuilder()
.cacheResponse(stripBody(cacheResponse))
.build();
}
不需要網(wǎng)路,緩存又ok,直接返回緩存response。
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());
}
}
需要發(fā)網(wǎng)絡(luò)請(qǐng)求,這時(shí)候可能是完整請(qǐng)求也可能是緩存校驗(yàn)請(qǐng)求,在getCandidate里已經(jīng)設(shè)置好了。
// If we have a cache response too, then we‘re doing a conditional get.
if (cacheResponse != null) {
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());
}
}
如果是緩存校驗(yàn)請(qǐng)求,服務(wù)器又返回304,表示本地緩存可用,更新本地緩存并返回緩存。如果資源有更新,關(guān)閉原有的緩存。
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;
最后就是將普通請(qǐng)求寫入緩存。