OkHttp原始碼之快取檔案介紹
在上篇文章ofollow,noindex">OkHttp原始碼之CacheInterceptor 中,我們介紹了okhttp是如何使用快取的,但沒有涉及到快取具體是如何儲存到磁碟的,又是以何種形式儲存的。今天我們重點介紹下快取檔案的形式以及快取檔案的初始化。
一、快取檔案的構造
首先,為了便於我們理解原始碼,這裡首先介紹下快取檔案構造和格式。首先我們配置好快取並請求一個介面:
http://api.apiopen.top/singlePoetry
然後在快取目錄下會出現三個檔案:
journal 2f6822d346ffd682c8e88bcd087a7d52.0 2f6822d346ffd682c8e88bcd087a7d52.1
這是請求結束後的,事實上,請求過程中會出現兩個臨時檔案:
2f6822d346ffd682c8e88bcd087a7d52.0.tmp 2f6822d346ffd682c8e88bcd087a7d52.1.tmp
檔名其實是url通過md5算出來的,每個url都會生成兩個檔案,首先看下.0檔案的內容:
http://api.apiopen.top/singlePoetry GET 0 HTTP/1.1 200 7 Server: nginx Date: Sat, 03 Nov 2018 08:39:11 GMT Content-Type: application/json;charset=UTF-8 Transfer-Encoding: chunked Connection: keep-alive OkHttp-Sent-Millis: 1541234351306 OkHttp-Received-Millis: 1541234351444
可以看到.0檔案都是http返回的Header中的內容,然後看下.1檔案內容:
{"code":200,"message":"成功!","result":{"author":"李清照","origin":"如夢令·昨夜雨疏風驟","category":"古詩文-天氣-寫雨","content":"昨夜雨疏風驟,濃睡不消殘酒。"}}
這裡存的是http返回的body,然後看下journal檔案:
libcore.io.DiskLruCache 1 201105 2 DIRTY 2f6822d346ffd682c8e88bcd087a7d52 CLEAN 2f6822d346ffd682c8e88bcd087a7d52 275 197 READ 2f6822d346ffd682c8e88bcd087a7d52 READ 2f6822d346ffd682c8e88bcd087a7d52 DIRTY 2f6822d346ffd682c8e88bcd087a7d52 CLEAN 2f6822d346ffd682c8e88bcd087a7d52 275 192
這裡重點介紹下journal檔案的構成
前五行是journal檔案固定頭部,分別是常量字串“libcore.io.DiskLruCache”,硬碟快取版本號,應用版本號,每個url快取的檔案數量以及一個空白行
- DIRTY開頭的行。表明快取正在被建立或更新,每一個dirty行後面必然跟著一個CLEAR或者REMOVE行,否則該檔案就是有問題的
- CLEAN開頭的行。表明一個請求被快取完畢
- READ開頭的行。表明正在讀取該快取
-
REMOVE開頭的行。表明快取被刪除
另外,那一串奇怪的十六進位制字串就是url通過md5得到的。
那麼journal檔案是幹什麼的呢?剛剛提到,每個url請求都會生成2個檔案,那麼10個請求就會生成20個檔案,那麼journal是不是用來索引各種檔案的呢?其實不然,因為快取檔名稱都有固定的規則,根據url算出來的,我們完全可以計算url對應的檔名稱,然後直接開啟。所以它的作用主要是記錄各個快取檔案的狀態,比如該檔案是否被其他執行緒寫入,該url對應的快取檔案是否被破壞等。
二、快取檔案初始化
事實上,在快取的寫入、刪除、更新時都會提前初始化好快取檔案,快取檔案初始化的目的其實就是把各個快取檔案的關係讀取到記憶體中,也就是把journal檔案的內容轉化到記憶體中。核心程式碼主要是通過initialize()方法實現的:
public synchronized void initialize() throws IOException { assert Thread.holdsLock(this); if (initialized) { return; // Already initialized. } // If a bkp file exists, use it instead. if (fileSystem.exists(journalFileBackup)) { // If journal file also exists just delete backup file. if (fileSystem.exists(journalFile)) { fileSystem.delete(journalFileBackup); } else { fileSystem.rename(journalFileBackup, journalFile); } } // Prefer to pick up where we left off. if (fileSystem.exists(journalFile)) { try { readJournal(); processJournal(); initialized = true; return; } catch (IOException journalIsCorrupt) { Platform.get().log(WARN, "DiskLruCache " + directory + " is corrupt: " + journalIsCorrupt.getMessage() + ", removing", journalIsCorrupt); } // The cache is corrupted, attempt to delete the contents of the directory. This can throw and // we'll let that propagate out as it likely means there is a severe filesystem problem. try { delete(); } finally { closed = false; } } rebuildJournal(); initialized = true; }
整個初始化做了三件事,第一件事就是檢測是否有備份的journal檔案,如果只有備份檔案,將其重新命名成journal;第二件事就是有journal存在時,讀取journal檔案;第三件事就是journal檔案損壞或不存在時重建journal。我們重點分析後兩件事。
讀取journal
核心程式碼就是通過兩個方法實現的:
try { readJournal(); processJournal(); initialized = true; return; } catch (IOException journalIsCorrupt) { Platform.get().log(WARN, "DiskLruCache " + directory + " is corrupt: " + journalIsCorrupt.getMessage() + ", removing", journalIsCorrupt); }
我們先看readJournal():
private void readJournal() throws IOException { BufferedSource source = Okio.buffer(fileSystem.source(journalFile)); try { //讀取前5行,判斷檔案是否被損壞 String magic = source.readUtf8LineStrict(); String version = source.readUtf8LineStrict(); String appVersionString = source.readUtf8LineStrict(); String valueCountString = source.readUtf8LineStrict(); String blank = source.readUtf8LineStrict(); if (!MAGIC.equals(magic) || !VERSION_1.equals(version) || !Integer.toString(appVersion).equals(appVersionString) || !Integer.toString(valueCount).equals(valueCountString) || !"".equals(blank)) { throw new IOException("unexpected journal header: [" + magic + ", " + version + ", " + valueCountString + ", " + blank + "]"); } int lineCount = 0; //迴圈讀取每一行 while (true) { try { readJournalLine(source.readUtf8LineStrict()); lineCount++; } catch (EOFException endOfJournal) { break; } } redundantOpCount = lineCount - lruEntries.size(); //省略無關程式碼 }
首先是讀取前5行,判斷journal檔案是否被損壞,然後就是迴圈讀取每一行。在分析讀取每一行的readJournalLine()方法之前,我們先認識一個類DiskLurCache.Entry:
private final class Entry { //key就是通過url md5計算後的編碼 final String key; //儲存的是每個檔案的長度 final long[] lengths; //儲存的是.0和.1檔案 final File[] cleanFiles; ////儲存的是.0.tmp和.1.tmp檔案 final File[] dirtyFiles; /** True if this entry has ever been published. */ boolean readable; /** The ongoing edit or null if this entry is not being edited. */ Editor currentEditor; }
每一個快取了的請求都會有一個DiskLruCache.Entry類來管理該請求相關的各個檔案,而所謂的初始化就是將journal檔案中記錄的一個個請求資訊讀入到記憶體構建一個Map:
//其中key是url算出來的key final LinkedHashMap<String, Entry> lruEntries = new LinkedHashMap<>(0, 0.75f, true);
這樣我們就能通過該map準確查詢一個請求是否快取過。現在我們來看看這個map的構造過程:
private void readJournalLine(String line) throws IOException { int firstSpace = line.indexOf(' '); if (firstSpace == -1) { throw new IOException("unexpected journal line: " + line); } int keyBegin = firstSpace + 1; int secondSpace = line.indexOf(' ', keyBegin); final String key; if (secondSpace == -1) { key = line.substring(keyBegin); //此時說明讀取到了REMOVE開頭的行,我們要將 //這行代表的請求從map中刪除 if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) { lruEntries.remove(key); return; } } else { key = line.substring(keyBegin, secondSpace); } Entry entry = lruEntries.get(key); if (entry == null) { //將以剩下的其他狀態開頭的行代表的請求資訊加入map entry = new Entry(key); lruEntries.put(key, entry); } if (secondSpace != -1 && firstSpace == CLEAN.length() && line.startsWith(CLEAN)) { //如果是以CLEAN開頭的,那麼該請求當前是可以被讀寫的 String[] parts = line.substring(secondSpace + 1).split(" "); entry.readable = true; entry.currentEditor = null; entry.setLengths(parts); } else if (secondSpace == -1 && firstSpace == DIRTY.length() && line.startsWith(DIRTY)) { //如果是以DIRTY開頭,說明該快取還在被其他執行緒寫入,這裡要設定下正在寫入的editor entry.currentEditor = new Editor(entry); } else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) { //以READ開頭表明有其他執行緒讀取過,無需任何其他操作 // This work was already done by calling lruEntries.get(). } else { throw new IOException("unexpected journal line: " + line); } }
上面的註釋寫的很清楚,就是把journal檔案中的每一行對應的內容轉換成一個map中的一個元素而已,不復雜。
到此為止,我們readJournal()方法分析完畢,這裡只是把資訊讀入到記憶體,然後我麼看下處理方法processJournal():
private void processJournal() throws IOException { //刪除journal的備份檔案 fileSystem.delete(journalFileTmp); for (Iterator<Entry> i = lruEntries.values().iterator(); i.hasNext(); ) { Entry entry = i.next(); if (entry.currentEditor == null) { for (int t = 0; t < valueCount; t++) { //統計當前已經快取完畢的所有檔案的大小, //便於以後控制總快取的大小 size += entry.lengths[t]; } } else { entry.currentEditor = null; for (int t = 0; t < valueCount; t++) { fileSystem.delete(entry.cleanFiles[t]); fileSystem.delete(entry.dirtyFiles[t]); } i.remove(); } } }
其實就幹兩件事,統計快取檔案大小和刪除有問題的快取檔案。這裡對於size的統計沒有問題,但大家對於else分支可能疑問比較大,進入else說明構建這個Entry時,journal中的那行是以dirty開頭的,一般來說,以dirty開頭的後面一行肯定會跟著CLEAN行或REMOVE行,這中情況下entry.currentEditor一定是為null的,也就是說不會進入到else分支,但這裡進來了。這就是異常情況了,比如寫入快取到一半時突然機器斷電,那麼這寫入到一半的快取就是垃圾資訊,所以這裡要把相關的快取檔案都刪除。
至此,整個初始化過程結束。
三、總結
經過初始化後,lruEntries這個成員變數就初始化完畢,下次尋找某個url對應的快取檔案時直接從這個lruEntries中獲取就可以了。
初始化後的快取讀取、寫入可以繼續看下面的文章