源码分析 从Volley解析HTTP缓存机制

网路框架中缓存一般分为内存、硬盘、网络三级缓存。其中网络为服务器缓存,客户端真正的缓存只有内存和硬盘缓存。
为简单起见,下面内存和硬盘缓存统称为缓存。
本节只分析什么时候使用缓存,即下图中的是否走缓存内部逻辑,不分析缓存的实现细节与网络实现。

概览1.jpg

背景与结论

背景

如下简单测试代码

ImageRequest request = new ImageRequest(img, new Response.Listener<Bitmap>() {
    @Override
    public void onResponse(Bitmap response) {

        iv.setImageBitmap(response);
    }
}, 600, 600, null, new Response.ErrorListener() {

    @Override
    public void onErrorResponse(VolleyError error) {

    }
});
queue.add(request);

StringRequest stringRequest = new StringRequest(Request.Method.GET, "https://www.baidu.com/", new Response.Listener<String>() {
    @Override
    public void onResponse(String response) {
        tv.setText(response);
    }
}, new Response.ErrorListener() {
    @Override
    public void onErrorResponse(VolleyError error) {
        tv.setText(error.getMessage());
    }
});
queue.add(stringRequest);

volley使用ImageRequestStringRequest请求数据后,会缓存数据。
断网重启,ImageRequest会返回缓存的图片,StringRequest却返回数据失败。
源码可知,Volley默认开启缓存。
那么,StringRequest是否真的缓存了数据呢?如果缓存了数据,为什么断网情况下获取不到呢?

结论

先上结论:

缓存数据能否在断网情况下正确返回,不取决于是什么类型的Request,而取决于http的Cache-control
Cache-control故名思义,是缓存控制机制。

接口返回的数据是否需要缓存,有如下三种情况:

1.不需要缓存,如baidu.com。它的响应头中Cache-Controlno-cacheno-store。这样它的数据将不会被缓存,每次访问都需要重新获取,即使Volley设置了shouldCache为true

2.需要缓存,且接口数据长期不变,如图片。故图片请求的响应头中,设置Cache-control的属性之一max-age很大。客户端请求时,会优先判断now_time是否小于max-age。如果小于,说明缓存仍有效,则取本地缓存数据,不走网络

3.需要缓存,但接口数据不定时更新,如新闻首页数据(如果新闻首页数据不缓存,不仅浪费流量,还会影响体验;但是缓存,就需要考虑到首页数据的更新时机)。故设置max-age在本地限制时间就显得不合理。由于数据是否更新只有服务器知道,所以需要依赖返回码。

http提供了俩种方法:

1.响应头返回ETag。客户端检测到有ETag就缓存下来,下次请求该接口时会在请求头里的If-None-Match带上ETag。服务端会对比客户端的ETag和最新的ETag,不同则说明数据已变更,返回200;否则返回304。
2.响应头返回last-modified。客户端检测到有last-modified就缓存下来,下次请求该接口时会在请求头的If-Modified-Since带上该数据。服务端会对比上次修改的时间和最新修改的时间。如果最新修改的时间要晚于上次修改时间,说明数据已变更,返回200;否则返回304。

由于返回304不需要在body里返回数据,只需要对比俩个变量即可,故速度要快很多。

返回码200则从网络获取数据,304则直接从缓存中拿数据

综上,假设逻辑如下:

有缓存,则走是否用缓存逻辑;
max-age > 当前时间,从缓存中获取,不需要网络;
max-age < 当前时间或max-age为空,则依据返回码200或304(ETag & last-modified),来决定取缓存,还是拿网络数据。

这样,就可以解释上述测试代码中,断网重启App,图片可以获取到,而数据获取不到。(从假设逻辑可以看出,对于某些不定时更新数据的接口,即使数据做了缓存,仍然需要联网以确认是否更新。假定无网络,返回码不会为304,故走onErrorResponse,不会取缓存)

那么Volley是否是这样实现的呢?

Volley从源码分析缓存使用时机

为简化逻辑,这里摘录了部分关键代码,不分析Volley的框架结构与执行流程。

首先,Volley从mCacheQueue中取出Request,并做如下处理:

1. 判断缓存Cache.Entry是否为空?
为空:无缓存,跳到第3步
//源码来自CacheDispatcher.run
Cache.Entry entry = mCache.get(request.getCacheKey());
if (entry == null) {
    request.addMarker("cache-miss");
    mNetworkQueue.put(request);
    continue;
}

实际上,Cache.Entry缓存了俩部分内容
一部分是Response.Body,用于返回需要的数据;(有缓存,max-age满足或响应头为304时返回)
一部分是Response.Header,用于再次请求该接口时请求头携带这些数据,如ETag&last-modified;(有缓存,max-age为null或max-age不满足时携带这些数据)
可参考下述逻辑

不为空:有缓存,跳到第2步
2. 判断缓存是否达到max-age?
未达到:从缓存获取数据,流程结束。
//源码来自CacheDispatcher.run
if (entry.isExpired()) {
    request.addMarker("cache-hit-expired");
    //这里给Request赋值了缓存!
    request.setCacheEntry(entry);
    mNetworkQueue.put(request);
    continue;
}

request.addMarker("cache-hit");
Response<?> response = request.parseNetworkResponse(
        new NetworkResponse(entry.data, entry.responseHeaders));
request.addMarker("cache-hit-parsed");
          
...      
mDelivery.postResponse(request, response);

判断是否达到max-age的源码如下:

