OkHttp原始碼之磁碟快取的實現
在上篇文章ofollow,noindex">okhttp原始碼之快取檔案介紹 中,我們大致介紹了okhttp磁碟快取的形式以及快取檔案的初始化,這篇文章中,我們繼續探討快取的讀寫操作以及一些其他的知識點.
一、快取讀取
在分析快取讀取前,我們先回顧下Cache是如何通過DiskLruCache來讀取快取的:
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); //…… }
可以看到,這裡先是計算了url的key,也就是journal檔案那串編碼,然後取得了一個Snapshot,藉助Snapshot構造一個Cache.Entry(這裡要和DiskLruCache.Entry分開),通過Entry獲取到Response。
所以我們重點分析這三步。
1.1 snapshot獲取
先直接看原始碼:
public synchronized Snapshot get(String key) throws IOException { initialize(); checkNotClosed(); validateKey(key); //先從列表中獲取對應的DiskLruCache.Entry,如果之前緩 //存過,那麼肯定會記錄在journal中,在呼叫initialize()方法後肯定能在列表中找到 Entry entry = lruEntries.get(key); if (entry == null || !entry.readable) return null; Snapshot snapshot = entry.snapshot(); if (snapshot == null) return null; redundantOpCount++; //往journal檔案中寫入READ開頭的行 journalWriter.writeUtf8(READ).writeByte(' ').writeUtf8(key).writeByte('\n'); if (journalRebuildRequired()) { executor.execute(cleanupRunnable); } return snapshot; }
這裡是直接通過url生成的key去取DiskLruCache.Entry,取到後生成一個Snapshot,至於Snapshot是什麼,我們看原始碼:
public final class Snapshot implements Closeable { private final String key; private final long sequenceNumber; private final Source[] sources; private final long[] lengths; }
可以看到Snapshot和DiskLruCache.Entry差不多,只不過這裡記錄的不是快取的檔案,而是Source[],這是okio的東西,其實可以看成對換成檔案打開了兩個InputStream[],有了Snapshot我麼就能方便的對檔案進行讀取操作。
同時獲取Snapshot後寫入了一行以READ開頭的內容到journal檔案中。
1.2 Cache.Entry的構建
獲取成功Snapshot後就是構建Cache.Entry的過程,首先看下Cache.Entry的結構:
private static final class Entry { /** Synthetic response header: the local time when the request was sent. */ private static final String SENT_MILLIS = Platform.get().getPrefix() + "-Sent-Millis"; /** Synthetic response header: the local time when the response was received. */ private static final String RECEIVED_MILLIS = Platform.get().getPrefix() + "-Received-Millis"; private final String url; private final Headers varyHeaders; private final String requestMethod; private final Protocol protocol; private final int code; private final String message; private final Headers responseHeaders; private final @Nullable Handshake handshake; private final long sentRequestMillis; private final long receivedResponseMillis; }
從成員變數就可以看出,這個Cache.Entry主要是用來存請求的Response的Header的,我們看下Cache.Entry的構造過程:
try { entry = new Entry(snapshot.getSource(ENTRY_METADATA)); } catch (IOException e) { Util.closeQuietly(snapshot); return null; }
這裡構造Cache.Entry的時候傳入的其實是.0檔案的Source(可以理解成InputStream),那麼構造過程應該就是從.0檔案讀取header的內容的過程:
Entry(Source in) throws IOException { try { BufferedSource source = Okio.buffer(in); url = source.readUtf8LineStrict(); requestMethod = source.readUtf8LineStrict(); Headers.Builder varyHeadersBuilder = new Headers.Builder(); int varyRequestHeaderLineCount = readInt(source); for (int i = 0; i < varyRequestHeaderLineCount; i++) { varyHeadersBuilder.addLenient(source.readUtf8LineStrict()); } varyHeaders = varyHeadersBuilder.build(); StatusLine statusLine = StatusLine.parse(source.readUtf8LineStrict()); protocol = statusLine.protocol; code = statusLine.code; message = statusLine.message; //省略程式碼 }
這裡省略了很多程式碼,其實就是按照寫入的順序讀出罷了,沒什麼好說的。
1.3 Response的構建
通過Cache.Entry來構建Response就一句程式碼:
Response response = entry.response(snapshot);
我們看具體實現:
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的獲取 .body(new CacheResponseBody(snapshot, contentType, contentLength)) .handshake(handshake) .sentRequestAtMillis(sentRequestMillis) .receivedResponseAtMillis(receivedResponseMillis) .build(); }
整個Response分成兩部分,Header和Body,Header的內容,之前都讀到Cache.Entry中去了,這裡可以直接獲取,所以我們重點關注Body的構建,這裡使用了一個CacheResponseBody,跟進去:
private static class CacheResponseBody extends ResponseBody { final DiskLruCache.Snapshot snapshot; private final BufferedSource bodySource; private final @Nullable String contentType; private final @Nullable String contentLength; }
可以看到,實際上我們的核心工作就是把Snapshot中的第二個檔案(也就是快取body的檔案)的輸入流賦值到這裡的bodySource就可以了,在構造方法中也確實這麼幹的:
CacheResponseBody(final DiskLruCache.Snapshot snapshot, String contentType, String contentLength) { this.snapshot = snapshot; this.contentType = contentType; this.contentLength = contentLength; Source source = snapshot.getSource(ENTRY_BODY); bodySource = Okio.buffer(new ForwardingSource(source) { @Override public void close() throws IOException { snapshot.close(); super.close(); } }); }
由於CacheResponseBody此時持有了.1檔案的輸入流,因此CacheResponseBody就能從該檔案中獲取Response的body了,從檔案中獲取內容和從網路中獲取內容其實沒有什麼區別,都是流的讀寫罷了.
至此,整個快取的讀取完成。
二、快取寫入
這塊我們還是從Cache的put方法開始:
@Nullable CacheRequest put(Response response) { //此處省略部分程式碼 //從Response中構造一個Cache.Entry Entry entry = new Entry(response); DiskLruCache.Editor editor = null; try { //從DiskLruCache中獲取一個editor editor = cache.edit(key(response.request().url())); if (editor == null) { return null; } //將Cache.Entry寫入到檔案 entry.writeTo(editor); //返回一個快取的Request return new CacheRequestImpl(editor); } catch (IOException e) { abortQuietly(editor); return null; } }
整個寫入部分分成4步,第一步構造Cache.Entry,通過上面的分析我們知道Cache.Entry存的都是Response中的Header資訊,所以這裡肯定是把Response的Header中內容賦值到Cache.Entry中,我們就不再深入,我們看後續三步。
2.1 獲取editor
我們先看下Editor是什麼:
public final class Editor { //此處的Entry是DiskLruCache.Entry,主要記錄的是每個請求涉及到的具體檔案 final Entry entry; //檔案是否可寫 final boolean[] written; private boolean done; //獲取某個檔案的輸入流,類似與InputStream public Source newSource(int index) { //省略方法實現 } //獲取某個檔案的輸出流,類似於OutputStream public Sink newSink(int index) { //省略方法實現 }
可以看到,這裡的Editor其實就是提供了一種對快取檔案的流操作而已,類似於前面提到的Snapshot,當然這裡多了一個數組記錄各個檔案是否可以寫入的狀態記錄。
現在,我們再來分析如何獲取一個Editor:
synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException { initialize(); //省略部分程式碼 Entry entry = lruEntries.get(key); //省略部分程式碼 // 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; }
可以看到,首先是往journal檔案中寫入了以DIRTY開頭的行,表明當前該請求的快取檔案已被某個執行緒準備寫入。如果之前沒有快取過要先生成一個DiskLruCache.Entry,最後生成一個Editor便於對檔案進行操作。
2.2將Cache.Entry寫入到檔案
其實就是這句:
entry.writeTo(editor);
具體看原始碼:
public void writeTo(DiskLruCache.Editor editor) throws IOException { BufferedSink sink = Okio.buffer(editor.newSink(ENTRY_METADATA)); sink.writeUtf8(url) .writeByte('\n'); sink.writeUtf8(requestMethod) .writeByte('\n'); sink.writeDecimalLong(varyHeaders.size()) .writeByte('\n'); for (int i = 0, size = varyHeaders.size(); i < size; i++) { sink.writeUtf8(varyHeaders.name(i)) .writeUtf8(": ") .writeUtf8(varyHeaders.value(i)) .writeByte('\n'); } sink.writeUtf8(new StatusLine(protocol, code, message).toString()) .writeByte('\n'); sink.writeDecimalLong(responseHeaders.size() + 2) .writeByte('\n'); for (int i = 0, size = responseHeaders.size(); i < size; i++) { sink.writeUtf8(responseHeaders.name(i)) .writeUtf8(": ") .writeUtf8(responseHeaders.value(i)) .writeByte('\n'); } sink.writeUtf8(SENT_MILLIS) .writeUtf8(": ") .writeDecimalLong(sentRequestMillis) .writeByte('\n'); sink.writeUtf8(RECEIVED_MILLIS) .writeUtf8(": ") .writeDecimalLong(receivedResponseMillis) .writeByte('\n'); 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(); }
這裡非常簡單,首先打開了.0檔案的輸出流,然後往裡按順序寫入Response的Header中的內容,也就是說這一步完成了Header的快取。
2.3 返回一個快取的CacheRequestImpl
最後,我麼看到整個put操作返回了一個CacheRequestImpl:
return new CacheRequestImpl(editor);
看到這裡大家可能會很奇怪,目前來看只是向快取檔案寫了一個Header中的資訊,並沒有快取Body的資訊,而且還返回了一個莫名奇妙的CacheRequestImpl。別急,要弄懂這個我們要回到整個Cache的put方法呼叫的地方,也就是CacheInterceptor裡:
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); } //省略程式碼 }
這裡的cache.put,我們剛才分析快取了Header資訊,那麼body資訊的快取必然是在cacheWritingResponse中了,在分析它的程式碼之前,我們先思考一個問題:Response的Body什麼時候快取合適?由於body是以流的形式讀取的,不像Header可以一次性寫入,所以body的快取必然是在讀取的時候,一邊從流裡讀,一邊快取到檔案。由於流只能讀一次,如果把流裡面的內容都讀出來返回給app呼叫層,就沒辦法重新讀一遍快取到檔案中了,所以需要把流內容拷貝,這也是為什麼要返回通過cacheWritingResponse()方法處理過後的Response的原因。
現在,我們跟進去看看這個方法:
private Response cacheWritingResponse(final CacheRequest cacheRequest, Response response) throws IOException { Source cacheWritingSource = new Source() { boolean cacheRequestClosed; @Override public long read(Buffer sink, long byteCount) throws IOException { long bytesRead; try { bytesRead = source.read(sink, byteCount); } catch (IOException e) { if (!cacheRequestClosed) { cacheRequestClosed = true; cacheRequest.abort(); // Failed to write a complete cache response. } throw e; } if (bytesRead == -1) { if (!cacheRequestClosed) { cacheRequestClosed = true; cacheBody.close(); // The cache response is complete! } return -1; } sink.copyTo(cacheBody.buffer(), sink.size() - bytesRead, bytesRead); cacheBody.emitCompleteSegments(); return bytesRead; } //省略很多程式碼 return response.newBuilder() .body(new RealResponseBody(contentType, contentLength, Okio.buffer(cacheWritingSource))) .build(); }
這個方法最終返回的是一個RealResponseBody,但最終在read的時候都會呼叫到生成的cacheWritingSource的read()方法中去,快取也是在這裡寫入的,核心就是這句:
sink.copyTo(cacheBody.buffer(), sink.size() - bytesRead, bytesRead);
往cacheBody中拷貝內容。結合上面的原始碼,這裡的cacheBody其實就是CacheRequestImpl中的body:
CacheRequestImpl(final DiskLruCache.Editor editor) { //省略部分程式碼 this.body = new ForwardingSink(cacheOut) { @Override public void close() throws IOException { synchronized (Cache.this) { //省略部分程式碼 editor.commit(); } }; }
可以看到,Body寫入到檔案後,最終還會呼叫editor的commit()方法,由於之前寫入Header和寫入body其實都是往dirty檔案,也就是xxx.0.tmp和xxx.1.tmp檔案中寫入,所以這裡的commit其實就是將.tmp檔案字尾名去掉,變成clean檔案而已,同時在journal檔案中加入一個CLEAN行。由於篇幅有限,這裡不再展開。
三、快取檔案大小控制
由於每個請求都會產生兩個檔案,同時每一次對快取檔案的操作,讀取、刪除等等都會在journal中新增一行,長此以往,整個快取目錄大小必然會膨脹起來。因此DiskLruCache有自己的清理機制。
所謂的清理機制就是執行一個Runnable而已:
private final Runnable cleanupRunnable = new Runnable() { public void run() { //省略具體實現 };
3.1 何時清理快取
之所以要清理快取必然是因為快取的檔案大小超過規定的最大大小導致的。因此,凡是影響快取檔案大小的時機以及修改最大快取值的時候都會開始清理快取。
Journal檔案太大
由於對快取的每一次操作都會在journal檔案中新增一行,行數太多,檔案會增大,必然會影響檔案讀取效率。由於快取的讀取、刪除、寫入都會往journal檔案中寫入內容,因此都會觸發清理機制,此處以讀取為例:
public synchronized Snapshot get(String key) throws IOException { //省略程式碼 redundantOpCount++; journalWriter.writeUtf8(READ).writeByte(' ').writeUtf8(key).writeByte('\n'); if (journalRebuildRequired()) { executor.execute(cleanupRunnable); } return snapshot; }
當然,由於journal檔案改動觸發的清理機制,清理之前肯定要判斷journal檔案是否過大,
這裡我們關注journalRebuildRequired()方法:
boolean journalRebuildRequired() { final int redundantOpCompactThreshold = 2000; return redundantOpCount >= redundantOpCompactThreshold && redundantOpCount >= lruEntries.size(); }
此處判斷條件就是如果journal檔案記錄的行數比實際的請求多了2000條就認為要清理。之所以會多,是因為對同一個請求會產生DIRTY、CLEAN、READ等行,所以肯定比實際快取請求的數目多。
真正的快取檔案大小超過規定
除了journal檔案外,每次寫入快取時都會統計當前所有檔案尺寸是否超過規定:
synchronized void completeEdit(Editor editor, boolean success) throws IOException { //省略程式碼 for (int i = 0; i < valueCount; i++) { File dirty = entry.dirtyFiles[i]; if (success) { if (fileSystem.exists(dirty)) { //此處會統計最新的size大小 size = size - oldLength + newLength; } } else { fileSystem.delete(dirty); } } //省略程式碼 //此處會判斷size是否符合要求以及journal檔案是否符合要求 if (size > maxSize || journalRebuildRequired()) { executor.execute(cleanupRunnable); } }
從註釋中可以看到,此處加入了size的條件控制
最大快取大小改變
public synchronized void setMaxSize(long maxSize) { this.maxSize = maxSize; if (initialized) { executor.execute(cleanupRunnable); } }
只要動態修改了最大快取大小,都要清理一次快取
3.2 如何清理快取檔案:
我們看下cleanRunnable的具體實現:
private final Runnable cleanupRunnable = new Runnable() { public void run() { synchronized (DiskLruCache.this) { if (!initialized | closed) { return; // Nothing to do } try { //刪除必要快取檔案 trimToSize(); } catch (IOException ignored) { mostRecentTrimFailed = true; } try { if (journalRebuildRequired()) { //重新建立journal檔案 rebuildJournal(); redundantOpCount = 0; } } catch (IOException e) { mostRecentRebuildFailed = true; journalWriter = Okio.buffer(Okio.blackhole()); } } } };
整個清理工作分成兩部分,一部分是刪除具體快取檔案,另一部分是重新生成journal檔案。
首先看刪除具體快取檔案:
void trimToSize() throws IOException { while (size > maxSize) { Entry toEvict = lruEntries.values().iterator().next(); removeEntry(toEvict); } mostRecentTrimFailed = false; }
就是一個簡單的迴圈,刪到快取大小符合要求,具體刪除就是檔案刪除,此處不再展開。
清理完快取檔案後,如果需要清理journal檔案的話在重新建立journal檔案.那麼journal檔案如何清理?
我們繼續回顧下之前的文章提到的journal檔案結構:
libcore.io.DiskLruCache 1 201105 2 DIRTY 2f6822d346ffd682c8e88bcd087a7d52 CLEAN 2f6822d346ffd682c8e88bcd087a7d52 275 197 READ 2f6822d346ffd682c8e88bcd087a7d52 READ 2f6822d346ffd682c8e88bcd087a7d52 DIRTY 2f6822d346ffd682c8e88bcd087a7d52 CLEAN 2f6822d346ffd682c8e88bcd087a7d52 275 192
可以看到目前journal檔案記錄6行,但其實這都是對一個url請求的 寫-->讀-->讀-->寫 操作,真正能代表當前快取狀態的其實是最後一行,因此只需要保留最後一行就可以了.
synchronized void rebuildJournal() throws IOException { if (journalWriter != null) { journalWriter.close(); } BufferedSink writer = Okio.buffer(fileSystem.sink(journalFileTmp)); try { //寫入journal的前5行 writer.writeUtf8(MAGIC).writeByte('\n'); writer.writeUtf8(VERSION_1).writeByte('\n'); writer.writeDecimalLong(appVersion).writeByte('\n'); writer.writeDecimalLong(valueCount).writeByte('\n'); writer.writeByte('\n'); for (Entry entry : lruEntries.values()) { if (entry.currentEditor != null) { writer.writeUtf8(DIRTY).writeByte(' '); writer.writeUtf8(entry.key); writer.writeByte('\n'); } else { writer.writeUtf8(CLEAN).writeByte(' '); writer.writeUtf8(entry.key); entry.writeLengths(writer); writer.writeByte('\n'); } } } finally { writer.close(); } if (fileSystem.exists(journalFile)) { fileSystem.rename(journalFile, journalFileBackup); } fileSystem.rename(journalFileTmp, journalFile); fileSystem.delete(journalFileBackup); journalWriter = newJournalWriter(); hasJournalErrors = false; mostRecentRebuildFailed = false; }
原始碼很簡單,可以看到,重建時,如果當前的請求正在寫入,則依然保留為DIRTY行,否則都只保留CLEAN行.
四. 總結
本文乍看之下比較冗長,但大家如果想將okhttp的快取思想吸收,這些細節才是關鍵,所以最好是開啟原始碼參照本文細細琢磨,耐心看下去.