天天看點

用戶端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浏覽器清除緩存

用戶端HTTP協定緩存的研究

二、用戶端緩存機制

浏覽器緩存控制機制有兩種:HTML Meta标簽 vs. HTTP頭資訊
移動APP(無Web網頁)緩存控制機制僅有一種:HTTP頭資訊

1、HTML Meta标簽控制緩存

浏覽器緩存機制,主要就是HTTP協定定義的緩存機制(如: Expires; Cache-control等)。但是也有非HTTP協定定義的緩存機制,如使用HTML Meta 标簽,Web開發者可以在HTML頁面的節點中加入标簽,代碼如下:
上述代碼的作用是告訴浏覽器目前頁面不被緩存,每次通路都需要去伺服器拉取。使用上很簡單,但隻有部分浏覽器可以支援,而且所有緩存代理伺服器都不支援,因為代理不解析HTML内容本身。廣泛應用的還是 HTTP頭資訊來控制緩存,下面主要介紹HTTP協定定義的緩存機制。

三、HTTP頭資訊控制緩存

1、用戶端請求流程

  • 第一次請求流程圖
    用戶端HTTP協定緩存的研究
  • 再次請求流程圖
    用戶端HTTP協定緩存的研究

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值可以是public、private、no-cache、no-store、no-transform、must-revalidate、proxy-revalidate、max-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 ;若最後修改時間較舊,說明資源無新修改,則響應HTTP  (無需包體,節省浏覽),告知浏覽器繼續使用所儲存的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 則與被請求資源的相應校驗串進行比對,決定傳回或。
           
既生Last-Modified何生Etag?你可能會覺得使用Last-Modified已經足以讓用戶端知道本地的緩存副本是否足夠新,為什麼還需要Etag(實體辨別)呢?HTTP1.1中Etag的出現主要是為了解決幾個Last-Modified比較難解決的問題:
Last-Modified标注的最後修改隻能精确到秒級,如果某些檔案在秒鐘以内,被修改多次的話,它将不能準确标注檔案的修改時間。
如果某些檔案會被定期生成,當有時内容并沒有任何變化,但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是一樣的。
  • 最後總結下幾種狀态碼的差別:
    用戶端HTTP協定緩存的研究

四、使用者行為與緩存

使用者操作 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 = ;
    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 =  *  * ;

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

            // 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 >  && !"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() == ) {//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的緩存機制

繼續閱讀