不一樣的HTTP快取體驗

crazysunj發表於2018-06-24

前言

繼上篇《來一份Android動畫全家桶》釋出後,我相信你對Android的動畫有一定的認識。這次我們講解的內容是關於HTTP快取,通過本篇我們不單單只是瞭解HTTP快取機制,更重要的是學以致用,至於怎麼用,嘿嘿。

不一樣的HTTP快取體驗

[溫馨提示]對HTTP快取已經有一定了解且對OkHttp快取原始碼實現感興趣,可以看看我寫的玩一玩OkHttp快取原始碼

HTTP快取

我們試著自己實現一套HTTP快取機制。首先我們必須瞭解HTTP是客戶端請求伺服器響應的標準。

客戶端快取

OK,假設我現在是伺服器,有一個客戶端請求我,我把他想要的內容響應給他,很愉快的一次交流。

好景不長,暴露出各種問題,例如有客戶端反應我響應太慢,你自己網速差,距離我遠,怪我嘍?這都是還好的,可氣的是每天不停地請求,甚至有時候同時多人請求,請求的內容還是重複的,我又不能不給他,我只想說住院費報銷嗎?

自己太累不要一個人扛著,說出來,於是我向老大反饋這件事情,老大不愧是老大,第二天就想出了一套方案。老大的方案我一遍就懂了(手動滑稽),我這裡給大家說說。

當客戶端第一次訪問伺服器的時候,伺服器如實把內容響應,其次在響應首部新增Expires,其值是一個GMT格式時間,告知客戶端把內容在本地存一份,只要不超過這個時間,客戶端請求時都直接讀取本地檔案。

我心裡想著,如果客戶端請求的內容具有時效性,那要是快取,我們的名聲豈不是一敗塗地?作為一名優秀的搬磚工得提醒老大啊,老大聽後露出欣慰的笑容。

如果想告知客戶端不要快取,那麼伺服器會在響應首部新增Pragma,其值是no-cache。

這是我們最初的版本(HTTP1.0)。但隨著版本釋出,出現了一個問題,我們無法保證客戶端和伺服器的時間一致,因為Expires的值是一個絕對時間,依賴於計算機時鐘的正確設定。於是老大想出了用相對時間,哇,老大的形象在我心裡又高大了。

伺服器原先返回Expires的時候,另外新增Cache-Control,其值為max-age=相對時間值,單位是秒。Expires仍然可用(主要用於相容),優先順序是Pragma -> Cache-Control -> Expires。

這是我們第二個版本(HTTP1.1)。XXX年後,客戶端這幫傢伙組團來到我們總部,聲討:你要我們快取就快取,不快取就不快取,我們不要的面子的啊?

不一樣的HTTP快取體驗

行行行,你們來說。

客戶端可以在請求首部新增Cache-Control,若其值為no-cache,那麼不使用快取而直接向伺服器發出請求,但返回的不一定不是快取,這是客戶端期望的快取策略。

伺服器快取

這群傢伙自從有了這個規定,又讓我們回到了過去,全給我no-cache,搞得我老大暴跳如雷。我知道,這時候是我的showtime,晉升指日可待。我告知老大,您當初的規範真的是一個偉大的決定,但可以用在客戶端為何不能用在伺服器呢?我老大深思一下,又欣慰對我一笑。

如果客戶端快取過期或者請求首部Cache-Control值為no-cache,會略過客戶端快取而直接向伺服器請求,此時伺服器採用條件方法再驗證。

條件方法再驗證一般使用兩種條件首部:If-Modified-Since和If-None-Match。前者需要配合Last-Modified[其值是GMT時間,其意是檔案的最後修改時間],過程是客戶端請求到伺服器最新資源時,伺服器會返回Last-Modified,當客戶端再次請求伺服器時,便會帶上If-Modified-Since[其值是上次伺服器返回的Last-Modified],伺服器會根據檔案的最終修改時間與此比較,若一致,則返回304 Not Modified響應報文,反之,正常返回200。

大致過程如下:

不一樣的HTTP快取體驗

老大聽完,先是對我的方案大讚一番,然後說可以改進,比如這兩個場景:一個檔案任你千萬次修改,但內容不變;一個檔案內容雖然改變了,但並不重要。哇!我對老大的敬佩之情猶如滔滔江水連綿不絕。

