客户端HTTP协议缓存的研究

题引:为了提升用户体验,同时减轻服务器压力和降低网络带宽,技术总监和技术经理决定对APP做缓存处理,包括图片的三级缓存(这里不讨论)和网络请求的数据缓存
  • 缓存基本认识
    1、缓存的分类
    2、Chrome浏览器清除缓存
  • 客户端缓存机制
    1、HTML Meta标签控制缓存
  • HTTP头信息控制缓存
    1、客户端请求流程
    2、客户端缓存的几个重要概念
  • 用户行为与缓存
  • 实践
    1、不同网络请求方式的缓存处理流程及方式
    2、okHttp3.0+retrofit2.0的缓存处理
    3、volley的缓存处理
  • Refer

一、缓存基本认识

1、缓存的分类

缓存分为服务端侧(server side,比如 Nginx、Apache)和客户端侧(client side,比如 web browser)。

服务端缓存又分为 代理服务器缓存 和 反向代理服务器缓存(也叫网关缓存,比如 Nginx反向代理、Squid等),广泛使用的 CDN 也是一种服务端缓存,目的都是让用户的请求走”捷径“,并且都是缓存图片、文件等静态资源。

客户端侧缓存一般指的是浏览器缓存和移动APP缓存,目的就是加速各种静态资源的访问。现在的大型网站,随便一个页面都是一两百个请求,每天 pv 都是亿级别。如果没有缓存,用户体验会急剧下降、同时服务器压力和网络带宽都会面临严重的考验。

2、Chrome浏览器清除缓存

Chrome浏览器缓存清除页

二、客户端缓存机制

浏览器缓存控制机制有两种:HTML Meta标签 vs. HTTP头信息
移动APP(无Web网页)缓存控制机制仅有一种:HTTP头信息
1、HTML Meta标签控制缓存

浏览器缓存机制,主要就是HTTP协议定义的缓存机制(如: Expires; Cache-control等)。但是也有非HTTP协议定义的缓存机制,如使用HTML Meta 标签,Web开发者可以在HTML页面的节点中加入标签,代码如下:

<META HTTP-EQUIV="Pragma" CONTENT="no-cache">

上述代码的作用是告诉浏览器当前页面不被缓存,每次访问都需要去服务器拉取。使用上很简单,但只有部分浏览器可以支持,而且所有缓存代理服务器都不支持,因为代理不解析HTML内容本身。广泛应用的还是 HTTP头信息来控制缓存,下面主要介绍HTTP协议定义的缓存机制。

三、HTTP头信息控制缓存

1、客户端请求流程
  • 第一次请求流程图
    第一次请求流程图
  • 再次请求流程图
    再次请求流程图
2、客户端(浏览器、APP等)缓存的几个重要概念
  • Expires策略:Expires是Web服务器响应消息头字段,在响应http请求时告诉客户端在过期时间前客户端可以直接从客户端缓存取数据,而无需再次请求。不过Expires 是HTTP 1.0的东西,现在客户端均默认使用HTTP 1.1,所以它的作用基本忽略。Expires 的一个缺点就是,返回的到期时间是服务器端的时间,这样存在一个问题,如果客户端的时间与服务器的时间相差很大(比如时钟不同步,或者跨时区),那么误差就很大,所以在HTTP 1.1版开始,使用Cache-Control: max-age=(秒)替代。

  • Cache-Control策略(重点关注):Cache-Control与Expires的作用一致,都是指明当前资源的有效期,控制客户端是否直接从客户端缓存取数据还是重新发请求到服务器取数据。只不过Cache-Control的选择更多,设置更细致,如果同时设置的话,其优先级高于Expires。

Cache-Control值可以是publicprivate、no-cache、no-store、no-transform、must-revalidate、proxy-revalidatemax-age

各个消息中的指令含义如下:

