HTTP 中的缓存
Expires
Expires 是 HTTP/1.0 中的 Header,它的作用类似于 Cache-Control:max-age,它告诉浏览器缓存的过期时间,是一个具体格式的时间字符串。
由于它记录的是一个具体的时间,浏览器之类的客户端应用会根据本地的时间与该具体时间对比从而判定是否过期。那么如果我们对本地的时间进行了修改,则 Expires 的功能显然会受到影响。
Cache-Control
HTTP/1.1 中引入了CacheControl,Cache-Control 相信大家都接触过,它是一个位于 Request 及 Response 的 Headers 中的一个字段,对于请求的指令及响应的指令,它有如下不同的取值:
请求缓存指令
- max-age=
:设置缓存存储的最大周期,超过这个的时间缓存被认为过期,时间是相对于请求的时间。  - max-stale[=
]:表明客户端愿意接收一个已经过期的资源。可以设置一个可选的秒数,表示响应不能已经过时超过该给定的时间。  - min-fresh=
:表示客户端希望获取一个能在指定的秒数内保持其最新状态的响应。  - no-cache :在发布缓存副本之前,强制要求缓存把请求提交给原始服务器进行验证。
 - no-store:缓存不应存储有关客户端请求的任何内容。
 - no-transform:不得对资源进行转换或转变,Content-Encoding、Content-Range、Content-Type等 Header 不能由代理修改。
 - only-if-cached:表明客户端只接受已缓存的响应,并且不向原始服务器检查是否有更新的数据。
响应缓存指令
 - must-revalidate:一旦资源过期(比如已经超过max-age),在成功向原始服务器验证之前,缓存不能用该资源响应后续请求。
 - no-cache:在发布缓存副本之前,强制要求缓存把请求提交给原始服务器进行验证
 - no-store:缓存不应存储有关服务器响应的任何内容。
 - no-transform:不得对资源进行转换或转变,Content-Encoding、Content-Range、Content-Type等 Header 不能由代理修改。
 - public:表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)缓存,即使是通常不可缓存的内容(例如,该响应没有 max-age 指令或 Expires 消息头)。
 - private:表明响应只能被单个用户缓存,不能作为共享缓存(即代理服务器不能缓存它),私有缓存可以缓存响应内容。
 - proxy-revalidate:与 must-revalidate 作用相同,但它仅适用于共享缓存(如代理),并被私有缓存忽略。
 - max-age=
:设置缓存存储的最大周期,超过这个的时间缓存被认为过期,时间是相对于请求的时间。  - s-maxage=
:覆盖 max-age 或者 Expires 头,但它仅适用于共享缓存(如代理),并被私有缓存忽略。  
Last-Modified / If-Modified-Since
这两个字段需要配合 Cache-Control 来使用
- Last-Modified:该响应资源最后的修改时间,服务器在响应请求的时候可以填入该字段。
 - If-Modified-Since:客户端缓存过期时(max-age 到达),发现该资源具有 Last-Modified 字段,可以在 Header 中填入 If-Modified-Since 字段,表示当前请求时间。服务器收到该时间后会与该资源的最后修改时间进行比较,若最后修改的时间更新一些,则会对整个资源响应,否则说明该资源在访问时未被修改,响应 code 304,告知客户端使用缓存的资源,这也就是为什么之前看到 CacheInterceptor 中对 304 做了特殊处理。
 
Etag / If-None-Match
这两个字段同样需要配合 Cache-Control 使用
- Etag:请求的资源在服务器中的唯一标识,规则由服务器决定
 - If-None-Match:若客户端在缓存过期时(max-age 到达),发现该资源具有 Etag 字段,就可以在 Header 中填入 If-None-Match 字段,它的值就是 Etag 中的值,之后服务器就会根据这个唯一标识来寻找对应的资源,根据其更新与否情况返回给客户端 200 或 304。
 
