okhttp 3.10缓存原理

主文okhttp 3.10详细介绍okhttp的缓存机制,缓存代码都在拦截器CacheInterceptor中实现,在看代码之前,先回顾http的缓存策略。

http缓存策略

http缓存中最常用的是下面几个:

  • Expires
  • Cache-control
  • Last-Modified / If-Modified-Since
  • Etag / If-None-Match

Expires和Cache-control

Expires和Cache-control看的是资源过期时间,如果在时间范围内,缓存命中,直接使用缓存;否则需要向服务器发送请求,拿到完整的数据。

Expires:Mon, 30 Apr 2018 05:24:14 GMT
Cache-Control:public, max-age=31536000

上面是第一次访问资源时,response返回的Expires和Cache-control。

Expires写死资源的过期时间,在时间范围内,客户端可以继续使用缓存,不需要发送请求。Expires是http1时代的东西,缺陷很明显,时间是服务器时间,和客户端时间可能存在误差。在http1.1,升级使用Cache-Control,同时存在Expires和Cache-control时,以Cache-control为准。

Cache-control常见的可选项有:

  • private:客户端可以缓存
  • public:客户端和代理服务器都可以缓存
  • max-age=x-seconds:多少秒内可以缓存
  • no-cache:不能直接缓存,需要用后面介绍的缓存校验
  • no-store:不能缓存

上面例子同时使用了public和max-age,max-age=31536000表示在365天内都可以直接使用缓存。

缓存校验

资源过期后,需要向服务器发送请求,但资源可能在服务器上没有修改过,没有必要完整拿回整个资源,这个时候缓存校验就派上用场。

  • Last-Modified / If-Modified-Since
  • Etag / If-None-Match

上面两组是缓存校验相关的字段,首先来看Last-Modified / If-Modified-Since。

第一次请求response
Last-Modified:Tue, 03 Apr 2018 10:26:36 GMT

第二次请求request
If-Modified-Since:Tue, 03 Apr 2018 10:26:36 GMT

第一次请求资源时,服务器会在response中带上资源最后修改时间,写在Last-Modified。当客户端再次请求资源,request用If-Modified-Since带上上次response中的Last-Modified,询问该时间后资源是否修改过:

  • 资源修改过,需要返回完整内容,响应200;
  • 资源没有修改过,只需要返回http头,响应304。

Last-Modified在时间上只到秒,Etag为资源生成唯一标识,更加精确。

第一次请求response
ETag:"2400-5437207ef2880"

第二次请求request
If-None-Match:"2400-5437207ef2880"

第一次请求资源时,response在ETag返回资源在服务器的唯一标识。当客户端再次请求资源时,request在If-None-Match带上上次的唯一标识,询问资源是否修改过:

  • 唯一标识不同,资源修改过,需要返回完整内容,响应200;
  • 唯一标识相同,资源没有修改过,只返回http头,响应304。

Last-Modified和ETag同时存在时,当然ETag优先。

测试缓存效果

进入正题,先来展示okhttp上使用缓存的效果。

Cache cache = new Cache(new File("/Users/heng/testCache"), 1024 * 1024);
OkHttpClient client = new OkHttpClient.Builder().cache(cache).build();

Request request = new Request.Builder().url("http://www.taobao.com/").
        cacheControl(new CacheControl.Builder().maxStale(365, TimeUnit.DAYS).build()).
        build();
Response response1 = client.newCall(request).execute();
response1.body().string();
System.out.println("response1.networkResponse:" + response1.networkResponse());
System.out.println("response1.cacheResponse:" + response1.cacheResponse());
System.out.println("");

Response response2 = client.newCall(request).execute();
response2.body().string();
System.out.println("response2.networkResponse:" + response2.networkResponse());
System.out.println("response2.cacheResponse:" + response2.cacheResponse());