伺服器返回ETag[其值一般是檔案的hash],時機與Last-Modified類似。客戶端下次請求伺服器時便帶上If-None-Match[其值是Etag],伺服器會與之匹配,若匹配上,則返回304 Not Modified響應報文,反之,正常返回200。針對內容微改不影響主體,HTTP1.1支援"弱驗證器",即原先ETag新增字首"W/"。

大致過程如下:

不一樣的HTTP快取體驗

高,實在是高!一股飯香撲鼻而來,低語,如果兩者同時存在咋整?

不一樣的HTTP快取體驗

老大說容我三思,然後出去了一趟,回來跟我說,這個簡單。

RFC2616提到除非所有請求首部一致,不然不可返回304。後來RFC2616拆分成6份,其中RFC7232提到如果兩者同時存在,那麼伺服器可以自由發揮,可以兩者都判斷,也可以有優先順序等等。

優秀啊!其實我還有一個問題,因為ETag會用一種演算法去計算值,如果伺服器採用了分散式(例如CDN),會導致ETag不一致。其實演算法保持一致就行啦。

快取分類

真是一刻都不得清閒,客戶端這幫傢伙又來鬧事,哭訴說,使用者表示有些內容極其私密,只能偷偷看;使用者表示經常訪問的需要快速開啟。

不一樣的HTTP快取體驗

一般來說,快取可以分為私有快取和公有快取。

私有快取

伺服器返回Cache-Control,其值帶有private,客戶端將檔案儲存在本地並允許使用者配置快取資訊,而伺服器並不會快取。

公有快取

伺服器返回Cache-Control,其值帶有public,預設為public,代理伺服器就會把檔案儲存下來,客戶端再次請求,若儲存的檔案可用,那麼直接返回給客戶端。代理伺服器又被稱為代理快取。

XXX日後釋出,從此世界和平!

第一次以故事形式講解知識點,首先我來說句公道話,我覺得寫得非常好,情節環環相扣又錯綜複雜(手動滑稽)!但你以為到這裡就已經結束了嗎?

不一樣的HTTP快取體驗

快取層次結構

我們先前講的客戶端和伺服器快取明顯的層次結構。首先客戶端發出請求,先驗證快取是否過期,若未過期則使用(快取命中),此為一級快取;若過期(快取未命中),那麼繼續向伺服器請求,伺服器驗證(新鮮度檢測)該快取仍然可用則使用(再驗證命中),此為二級快取;若不可用(再驗證未命中),那麼伺服器返回原始檔案(200),此為三級快取;如果源伺服器的檔案已經被刪除,那麼返回404。

但理想很豐滿,現實很骨感。例如分散式(CDN),即使是較為易懂的單中心節點結構和多中心節點結構都比我們所說的鏈式結構複雜的多,更不用說網狀結構(網狀快取)。其難點也不難發現,例如如何讓快取更快地更新或廢棄,如何更快代價更低地讓客戶端獲取快取。

如果下一級快取有多個選擇,那麼這些選擇組成的快取美其名曰兄弟快取。

這裡我們獻上美圖簡單介紹本節內容:

不一樣的HTTP快取體驗

總結

這裡我們針對上面所說做一個小總結。其實在上一節快取層次結構已經跟大家過了一遍流程。我們的口號是什麼?No picture,say a J8!

不一樣的HTTP快取體驗

大佬發現有問題,望指正,感激不盡!

實戰

理論終究只是理論,我們還是要回到日常!由於本人是個地地道道的Android搬磚工,所以你懂的。程式碼講解基於Retrofit+OkHttp+HTTP1.1。

根據我們上面的分析,客戶端首次請求的時候,服務端會返回一個Cache-Control響應首部來控制快取。當然,後面我們也瞭解到客戶端其實可以發起一個Cache-Control請求首部來期望自己的快取策略。

@Headers("Cache-Control: public, max-age=300")//快取時間為5分鐘
@GET("random/data/{type}/{count}")
Flowable<GankioEntity> getGankio(@Path("type") String type, @Path("count") int count);
複製程式碼

但最終的快取策略還是由伺服器控制,假設伺服器並沒有快取策略呢?懵逼了吧?放心,困難總是沒有辦法多,OkHttp裡面有個好玩的東西叫Interceptor,我們可以在這裡到伺服器返回的資訊並修改。