同时,这两个字段的优先级是比 Last-Modified 及 If-Modified-Since 两个字段的优先级要高的
OkHttp 中的缓存机制
InternalCache
1  | public interface InternalCache {  | 
Cache
Cache 类并没有实现 InternalCache 这个类,而是在内部持有了一个实现了 InternalCache 的内部对象 internalCache。
在Cache中可以发现, OkHttp 的缓存的实现是基于 DiskLruCache 实现的。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25final InternalCache internalCache = new InternalCache() {
    public Response get(Request request) throws IOException {
     return Cache.this.get(request);
   }
    public CacheRequest put(Response response) throws IOException {
     return Cache.this.put(response);
   }
    public void remove(Request request) throws IOException {
     Cache.this.remove(request);
   }
    public void update(Response cached, Response network) {
     Cache.this.update(cached, network);
   }
    public void trackConditionalCacheHit() {
     Cache.this.trackConditionalCacheHit();
   }
    public void trackResponse(CacheStrategy cacheStrategy) {
     Cache.this.trackResponse(cacheStrategy);
   }
 };
构建
Cache的构建是在创建okhttp的时候。1
2
3
4
5
6
7File cacheFile = new File(cachePath);	// 缓存路径
int cacheSize = 10 * 1024 * 1024;		// 缓存大小10MB
Cache cache = new Cache(cacheFile, cacheSize);
OkHttpClient client = new OkHttpClient.Builder()
			// ...
			.cache(cache)
			.build();
看下Cache的构造函数,它的构造函数中构造了一个 DiskLruCache 对象。1
2
3
4
5
6
7
8
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);
}
put
1  | CacheRequest put(Response response) {  | 
它主要的实现就是根据 Response 构建 Entry,之后将其写入到 DiskLruCache.Editor 中,写入的过程中调用了 key 方法根据 url 产生了其存储的 key。
OkHttp 的作者认为虽然能够实现如 POST、HEAD 等请求的缓存,但其实现会比较复杂,且收益不高,因此只允许缓存 GET 请求的 Response。
key 方法的实现如下:1
2
3public static String key(HttpUrl url) {
  return ByteString.encodeUtf8(url.toString()).md5().hex();
}
其实就是将 url 转变为 UTF-8 编码后进行了 md5 加密。
接着我们看到 Entry 构造函数,看看它是如何存储 Response 相关的信息的:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58Entry(Response response) {
  this.url = response.request().url().toString();
  this.varyHeaders = HttpHeaders.varyHeaders(response);
  this.requestMethod = response.request().method();
  this.protocol = response.protocol();
  this.code = response.code();
  this.message = response.message();
  this.responseHeaders = response.headers();
  this.handshake = response.handshake();
  this.sentRequestMillis = response.sentRequestAtMillis();
  this.receivedResponseMillis = response.receivedResponseAtMillis();
}
public void writeTo(DiskLruCache.Editor editor) throws IOException {
  BufferedSink sink = Okio.buffer(editor.newSink(ENTRY_METADATA));
  sink.writeUtf8(url)
      .writeByte('\n');
  sink.writeUtf8(requestMethod)
      .writeByte('\n');
  sink.writeDecimalLong(varyHeaders.size())
      .writeByte('\n');
  for (int i = 0, size = varyHeaders.size(); i < size; i++) {
    sink.writeUtf8(varyHeaders.name(i))
        .writeUtf8(": ")
        .writeUtf8(varyHeaders.value(i))
        .writeByte('\n');
  }
  sink.writeUtf8(new StatusLine(protocol, code, message).toString())
      .writeByte('\n');
  sink.writeDecimalLong(responseHeaders.size() + 2)
      .writeByte('\n');
  for (int i = 0, size = responseHeaders.size(); i < size; i++) {
    sink.writeUtf8(responseHeaders.name(i))
        .writeUtf8(": ")
        .writeUtf8(responseHeaders.value(i))
        .writeByte('\n');
  }
  sink.writeUtf8(SENT_MILLIS)
      .writeUtf8(": ")
      .writeDecimalLong(sentRequestMillis)
      .writeByte('\n');
  sink.writeUtf8(RECEIVED_MILLIS)
      .writeUtf8(": ")
      .writeDecimalLong(receivedResponseMillis)
      .writeByte('\n');
  if (isHttps()) {
    sink.writeByte('\n');
    sink.writeUtf8(handshake.cipherSuite().javaName())
        .writeByte('\n');
    writeCertList(sink, handshake.peerCertificates());
    writeCertList(sink, handshake.localCertificates());
    sink.writeUtf8(handshake.tlsVersion().javaName()).writeByte('\n');
  }
  sink.close();
}
这里主要是利用了 Okio 这个库中的 BufferedSink 实现了写入操作,将一些 Response 中的信息写入到 Editor。关于 Okio,会在后续文章中进行介绍。
get
1  | Response get(Request request) {  | 
这里拿到了 DiskLruCache.Snapshot,之后通过它的 source 创建了 Entry,然后再通过 Entry 来获取其 Response。
我们看看通过 Snapshot.source 是如何创建 Entry 的:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53Entry(Source in) throws IOException {
    try {
      BufferedSource source = Okio.buffer(in);
      url = source.readUtf8LineStrict();
      requestMethod = source.readUtf8LineStrict();
      Headers.Builder varyHeadersBuilder = new Headers.Builder();
      int varyRequestHeaderLineCount = readInt(source);
      for (int i = 0; i < varyRequestHeaderLineCount; i++) {
        varyHeadersBuilder.addLenient(source.readUtf8LineStrict());
      }
      varyHeaders = varyHeadersBuilder.build();
      StatusLine statusLine = StatusLine.parse(source.readUtf8LineStrict());
      protocol = statusLine.protocol;
      code = statusLine.code;
      message = statusLine.message;
      Headers.Builder responseHeadersBuilder = new Headers.Builder();
      int responseHeaderLineCount = readInt(source);
      for (int i = 0; i < responseHeaderLineCount; i++) {
        responseHeadersBuilder.addLenient(source.readUtf8LineStrict());
      }
      String sendRequestMillisString = responseHeadersBuilder.get(SENT_MILLIS);
      String receivedResponseMillisString = responseHeadersBuilder.get(RECEIVED_MILLIS);
      responseHeadersBuilder.removeAll(SENT_MILLIS);
      responseHeadersBuilder.removeAll(RECEIVED_MILLIS);
      sentRequestMillis = sendRequestMillisString != null
          ? Long.parseLong(sendRequestMillisString)
          : 0L;
      receivedResponseMillis = receivedResponseMillisString != null
          ? Long.parseLong(receivedResponseMillisString)
          : 0L;
      responseHeaders = responseHeadersBuilder.build();
      if (isHttps()) {
        String blank = source.readUtf8LineStrict();
        if (blank.length() > 0) {
          throw new IOException("expected \"\" but was \"" + blank + "\"");
        }
        String cipherSuiteString = source.readUtf8LineStrict();
        CipherSuite cipherSuite = CipherSuite.forJavaName(cipherSuiteString);
        List<Certificate> peerCertificates = readCertificateList(source);
        List<Certificate> localCertificates = readCertificateList(source);
        TlsVersion tlsVersion = !source.exhausted()
            ? TlsVersion.forJavaName(source.readUtf8LineStrict())
            : TlsVersion.SSL_3_0;
        handshake = Handshake.get(tlsVersion, cipherSuite, peerCertificates, localCertificates);
      } else {
        handshake = null;
      }
    } finally {
      in.close();
    }
  }
可以看到,同样是通过 Okio 进行了读取,看来 OkHttp 中的大部分 I/O 操作都使用到了 Okio。我们接着看到 Entry.response 方法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21  public Response response(DiskLruCache.Snapshot snapshot) {
    String contentType = responseHeaders.get("Content-Type");
    String contentLength = responseHeaders.get("Content-Length");
    Request cacheRequest = new Request.Builder()
        .url(url)
        .method(requestMethod, null)
        .headers(varyHeaders)
        .build();
    return new Response.Builder()
        .request(cacheRequest)
        .protocol(protocol)
        .code(code)
        .message(message)
        .headers(responseHeaders)
        .body(new CacheResponseBody(snapshot, contentType, contentLength))
        .handshake(handshake)
        .sentRequestAtMillis(sentRequestMillis)
        .receivedResponseAtMillis(receivedResponseMillis)
        .build();
  }
}
其实就是根据 response 的相关信息重新构建了 Response 对象。
remove
1  | void remove(Request request) throws IOException {  | 
remove 的实现非常简单,它直接调用了 DiskLruCache.remove.
update
1  | void update(Response cached, Response network) {  | 
update 的实现也十分简单,这里不再解释,和 put 比较相似。
CacheStrategy
我们前面介绍了缓存的使用,但还没有介绍在 CacheInterceptor 中使用到的缓存策略类 CacheStrategy。我们先看到 CacheStrategy.Factory 构造函数的实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28public Factory(long nowMillis, Request request, Response cacheResponse) {
     this.nowMillis = nowMillis;
     this.request = request;
     this.cacheResponse = cacheResponse;
     if (cacheResponse != null) {
       this.sentRequestMillis = cacheResponse.sentRequestAtMillis();
       this.receivedResponseMillis = cacheResponse.receivedResponseAtMillis();
       Headers headers = cacheResponse.headers();
       for (int i = 0, size = headers.size(); i < size; i++) {
         String fieldName = headers.name(i);
         String value = headers.value(i);
         if ("Date".equalsIgnoreCase(fieldName)) {
           servedDate = HttpDate.parse(value);
           servedDateString = value;
         } else if ("Expires".equalsIgnoreCase(fieldName)) {
           expires = HttpDate.parse(value);
         } else if ("Last-Modified".equalsIgnoreCase(fieldName)) {
           lastModified = HttpDate.parse(value);
           lastModifiedString = value;
         } else if ("ETag".equalsIgnoreCase(fieldName)) {
           etag = value;
         } else if ("Age".equalsIgnoreCase(fieldName)) {
           ageSeconds = HttpHeaders.parseSeconds(value, -1);
         }
       }
     }
   }
这里主要是对一些变量的初始化,接着我们看到 Factory.get 方法,之前通过该方法我们就获得了 CacheStrategy 对象:1
2
3
4
5
6
7
8
9
10public CacheStrategy get() {
   CacheStrategy candidate = getCandidate();
   if (candidate.networkRequest != null && request.cacheControl().onlyIfCached()) {
     // We're forbidden from using the network and the cache is insufficient.
     return new CacheStrategy(null, null);
   }
   return candidate;
 }
这里首先通过 getCandidate 方法获取到了对应的缓存策略。
如果发现我们的请求中指定了禁止使用网络,只使用缓存(指定 CacheControl 为 only-if-cached ),则创建一个 networkRequest 及 cacheResponse 均为 null 的缓存策略。
我们接着看到 getCandidate 方法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82private CacheStrategy getCandidate() {
   // No cached response.
   if (cacheResponse == null) {
     return new CacheStrategy(request, null);
   }
   // Drop the cached response if it's missing a required handshake.
   if (request.isHttps() && cacheResponse.handshake() == null) {
     return new CacheStrategy(request, null);
   }
   // If this response shouldn't have been stored, it should never be used
   // as a response source. This check should be redundant as long as the
   // persistence store is well-behaved and the rules are constant.
   if (!isCacheable(cacheResponse, request)) {
     return new CacheStrategy(request, null);
   }
   CacheControl requestCaching = request.cacheControl();
   if (requestCaching.noCache() || hasConditions(request)) {
     return new CacheStrategy(request, null);
   }
   CacheControl responseCaching = cacheResponse.cacheControl();
   if (responseCaching.immutable()) {
     return new CacheStrategy(null, cacheResponse);
   }
   long ageMillis = cacheResponseAge();
   long freshMillis = computeFreshnessLifetime();
   if (requestCaching.maxAgeSeconds() != -1) {
     freshMillis = Math.min(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds()));
   }
   long minFreshMillis = 0;
   if (requestCaching.minFreshSeconds() != -1) {
     minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds());
   }
   long maxStaleMillis = 0;
   if (!responseCaching.mustRevalidate() && requestCaching.maxStaleSeconds() != -1) {
     maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds());
   }
   if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
     Response.Builder builder = cacheResponse.newBuilder();
     if (ageMillis + minFreshMillis >= freshMillis) {
       builder.addHeader("Warning", "110 HttpURLConnection \"Response is stale\"");
     }
     long oneDayMillis = 24 * 60 * 60 * 1000L;
     if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
       builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\"");
     }
     return new CacheStrategy(null, builder.build());
   }
   // Find a condition to add to the request. If the condition is satisfied, the response body
   // will not be transmitted.
   String conditionName;
   String conditionValue;
   if (etag != null) {
     conditionName = "If-None-Match";
     conditionValue = etag;
   } else if (lastModified != null) {
     conditionName = "If-Modified-Since";
     conditionValue = lastModifiedString;
   } else if (servedDate != null) {
     conditionName = "If-Modified-Since";
     conditionValue = servedDateString;
   } else {
     return new CacheStrategy(request, null); // No condition! Make a regular request.
   }
   Headers.Builder conditionalRequestHeaders = request.headers().newBuilder();
   Internal.instance.addLenient(conditionalRequestHeaders, conditionName, conditionValue);
   Request conditionalRequest = request.newBuilder()
       .headers(conditionalRequestHeaders.build())
       .build();
   return new CacheStrategy(conditionalRequest, cacheResponse);
 }
