Volley对HTTP缓存的处理

客户端开发中,调用远程服务接口获取数据更新UI是最常见不过的了。有些纯展示的页面服务端数据的更新其实没那么快,如果每次打开这种页面都要向服务器请求数据,而服务端数据可能在你上次请求之后并没有更新数据,这样就造成了流量的浪费,更重要的,每次和服务器交互都是耗时的,获取数据间隙严重影响用户体验,而这一切本是可以避免的(数据明明没有任何更新,为什么还要再次发送请求呢?同样的数据上次已经获取过了啊)。

其实最好的请求是不必与服务器进行通信的请求:通过响应本地的副本,避免所有的网络延迟以及数据传输的成本。HTTP1.1 规范定义了一系列服务器返回的不同的 Cache-Control 指令,控制客户端如何缓存某个响应以及缓存多长时间。Volley按照HTTP规范的定义,实现了请求缓存的功能。

##网络缓存的处理
Volley在初始化的时候默认会启动5个线程,4个network线程和1个cache线程(其实这里可以优化一下,像Picasso那样根据用户网络类型的不同(wifi、4G、3G、2G等),动态设置network线程的数量)。如果一个新的Request添加到RequestQueue之后,请求如果没有设置禁用缓存的话(默认是开启的,mShouldCache = true)会把这次请求添加到cache线程所维护的队列中,看一下CacheDispatcher的run方法。

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
@Override
public void run() {
if (DEBUG) VolleyLog.v("start new dispatcher");
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
// Make a blocking call to initialize the cache.
mCache.initialize();
while (true) {
try {
// Get a request from the cache triage queue, blocking until
// at least one is available.
final Request<?> request = mCacheQueue.take();
request.addMarker("cache-queue-take");
// If the request has been canceled, don't bother dispatching it.
if (request.isCanceled()) {
request.finish("cache-discard-canceled");
continue;
}
// Attempt to retrieve this item from cache.
Cache.Entry entry = mCache.get(request.getCacheKey());
if (entry == null) {
request.addMarker("cache-miss");
// Cache miss; send off to the network dispatcher.
mNetworkQueue.put(request);
continue;
}
// 下面的代码省略,后面再分析
} catch (InterruptedException e) {
// We may have been interrupted because it was time to quit.
if (mQuit) {
return;
}
continue;
}
}
}

第7行进入死循环之前先初始化DiskBaseCache。
13行BlockingQueue的take()方法会阻塞,直到队列中有元素的时候才会往下继续执行。
23行先尝试从本地的文件缓存中去取,如果取不到数据则把该次请求交给network线程处理。看下NetworkDispatcher的run()方法。

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
...
// Perform the network request.
NetworkResponse networkResponse = mNetwork.performRequest(request);
request.addMarker("network-http-complete");
// If the server returned 304 AND we delivered a response already,
// we're done -- don't deliver a second identical response.
if (networkResponse.notModified && request.hasHadResponseDelivered()) {
request.finish("not-modified");
continue;
}
// Parse the response here on the worker thread.
Response<?> response = request.parseNetworkResponse(networkResponse);
request.addMarker("network-parse-complete");
// Write to cache if applicable.
// TODO: Only update cache metadata instead of entire record for 304s.
if (request.shouldCache() && response.cacheEntry != null) {
mCache.put(request.getCacheKey(), response.cacheEntry);
request.addMarker("network-cache-written");
}
// Post the response back.
request.markDelivered();
mDelivery.postResponse(request, response);
...

