你真的了解 OkHttp 缓存控制吗?

3,629 阅读5分钟

源码分析,如需转载,请注明作者: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 是个优秀的网站
  • 遇到问题多读源码,只有源码才不会骗人
  • 实践是检验真理的唯一标准