// run result
response1.networkResponse:Response{protocol=http/1.1, code=200, message=OK, url=https://www.taobao.com/}
response1.cacheResponse:null

response2.networkResponse:null
response2.cacheResponse:Response{protocol=http/1.1, code=200, message=OK, url=https://www.taobao.com/}

创建一个Cache对象,参数是缓存在磁盘的路径和大小,传递给OkHttpClient。请求淘宝主页两次,可以看到第一次请求是通过网络,第二次请求是通过缓存,networkResponse和cacheResponse分别表示请求从哪个途径获取数据。

查看磁盘,多了下面三个文件。

journal
bb35d9b59f4cc10d8fa23899f8cbb054.0  
bb35d9b59f4cc10d8fa23899f8cbb054.1

journal是DiskLruCache日志文件,用DiskLruCache注释里的例子学习写入格式:

libcore.io.DiskLruCache
1
100
2

CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054
DIRTY 335c4c6028171cfddfbaae1a9c313c52
CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342
REMOVE 335c4c6028171cfddfbaae1a9c313c52
DIRTY 1ab96a171faeeee38496d8b330771a7a
CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234
READ 335c4c6028171cfddfbaae1a9c313c52
READ 3400330d1dfc7f3f7f4b8d4d803dfcf6

头几行每行是个字段,具体含义:

  • 第一行:固定写libcore.io.DiskLruCache;
  • 第二行:缓存版本;
  • 第三行:应用版本;
  • 第四行:指valueCount,后文会介绍。

接下来每一行是一次操作记录,每次操作Cache都会产生一条。

  • DIRTY:说明缓存数据正在创建或更新,每个成功的DIRTY都要对应一个CLEAN或REMOVE,如果对不上,说明操作失败,要清理;
  • CLEAN:说明操作成功,每行后面记录value的长度
  • READ:一次读取
  • REMOVE:一次清除

Cache和文件

磁盘上的日志文件是如何关联Cache并支持增删改查的呢,我们从底层File开始,逐层解开okhttp对缓存数据的管理。

Cache内部使用了DiskLruCache,这个DiskLruCache是okhttp自己实现的,使用okio作为输入输出。

第一步:FileSystem封装File的操作

首先是FileSystem,封装了File常用操作,没有使用java.io的InputStream和OutputStream作为输入输出流,取而代之的是okio。FileSystem是个接口,直接在interface里提供了个实现类SYSTEM(我要参考)。

public interface FileSystem {
  Source source(File file) throws FileNotFoundException;
  Sink sink(File file) throws FileNotFoundException;
  Sink appendingSink(File file) throws FileNotFoundException;
  void delete(File file) throws IOException;
  boolean exists(File file);
  long size(File file);
  void rename(File from, File to) throws IOException;
  void deleteContents(File directory) throws IOException;
}

第二步:DiskLruCache.Entry和DiskLruCache.Editor

private final class Entry {
    final String key;
    final File[] cleanFiles;
    final File[] dirtyFiles;
    //...
}

DiskLruCache.Entry维护请求url对应的缓存文件,url的md5作为key,value_count说明对应几多个文件,预设是2。cleanFiles和dirtyFiles就是对应上面讲的CLEAN和DIRTY,描述数据进入修改和已经稳定两种状态。

看上面我们实操得到的两个缓存文件,名字都是key,结尾不同:

  • .0:记录请求的内容和握手信息;
  • .1:真正缓存的内容。

拒绝魔法数字,Cache上定义了0和1的常量:

private static final int ENTRY_METADATA = 0;
private static final int ENTRY_BODY = 1;

操作DiskLruCache.Entry的是DiskLruCache.Editor,它的构造函数传入DiskLruCache.Entry对象,里面有两个方法:

public Source newSource(int index){}
public Sink newSink(int index){}

通过传入的index定位,读取cleanFiles,写入dirtyFiles,对外暴露okio的Source和Sink。于是,我们可以通过DiskLruCache.Editor读写磁盘上的缓存文件了。

第三步:Snapshot封装缓存结果

从DiskLruCache获取缓存结果,不是返回DiskLruCache.Entry,而是缓存快照Snapshot。我们只关心当前缓存的内容,其他东西知道得越少越好。

 public final class Snapshot implements Closeable {
    private final String key;
    private final Source[] sources;
}

Snapshot保存了key和sources,sources的来源通过FileSystem获取cleanFiles的Source。

Snapshot snapshot() {
  if (!Thread.holdsLock(DiskLruCache.this)) throw new AssertionError();

  Source[] sources = new Source[valueCount];
  long[] lengths = this.lengths.clone(); // Defensive copy since these can be zeroed out.
  try {
    for (int i = 0; i < valueCount; i++) {
      sources[i] = fileSystem.source(cleanFiles[i]);
    }
    return new Snapshot(key, sequenceNumber, sources, lengths);
  } catch (FileNotFoundException e) {
    //
  }
}

缓存增删改查

Cache通过InternalCache供外部包调用,提供增删改查的能力,实质调用DiskLruCache对应方法。

  • get -> get
  • put -> edit
  • update -> edit
  • remove -> remove

箭头左边是Cache的方法,右边是DiskLruCache的方法。

DiskLruCache核心的数据结构是LinkedHashMap,key是字符串,对应一个Entry,要注意Cache的Entry和DiskLruCache的Entry不是同一回事。

final LinkedHashMap<String, Entry> lruEntries = new LinkedHashMap<>(0, 0.75f, true);

简单几句回顾LinkedHashMap的特点,它在HashMap的基础上,主要增加维护顺序的双向链表,元素Entry增加before和after描述前后指向的元素。

顺序的控制有两种,由标志位accessOrder控制:

  • 插入顺序
  • 访问顺序

如果使用LinkedHashMap实现LRU,accessOrder需要设置为true,按访问排序,head后的第一个Entry就是最近最少使用的节点。


//...
Entry entry = new Entry(response);
DiskLruCache.Editor editor = null;
try {
  editor = cache.edit(key(response.request().url()));
  if (editor == null) {
    return null;
  }
  entry.writeTo(editor);
  return new CacheRequestImpl(editor);
} catch (IOException e) {
  abortQuietly(editor);
  return null;
}

上面代码片段是Cache.put重要部分,首先将response封装到Cache.Entry,然后获取DiskLruCache.Editor。

synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
   initialize();

   checkNotClosed();
   validateKey(key);
   Entry entry = lruEntries.get(key);
   if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null
       || entry.sequenceNumber != expectedSequenceNumber)) {
     return null; // Snapshot is stale.
   }
   if (entry != null && entry.currentEditor != null) {
     return null; // Another edit is in progress.
   }
   if (mostRecentTrimFailed || mostRecentRebuildFailed) {
     // The OS has become our enemy! If the trim job failed, it means we are storing more data than
     // requested by the user. Do not allow edits so we do not go over that limit any further. If
     // the journal rebuild failed, the journal writer will not be active, meaning we will not be
     // able to record the edit, causing file leaks. In both cases, we want to retry the clean up
     // so we can get out of this state!
     executor.execute(cleanupRunnable);
     return null;
   }

   // 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;
 }

