在上篇文章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中获取就可以了。
初始化后的缓存读取、写入可以继续看下面的文章