第3行调用BasicNetwork的performRequest方法发送请求,并把响应的code、header、content等信息封装成NetworkResponse返回。
第8行如果返回是responseCode是304并且这次请求已经把结果向上回调的话,不再继续向下执行。
14行调用请求的parseNetworkResponse()方法,该方法是abstract的,继承Request的请求要实现该方法的逻辑,完成对HttpResponse的处理,根据自己项目的需要,通用的做法是把请求的响应数据转化为我们需要的bean,处理完成后,生成parseNetworkResponse()返回的Response会调用HttpHeaderParser.parseCacheHeaders()先生成Cache.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
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
82
83
public static Cache.Entry parseCacheHeaders(NetworkResponse response) {
long now = System.currentTimeMillis();
Map<String, String> headers = response.headers;
long serverDate = 0;
long lastModified = 0;
long serverExpires = 0;
long softExpire = 0;
long finalExpire = 0;
long maxAge = 0;
long staleWhileRevalidate = 0;
boolean hasCacheControl = false;
boolean mustRevalidate = false;
String serverEtag = null;
String headerValue;
headerValue = headers.get("Date");
if (headerValue != null) {
serverDate = parseDateAsEpoch(headerValue);
}
headerValue = headers.get("Cache-Control");
if (headerValue != null) {
hasCacheControl = true;
String[] tokens = headerValue.split(",");
for (int i = 0; i < tokens.length; i++) {
String token = tokens[i].trim();
if (token.equals("no-cache") || token.equals("no-store")) {
return null;
} else if (token.startsWith("max-age=")) {
try {
maxAge = Long.parseLong(token.substring(8));
} catch (Exception e) {
}
} else if (token.startsWith("stale-while-revalidate=")) {
try {
staleWhileRevalidate = Long.parseLong(token.substring(23));
} catch (Exception e) {
}
} else if (token.equals("must-revalidate") || token.equals("proxy-revalidate")) {
mustRevalidate = true;
}
}
}
headerValue = headers.get("Expires");
if (headerValue != null) {
serverExpires = parseDateAsEpoch(headerValue);
}
headerValue = headers.get("Last-Modified");
if (headerValue != null) {
lastModified = parseDateAsEpoch(headerValue);
}
serverEtag = headers.get("ETag");
// Cache-Control takes precedence over an Expires header, even if both exist and Expires
// is more restrictive.
if (hasCacheControl) {
softExpire = now + maxAge * 1000;
finalExpire = mustRevalidate
? softExpire
: softExpire + staleWhileRevalidate * 1000;
} else if (serverDate > 0 && serverExpires >= serverDate) {
// Default semantic for Expire header in HTTP specification is softExpire.
softExpire = now + (serverExpires - serverDate);
finalExpire = softExpire;
}
Cache.Entry entry = new Cache.Entry();
entry.data = response.data;
entry.etag = serverEtag;
entry.softTtl = softExpire;
entry.ttl = finalExpire;
entry.serverDate = serverDate;
entry.lastModified = lastModified;
entry.responseHeaders = headers;
return entry;
}

所有对HTTP缓存的处理逻辑都在这个方法里了。按照http协议的格式,通过对响应header的解析,构造Cache.Entry的各个参数。
24~46行解析”Cache-Control”头部,
“no-cache”:表示客户端必须先与服务器确认返回的响应是否被更改,然后才能使用该响应来满足后续对同一个url的请求。也就是说服务器这次给你返回了”Cache-Control:no-cache”,本次响应你可以无视,因为无论你保存不保存,如果下次再请求该url的时候,你还是没办法直接使用本地的缓存(即使你保存了),因为按照协议规定你必须再次请求服务器获取最新数据,也就是说每次请求都必须向服务器发送。Volley在这里直接忽略了响应信息,而FireFox浏览器则会缓存响应。

“no-store”:比较严格,标示服务器禁止客户端保存这次请求的响应。
这两种情况下不保存响应信息,直接返回null。

“max-age”:单位是秒,表示从此刻起,多少秒之内再次访问该url时可以不必向服务发送请求,直接使用本地缓存即可。

“stale-while-revalidate”:用的较少,查阅《http协议详解》,该字段表示缓存过期后还可以继续使用多久,单位是秒。RFC5861也有相关说明和例子

