你真的瞭解 OkHttp 快取控制嗎?

Yuloran發表於2019-01-02

原始碼分析,如需轉載,請註明作者:Yuloran (t.cn/EGU6c76)

前言

最近在寫一個開源專案,需要用到 Http 的快取機制。由於專案所使用的 Http 客戶端為 OkHttp,所以需要了解如何使用 OkHttp 來實現 Http 的快取控制。很慚愧,這一塊不太熟悉,所以就到網上 CV 了一下。雖然我知道網上很多部落格不太靠譜,但是沒想到,居然真掉坑裡了。

錯誤示例

不點名了,網上很多:

public class CacheControlInterceptor implements Interceptor
{
    @Override
    public Response intercept(Chain chain) throws IOException
    {
        Request request = chain.request();

        if (!NetworkUtil.isNetworkConnected())
        {
            request = request.newBuilder().cacheControl(CacheControl.FORCE_CACHE).build();
        }

        Response.Builder builder = chain.proceed(request).newBuilder();
        if (NetworkUtil.isNetworkConnected())
        {
            // 有網路時, 不快取, 最大儲存時長為1min
            builder.header("Cache-Control", "public, max-age=60").removeHeader("Pragma");
        } else
        {
            // 無網路時,設定超時為1周
            long maxStale = 60 * 60 * 24 * 7;
            builder.header("Cache-Control", "public, only-if-cached, max-stale=" + maxStale).removeHeader("Pragma");
        }
        return builder.build();
    }
}

// 省略...
builder.addNetworkInterceptor(new CacheControlInterceptor());
複製程式碼

這段程式碼的表現結果:請求成功後,斷開網路,重新開啟頁面,1min 內可以看到資料,1min 後資料消失。

錯誤原因

在看了 OKHttp 攔截器呼叫原始碼以及 Http Cache-Control 後,發現上述程式碼可以說沒有一行是正確的,也就是說邏輯完全不對:

  1. 沒有網路時,修改請求頭設為強制使用快取的邏輯,應當置於普通攔截器(addInterceptor)中,而不是網路攔截器(addNetworkInterceptor)。因為沒有網路時,OkHttp 的 ConnectInterceptor 會丟擲 UnKnownHostException,終止執行後續攔截器。而 networkInterceptors 正是位於 ConnectInterceptor 之後;

  2. 對於 OkHttp 來說,即使伺服器沒有設定 Cache-Control 響應頭,客戶端也不用額外設定。因為在開啟 OkHttpClient 的快取功能後,GET 請求的響應報文會被自動快取。若要禁止快取,在介面上加上 @Headers("Cache-Control: no-store") 註解即可;

  3. only-if-cached, max-stale 是請求頭的屬性,而非響應頭。

錯誤證明

直接從關鍵點切入:

RealCall::execute()

  @Override public Response execute() throws IOException {
    synchronized (this) {
      if (executed) throw new IllegalStateException("Already Executed");
      executed = true;
    }
    captureCallStackTrace();
    eventListener.callStart(this);
    try {
      client.dispatcher().executed(this);
      // 發起請求並獲得響應
      Response result = getResponseWithInterceptorChain();
      if (result == null) throw new IOException("Canceled");
      return result;
    } catch (IOException e) {
      eventListener.callFailed(this, e);
      throw e;
    } finally {
      client.dispatcher().finished(this);
    }
  }
複製程式碼

RealCall::getResponseWithInterceptorChain()

  Response getResponseWithInterceptorChain() throws IOException {
    // Build a full stack of interceptors.
    // 新建一個陣列,並把所有攔截器都加進去。因為是陣列,所以只能按照攔截器的新增順序依次執行
    List<Interceptor> interceptors = new ArrayList<>();
    interceptors.addAll(client.interceptors()); // 1. 普通攔截器
    interceptors.add(retryAndFollowUpInterceptor); // 2. 連線重試攔截器 
    interceptors.add(new BridgeInterceptor(client.cookieJar())); // 3. 請求頭,響應頭再加工攔截器
    interceptors.add(new CacheInterceptor(client.internalCache())); // 4. 快取儲存與讀取攔截器
    interceptors.add(new ConnectInterceptor(client)); // 5. 建立連線攔截器
    if (!forWebSocket) {
      interceptors.addAll(client.networkInterceptors()); // 6. 網路攔截器
    }
    interceptors.add(new CallServerInterceptor(forWebSocket)); // 7. 介面請求攔截器

    Interceptor.Chain chain = new RealInterceptorChain(interceptors, null, null, null, 0,
        originalRequest, this, eventListener, client.connectTimeoutMillis(),
        client.readTimeoutMillis(), client.writeTimeoutMillis());

    return chain.proceed(originalRequest);
  }
複製程式碼

