OkHttp源码之缓存文件介绍

在上篇文章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中获取就可以了。

初始化后的缓存读取、写入可以继续看下面的文章

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,076评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,658评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,732评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,493评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,591评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,598评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,601评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,348评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,797评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,114评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,278评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,953评论 5 339
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,585评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,202评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,442评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,180评论 2 367
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,139评论 2 352