“must-revalidate”、”proxy-revalidate”:强制要求缓存过期后必须请求服务器,防止某些情况下客户端会忽略服务器设置的缓存过期时间。

根据上面的各个字段,控制客户端如何缓存响应以及缓存多长时间,计算该次请求缓存的过期和刷新时间等。

48行是解析”Expires”头部,这是HTTP1.0规范设置缓存过期时间,在HTTP1.1中已经被”Cache-Control”取代,62~71行可以看到会有限使用”Cache-Control”计算缓存过期时间,在没有”Cache-Control”头部的时候才会使用”Expires”。

53行解析”Last-Modified”,记录服务器最后一次修改时间。
58行解析”ETag”,ETag信息是服务器生成的一个随机令牌,通常是文件内容的哈希值或者某个其他指纹码,便于服务器验证资源是否有修改。

再次回到NetworkDispatcher的run()方法中,response获取之后,会把本次请求的Cache.Entry信息保存到文件缓存中。然后把结果向上回调,同时把该次请求标识为已发送回调。

至此,一次全新的请求就执行完了,可以看到响应信息会通过按照HTTP协议的格式解析然后保持到本地文件中去。

回过头来再来看CacheDispatcher的run()方法后面的代码

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
// If it is completely expired, just send it to the network.
if (entry.isExpired()) {
request.addMarker("cache-hit-expired");
request.setCacheEntry(entry);
mNetworkQueue.put(request);
continue;
}
// We have a cache hit; parse its data for delivery back to the request.
request.addMarker("cache-hit");
Response<?> response = request.parseNetworkResponse(
new NetworkResponse(entry.data, entry.responseHeaders));
request.addMarker("cache-hit-parsed");
if (!entry.refreshNeeded()) {
// Completely unexpired cache hit. Just deliver the response.
mDelivery.postResponse(request, response);
} else {
// Soft-expired cache hit. We can deliver the cached response,
// but we need to also send the request to the network for
// refreshing.
request.addMarker("cache-hit-refresh-needed");
request.setCacheEntry(entry);
// Mark the response as intermediate.
response.intermediate = true;
// Post the intermediate response back to the user and have
// the delivery then forward the request along to the network.
mDelivery.postResponse(request, response, new Runnable() {
@Override
public void run() {
try {
mNetworkQueue.put(request);
} catch (InterruptedException e) {
// Not much we can do about this.
}
}
});
}

如果一个请求已经缓存到本地了,再次请求的话尝试先从本地缓存文件中取,假设缓存没有被清理,这时候是可以取到数据的。拿到Cache.Entry信息后,判断缓存是否过期,如果没有过期,则判断是否需要刷新,如果不需要刷新,直接把缓存数据返回,不再往下执行。如果需要刷新的话,则先把缓存数据向上回调,并把该次请求标识为已发送过回调(request.markDelivered()),防止服务端返回304时再次向上回调,同时再发起一个网络请求。在发送请求时会添加上cache相关的header信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
private void addCacheHeaders(Map<String, String> headers, Cache.Entry entry) {
// If there's no cache entry, we're done.
if (entry == null) {
return;
}
if (entry.etag != null) {
headers.put("If-None-Match", entry.etag);
}
if (entry.lastModified > 0) {
Date refTime = new Date(entry.lastModified);
headers.put("If-Modified-Since", DateUtils.formatDate(refTime));
}
}

If-None-Match:带上上次服务器下发的验证令牌,服务器会针对当前最新的资源检查令牌,如果未被修改过,则返回304 Not Modified响应。

If-Modified-Since:带上上次服务器返回的资源修改时间,服务器会会针对当前最新资源的更新时间进行判断,如果此后没更新,则返回304 Not Modified响应。