通过key获取editor,里面是一系列工作:

  • initialize初始化,关联journal文件并按格式读取;
  • journal写入DIRTY行;
  • 获取或创建DiskLruCache.Entry;
  • 创建Editor对象。

具体写入文件有两步,第一步调用entry.writeTo(editor),里面是一堆write操作,写入目标是ENTRY_METADATA,也就是上面说过以.0结尾的文件。

第二步调用new CacheRequestImpl(editor),返回一个CacheRequest。

CacheRequestImpl(final DiskLruCache.Editor editor) {
  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();
    }
  };
}

CacheRequestImpl在构造函数里直接执行逻辑,文件操作目标是ENTRY_BODY(具体的缓存数据)。Editor有commit和abort两个重要操作,我们来看commit,里面继续调用completeEdit:

synchronized void completeEdit(Editor editor, boolean success) throws IOException {
  Entry entry = editor.entry;
  //..

  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);
        long oldLength = entry.lengths[i];
        long newLength = fileSystem.size(clean);
        entry.lengths[i] = newLength;
        size = size - oldLength + newLength;
      }
    } else {
      fileSystem.delete(dirty);
    }
  }

  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);
  }
}

具体commit过程,是将DIRTY改为CLEAN,并写入CLEAN行。

过了一遍最复杂的put,里面还有很多细节没有写出来,但足够让我们了解写入journal和缓存文件的过程。

光速看完其他三个操作,update类似put,路过。

public synchronized Snapshot get(String key) throws IOException {
  initialize();

  checkNotClosed();
  validateKey(key);
  Entry entry = lruEntries.get(key);
  if (entry == null || !entry.readable) return null;

  Snapshot snapshot = entry.snapshot();
  if (snapshot == null) return null;

  redundantOpCount++;
  journalWriter.writeUtf8(READ).writeByte(' ').writeUtf8(key).writeByte('\n');
  if (journalRebuildRequired()) {
    executor.execute(cleanupRunnable);
  }

  return snapshot;
}

get方法直接从lruEntries获取到entry,转为Snapshot返回,写入一条READ行。最后会从ENTRY_METADATA再读一次entry,比较确认匹配。

boolean removeEntry(Entry entry) throws IOException {
  if (entry.currentEditor != null) {
    entry.currentEditor.detach(); // Prevent the edit from completing normally.
  }

  for (int i = 0; i < valueCount; i++) {
    fileSystem.delete(entry.cleanFiles[i]);
    size -= entry.lengths[i];
    entry.lengths[i] = 0;
  }

  redundantOpCount++;
  journalWriter.writeUtf8(REMOVE).writeByte(' ').writeUtf8(entry.key).writeByte('\n');
  lruEntries.remove(entry.key);

  if (journalRebuildRequired()) {
    executor.execute(cleanupRunnable);
  }

  return true;
}

删除就是删除clean文件和写入REMOVE行。


补充介绍日志的清理,当满足冗余日志超过日志本体或者超过2000(journalRebuildRequired),需要执行清理。

执行的线程池只有一条清理线程cleanupRunnable,直接新建journal去除冗余记录。(ConnectionPool都是用线程池执行清理线程,好像挺好用,记住)

