美文网首页Android技术知识Android开发
OkHttp源码学习系列三:缓存总结

OkHttp源码学习系列三:缓存总结

作者: 业松 | 来源:发表于2018-07-21 18:30 被阅读1次

    本文为本人原创,转载请注明作者和出处。

    缓存在面试中经常被问到,在这一章我会单独讲解有关于http缓存以及我们okhttp实现缓存的方方面面。

    系列文章索引:
    OkHttp源码学习系列一:总流程和Dispatcher分析
    OkHttp源码学习系列二:拦截链分析
    OkHttp源码学习系列三:缓存总结

    零、http缓存规则

    试想一下如果我们使用了缓存,那我们应该在何时使用缓存呢,或者说我们如何才能知道我们的缓存没有过期,可以使用。这就需要我们先学习http的缓存规则。服务端和客户端都必须遵守这套规则才能够使得我们的缓存可以在合适的时机被使用。其实这套规则还是稍微有点复杂的,笔者也是花了不少时间才完全明白这一套规则是怎么回事。为了全面搞清楚这套流程,我画了如下这张图,一起来看下吧:


    http缓存规则

    从客户端发起请求开始,客户端先去缓存文件中根据url查找是否有缓存,取到缓存的http报文后,首先要做的事是判断是否过期。判断过期的标志是根据报文的两个头域,Expires和Cache-Control。

    Expires规定了缓存的报文的过期时间,Cache-Control规定了缓存的有效期。前者是http1.0的标准,基本已被弃用,后者是http1.1的标准,两者同时存在的话以Cache-Control为准。Cache-Control设定过期时间一般是Cache-Control: max-age=3600这样的形式,3600代表3600秒,即一小时后过期。如果判断后没有过期的话,则客户端直接使用缓存,不会向服务器发起请求。

    看到这里你会不会有疑问?那这样缓存策略岂不是很简单,过期就重新请求,没过期就直接用,怎么那张图后面还有那么多流程?是的,如果我们要请求的资源对即时性要求不高,我们确实可以这样,服务器规定个过期时间,过期就重新请求,没过期就直接使用缓存。但是但是,我们大部分请求都不会使用这种过期策略,即使是对即时性要求不高的。为什么?举个例子,假如运维小哥在首页中不小心加入了些不和谐内容,如果使用过期策略,那即使运维小哥秒删,用户也得等缓存过期后才会重现请求,这样是不是很囧?

    那么又有疑问了,既然绝大多数请求都不会设定一个缓存有效期,或者说我们的缓存永远都是过期的,那缓存还有什么用?其实,上图中后面的流程也很简单,就是服务器会把我们将要使用的缓存与服务器实时数据进行对比,如果服务器的更新则缓存不可用,需要使用服务器返回的最新数据。假如服务器对比发现客户端的缓存已经说最新的了,那么就会返回304响应码,不返回资源内容,客户端接受到304响应码后直接使用缓存。

    那么服务器是如何对比呢?有两种情况,一种是ETag。服务器会对资源做一个ETag算法,生成一个字符串。这个字符串实际上代表了资源在服务器的版本。服务器在响应头中会加入这个ETag给客户端。客户端缓存后下次继续请求的时候把缓存中的ETag取出来加入到请求头。服务器取出请求头中的ETag,与当前服务器该资源实时的ETag作对比。如果不一致则表明服务器有更新资源,则返回200响应码,并返回资源。如果客户端的ETag和服务端的相同,代表服务端没有更新数据,则返回304响应码,并不返回资源。这样就达到了使用缓存去节省时间和流量的目的。另一种是Last-Modified,它代表的是资源在服务器更新的时间,和ETag类似客户端从缓存中取出这个时间加入到请求头,服务器根据最近的一次更新时间与之对比,决定是返回资源还是返回304。

    总结下,如果服务器通过Cache-Control头规定了过期时间,没过期的话可以直接使用缓存。否则,使用Etag或者Last-Modified加入请求体,服务器接收后与最新的数据进行比对,决定直接返回最新数据还是304响应码。是不是其实一点都不复杂呢?

    一、Okhttp的缓存策略的实现

    http协议规定了缓存的使用规则,那么Okhttp是如何实现这些规则的呢?
    其实我写到这块有点无从起笔了,在上一章讲解CacheIntercept的时候已经将Okhttp使用缓存的过程讲的差不多了,没有看过的同学可以先翻去看上一章。此时我们可以不着急看源码,在学习完http缓存规则后,我们可以思考下,我们的客户端也就是OkHttpClient在缓存这块要做哪些事呢?首先,需要取缓存,并确定缓存有无过期。如果过期,看看有没有Etag或Last-Modified可以加到请求体。最后如果服务器返回304,我们要直接使用缓存,如果返回200,我们需要更新缓存。按照这个顺序,我们来在源码中找一找,这些操作是如何实现的。

    在上一章中,CacheIntercept先取缓存,再通过缓存策略类CacheStrategy获取处理后的缓存response,一起来回顾下这段代码:

        Response cacheCandidate = cache != null
            ? cache.get(chain.request())
            : null;
    
        long now = System.currentTimeMillis();
    
        CacheStrategy strategy = new CacheStrategy.Factory(now, chain.request(), cacheCandidate).get();
        Request networkRequest = strategy.networkRequest;
        Response cacheResponse = strategy.cacheResponse;
    

    首先从cache对象中根据request取到缓存的response,然后调用CacheStrategy的构建方法并获取实例对象,从这个CacheStrategy中获取处理后的response。现在我们就来看下CacheStrategy到底怎么处理的cacheResponse,先从factory方法开始吧:

    public Factory(long nowMillis, Request request, Response cacheResponse) {
      this.nowMillis = nowMillis;
      this.request = request;
      this.cacheResponse = cacheResponse;
    
      if (cacheResponse != null) {
        this.sentRequestMillis = cacheResponse.sentRequestAtMillis();
        this.receivedResponseMillis = cacheResponse.receivedResponseAtMillis();
        Headers headers = cacheResponse.headers();
        for (int i = 0, size = headers.size(); i < size; i++) {
          String fieldName = headers.name(i);
          String value = headers.value(i);
          if ("Date".equalsIgnoreCase(fieldName)) {
            servedDate = HttpDate.parse(value);
            servedDateString = value;
          } else if ("Expires".equalsIgnoreCase(fieldName)) {
            expires = HttpDate.parse(value);
          } else if ("Last-Modified".equalsIgnoreCase(fieldName)) {
            lastModified = HttpDate.parse(value);
            lastModifiedString = value;
          } else if ("ETag".equalsIgnoreCase(fieldName)) {
            etag = value;
          } else if ("Age".equalsIgnoreCase(fieldName)) {
            ageSeconds = HttpHeaders.parseSeconds(value, -1);
          }
        }
      }
    }
    

    这个构造方法做的事情很简单,就是记录下这个缓存的response的请求发送时间,接收response的时间,以及Expires、Last-Modified、ETag等与缓存有关的响应头,为后续的判断做准备。继续来看get方法:

    public CacheStrategy get() {
      CacheStrategy candidate = getCandidate();
    
      if (candidate.networkRequest != null && request.cacheControl().onlyIfCached()) {
        // We're forbidden from using the network and the cache is insufficient.
        return new CacheStrategy(null, null);
      }
    
      return candidate;
    }
    

    这里调用了一个最为重要的getCandidate()方法,返回的仍然是一个CacheStrategy对象,后面的那个判断是在Cache-Control响应头为only-if-cached且需要进行网络请求的情况,遇到这种情况返回的CacheStrategy对象networkRequest和cacheResponse为空,在CacheIntercept中针对这种情况会返回504错误码。这种情况其实就是服务器要求使用缓存,而我们没有缓存或缓存过期了,只能作为错误情况处理。在CacheStrategy源码的时候要记得,networkRequest其实就是传进来的我们的request,只是如果不需要进行网络请求的话CacheStrategy为把它置为null;cacheResponse则是我们的缓存response,只是如果缓存不可用会将它置为null。现在我们来重点看下这个getCandidate()方法,该方法非常的长,我们还是一段一段地看:

      if (cacheResponse == null) {
        return new CacheStrategy(request, null);
      }
    
      // Drop the cached response if it's missing a required handshake.
      if (request.isHttps() && cacheResponse.handshake() == null) {
        return new CacheStrategy(request, null);
      }
    
      if (!isCacheable(cacheResponse, request)) {
        return new CacheStrategy(request, null);
      }
    
      CacheControl requestCaching = request.cacheControl();
      if (requestCaching.noCache() || hasConditions(request)) {
        return new CacheStrategy(request, null);
      }
    

    这四种情况最终的结果都一样,networkRequest不为null,cacheResponse为null,代表我们的缓存不可用,需要请求网络。第一种是我们的缓存为空,即我们没取到缓存;第二种是连接上https,而缓存response没有握手信息,这时候缓存也用不了;第三种调用了isCacheable方法,这个方法就不贴了,大致就是判断响应码和Cache-Control是否符合缓存要求,不符合的缓存也不能使用;最后一种是判断请求头中的Cache-Control是否允许缓存,不允许的话也不能使用缓存。

    在这里额外说一下,一开始我也对请求头中的Cache-Control很困惑。后来了解到客户端请求头中确实是可以有Cache-Control用于让客户端来控制缓存策略。但由于这样对服务器来说有安全隐患,因此几乎很少有服务器会实现客户端的缓存策略。

    long ageMillis = cacheResponseAge();
    long freshMillis = computeFreshnessLifetime();
    
    //略过requestCacheing部分
          
    long maxStaleMillis = 0;
    CacheControl responseCaching = cacheResponse.cacheControl();
    if (!responseCaching.mustRevalidate() && requestCaching.maxStaleSeconds() != -1) {
    maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds());
    }
        
    if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
        Response.Builder builder = cacheResponse.newBuilder();
        if (ageMillis + minFreshMillis >= freshMillis) {
        builder.addHeader("Warning", "110 HttpURLConnection \"Response is stale\"");
        }
        long oneDayMillis = 24 * 60 * 60 * 1000L;
        if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
        builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\"");
        }
        return new CacheStrategy(null, builder.build());
    }
    

    这一段的话就是计算过期时间的代码了。这里讲下ageMillis就是缓存的已存在的时间(当前时间减去缓存当时的时间),freshMillis就是缓存的过期时间。然后下面的一大段都是requestCaching里的缓存策略就不看了,原因我上面说了很少有服务器会实现客户端的缓存策略。然后通过response中的Cache-Control获取缓存的最大允许过期时间maxStaleMillis。接下来有个计算ageMillis + minFreshMillis < freshMillis + maxStaleMillis,也就是缓存在允许过期的时间范围内。在这种情况缓存是可以使用的,因此后续return的CacheStrategy的networkRequest为null,cacheResponse不为null,代表我们客户端直接用缓存,不需要进行网络请求。但后续还有两个判断,第一个是在缓存过期的情况(虽然过期但是在允许的过期范围内),加入一个warning响应头;第二个是缓存存在超过一天且是否过期存在模糊不清的情况下,也加一个warning响应头作为提醒。

      String conditionName;
      String conditionValue;
      if (etag != null) {
        conditionName = "If-None-Match";
        conditionValue = etag;
      } else if (lastModified != null) {
        conditionName = "If-Modified-Since";
        conditionValue = lastModifiedString;
      } else if (servedDate != null) {
        conditionName = "If-Modified-Since";
        conditionValue = servedDateString;
      } else {
        return new CacheStrategy(request, null); 
      }
    
      Headers.Builder conditionalRequestHeaders = request.headers().newBuilder();
      Internal.instance.addLenient(conditionalRequestHeaders, conditionName, conditionValue);
    
      Request conditionalRequest = request.newBuilder()
          .headers(conditionalRequestHeaders.build())
          .build();
      return new CacheStrategy(conditionalRequest, cacheResponse);
    

    剩下的情况不说大家也能猜到了,即缓存可用,但是需要服务器根据ETag或lastModified来比对是否过期。可以看到Etag的优先级最高,如果这些判定条件都没有则缓存不可用。之后把Etag或lastModified加入到请求头中,让后续的网络请求发送给服务器,让服务器来比对该缓存是否可用。

    好了到这里,Okhttp判断缓存是否过期的代码已经讲完了,下面选择性的看下如果服务器返回304,即我们的缓存可用,客户端怎么处理的:

    // If we have a cache response too, then we're doing a conditional get.
    if (cacheResponse != null) {
      if (networkResponse.code() == HTTP_NOT_MODIFIED) {
        Response response = cacheResponse.newBuilder()
            .headers(combine(cacheResponse.headers(), networkResponse.headers()))
            .sentRequestAtMillis(networkResponse.sentRequestAtMillis())
            .receivedResponseAtMillis(networkResponse.receivedResponseAtMillis())
            .cacheResponse(stripBody(cacheResponse))
            .networkResponse(stripBody(networkResponse))
            .build();
        networkResponse.body().close();
    
        // Update the cache after combining headers but before stripping the
        // Content-Encoding header (as performed by initContentStream()).
        cache.trackConditionalCacheHit();
        cache.update(cacheResponse, response);
        return response;
      } else {
        closeQuietly(cacheResponse.body());
      }
    }
    

    首先当然是根据我们的缓存response封装成一个最终的response,然后调用cache类的trackConditionalCacheHit()方法。这个方法是干嘛的呢?我们的缓存大小是有限制的,由于我们底层缓存算法用的Lru算法,即最近最少使用原则更新缓存,我们需要记录缓存的命中次数,在缓存超出限制后就可以根据算法清除最近最少使用的缓存。另外如果服务器没有返回304,我们当然也会刷新缓存,用新的response替换旧的。

    二、其它

    Cache类

    除了缓存策略类CacheStrategy,还有一个重要的类Cache没讲。这里简单讲下这个类就是负责存取缓存的。它里面存储缓存的对象是DiskLruCache,而DiskLruCache内部又是LinkedHashMap<String, Entry>,关于LinkedHashMap大家有可以查阅一下相关文章,它底层是一个哈希表和链表的组合,并且由于同时维护了一个可以给存储对象排序的双向链表,因此可以实现Lru算法。关于DiskLruCache是如何在内存和文件之间来回读写的,这里就不展开讲解了,也是常考的面试题,有兴趣的同学可以自己看看。

    无网情况下强制使用缓存

    当没有网络的时候,我们无法发送给服务器进行比对是否过期。在某些场景,可能会要求我们在无网的时候直接使用缓存,这该怎么做呢?很简单,我们只要自定义一个拦截器,在我们的请求头中判断没有网络可用时,缓存策略为强制使用缓存。

    Interceptor interceptor = new Interceptor() {
        @Override
        public Response intercept(Chain chain) throws IOException {
            Request request = chain.request();
            if (!context.isNetworkReachable()) {
                request = request.newBuilder()
                        .cacheControl(CacheControl.FORCE_CACHE)
                        .build();
                Log.d("OkHttp", "网络不可用请求拦截");
            } 
    
            Response response = chain.proceed(request);
            return response;
        }
    };
    
    httpClient.interceptors().add(interceptor);
    httpClient.networkInterceptors().add(interceptor);
    

    实际上就是当没有网络的时候,在请求头上加入Cache-Control:only-if-cached,同时还会把允许过期时间改为无限大,这样就可以强制使用缓存。

    相关文章

      网友评论

        本文标题:OkHttp源码学习系列三:缓存总结

        本文链接:https://www.haomeiwen.com/subject/iftkmftx.html