服务器端根据header里的参数进行逻辑判断,返回200或304,如果是304,客户端直接使用本地缓存数据向上回调,如果返回200,则用返回的数据向上回调,同时更新缓存信息。

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
HttpResponse httpResponse = null;
byte[] responseContents = null;
Map<String, String> responseHeaders = Collections.emptyMap();
try {
// Gather headers.
Map<String, String> headers = new HashMap<String, String>();
addCacheHeaders(headers, request.getCacheEntry());
httpResponse = mHttpStack.performRequest(request, headers);
StatusLine statusLine = httpResponse.getStatusLine();
int statusCode = statusLine.getStatusCode();
responseHeaders = convertHeaders(httpResponse.getAllHeaders());
// Handle cache validation.
if (statusCode == HttpStatus.SC_NOT_MODIFIED) {
Entry entry = request.getCacheEntry();
if (entry == null) {
return new NetworkResponse(HttpStatus.SC_NOT_MODIFIED, null,
responseHeaders, true,
SystemClock.elapsedRealtime() - requestStart);
}
// A HTTP 304 response does not have all header fields. We
// have to use the header fields from the cache entry plus
// the new ones from the response.
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.5
entry.responseHeaders.putAll(responseHeaders);
return new NetworkResponse(HttpStatus.SC_NOT_MODIFIED, entry.data,
entry.responseHeaders, true,
SystemClock.elapsedRealtime() - requestStart);
}
// Some responses such as 204s do not have content. We must check.
if (httpResponse.getEntity() != null) {
responseContents = entityToBytes(httpResponse.getEntity());
} else {
// Add 0 byte response as a way of honestly representing a
// no-content request.
responseContents = new byte[0];
}
// if the request is slow, log it.
long requestLifetime = SystemClock.elapsedRealtime() - requestStart;
logSlowRequests(requestLifetime, request, responseContents, statusLine);
if (statusCode < 200 || statusCode > 299) {
throw new IOException();
}
return new NetworkResponse(statusCode, responseContents, responseHeaders, false,
SystemClock.elapsedRealtime() - requestStart);
} catch (SocketTimeoutException e) {
}
//省略各种Exception

官方文档的注释很清楚,各种异常的处理也很规范,如果请求出错了,上层可以很简单的通过异常的类型来提示用户接下来的操作(连接超时点击重试、无网络点击去设置、未授权点击去登录、服务器异常等)。

##本地缓存的处理
最后看下Volley的文件缓存类DiskBasedCache

1
2
3
4
public DiskBasedCache(File rootDirectory, int maxCacheSizeInBytes) {
mRootDirectory = rootDirectory;
mMaxCacheSizeInBytes = maxCacheSizeInBytes;
}

通过构造方法可以设置缓存文件夹的地址和大小(默认是5M,超过这个阀值再添加就会remove)

通过设置LinkedHashMap按照访问顺序排序来实现LRU算法

1
2
private final Map<String, CacheHeader> mEntries =
new LinkedHashMap<String, CacheHeader>(16, .75f, true);

CacheHeader由从缓存文件中读取响应的Cache-Control头信息生成。

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
/**
* Puts the entry with the specified key into the cache.
*/
@Override
public synchronized void put(String key, Entry entry) {
pruneIfNeeded(entry.data.length);
File file = getFileForKey(key);
try {
BufferedOutputStream fos = new BufferedOutputStream(new FileOutputStream(file));
CacheHeader e = new CacheHeader(key, entry);
boolean success = e.writeHeader(fos);
if (!success) {
fos.close();
VolleyLog.d("Failed to write header for %s", file.getAbsolutePath());
throw new IOException();
}
fos.write(entry.data);
fos.close();
putEntry(key, e);
return;
} catch (IOException e) {
}
boolean deleted = file.delete();
if (!deleted) {
VolleyLog.d("Could not clean up file %s", file.getAbsolutePath());
}
}