Public指示响应可被任何缓存区缓存。
Private指示对于单个用户的整个或部分响应消息,不能被共享缓存处理。这允许服务器仅仅描述当用户的部分响应消息,此响应消息对于其他用户的请求无效。
no-cache指示请求或响应消息不能缓存,该选项并不是说可以设置“不缓存”,容易望文生义。
no-store用于防止重要的信息被无意的发布。在请求消息中发送将使得请求和响应消息都不使用缓存,完全不存下來。
max-age指示客户机可以接收生存期不大于指定时间(以秒为单位)的响应。
min-fresh指示客户机可以接收响应时间小于当前时间加上指定时间的响应。
max-stale指示客户机可以接收超出超时期间的响应消息。如果指定max-stale消息的值,那么客户机可以接收超出超时期指定值之内的响应消息。
  • Last-Modified/If-Modified-Since:Last-Modified/If-Modified-Since要配合Cache-Control使用。
Last-Modified:标示这个响应资源的最后修改时间。web服务器在响应请求时,告诉浏览器资源的最后修改时间。
If-Modified-Since:当资源过期时(使用Cache-Control标识的max-age),发现资源具有Last-Modified声明,则再次向web服务器请求时带上头 If-Modified-Since,表示请求时间。web服务器收到请求后发现有头If-Modified-Since 则与被请求资源的最后修改时间进行比对。若最后修改时间较新,说明资源又被改动过,则响应整片资源内容(写在响应消息包体内),HTTP 200;若最后修改时间较旧,说明资源无新修改,则响应HTTP 304 (无需包体,节省浏览),告知浏览器继续使用所保存的cache
  • Etag/If-None-Match:Etag/If-None-Match也要配合Cache-Control使用。
Etag:web服务器响应请求时,告诉浏览器当前资源在服务器的唯一标识(生成规则由服务器决定)。Apache中,ETag的值,默认是对文件的索引(INode),大小(Size)和最后修改时间(MTime)进行Hash后得到的。  
If-None-Match:当资源过期时(使用Cache-Control标识的max-age),发现资源具有Etage声明,则再次向web服务器请求时带上头If-None-Match(Etag的值)。web服务器收到请求后发现有头If-None-Match 则与被请求资源的相应校验串进行比对,决定返回200304

既生Last-Modified何生Etag?你可能会觉得使用Last-Modified已经足以让客户端知道本地的缓存副本是否足够新,为什么还需要Etag(实体标识)呢?HTTP1.1中Etag的出现主要是为了解决几个Last-Modified比较难解决的问题:

Last-Modified标注的最后修改只能精确到秒级,如果某些文件在1秒钟以内,被修改多次的话,它将不能准确标注文件的修改时间。
如果某些文件会被定期生成,当有时内容并没有任何变化,但Last-Modified却改变了,导致文件没法使用缓存。
有可能存在服务器没有准确获取文件修改时间,或者与代理服务器时间不一致等情形。

Etag是服务器自动生成或者由开发者生成的对应资源在服务器端的唯一标识符,能够更加准确的控制缓存。Last-Modified与ETag一起使用时,服务器会优先验证ETag。

  • yahoo的Yslow法则中则提示谨慎设置Etag:需要注意的是分布式系统里多台机器间文件的last-modified必须保持一致,以免负载均衡到不同机器导致比对失败,Yahoo建议分布式系统尽量关闭掉Etag(每台机器生成的etag都会不一样,因为除了last-modified、inode 也很难保持一致)。
  • Pragma行是为了兼容HTTP1.0,作用与Cache-Control: no-cache是一样的。
  • 最后总结下几种状态码的区别:
    状态码区别图

四、用户行为与缓存

用户操作

Expires/Cache-Control

Last-Modified/Etag

地址栏回车

有效

有效

页面链接跳转

有效

有效

新开窗口

有效

有效

前进、后退

有效

有效

F5刷新

无效(BR重置max-age=0)

有效

Ctrl+F5强制刷新

无效(重置CC=no-cache)

无效(请求头丢弃该选项)

五、实践

1、不同网络请求方式的缓存处理流程及方式

  • 请求头添加If-None-Match/ETag相关信息
  • 如果网络畅通,且响应码在[200,300)区间内则将更新数据缓存并返回数据
  • 如果网络畅通,且响应码为304,则返回缓存数据
  • 如果网络不畅通,则直接返回缓存数据

2、okHttp3.0+retrofit2.0的缓存处理

