系列索引
本系列文章基于 OkHttp3.14
OkHttp 源码剖析系列(一)——请求的发起及拦截器机制概述
OkHttp 源码剖析系列(六)——连接复用机制及连接的建立
前言
我们知道,在 CacheInterceptor
中实现了 OkHttp 中对 Response
的缓存功能,CacheInterceptor
的具体逻辑在前面的博客已经分析过,但里面对缓存机制的详细实现没有进行介绍。这篇文章中我们将对 OkHttp 的缓存机制的具体实现进行详细的介绍。
HTTP 中的缓存
我们先来了解一下 HTTP 协议中与缓存相关的知识。
Cache-Control
Cache-Control
相信大家都接触过,它是一个处于 Request
以及 Response
的 Headers 中的一个字段,对于请求的指令及响应的指令,它有如下不同的取值:
请求缓存指令
-
max-age=<seconds>
:设置缓存存储的最大周期,超过这个的时间缓存被认为过期,时间是相对于请求的时间。 -
max-stale[=<seconds>]
:表明客户端愿意接收一个已经过期的资源。可以设置一个可选的秒数,表示响应不能已经过时超过该给定的时间。 -
min-fresh=<seconds>
:表示客户端希望获取一个能在指定的秒数内保持其最新状态的响应。 -
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=<seconds>
:设置缓存存储的最大周期,超过这个的时间缓存被认为过期,时间是相对于请求的时间。 -
s-maxage=<seconds>
:覆盖max-age
或者Expires
头,但它仅适用于共享缓存(如代理),并被私有缓存忽略。
其中我们常用的就是加粗的几个字段(max-age
、max-stale
、no-cache
)。
Expires
Expires
头是 HTTP1.0 中的内容,它的作用类似于 Cache-Control:max-age
,它告诉浏览器缓存的过期时间,这段时间浏览器就可以不用直接再向服务器请求了。
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 中的缓存机制
了解完 HTTP 协议的缓存相关 Header 之后,我们来学习一下 OkHttp 对缓存相关的实现。
InternalCache
首先我们通过之前的文章可以知道,CacheInterceptor
中通过 cache
这个 InternalCache
对象进行对缓存的 CRUD 操作。这里 InternalCache
只是一个接口,它定义了对 HTTP 请求的缓存的 CRUD 接口。让我们看看它的定义:
/**
* OkHttp's internal cache interface. Applications shouldn't implement this: instead use {@link
* okhttp3.Cache}.
*/
public interface InternalCache {
@Nullable
Response get(Request request) throws IOException;
@Nullable
CacheRequest put(Response response) throws IOException;
/**
* Remove any cache entries for the supplied {@code request}. This is invoked when the client
* invalidates the cache, such as when making POST requests.
*/
void remove(Request request) throws IOException;
/**
* Handles a conditional request hit by updating the stored cache response with the headers from
* {@code network}. The cached response body is not updated. If the stored response has changed
* since {@code cached} was returned, this does nothing.
*/
void update(Response cached, Response network);
/**
* Track an conditional GET that was satisfied by this cache.
*/
void trackConditionalCacheHit();
/**
* Track an HTTP response being satisfied with {@code cacheStrategy}.
*/
void trackResponse(CacheStrategy cacheStrategy);
}
看到该接口的 JavaDoc 可以知道,官方禁止使用者实现这个接口,而是使用 Cache
这个类。
Cache
那么 Cache
难道是 InternalCache
的实现类么?让我们去看看 Cache
类。
代码非常多这里就不全部贴出来了,Cache
类并没有实现 InternalCache
这个类,而是在内部持有了一个实现了 InternalCache
的内部对象 internalCache
:
final InternalCache internalCache = new InternalCache() {
@Override
public @Nullable
Response get(Request request) throws IOException {
return Cache.this.get(request);
}
@Override
public @Nullable
CacheRequest put(Response response) throws IOException {
return Cache.this.put(response);
}
@Override
public void remove(Request request) throws IOException {
Cache.this.remove(request);
}
@Override
public void update(Response cached, Response network) {
Cache.this.update(cached, network);
}
@Override
public void trackConditionalCacheHit() {
Cache.this.trackConditionalCacheHit();
}
@Override
public void trackResponse(CacheStrategy cacheStrategy) {
Cache.this.trackResponse(cacheStrategy);
}
};
这里转调到了 Cache
类中的 CRUD 相关实现,这里采用了组合的方式,提高了设计的灵活性。
同时,在 Cache
类中,还可以看到一个熟悉的身影——DiskLruCache
(关于它的原理这里不再进行详细分析,具体原理分析可以看我之前的博客 Android 中的 LRU 缓存——内存缓存与磁盘缓存,看来 OkHttp 的缓存的实现是基于 DiskLruCache
实现的。
现在可以大概猜测,Cache
中的 CRUD 操作都是在对 DiskLruCache
对象进行操作。
构建
而我们的 Cache
对象是何时构建的呢?其实是在 OkHttpClient
创建时构建并传入的:
File 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
的构造函数,它最后调用到了 Cache(directory, maxSize, fileSystem)
,而 fileSystem
传入的是 FileSystem.SYSTEM
Cache(File directory, long maxSize, FileSystem fileSystem) {
this.cache = DiskLruCache.create(fileSystem, directory, VERSION, ENTRY_COUNT, maxSize);
}
在它的构造函数中构造了一个 DiskLruCache
对象。
put
接着让我们看一下它的 put
方法是如何实现的:
@Nullable
CacheRequest put(Response response) {
String requestMethod = response.request().method();
// 对request的method进行校验
if (HttpMethod.invalidatesCache(response.request().method())) {
try {
// 若method为POST PATCH PUT DELETE MOVE其中一个,删除现有缓存并结束
remove(response.request());
} catch (IOException ignored) {
// The cache cannot be written.
}
return null;
}
if (!requestMethod.equals("GET")) {
// 虽然技术上允许缓存POST请求及HEAD请求,但这样实现较为复杂且收益不高
// 因此OkHttp只允许缓存GET请求
return null;
}
if (HttpHeaders.hasVaryAll(response)) {
return null;
}
// 根据response创建entry
Entry entry = new Entry(response);
DiskLruCache.Editor editor = null;
try {
// 尝试获取editer
editor = cache.edit(key(response.request().url()));
if (editor == null) {
return null;
}
// 将entry写入Editor
entry.writeTo(editor);
// 根据editor获取CacheRequest对象
return new CacheRequestImpl(editor);
} catch (IOException e) {
abortQuietly(editor);
return null;
}
}
它主要的实现就是根据 Response
构建 Entry
,之后将其写入到 DiskLruCache.Editor
中,写入的过程中调用了 key
方法根据 url
产生了其存储的 key
。
同时从注释中可以看出,OkHttp 的作者认为虽然能够实现如 POST、HEAD 等请求的缓存,但其实现会比较复杂,且收益不高,因此只允许缓存 GET 请求的 Response
key
方法的实现如下:
public static String key(HttpUrl url) {
return ByteString.encodeUtf8(url.toString()).md5().hex();
}
其实就是将 url
转变为 UTF-8 编码后进行了 md5 加密。
接着我们看到 Entry
构造函数,看看它是如何存储 Response
相关的信息的:
Entry(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();
}
主要是一些赋值操作,我们接着看到 Entry.writeTo
方法
public void writeTo(DiskLruCache.Editor editor) throws IOException {
BufferedSink sink = Okio.buffer(editor.newSink(ENTRY_METADATA));
sink.writeUtf8(url)
.writeByte('\n');
// ... 一些write操作
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
我们接着看到 get
方法的实现:
@Nullable
Response get(Request request) {
String key = key(request.url());
DiskLruCache.Snapshot snapshot;
Entry entry;
try {
snapshot = cache.get(key);
if (snapshot == null) {
return null;
}
} catch (IOException e) {
// Give up because the cache cannot be read.
return null;
}
try {
entry = new Entry(snapshot.getSource(ENTRY_METADATA));
} catch (IOException e) {
Util.closeQuietly(snapshot);
return null;
}
Response response = entry.response(snapshot);
if (!entry.matches(request, response)) {
Util.closeQuietly(response.body());
return null;
}
return response;
}
这里拿到了 DiskLruCache.Snapshot
,之后通过它的 source
创建了 Entry
,然后再通过 Entry
来获取其 Response
。
我们看看通过 Snapshot.source
是如何创建 Entry
的:
Entry(Source in) throws IOException {
try {
BufferedSource source = Okio.buffer(in);
url = source.readUtf8LineStrict();
requestMethod = source.readUtf8LineStrict();
Headers.Builder varyHeadersBuilder = new Headers.Builder();
// 一些read操作
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
方法:
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
对象。
可以发现,写入和读取的过程都有用到 Entry
类,看来 Entry
类就是 OkHttp 中 Response
缓存的桥梁了,这里要注意的是,这里的 Entry 与 DiskLruCache 中的 Entry 是不同的。
remove
remove
的实现非常简单,它直接调用了 DiskLruCache.remove
:
void remove(Request request) throws IOException {
cache.remove(key(request.url()));
}
update
update
的实现也十分简单,这里不再解释,和 put
比较相似
void update(Response cached, Response network) {
Entry entry = new Entry(network);
DiskLruCache.Snapshot snapshot = ((CacheResponseBody) cached.body()).snapshot;
DiskLruCache.Editor editor = null;
try {
editor = snapshot.edit(); // Returns null if snapshot is not current.
if (editor != null) {
entry.writeTo(editor);
editor.commit();
}
} catch (IOException e) {
abortQuietly(editor);
}
}
CacheStrategy
我们前面介绍了缓存的使用,但还没有介绍在 CacheInterceptor
中使用到的缓存策略类 CacheStrategy
。我们先看到 CacheStrategy.Factory
构造函数的实现:
public 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
对象:
/**
* Returns a strategy to satisfy {@code request} using the a cached response {@code response}.
*/
public 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
方法:
/**
* Returns a strategy to use assuming the request can use the network.
*/
private CacheStrategy getCandidate() {
// 若没有缓存的response,则默认采用网络请求
if (cacheResponse == null) {
return new CacheStrategy(request, null);
}
// 如果HTTPS下缓存的response丢失了需要的握手相关数据,忽略本地缓存response
if (request.isHttps() && cacheResponse.handshake() == null) {
return new CacheStrategy(request, null);
}
// 对缓存的response的状态码进行校验,一些特殊的状态码不论怎样都走网络请求
if (!isCacheable(cacheResponse, request)) {
return new CacheStrategy(request, null);
}
CacheControl requestCaching = request.cacheControl();
// 如果请求的Cache-Control中指定了no-cache,则使用网络请求
if (requestCaching.noCache() || hasConditions(request)) {
return new CacheStrategy(request, null);
}
CacheControl responseCaching = cacheResponse.cacheControl();
// 计算当前缓存的response的存活时间以及缓存应当被刷新的时间
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());
}
// 对If-None-Match、If-Modified-Since等Header进行处理
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 {
// 若上述Header都不存在,则采用寻常网络请求
return new CacheStrategy(request, null);
}
// 若存在上述Header,则在原request中添加对应header,之后结合本地cacheResponse创建缓存策略
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。
网友评论