從原始碼中可看出,所有攔截器都儲存在同一個陣列中,然後新建一個 chain,並將該陣列儲存到這個 chain 中。這個 chain,就是啟動整個攔截器執行鏈的頭結點。具體過程如下:

OkHttp攔截器執行鏈

那麼,為什麼在網路攔截器中修改請求頭為 FORCE_CACHE 沒有用呢?因為在沒有網路時,ConnectInterceptor 會直接丟擲 UnKnownHostException,終止執行鏈繼續向下執行,所以位於其後面的網路攔截器不會被執行:

UnKnownHostException

至於請求頭與響應頭,Cache-Control 如何設定才是正確的,Http Cache-Control 裡有詳細描述。

正確示例

無網時,強制使用快取:

1. 建立請求頭攔截器

public class RequestHeadersInterceptor implements Interceptor
{
    private static final String TAG = "RequestHeadersInterceptor";

    @Override
    public Response intercept(Chain chain) throws IOException
    {
        Logger.debug(TAG, "RequestHeadersInterceptor.");
        Request request = chain.request();
        Request.Builder builder = request.newBuilder();
        // builder.header("Content-Type", "application/json;charset=UTF-8")
        //       .header("Accept-Charset", "UTF-8");
        if (!NetworkService.getInstance().getNetworkInfo().isConnected())
        {
            // 無網路時,強制使用快取
            Logger.debug(TAG, "network unavailable, force cache.");
            builder.cacheControl(CacheControl.FORCE_CACHE);
        }
        return chain.proceed(builder.build());
    }
}
複製程式碼

NetworkService 是我寫的網路連線探測器,基於 API 21,需要的可以自取:點我

2. 新增請求頭攔截器

// 快取大小 100M
int size = 100 * 1024 * 1024;
Cache cache = new Cache(cacheDir, size);
OkHttpClient.Builder builder = new OkHttpClient.Builder();
builder.cache(cache).addInterceptor(new RequestHeadersInterceptor());
...
複製程式碼

篡改伺服器響應頭

一般情況下,客戶端不應該修改響應頭。客戶端使用什麼樣的快取策略,應當由伺服器兄弟確定。只有特殊情況下,才需要客戶端額外配置。比如呼叫的是第三方伺服器介面,其快取策略不符合客戶端的要求等。這裡給出一個簡單示例:

1. 建立響應頭攔截器

public class CacheControlInterceptor implements Interceptor
{
    private static final String TAG = "CacheControlInterceptor";

    @Override
    public Response intercept(Chain chain) throws IOException
    {
        Logger.debug(TAG, "CacheControlInterceptor.");
        Response response = chain.proceed(chain.request());
        String cacheControl = response.header("Cache-Control");
        if (StringUtil.isEmpty(cacheControl))
        {
            Logger.debug(TAG, "'Cache-Control' not set by the backend, add it ourselves.");
            return response.newBuilder().removeHeader("Pragma").header("Cache-Control", "public, max-age=60").build();
        }
        return response;
    }
}
複製程式碼

2. 新增響應頭攔截器

// 快取大小 100M
int size = 100 * 1024 * 1024;
Cache cache = new Cache(cacheDir, size);
OkHttpClient.Builder builder = new OkHttpClient.Builder();
builder.cache(cache).addNetworkInterceptor(new CacheControlInterceptor ());
...
複製程式碼

結語

請求與響應的本質是不同主機利用各自的 IP 地址和埠號,通過 Socket 程式設計介面互相傳送資訊。為了約束資料交換格式,產生了 Http 協議。由於 Http 是明文傳輸,為了傳輸安全,又產生了 Https 協議。既然是協議,那麼只有在雙方都遵守的情況下才會生效。所以,在專案開發中,我們經常需要跟伺服器兄弟進行介面聯調,以保證約定被正確實現。OkHttp 扮演的角色類似於瀏覽器,共同點是都將請求與響應封裝成了使用者友好的形式,都支援錯誤重連、報文快取等機制,不同的是瀏覽器還需要負責網頁渲染等。

本文表面上描述的是如何利用 OkHttp 實現快取控制,實則闡述了 OkHttp 的請求與響應的執行機制。所謂通則一通百通,利用 OKHttp 實現其它功能現在應該也不是問題了。比如實現一個加解密攔截器,對請求體進行加密,對響應報文進行解密,顯然,這個攔截器,需要加到網路攔截器中。

OkHttp 的 Response 物件,是對真正響應報文(networkResponse 和 cacheResponse)的封裝。所以,只要不在攔截器中呼叫 response.body() 方法,就不會導致請求阻塞,尤其是響應報文很大的時候,更不能呼叫。

最後,針對 Cahce-Control 有三點總結:

  • 要正確理解 Http 協議的約定,MDN 是個優秀的網站
  • 遇到問題多讀原始碼,只有原始碼才不會騙人
  • 實踐是檢驗真理的唯一標準

相關文章