《庖丁解牛Android源碼 - OkHttp源碼》(一) 拆解 DiskLruCache

1.簡述

OkHttpSquare 組織出品的一套支持 http1.x/http 2/WebSocket 的網絡框架。因爲其易用性被廣大的公司所採用。 OkHttp 跟以往的網絡不一樣還在於: OkHttp 拋棄掉以往基於 Android 平臺或者 Java 平臺上原生的 HTTP 協議支持的Api ,而本身實現了一套 HTTP 協議。而且這套協議不只支持 1.xHTTP 協議,還支持 2.0HTTP協議android

(本文將先拆解 OkHttp 中的各類組件,而後整合到一塊兒分析 OkHttp 框架)算法

今天咱們要分析的類是 DiskLruCacheDisk 表明這個 cache 針對的是外置存儲器,多是磁盤或者 sdcardLru 表明這個 cache 所使用的淘汰算法。其實,將 DiskLruCache 歸類於 OkHttp 並不許確,這個類本來屬於 android 4.1 的系統類,位於 libcore.io 包下。抽取出來之後就能夠應用於任何版本的 Android 系統。實際上,咱們依舊能在 OkHttp 的 DiskLruCache 代碼中找到 libcore.io 的影子:緩存

// OkHttp3
public final class DiskLruCache implements Closeable, Flushable { 
    ...
    static final String MAGIC = "libcore.io.DiskLruCache";//文件魔數依舊保留libcore包名
   ....
/*
     * This cache uses a journal file named "journal". A typical journal file
     * looks like this:
     *     libcore.io.DiskLruCache
      ....
*/
}
複製代碼

如上面源碼所述,DiskLruCache 會將一些元數據文件信息記錄到本身的一個數據文件中,而文件魔數 MAGIC 依然保留着 libcore 的包名,甚至連註釋,也直接 copy 的當時 libcore 時候的註釋。固然,OkHttp 跟 libcore 裏的 DiskLruCache 也有差異,主要體如今 IO 處理上。OkHttp 是基於 Okio 框架開發的,所以 OkHttp 在處理 IO 的時候使用了更爲方便的 Okio 接口。安全

(若是你對 Okio 並不熟悉,能夠參考個人 Okio 系列文章:Okio源碼解析 )bash

DiskLruCache 的構造須要調用它的靜態工廠方法 create :網絡

public static DiskLruCache create(FileSystem fileSystem, File directory, int appVersion,
      int valueCount, long maxSize) {
    if (maxSize <= 0) {
      throw new IllegalArgumentException("maxSize <= 0");
    }
    if (valueCount <= 0) {
      throw new IllegalArgumentException("valueCount <= 0");
    }

    // Use a single background thread to evict entries.
    Executor executor = new ThreadPoolExecutor(0, 1, 60L, TimeUnit.SECONDS,
        new LinkedBlockingQueue<Runnable>(), Util.threadFactory("OkHttp DiskLruCache", true));//構建單線程線程池

    return new DiskLruCache(fileSystem, directory, appVersion, valueCount, maxSize, executor);
  }
複製代碼

靜態工廠方法 create 所須要的形參,基本就是 DiskLruCache 構造器的形參。所須要的參數分別是:數據結構

參數名 參數類型 參數含義
fileSystem FileSystem 對文件操做相關接口的抽象
directory File 目錄文件,表示緩存文件所在目錄
appVersion int 應用版本號
valueCount int 表示最後所存儲的數據記錄包含多少數據
好比在 Okhttp 中 須要包含文件信息和文件內容兩個數據,那麼 valueCount 就爲2
maxSize long 文件夾最大能夠保存的文件大小
executor ThreadPoolExecutor 淘汰處理線程池,通常是單線程池

通常狀況下,咱們不須要指定這麼多的參數,OkHttp 給我提供了一個很好的門面 okhttp3.CacheCache 類將持有一個 DiskLruCache 對象,最後的實際操做將交給 DiskLruCache 對象去執行,比較相似 ContextWarpperContextImpl 的關係。app

public Cache(File directory, long maxSize) {//構造器
    this(directory, maxSize, FileSystem.SYSTEM);
  }

  Cache(File directory, long maxSize, FileSystem fileSystem) {
    this.cache = DiskLruCache.create(fileSystem, directory, VERSION, ENTRY_COUNT, maxSize);//構建一個DiskLruCache 存到成員變量 cache中去
  }