在缓存策略的创建中,主要是以下几步:
- 没有缓存 response,直接进行寻常网络请求
 - HTTPS 的 response 丢失了握手相关数据,丢弃缓存直接进行网络请求
 - 缓存的 response 的 code 不支持缓存,则忽略缓存,直接进行寻常网络请求
 - 对 Cache-Control 中的字段进行处理,主要是计算缓存是否还能够使用(比如超过了 max-age 就不能再使用)
 - 对 If-None-Match、If-Modified-Since 字段进行处理,填入相应 Header(同时可以看出 Etag 确实比 Last-Modified 优先级要高
 
我们可以发现,OkHttp 中实现了一个 CacheControl 类,用于以面向对象的形式表示 HTTP 协议中的 Cache-Control Header,从而支持获取 Cache-Control 中的值。
同时可以看出,我们的缓存策略主要存在以下几种情况:
request != null, response == null:执行寻常网络请求,忽略缓存
request == null, response != null:采用缓存数据,忽略网络数据
request != null, response != null:存在 Last-Modified、Etag 等相关数据,结合 request 及缓存中的 response
request == null, response == null:不允许使用网络请求,且没有缓存,在 CacheInterceptor 中会构建一个 504 的 response
总结
OkHttp 的缓存机制主要是基于 DiskLruCache 这个开源库实现的,从而实现了缓存在磁盘中的 LRU 存储。通过在 OkHttpClient 中对 Cache 类的配置,我们可以实现对缓存位置及缓存空间大小的配置,同时 OkHttp 提供了 CacheStrategy 类对 Cache-Control 中的值进行处理,从而支持 HTTP 协议的缓存相关 Header。