okhttp缓存处理的一些理解:

  • 离线时使用cache,在线时访问网络并更新cache
  • OkHttpClient设置cache后,response自动进行缓存
  • 通过拦截器,离线时request添加头信息header(“Cache-Control”, “only-if-cached”)强制使用缓存
  • 如果不想使用okhttp的cache机制,也可以自己通过对象序列化等方式自己保存reponse结果
  • 使用 Cache-Control : only-if-cached header,报文将永远也不会到达服务器。只有存在可用缓存响应时才会检查并返回它。不然的话,会抛出 504 错误,所以开发的时候别忘了处理这个异常。
  • 使用 Cache-Control : max-stale=[seconds] 报头,这种办法更灵活一些,它向客户端表明可以接收缓存响应,只要该缓存响应没有超出其生命周期,并且该缓存响应是否可用由 OkHttp 检查,不需要与服务器建立连接来判断。不过,当缓存响应超出其生命周期,网络操作还是会进行,然后得到服务器返回的新内容。
  • 通过 Response 对象的 cacheResponse() 和 networkResponse() 方法可以得到缓存的响应和从实际的 HTTP 请求得到的响应。无缓存时,cacheResponse为null;无网络时networkResponse为null;有缓存无网络时,cacheResponse不为null,networkResponse为null;无缓存有网络时,cacheResponse为null,networkResponse不为null;
  • 如果缓存候选响应包含 ETag 报头,那么新的 If-None-Match 请求报文会使用相同的 ETag 值。
  • 如果上一点没有被满足,且缓存候选响应包含 Last-Modified,那么新的 If-Modified-Since 请求报文会使用该值。
  • 如果以上两点都没有被满足,且缓存候选响应包含 Date,那么新的 If-Modified-Since 请求报文会使用该值。
  • 如果请求报文存在 If-None-Match,则会跳过后续的一系列检查,直接请求网络。如果请求网络返回304,则使用缓存候选响应;如果请求网络返回[200,300),则不使用缓存候选响应,而是使用实际的http响应,并更新缓存。

这些方法不支持cache:post、patch、put、delete、move

public static boolean invalidatesCache(String method) {
    return method.equals(“POST”) || method.equals(“PATCH”) || method.equals(“PUT”) || method.equals(“DELETE”) || method.equals(“MOVE”);
    }

OkhttpManager

package com.network;

import com.hyphenate.easeui.EaseUIApplication;
import com.hyphenate.easeui.service.AppHelper;
import com.hyphenate.easeui.utils.AppConfig;
import com.utils.NetUtils;

import java.io.IOException;
import java.security.cert.CertificateException;
import java.util.concurrent.TimeUnit;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;

import okhttp3.Cache;
import okhttp3.CacheControl;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.logging.HttpLoggingInterceptor;

/**
 * Created by Zhang BiaoJiang on 2017/5/22.
 */
public class OkhttpManager {
    /**
     * 默认请求超时时间
     */
    private static long DEFAULT_REQUEST_TIME_OUT = 30;
    private static AppHelper helper;
    private static OkHttpClient okHttpClient;

    /**
     * 获取OkHttp客户端
     *
     * @return okHttp客户端
     */
    public OkHttpClient getOkhttpClient() {
        if (null == helper)
            helper = new AppHelper();
        if (null != okHttpClient)
            return okHttpClient;

        if (AppConfig.IS_HTTPS) {
            okHttpClient = getHttpsClient();
        } else {
            okHttpClient = getClient();
        }
        return okHttpClient;
    }

    /**
     * 缓存目录大小
     */
    private final static int CACHE_SIZE_BYTES = 1024 * 1024 * 2;

    private OkHttpClient getClient() {
        CacheControlInterceptor cacheControlInterceptor = new CacheControlInterceptor();
        OkHttpClient.Builder builder = new OkHttpClient.Builder();
        //网络日志打印
        HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
        //设置日志级别
        logging.setLevel(HttpLoggingInterceptor.Level.BODY);
        builder.addInterceptor(logging);
        //设置超时
        builder.connectTimeout(DEFAULT_REQUEST_TIME_OUT, TimeUnit.SECONDS);
        //拦截器需要同时设置networkInterceptors和interceptors
        builder.addInterceptor(cacheControlInterceptor);
        builder.addNetworkInterceptor(cacheControlInterceptor);
        //设置不进行连接失败重试
        builder.retryOnConnectionFailure(false);
        //设置缓存
        builder.cache(new Cache(EaseUIApplication.getContext().getCacheDir(), CACHE_SIZE_BYTES));
        return builder.build();

    }