put的时候先把响应的header部分写入文件,然后再写入body体。get在组装Entry对象的data数据时会从文件中把头信息过滤掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void pruneIfNeeded(int neededSpace) {
if ((mTotalSize + neededSpace) < mMaxCacheSizeInBytes) {
return;
}
Iterator<Map.Entry<String, CacheHeader>> iterator = mEntries.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, CacheHeader> entry = iterator.next();
CacheHeader e = entry.getValue();
boolean deleted = getFileForKey(e.key).delete();
if (deleted) {
mTotalSize -= e.size;
} else {
VolleyLog.d("Could not delete cache entry for key=%s, filename=%s",
e.key, getFilenameForKey(e.key));
}
iterator.remove();
prunedFiles++;
if ((mTotalSize + neededSpace) < mMaxCacheSizeInBytes * HYSTERESIS_FACTOR) {
break;
}
}
}

缓存文件大小达到设定的阀值后,找出排在LinkedHashMap最前面的(也就是时间最久未使用过的)文件,删除本地文件已经在LinkedHashMap中的数据,直到剩余的空间能够存下新的要add的缓存文件为止。
注意,这里在删除LinkedHashMap中元素的时候,要使用Iterator迭代器的方式,否则会报ConcurrentModificationException.

Volley初始化的时候通过initialize()方法会把本地所有的缓存文件缓存到内存中

1
2
3
4
5
6
7
8
9
10
11
File[] files = mRootDirectory.listFiles();
if (files == null) {
return;
}
for (File file : files) {
BufferedInputStream fis = null;
fis = new BufferedInputStream(new FileInputStream(file));
CacheHeader entry = CacheHeader.readHeader(fis);
entry.size = file.length();
putEntry(entry.key, entry);
}

这里其实有一点问题,初始化的时候只是单纯的遍历所有缓存文件,并没有对缓存文件的有效期做校验。而通过上面CacheDispatcher的run()方法可知,如果缓存文件过期的话,请求就会走网络,而不使用缓存文件,此时缓存文件相当于是脏数据,没任何用。所以这里可以优化一下,在初始化的时候把过期的缓存删除掉,不仅能够减小缓存文件的磁盘空间占用,还能有效提高缓存的命中率(简单的依赖文件名进行遍历、添加,会导致有些过期的缓存文件在LinkedHashMap中放在没过期文件的后面,在缓存达到上限需要删除数据的时候就会把没过期的文件删除却保留了过期的缓存文件)。

总结

  • CacheDispatcher的run()方法是请求的入口,所有的请求都会先被添加到这里。请求存在缓存且可用则直接返回缓存数据,否则交给network线程处理。network线程数量可优化成动态调整。

  • 获取服务器响应后,严格按照HTTP协议的规范解析Cache相关的头部信息,并保存到本地文件。同一个请求再次发出时,在header里带上本地缓存的Cache相关的头部信息。如果服务器并没有按照HTTP规范实现相关的缓存协议,Volley的缓存功能就失效了,每次请求都是一个新请求,所以需要服务端配合。

  • 文件缓存通过LinkedHashMap实现LRU算法,管理对本地的缓存文件的操作。初始化缓存数据到LinkedHashMap中时可优化,删除过期的缓存文件。

  • Volley在请求时对缓存相关的头信息处理不太符合HTTP协议规范。
    缓存没过期时直接使用缓存,缓存过期需要验证的时候,传入了”If-None-Match”和”If-Modified-Since”。而有一种情况,如果某次请求上次已经缓存,且缓存没过期,此时再次请求时并不想使用本地的缓存数据而是强制拉取服务端最新数据时,比如下拉刷新,如果只是按照HTTP协议添加请求头信息”Cache-Control:no-cache”的话,Volley还是默认会使用本地缓存,这就有问题啦。其实曲线救国、重新设置Request.setShouldCache(false)也可以做到不使用本地缓存,但是Volley没有遵守HTTP协议。因为根据HTTP协议的规范,在请求头中是可以指定不使用本地缓存、只使用本地缓存、设置最大的缓存过期时间等。希望Volley能兼容Cache-Control头部的”no-cache”、”max-age”、”only-if-cached”、”max-stale”、”min-fresh”等指令。