private static class CrazyDailyCacheNetworkInterceptor implements Interceptor {
	...
    @Override
    public Response intercept(Chain chain) throws IOException {
        final Request request = chain.request();
        final Response response = chain.proceed(request);
        final String requestHeader = request.header(CACHE_CONTROL);
        //判斷條件最好加上TextUtils.isEmpty(response.header(CACHE_CONTROL))來判斷伺服器是否返回快取策略,如果返回,就按伺服器的來,我這裡全部客戶端控制了
        if (!TextUtils.isEmpty(requestHeader)) {
            ...
            return response.newBuilder().header(CACHE_CONTROL, requestHeader).removeHeader("Pragma").build();
        }
        return response;
    }
}
複製程式碼

首先我們取到Request,很明顯,這是客戶端的請求資訊,然後再拿到伺服器的資訊Response,呼叫header修改Cache-Control的值,這裡記得呼叫removeHeader("Pragma"),為何?還記得我們分析的Pragma的優先順序是最高的嗎?既然比不過他,那麼將他移除我們就第一了。

不一樣的HTTP快取體驗

那麼如何告訴OkHttp呢?Android同學肯定很應手。

OkHttpClient.Builder builder = new OkHttpClient.Builder();
...
builder.addNetworkInterceptor(new CrazyDailyCacheNetworkInterceptor());
複製程式碼

但是我們常常有在山洞的場景,那肯定沒網啊!為啥常常在山洞?這個我們暫且放一邊,沒網肯定不可能到伺服器啊,那我們也接受不到快取策略。能不能在無網的時候,我們客戶端制定一個快取策略,比如無網時快取支援一天,若超過一天,那麼error。

private static class CrazyDailyCacheInterceptor implements Interceptor {
    @Override
    public Response intercept(Chain chain) throws IOException {
        Request request = chain.request();
        CacheControl cacheControl = request.cacheControl();
        //header可控制不走這個邏輯
        boolean noCache = cacheControl.noCache() || cacheControl.noStore() || cacheControl.maxAgeSeconds() == 0;
        if (!noCache && !NetworkUtils.isNetworkAvailable()) {
            Request.Builder builder = request.newBuilder();
            ...
            CacheControl newCacheControl = new CacheControl.Builder().maxStale(1, TimeUnit.DAYS).build();
            request = builder.cacheControl(newCacheControl).build();
            return chain.proceed(request);
        }
        return chain.proceed(request);
    }
}

builder.addInterceptor(new CrazyDailyCacheInterceptor());
複製程式碼

有了上面的分析,我相信程式碼並不難理解,這裡有個注意點就是判斷有無網的時候最好用ping的方法去檢測,但這玩意是阻塞的,要注意。

那麼,問題又來了,addInterceptor和addNetworkInterceptor有什麼區別呢?一圖勝千言,不多BB(官方)。而想知道具體原因,可以看我的玩一玩OkHttp快取原始碼的擴充套件章節。

不一樣的HTTP快取體驗

誒,是不是漏了什麼?我們快取放在哪兒呢?

//設定快取 20M
Cache cache = new Cache(new File(context.getExternalCacheDir(), CacheConstant.CACHE_DIR_API), 20 * 1024 * 1024);
builder.cache(cache);
複製程式碼

Android中的快取實現就這麼簡單,簡單?這是不可能的,這輩子都不可能,設計一套好的快取策略是個大考驗。

如果對OkHttp快取原始碼實現感興趣,可以看看我寫的玩一玩OkHttp快取原始碼

騷聊

又到了緊張刺激的騷聊環節。HTTP快取並不是什麼新鮮的技術,但它卻很重要,雖然我並沒有總結HTTP快取到底有什麼好處,但並不難發現,例如它減少了頻寬,減少了伺服器壓力,提高了使用者體驗等等。我們不要拘泥簡單瞭解機制,而應該學習它的思想運用到我們開發中,例如我們老生常談的圖片三級快取。人生就是痛並快樂著,加油吧,騷年!

最後,感謝一直支援我的人!

傳送門

Github:github.com/crazysunj/

部落格:crazysunj.com/

相關文章