複製代碼

經過 okhttp3.Cache ,調用者只須要經過傳遞一個目錄和最大存儲值便可構建一個 DiskLruCache 。OkHttp 之因此要在 DiskLruCache 這個類以外再包裝一個 Cache 主要是由於 DiskLruCache 並不關注具體的業務種類。而 okhttp3.Cache 主要功能,是將 DiskLruCache 包裝成爲能夠方便處理 OkHttp 的網絡相關業務的類。框架

2.Demo

咱們先簡單使用一下 DiskLruCache :ide

private static void test(DiskLruCache cache ,String tag)throws Exception {
		Editor editor = cache.edit(tag);//開啓名字爲 tag 的事務
		File file = new File("/Users/david/temp2.txt");//temp2.txt 佔用1303個字節
		Buffer  buffer = new Buffer();
		Source source = Okio.source(file);
		source.read(buffer, file.length());
		source.close();
		editor.newSink(0).write(buffer,
				buffer.size());
		editor.commit();
	}
	
// test code
DiskLruCache cache = DiskLruCache.create(FileSystem.SYSTEM, 
					new File("/Users/david/cache"), 1, 1, 3000);
			cache.initialize();
			test(cache,"hello1");
			test(cache,"hello2");
			test(cache,"hello3");
			test(cache,"hello4");
複製代碼

代碼執行以後,將會在咱們的 cache 目錄下生成下列文件:

cache文件夾目錄

  1. journal 文件相似一個日誌文件,用來保存你對該文件夾的操做記錄
  2. hello*.0 文件須要分紅兩部分 "." 好前部分 "hello*" 就是咱們傳入的 key。後面的阿拉伯數字表明咱們所對應的數據段索引。

3. journal 文件和初始化

journal 是一個日誌文件,它其實是一個文本文件,它的文件結構以下圖:

MAGIC
DiskLruCache 版本號
APP_VERSION
VALUE_COUNT
BLANK
...
RECORDS
...

上面的例子中咱們將獲得文件內容:

libcore.io.DiskLruCache //MAGIC
1 //DiskLruCache 版本號
1 //APP_VERSION
1 //VALUE_COUNT
  //BLANK
DIRTY hello1 //RECORDS
CLEAN hello1 1303
DIRTY hello2
CLEAN hello2 1303
DIRTY hello3
CLEAN hello3 1303
DIRTY hello4
REMOVE hello1
CLEAN hello4 1303
複製代碼

當外部須要訪問 DiskLruCache 中的數據時候, DiskLruCache 將調用 initialize() 函數,這個函數將讀取 journal 文件進行初始化操做。好比你在使用 DiskLruCache.get 獲取緩存的時候:

public synchronized Snapshot get(String key) throws IOException {
    initialize();
    ...具體get操做
}
複製代碼

initialize() 函數將會在全部數據訪問操做以前執行,相似 AOP 。DiskLruCache 在記錄的管理上,保持了比較高的安全策略。爲了保證數據的準確性,須要維護多個 journal 文件,避免管理出錯

public synchronized void initialize() throws IOException {
    。。。
    if (initialized) {
      return; // Already initialized.
    }

    // If a bkp file exists, use it instead.
    if (fileSystem.exists(journalFileBackup)) {
        ...
      }
    }//若是原始文件消失,能夠採用備份文件

    // Prefer to pick up where we left off.
    if (fileSystem.exists(journalFile)) {
      try {
        readJournal();//讀取journal文件
        processJournal();//處理從journal文件讀取出來的數據,刪除沒必要要的數據
        initialized = true;
        return;
      } catch (IOException journalIsCorrupt) {
        ...
        delete();//若是文件解析出現異常,刪除掉journal文件
        closed = false;
      }
    }
    rebuildJournal();//從新構建一個journal文件
    initialized = true;
  }
複製代碼

Journal 文件的讀取依賴於文件的數據結構,日誌記錄數據將經過調用 readJournalLine 方法實現:

private void readJournal() throws IOException {
    BufferedSource source = Okio.buffer(fileSystem.source(journalFile));
    try {
      String magic = source.readUtf8LineStrict();//MAGIC
      String version = source.readUtf8LineStrict();//VERSION
      String appVersionString = source.readUtf8LineStrict();//APPVERSION
      String valueCountString = source.readUtf8LineStrict();//VALUE_COUNT
      String blank = source.readUtf8LineStrict();//BLANK
      ...
      int lineCount = 0;
      while (true) {
        try {
          readJournalLine(source.readUtf8LineStrict());//解析記錄數據
          lineCount++;
        } catch (EOFException endOfJournal) {
          break;
        }
      }
      ...
  }
複製代碼

每個記錄都將分紅幾部分:指令 + key + [文件大小+],每個數據元都使用空格分隔。而若是指令能夠攜帶文件大小參數的話,那麼這個文件大小參數能夠是多個,個數根據參數 valueCount 指定。好比上面的例子中

指令 空格佔位 Key 空格佔位 文件長度
CLEAN hello1 1303

Journal 有四個指令:

private static final String CLEAN = "CLEAN"; //須要攜帶文件大小
  private static final String DIRTY = "DIRTY";
  private static final String REMOVE = "REMOVE";
  private static final String READ = "READ";
複製代碼

其中,只有 "CLEAN" 指令須要攜帶文件大小。這四個指令的含義分別是:

指令 指令含義
CLEAN 該記錄的修改或者添加已經正確持久化
DIRTY 該記錄被修改,但還未持久化
REMOVE 這條記錄已經被刪除
READ 表示這條文件記錄被訪問過
private void readJournalLine(String line) throws IOException {
    int firstSpace = line.indexOf(' ');
    ...
    int keyBegin = firstSpace + 1;
    int secondSpace = line.indexOf(' ', keyBegin);//判斷 CLEAN 指令
    final String key;
    if (secondSpace == -1) {
      key = line.substring(keyBegin);
      //code step1
      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) {
      entry = new Entry(key);
      lruEntries.put(key, entry);//構建 lruEntries
    }

    if (secondSpace != -1 && firstSpace == CLEAN.length() && line.startsWith(CLEAN)) {
        //code step2
      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)) {
      entry.currentEditor = new Editor(entry);
    } else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) {
        //nothing
    } else {
      throw new IOException("unexpected journal line: " + line);
    }
  }
複製代碼

反覆調用 readJournalLine 函數的目的,是爲了構建 lruEntries 對象,而在將字符串指令轉換成爲具體對象的時候,根據指令的特性,DiskLruCache 運用了一些簡單的算法:

  1. CLEAN: 當第二個空格存在的時候,表明着後面攜帶文件大小參數,(對應代碼 step2 位置),獲得文件大小參數字符串 line.substring(secondSpace + 1) 經過調用 entry.setLengths 方法設置到 entry 文件內存記錄中去。
  2. DIRTY: 判斷指令字符和長度
  3. READ: 判斷指令字符和長度
  4. REMOVE: 判斷指令長度和長度

initialize 函數經過調用 readJournal 完成配置以後,將會調用 processJournal 。這個函數一方面是用於計算 key 對應的數據的總大小,一方面是對一些髒數據處理,最後狀態 DIRTY 的數據是不安全的。多是你在準備寫入的時候,程序中斷,致使這個事務並沒被執行。爲了保證數據的完整性和安全性,DiskLruCache 會將這個 key 對應的相關數據刪除。

private void processJournal() throws IOException {
    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 {
        ...
        //髒數據進行處理
        for (int t = 0; t < valueCount; t++) {
          fileSystem.delete(entry.cleanFiles[t]);
          fileSystem.delete(entry.dirtyFiles[t]);
        }
        i.remove();
      }
    }
  }
複製代碼

4. 添加記錄

經過咱們上面的 demo 和後面的代碼分析。咱們知道,對 DiskLruCache 對象的處理是基於事務對象 Editor 。這種作法像極了咱們的 SharePerference 對象。DiskLruCacheEditor 事務是經過調用 DiskLruCache.edit 函數得到:

synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
    initialize();//初始化
    ...
    validateKey(key);//檢測key名字是否合法
    Entry entry = lruEntries.get(key);
    ...
    if (entry != null && entry.currentEditor != null) {//保證每次只有一個東西在操做文件
      return null; // Another edit is in progress.
    }
    if (mostRecentTrimFailed || mostRecentRebuildFailed) {
      executor.execute(cleanupRunnable);//執行淘汰機制
      return null;
    }
    journalWriter.writeUtf8(DIRTY).writeByte(' ').writeUtf8(key).writeByte('\n');//保存DIRTY 記錄
    journalWriter.flush();
    ...
    if (entry == null) {
      entry = new Entry(key);
      lruEntries.put(key, entry);
    }
    Editor editor = new Editor(entry);
    entry.currentEditor = editor;//將該事務分配在這條記錄之上
    return editor;
  }