CacheInterceptor

对日志的操作不感冒,为了学习的完整性,分析了一轮。其实缓存机制不外乎就是对上面操作的调用,磨刀不误砍柴工。

首先需要弄懂的是CacheStrategy,顾名思义,定义了缓存的策略,基本就是http缓存协议的实现。

CacheStrategy提供了Factory,传入原始request和当前缓存response,从requst里读取我们熟悉的"Expires"、"Last-Modified"、"ETag"几个缓存相关字段。CacheStrategy的get方法调用了getCandidate方法,getCandidate代码很长,而且是根据RFC标准文档对http协议的实现,很死板贴。最后创建了CacheStrategy对象,根据是否有缓存、是否开启缓存配置、缓存是否失效等设置networkRequest和cacheResponse。

巴拉巴拉说了这么多,记住CacheStrategy的目标就是得到networkRequest和cacheResponse,具体代码自己看。

根据networkRequest和cacheResponse是否为空,两两组合有四种情况:

networkRequest cacheResponse 结果
null null 不需要网络,又无缓存,所以配置了only-if-cached,返回504
null non-null 缓存有效,使用缓存,不需要网络
non-null null 无缓存或者失效,直接网络
non-null non-null 缓存校验,需要网络

CacheInterceptor的实现就依据上面四种情况,我们逐段分析:

Response cacheCandidate = cache != null
    ? cache.get(chain.request())
    : null;

long now = System.currentTimeMillis();

CacheStrategy strategy = new CacheStrategy.Factory(now, chain.request(), cacheCandidate).get();
Request networkRequest = strategy.networkRequest;
Response cacheResponse = strategy.cacheResponse;

 if (cache != null) {
    cache.trackResponse(strategy);
 }

获取缓存和缓存策略,上面已经讲完,trackResponse统计缓存命中率。

if (cacheCandidate != null && cacheResponse == null) {
  closeQuietly(cacheCandidate.body()); // The cache candidate wasn‘t applicable. Close it.
}

// If we’re forbidden from using the network and the cache is insufficient, fail.
if (networkRequest == null && cacheResponse == null) {
  return new Response.Builder()
      .request(chain.request())
      .protocol(Protocol.HTTP_1_1)
      .code(504)
      .message("Unsatisfiable Request (only-if-cached)")
      .body(Util.EMPTY_RESPONSE)
      .sentRequestAtMillis(-1L)
      .receivedResponseAtMillis(System.currentTimeMillis())
      .build();
}

networkRequest和cacheResponse同时为空,说明设置了只用缓存,但又没有缓存,返回504。

// If we don't need the network, we're done.
if (networkRequest == null) {
 return cacheResponse.newBuilder()
     .cacheResponse(stripBody(cacheResponse))
     .build();
}

不需要网路,缓存又ok,直接返回缓存response。

Response networkResponse = null;
try {
  networkResponse = chain.proceed(networkRequest);
} finally {
  // If we're crashing on I/O or otherwise, don't leak the cache body.
  if (networkResponse == null && cacheCandidate != null) {
    closeQuietly(cacheCandidate.body());
  }
}

需要发网络请求,这时候可能是完整请求也可能是缓存校验请求,在getCandidate里已经设置好了。

// If we have a cache response too, then we‘re doing a conditional get.
if (cacheResponse != null) {
  if (networkResponse.code() == HTTP_NOT_MODIFIED) {
    Response response = cacheResponse.newBuilder()
        .headers(combine(cacheResponse.headers(), networkResponse.headers()))
        .sentRequestAtMillis(networkResponse.sentRequestAtMillis())
        .receivedResponseAtMillis(networkResponse.receivedResponseAtMillis())
        .cacheResponse(stripBody(cacheResponse))
        .networkResponse(stripBody(networkResponse))
        .build();
    networkResponse.body().close();

    // Update the cache after combining headers but before stripping the
    // Content-Encoding header (as performed by initContentStream()).
    cache.trackConditionalCacheHit();
    cache.update(cacheResponse, response);
    return response;
  } else {
    closeQuietly(cacheResponse.body());
  }
}

如果是缓存校验请求,服务器又返回304,表示本地缓存可用,更新本地缓存并返回缓存。如果资源有更新,关闭原有的缓存。

Response response = networkResponse.newBuilder()
    .cacheResponse(stripBody(cacheResponse))
    .networkResponse(stripBody(networkResponse))
    .build();

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);
  }

  if (HttpMethod.invalidatesCache(networkRequest.method())) {
    try {
      cache.remove(networkRequest);
    } catch (IOException ignored) {
      // The cache cannot be written.
    }
  }
}
return response;

最后就是将普通请求写入缓存。

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

推荐阅读更多精彩内容