//源码来自Cache.Entry
public boolean isExpired() {
    return this.ttl < System.currentTimeMillis();
}

this.ttl的赋值如下:

//源码来自HttpHeaderParser.parseCacheHeaders
if (hasCacheControl) {
    //maxAge单位是s,但是ttl单位是ms,所以需要 * 1000
    softExpire = now + maxAge * 1000;
    finalExpire = mustRevalidate
            ? softExpire
            : softExpire + staleWhileRevalidate * 1000;
}
...
entry.ttl = finalExpire;

max-age来自响应头中的Cache-Control
Volley优先判断max-age,如果当前时间<max-age,说明数据仍在有效期内,则数据取本地缓存,不走网络。

已达到:max-age已失效,Request赋值缓存,跳到第3步
3. 发起网络请求。

网络请求中,如果有缓存,则请求头携带cache-control缓存数据;没有则跳过。

请求头携带缓存数据的代码如下:

//源码来自BasicNetwork
addCacheHeaders(headers, request.getCacheEntry());
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:entry.etag
If-Modified-Since:entry.lastModified

ETaglastModified的用途上面已经说过,服务端会根据这俩个值,来决定返回的响应头为200还是304。

4. 网络请求成功,判断响应头状态码?
304:数据无变更,取缓存,流程结束。
//源码来自BasicNetwork
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);
}

    entry.responseHeaders.putAll(responseHeaders);
    return new NetworkResponse(HttpStatus.SC_NOT_MODIFIED, entry.data,
            entry.responseHeaders, true,
            SystemClock.elapsedRealtime() - requestStart);
}
200:网络获取数据,流程结束。
结论

缓存的使用机制为:

缓存使用机制流程图1.jpg

关于缓存保存时机:

这里浅谈下Cache.Entry缓存数据的保存时机。

如果重写过Volley的Request,就会发现有个方法需要重写:

@Override
protected Response<String> parseNetworkResponse(NetworkResponse response) {
}

它会在NetworkDispatcher调用,用于将Volley内部的NetworkResponse转为外部可用的Response

Response<?> response = request.parseNetworkResponse(networkResponse);

所有重写的parseNetworkResponse,最后都会如下生成返回的Response

return Response.success(parsed, HttpHeaderParser.parseCacheHeaders(response));

其中,HttpHeaderParser.parseCacheHeaders()把Header和Body转为了Cache.Entry
同时也可以看到,Volley对于no-cacheno-store,直接返回null,不缓存,遵从http机制。

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

最后,它会在NetworkDispatcher中,在分发回调Listener之前被缓存:

if (request.shouldCache() && response.cacheEntry != null) {
    mCache.put(request.getCacheKey(), response.cacheEntry);
    request.addMarker("network-cache-written");
}
request.markDelivered();
mDelivery.postResponse(request, response);

mChche,就是缓存使用时机第一步中缓存的获取来源,同时也是Volley构造函数中熟悉的DiskBasedCache,用于内存和硬盘缓存,其实现机制本节暂不分析。

总结

缓存使用机制流程图1.jpg

首先,缓存的使用机制,不是无脑的有缓存就从内存获取、从硬盘获取这么简单。因为不可缓存接口、数据不定时变更接口的存在,需要对缓存的使用有特殊的逻辑判断。

正确的来讲,从内存、硬盘的获取属于缓存的get和set逻辑,而不是使用机制。二级缓存应该属于上图中的缓存获取数据的内部实现

即,本节描述是否走缓存的内部逻辑。不涉及网络部分和内存、硬盘缓存。

概览1.jpg

其次,可以发现,之前假设的逻辑和volley内部的实现无出其右。

响应头max-age、响应头last-modified、响应头ETag、请求头If-None-Match、请求头If-Modified-Since都是http规定的Cache-control机制,Volley内部只是根据这些机制实现了缓存的使用与存储逻辑。

所以,只要熟知http的这些规则,便可以定制自己的网络缓存框架。

下面附加接口的各种场景和Cache-control在该场景下如何使用:

情景 Respon 用途 举例
接口不缓存 no-cache no-store 用于普通网页,直接走网络 baidu.com
接口缓存且长期不变 max-age 用于图片等长久不变的数据,直接取本地缓存 图片
接口缓存且数据每次都变的 private 200 用于断网、初次启动需要显示缓存数据,但接口获取成功显示最新数据 凤凰新闻首页
接口缓存且数据不定时更新 ETag Last-Modified 200 304 用于接口数据不变时,取缓存;反之走网络。(断网、初次启动是否先取缓存视情况而定) 游民首页

凤凰新闻首页的数据每次刷新都在变化,这就要求:
本地缓存,用于断网、初次进入展示;但服务器又不必判断ETag,因为服务器已知数据是每次都变的。

  1. Response使用private,表示本地走缓存;
  2. Response不使用ETag、last-modified —> 客户端不缓存ETag、last-modified —> 客户端Request不携带ETag、last-modified —> 服务器不判断 —> 304不存在,所以只要接口Response不返回ETag、last-modified即可实现服务器与客户端俩端取消304逻辑。然后200,更新数据。

另外前文说过,即使缓存了数据,也只有在304才会返回而不会立即返回。故对于断网、初次启动获取缓存数据,可以如下提前获取:Cache.Entry entry = queue.getCache().get(stringRequest.getCacheKey());。这个代码通过阅读源码即可知道。

后续会依次研究内存和硬盘缓存的实现原理、多线程与网络。

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

推荐阅读更多精彩内容