複製代碼

lruEntriesLinkedHashMap類型,Linked 前綴的 Map 類型表示,當採用迭代器方式獲取 Map 中的數據時候,將以 LinkedList 的有序序列返回,利用這個中有序性,就能夠實現 LRU 的簡單算法。當你對 Edit 事務都處理完了之後,就須要調用 Edit.commit() 函數提交最後的修改,實際上就是在 Journal 文件的最後將你操做記錄的文件狀態設置爲 CLEAN

public void commit() throws IOException {
      synchronized (DiskLruCache.this) {
        if (done) {
          throw new IllegalStateException();
        }
        if (entry.currentEditor == this) {
          completeEdit(this, true);//第二個參數表明這個事務是否執行正常
        }
        done = true;
      }
    }
    
synchronized void completeEdit(Editor editor, boolean success) throws IOException {
    ...
    if (success && !entry.readable) {
      for (int i = 0; i < valueCount; i++) {
        if (!editor.written[i]) {//step1 保證每個數據都被寫入
          editor.abort();
          throw new IllegalStateException("Newly created entry didn't create value for index " + i);
        }
        if (!fileSystem.exists(entry.dirtyFiles[i])) {
          editor.abort();//dirty文件不存在,那麼須要將該事務棄置
          return;
        }
      }
    }
    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);//dirtyFile->cleanFile
          ...
        }
      }
    }

    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);
    }
  }
複製代碼

這裏,size 變量表明的是目前的目錄下全部文件的大小。在 Edit.commit 裏調用了內部函數 completeEdit(Editor editor, boolean success) 。代碼 step1 用於檢查是否全部的數據都被覆蓋。若是你要修改一個數據,須要將這條記錄的全部文件都要修改。好比咱們的 OkHttp 框架,每一次 OkHttp 修改記錄的時候都會對全部的索引文件進行修改:

//code com.squareup.okhttp.Cache
public final class Cache {
    private static final int ENTRY_METADATA = 0;
    private static final int ENTRY_BODY = 1;
    private static final int ENTRY_COUNT = 2;
}
複製代碼

每個 OkHttp 記錄包含兩個數據段,對應的索引分別是:

  • ENTRY_METADATA: 元數據段,用於儲存請求信息和時間信息
  • ENTRY_BODY: 數據體,用於儲存實際的數據

當經過 Cache.put(Response response) 往緩衝池裏存入數據的時候,會調用到咱們上面所闡述的 DiskLruCache 存放記錄的流程:

private CacheRequest put(Response response) throws IOException {
    ...
    Entry entry = new Entry(response);
    DiskLruCache.Editor editor = null;
    try {
      editor = cache.edit(urlToKey(response.request()));
      if (editor == null) {
        return null;
      }
      entry.writeTo(editor);
      //這個函數用於寫入索引爲ENTRY_METADATA的數據
      return new CacheRequestImpl(editor); 
      //CacheRequestImpl 這個對象包裝了寫入ENTRY_BODY的操做和commit 操做
    } catch (IOException e) {
      abortQuietly(editor);
      return null;
    }
  }
複製代碼

當調用Cache.put(Response response) 函數的時候,OkHttp 會生成一個 Entry 對象,這個Entry 用於表示 OkHttp 的每個請求的記錄,而 DiskLruCache 中的 Entry 是用來表示key數據的記錄。OkHttp 會經過調用 entry.writeTo(editor) 的方式將 ENTRY_METADATA 數據寫入 editor 事務中去,而且構建了一個 CacheRequest 對象給上層,用於後面 ENTRY_BODY 數據的輸入。

//code Cache.Entry
public void writeTo(DiskLruCache.Editor editor) throws IOException {
      BufferedSink sink = Okio.buffer(editor.newSink(ENTRY_METADATA));
      ....
      sink.close();
      //注意,此處並無對 editor 作commit,是由於 ENTRY_BODY 還沒寫
 }
複製代碼

CacheRequest 對象其實是作了一層簡單的裝飾,保證當你數據書寫完畢之後 editor 對象被 commit :