    /**
     * 兼容https
     */
    private OkHttpClient getHttpsClient() {
        CacheControlInterceptor cacheControlInterceptor = new CacheControlInterceptor();
        try {
            // Create a trust manager that does not validate certificate chains
            final TrustManager[] trustAllCerts = new TrustManager[]{new X509TrustManager() {
                @Override
                public void checkClientTrusted(java.security.cert.X509Certificate[] chain,
                                               String authType) throws CertificateException {
                }

                @Override
                public void checkServerTrusted(java.security.cert.X509Certificate[] chain,
                                               String authType) throws CertificateException {
                }

                @Override
                public java.security.cert.X509Certificate[] getAcceptedIssuers() {
                    return new java.security.cert.X509Certificate[0];
                }
            }};

            // Install the all-trusting trust manager
            final SSLContext sslContext = SSLContext.getInstance("TLS");
            sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
            // Create an ssl socket factory with our all-trusting manager
            final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();

            OkHttpClient okHttpClient = new OkHttpClient();
            OkHttpClient.Builder builder = okHttpClient.newBuilder()
                    .sslSocketFactory(sslSocketFactory)
                    .hostnameVerifier(org.apache.http.conn.ssl.SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);

            //网络日志打印
            HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
            //设置日志级别
            logging.setLevel(HttpLoggingInterceptor.Level.BODY);
            builder.addInterceptor(logging);
            builder.connectTimeout(DEFAULT_REQUEST_TIME_OUT, TimeUnit.SECONDS);
            builder.addInterceptor(cacheControlInterceptor);
            builder.addNetworkInterceptor(cacheControlInterceptor);
            builder.cache(new Cache(EaseUIApplication.getContext().getCacheDir(), CACHE_SIZE_BYTES));
            //设置不进行连接失败重试
            builder.retryOnConnectionFailure(false);
            return builder.build();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 缓存控制拦截器
     */
    private class CacheControlInterceptor implements Interceptor {

        @Override
        public Response intercept(Chain chain) throws IOException {
            Request original = chain.request();
            /**
             * 如果网络不可用,强制使用缓存。
             * 当没用可用缓存时,会报504错误
             */
            if (!NetUtils.isNetworkAvailable()) {
                original = original.newBuilder()
                        .cacheControl(CacheControl.FORCE_CACHE)
                        .build();
                HttpLoggingInterceptor.Logger.DEFAULT.log("no network");
            }
            Request.Builder rb = original.newBuilder();
            /**
             * 解决Okhttp访问网络出现EOF异常,避免添加两次Connection信息
             */
            if (Build.VERSION.SDK != null && Build.VERSION.SDK_INT > 13 && !"close".equals(original.header("Connection"))) {
                rb.addHeader("Connection", "close");
            }
            Request request = rb.build();
            /**
             * 执行此方法后可能抛IOException(SocketTimeoutException)
             * 这里不处理该异常,直接抛出由上层反馈给用户
             */
            Response networkResponse = chain.proceed(request);

            /**
             * 处理504错误
             */
            CacheControl cacheControl = request.cacheControl();
            if (networkResponse.code() == 504) {//OkHttp如果缓存请求不到会报504
                if (CacheControl.FORCE_CACHE.toString().equals(cacheControl.toString())) {
                    HttpLoggingInterceptor.Logger.DEFAULT.log("cached not found");
                }
                return networkResponse;
            }
            /**
             * 如果网络可用,则更新缓存
             * 如果网络不可用,则标明可以使用过期缓存
             */
            if (NetUtils.isNetworkAvailable()) {
                return networkResponse.newBuilder()
                        .header("Cache-Control", cacheControl.toString())
                        .removeHeader("Pragma")
                        .build();
            } else {
                return networkResponse.newBuilder()
                        .header("Cache-Control", "public, only-if-cached,  max-stale=" + Integer.MAX_VALUE)
                        .removeHeader("Pragma")
                        .build();
            }
        }

    }

}

Get请求封装函数如下:

    /**
     * 功能:Get请求封装函数
     * @param url 请求地址
     * @param isAppendUrl 是否追加
     * @param maxAge 在 max-age 指定的时间内,缓存副本可以直接使用,不需要与服务端协商
     * @param maxStale 缓存最大过期时间
     * @param isCache 是否缓存
     * @return 请求的网络数据
     * @throws UnknownHostException
     */
    public static String getRemoteRequest(String url, boolean isAppendUrl, int maxAge, int maxStale,boolean isCache) throws UnknownHostException {
        if (isAppendUrl) {
            url = appendUrl(url);
        }
        OkHttpClient okHttpClient = okhttpManager.getOkhttpClient();
        CacheControl cacheControl = new CacheControl.Builder()
                .maxAge(maxAge, TimeUnit.SECONDS)
                .maxStale(maxStale, TimeUnit.SECONDS)
                .build();
        Request.Builder rb = new Request.Builder();
        rb = rb.url(url);
        if(isCache){
            rb = rb.cacheControl(cacheControl);
        }
        Request request = rb.build();

        Call call = okHttpClient.newCall(request);
        Response response = null;
        try {
            response = call.execute();
        } catch (IOException e) {
            e.printStackTrace();
        }
        String result = "";
        try {
            if (null!=response&&response.isSuccessful()) {
                result = response.body().string();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        return result;
    }

3、volley的缓存处理
第一步:在请求头中添加Cache-Control,强制volley处理请求时考虑缓存处理,比如先找缓存,找不到缓存在请求网络。

    @Override
    public Map<String, String> getHeaders() throws AuthFailureError {
        Map<String,String> headers =  new HashMap<String, String>();
        headers.put("Cache-Control", "public,max-age="+max_age+",max-stale="+max_stale);
        return headers;
    }

第二步:重写parseNetworkResponse,强制volley框架根据响应头信息做缓存处理。

    @Override
    protected Response<String> parseNetworkResponse(NetworkResponse response) {
        Response<String> resp;
        try {
            /**
             * 描述:1、服务器没有配合的情况下,需要自己修改响应头信息
             * 2、volley框架会根据响应头信息(Cache-Control)对返回的数据做缓存处理
             * 3、volley框架根据缓存的响应头信息在下次请求网络时在请求头添加相应信息
             *    例子:volley框架会把返回的ETag的值赋予请求头中的If-None-Match属性
             */
            response.headers.remove("Pragma");
            String cache_control = getHeaders().get("Cache-Control");
            response.headers.put("Cache-Control", cache_control);
            String jsonStr = getRealString(response.data);
            resp = Response.success(jsonStr, parseCacheHeaders(response));
        }catch (Exception e) {
            resp = Response.error(new VolleyError(e));
        }
        return resp;
    }

第三步:重写deliverError,用于实现离线缓存。

    @Override
    public void deliverError(VolleyError error) {
        /**
         * 离线缓存:没有网的情况即出现了异常,然后就会调用到deliverError
         **/
        if (error instanceof NoConnectionError) {
            Cache.Entry entry = this.getCacheEntry();
            if (entry != null) {
                Response<String> response = parseNetworkResponse(new NetworkResponse(entry.data, entry.responseHeaders));
                deliverResponse(response.result);
                return;
            }
        }
        super.deliverError(error);
    }

六、Refer

[1] 浏览器缓存机制

[2] Web 开发人员需知的 Web 缓存知识

[3] 浏览器缓存详解:expires,cache-control,last-modified,etag详细说明

[4] 在浏览器地址栏按回车、F5、Ctrl+F5刷新网页的区别

[5] Cache Control 與 ETag

[6] 缓存的故事

[7] Google的PageSpeed网站优化理论中提到使用Etag可以减少服务器负担

[8] yahoo的Yslow法则中则提示谨慎设置Etag

[9] H5 缓存机制浅析 移动端 Web 加载性能优化

[10] 网页性能: 缓存效率实践

[11] 透过浏览器看HTTP缓存

[12] 浏览器缓存知识小结及应用

[13] 大公司里怎样开发和部署前端代码?

[14] 浏览器缓存机制详解

[15] 请注意,Volley已默认使用磁盘缓存

[16] 从源码带看Volley的缓存机制

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值