//code CacheRequestImpl
public CacheRequestImpl(final DiskLruCache.Editor editor) throws IOException {
      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();
          //保證close函數調用之後事務 editor 被commit
        }
      };
    }
複製代碼

4. 淘汰和排序記錄

上面咱們說明了如何經過 DiskLruCache 對象添加一條記錄,以及在 OkHttp 裏是如何添加記錄的,可是咱們還遺留了一個問題,就是在 DiskLruCache中是如何實現 LRU 的?咱們彷佛並無在代碼中找到對應的代碼。上面咱們說到,文件記錄的存儲是放在一個 Map 對象 lruEntries 中去,而這個 lruEntries 是一個 LinkedHashMap 類型。在 DiskLruCache 生成 lruEntries 對象的時候,調用的是LinkedHashMap 三參構造器:

public final class DiskLruCache {
    ...
    final LinkedHashMap<String, Entry> lruEntries = new LinkedHashMap<>(0, 0.75f, true);
    ...
}
複製代碼

咱們說的Lru算法實現就依賴於 LinkedHashMap 構造器的最後一個參數。參數註釋翻譯過來就是是否支持訪問後迭代排序。

/**
     * The iteration ordering method for this linked hash map: <tt>true</tt>
     * for access-order, <tt>false</tt> for insertion-order.
     *
     * @serial
     */
    private final boolean accessOrder;
    public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }
複製代碼

咱們來看下這個參數是如何影響最後的迭代序列的,咱們從訪問函數 get 入手:

//code LinkedHashMap
 public V get(Object key) {
        LinkedHashMapEntry<K,V> e = (LinkedHashMapEntry<K,V>)getEntry(key);
        if (e == null)
            return null;
        e.recordAccess(this);
        return e.value;
    }
//code LinkedHashMapEntry
void recordAccess(HashMap<K,V> m) {
            LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
            if (lm.accessOrder) {
                lm.modCount++;
                remove();//將本身從迭代隊列中刪除
                addBefore(lm.header);// 將本身放入迭代隊列隊頭
            }
        }
複製代碼

每一次訪問操做都會觸發 LinkedHashMapEntry.recordAccess 接口。 LinkedHashMapEntry 是迭代器線性列表中的一個節點,在調用 recordAccess 的時候會判斷 lm.accessOrder 屬性是否爲 true 。而後將經過調用 removeaddBefore(lm.header)lm.header LinkedHashMap 的散列隊列的隊列頭部。經過這兩步操做,這個LinkedHashMapEntry 對象就將本身變成隊列頭部,從而實現了 LRU 算法。

DiskLruCache 認爲數據被訪問依賴於兩個函數:

  1. Editor edit(String): 用於開啓編輯事務
  2. Snapshot get(String key) : 用於獲取記錄快照

當須要重建 journal 文件或者觸發清理操做的時候,會往線程池中拋出一個 cleanupRunnable 消息:

private final Runnable cleanupRunnable = new Runnable() {
    public void run() {
      synchronized (DiskLruCache.this) {
        ...
          trimToSize();
        ...
      }
    }
  };
  
void trimToSize() throws IOException {
    while (size > maxSize) {
      Entry toEvict = lruEntries.values().iterator().next();
      removeEntry(toEvict);
    }
    mostRecentTrimFailed = false;
  }  
  
複製代碼

cleanupRunnable 命令中會調用 trimToSize 函數,用於刪除和計算當前 cache 文件夾中所包含的文件大小總和。清理操做由 removeEntry(Entry) 完成:

boolean removeEntry(Entry entry) throws IOException {
    ...
    for (int i = 0; i < valueCount; i++) {
      fileSystem.delete(entry.cleanFiles[i]);
      size -= entry.lengths[i];
      entry.lengths[i] = 0;
    }
    ...
    return true;
  }
複製代碼

所謂清理,也就是刪除掉掉這條記錄下全部文件。

5. 總結

DiskLruCache 這個類,或者這種模式有很好的通用性,目前也非只有 OkHttp 一個框架在用。做者但願讀者們將這篇做爲一篇工具文檔,而不是做爲一篇知識儲備文檔,結合這篇工具文檔去結合代碼去看或者去實際操做,這樣能更加深入的理解DiskLruCache 這類工具。

相關文章
相關標籤/搜索