OkHttp讲解(三)-缓存策略

Android知识总结

一、HTTP缓存机制

1.1、分类

1、强制缓存
已存在缓存数据时,仅基于强制缓存,请求数据流程如下:


2、对比缓存
已存在缓存数据时,仅基于对比缓存,请求数据的流程如下:

1.2、HTTP报文

HTTP报文就是客户端和服务器之间通信时发送及其响应的数据块。客户端向服务器请求数据,发送请求(request)报文;服务器向客户端下发返回数据,返回响应(response)报文,报文信息主要分为两部分

  • 1 包含属性的头部(header)-------------附加信息(cookie,缓存信息等),与缓存相关的规则信息,均包含在header中
  • 2 包含数据的主体部分(body)--------------HTTP请求真正想要传输的部分

二、CacheStrategy 缓存策略详细流程

    CacheStrategy strategy = new CacheStrategy.Factory(now, chain.request(), cacheCandidate).get();
    Request networkRequest = strategy.networkRequest;
    Response cacheResponse = strategy.cacheResponse;

由上面CacheInterceptor缓存拦截器里面CacheStrategy#Factory来构建流程如下:
如果从缓存中获得了本次请求URL对应的 Response ,首先会从响应中获得以上数据备用。

  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();
        //获取cacheReposne中的header中值
        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);
          }
        }
      }
    }

判断缓存的命中会使用 get() 方法

    public CacheStrategy get() {
      CacheStrategy candidate = getCandidate();
      //如果可以使用缓存,那networkRequest必定为null;指定了只使用缓存但是networkRequest又不为null,冲突。那就gg(拦截器返回504)
      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() 方法来完成真正的缓存判断。

    private CacheStrategy getCandidate() {
      // No cached response.
      //如果没有缓存响应,返回一个没有响应的策略
      if (cacheResponse == null) {
        return new CacheStrategy(request, null);
      }
       //如果是https,丢失了握手,返回一个没有响应的策略
      // Drop the cached response if it's missing a required handshake.
      if (request.isHttps() && cacheResponse.handshake() == null) {
        return new CacheStrategy(request, null);
      }
     
      // 响应不能被缓存
      // If this response shouldn't have been stored, it should never be used
      // as a response source. This check should be redundant as long as the
      // persistence store is well-behaved and the rules are constant.
      if (!isCacheable(cacheResponse, request)) {
        return new CacheStrategy(request, null);
      }
     
      //获取请求头里面的CacheControl
      CacheControl requestCaching = request.cacheControl();
      //如果请求里面设置了不缓存,则不缓存
      if (requestCaching.noCache() || hasConditions(request)) {
        return new CacheStrategy(request, null);
      }
      //获取响应的年龄
      long ageMillis = cacheResponseAge();
      //获取上次响应刷新的时间
      long freshMillis = computeFreshnessLifetime();
      //如果请求里面有最大持久时间要求,则两者选择最短时间的要求
      if (requestCaching.maxAgeSeconds() != -1) {
        freshMillis = Math.min(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds()));
      }

      long minFreshMillis = 0;
      //如果请求里面有最小刷新时间的限制
      if (requestCaching.minFreshSeconds() != -1) {
         //用请求中的最小更新时间来更新最小时间限制
        minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds());
      }
      //最大验证时间
      long maxStaleMillis = 0;
      //响应缓存控制器
      CacheControl responseCaching = cacheResponse.cacheControl();
      //如果响应(服务器)那边不是必须验证并且存在最大验证秒数
      if (!responseCaching.mustRevalidate() && requestCaching.maxStaleSeconds() != -1) {
        //更新最大验证时间
        maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds());
      }
     //响应支持缓存
       //持续时间+最短刷新时间<上次刷新时间+最大验证时间 则可以缓存
      //现在时间(now)-已经过去的时间(sent)+可以存活的时间<最大存活时间(max-age)
      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());
      }
    
      //如果想缓存request,必须要满足一定的条件
      // Find a condition to add to the request. If the condition is satisfied, the response body
      // will not be transmitted.
      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 {
        //没有条件则返回一个定期的request
        return new CacheStrategy(request, null); // No condition! Make a regular request.
      }
      
      Headers.Builder conditionalRequestHeaders = request.headers().newBuilder();
      Internal.instance.addLenient(conditionalRequestHeaders, conditionName, conditionValue);

      Request conditionalRequest = request.newBuilder()
          .headers(conditionalRequestHeaders.build())
          .build();
      //返回有条件的缓存request策略
      return new CacheStrategy(conditionalRequest, cacheResponse);
    }
  • 1、缓存是否存在
    整个方法中的第一个判断是缓存是不是存在:
if (cacheResponse == null) {
    return new CacheStrategy(request, null);
}

cacheResponse 是从缓存中找到的响应,如果为null,那就表示没有找到对应的缓存,创建的CacheStrategy 实例对象只存在 networkRequest ,这代表了需要发起网络请求。

  • 2、https请求的缓存
    继续往下走意味着 cacheResponse 必定存在,但是它不一定能用。后续进行有效性的一系列判断
      if (request.isHttps() && cacheResponse.handshake() == null) {
        return new CacheStrategy(request, null);
      }

如果本次请求是HTTPS,但是缓存中没有对应的握手信息,那么缓存无效。

  • 3、响应码以及响应头
if (!isCacheable(cacheResponse, request)) {
    return new CacheStrategy(request, null);
}

整个逻辑都在 CacheStrategy#isCacheable 中,他的内容是:

  public static boolean isCacheable(Response response, Request request) {
    // Always go to network for uncacheable response codes (RFC 7231 section 6.1),
    // This implementation doesn't support caching partial content.
    switch (response.code()) {
      case HTTP_OK:
      case HTTP_NOT_AUTHORITATIVE:
      case HTTP_NO_CONTENT:
      case HTTP_MULT_CHOICE:
      case HTTP_MOVED_PERM:
      case HTTP_NOT_FOUND:
      case HTTP_BAD_METHOD:
      case HTTP_GONE:
      case HTTP_REQ_TOO_LONG:
      case HTTP_NOT_IMPLEMENTED:
      case StatusLine.HTTP_PERM_REDIRECT:
        // These codes can be cached unless headers forbid it.
        break;

      case HTTP_MOVED_TEMP:
      case StatusLine.HTTP_TEMP_REDIRECT:
        // These codes can only be cached with the right response headers.
        // http://tools.ietf.org/html/rfc7234#section-3
        // s-maxage is not checked because OkHttp is a private cache that should ignore s-maxage.
        if (response.header("Expires") != null
            || response.cacheControl().maxAgeSeconds() != -1
            || response.cacheControl().isPublic()
            || response.cacheControl().isPrivate()) {
          break;
        }
        // Fall-through.

      default:
        // All other codes cannot be cached.
        return false;
    }

    // A 'no-store' directive on request or response prevents the response from being cached.
    return !response.cacheControl().noStore() && !request.cacheControl().noStore();
  }

缓存响应中的响应码为 200, 203, 204, 300, 301, 404, 405, 410, 414, 501, 308 的情况下,只判断服务器是不是给了Cache-Control: no-store (资源不能被缓存),所以如果服务器给到了这个响应头,那就和前面两个判定一致(缓存不可用)。否则继续进一步判断缓存是否可用。

而如果响应码是302/307(重定向),则需要进一步判断是不是存在一些允许缓存的响应头。如果存在 Expires 或者 Cache-Control 的值为:

    1. max-age=[秒] :资源最大有效时间;
    1. public :表明该资源可以被任何用户缓存,比如客户端,代理服务器等都可以缓存资源;
    1. private :表明该资源只能被单个用户缓存,默认是private。

同时不存在 Cache-Control: no-store ,那就可以继续进一步判断缓存是否可用。

所以综合来看判定优先级如下:

  • 1、响应码不为 200, 203, 204, 300, 301, 404, 405, 410, 414, 501, 308,302,307 缓存不可用;
  • 2、当响应码为302或者307时,未包含某些响应头,则缓存不可用;
  • 3、当存在 Cache-Control: no-store 响应头则缓存不可用。

如果响应缓存可用,进一步再判断缓存有效性

  • 4、用户的请求配置
CacheControl requestCaching = request.cacheControl();
if (requestCaching.noCache() || hasConditions(request)) {
    return new CacheStrategy(request, null);
}
private static boolean hasConditions(Request request) {
    return request.header("If-Modified-Since") != null || request.header("If-None-Match") !=null;
}

走到这一步,OkHttp需要先对用户本次发起的 Request 进行判定,如果用户指定了 Cache-Control: no-cache (不使用缓存)的请求头或者请求头包含 If-Modified-Since 或 If-None-Match (请求验证),那么就不允许使用缓存。


这意味着如果用户请求头中包含了这些内容,那就必须向服务器发起请求。但是需要注意的是,OkHttp并不会缓存304的响应,如果是此种情况,即用户主动要求与服务器发起请求,服务器返回的304(无响应体),则直接把304的响应返回给用户:既然你主动要求,我就只告知你本次请求结果。而如果不包含这些请求头,那继续判定缓存有效性

  • 5、资源是否不变
      CacheControl responseCaching = cacheResponse.cacheControl();
      if (responseCaching.immutable()) {
        return new CacheStrategy(null, cacheResponse);
      }

如果缓存的响应中包含 Cache-Control: immutable,这意味着对应请求的响应内容将一直不会改变。此时就可以直接使用缓存。否则继续判断缓存是否可用。

  • 6、响应的缓存有效期
    这一步为进一步根据缓存响应中的一些信息判定缓存是否处于有效期内。如果满足:

缓存存活时间 < 缓存新鲜度 - 缓存最小新鲜度 + 过期后继续使用时长

代表可以使用缓存。其中新鲜度可以理解为有效时间,而这里的 缓存新鲜度-缓存最小新鲜度 就代表了缓存真正有效的时间

      // 6.1、获得缓存的响应从创建到现在的时间
      long ageMillis = cacheResponseAge();
      // 6.2、获取这个响应有效缓存的时长
      long freshMillis = computeFreshnessLifetime();

      if (requestCaching.maxAgeSeconds() != -1) {
        //todo 如果请求中指定了 max-age 表示指定了能拿的缓存有效时
        //长,就需要综合响应有效缓存时长与请求能拿缓存的时长,获得最小的能够使用响应缓存的时长
        freshMillis = Math.min(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds()));
      }
      //6.3 请求包含 Cache-Control:min-fresh=[秒] 能够使用还未过指定时间的缓存 (请求认为的缓存有效时间)
      long minFreshMillis = 0;
      if (requestCaching.minFreshSeconds() != -1) {
        minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds());
      }
      // 6.4
      // 6.4.1、Cache-Control:must-revalidate 可缓存但必须再向源服务器进行确认
      // 6.4.2、Cache-Control:max-stale=[秒] 缓存过期后还能使用指定的时长 如果未指定多少秒,则表示无论过期
      //多长时间都可以;如果指定了,则只要是指定时间内就能使用缓存
      // 前者会忽略后者,所以判断了不必须向服务器确认,再获得请求头中的max-stale
      long maxStaleMillis = 0;
      if (!responseCaching.mustRevalidate() && requestCaching.maxStaleSeconds() != -1) {
        maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds());
      }
      // 6.5 不需要与服务器验证有效性 && 响应存在的时间+请求认为的缓存有效时间 小于 缓存有效时长+过期后还可以使用的时间
      // 允许使用缓存
      if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
        Response.Builder builder = cacheResponse.newBuilder();
        //todo 如果已过期,但未超过 过期后继续使用时长,那还可以继续使用,只用添加相应的头部字段
        if (ageMillis + minFreshMillis >= freshMillis) {
          builder.addHeader("Warning", "110 HttpURLConnection \"Response is stale\"");
        }
        //todo 如果缓存已超过一天并且响应中没有设置过期时间也需要添加警告
        long oneDayMillis = 24 * 60 * 60 * 1000L;
        if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
          builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\"");
        }
        return new CacheStrategy(null, builder.build());
      }

6.1、缓存到现在存活的时间:ageMillis
首先 cacheResponseAge() 方法获得了响应大概存在了多久:

   long ageMillis = cacheResponseAge();

    private long cacheResponseAge() {
      long apparentReceivedAge = servedDate != null
          ? Math.max(0, receivedResponseMillis - servedDate.getTime())
          : 0;
      long receivedAge = ageSeconds != -1
          ? Math.max(apparentReceivedAge, SECONDS.toMillis(ageSeconds))
          : apparentReceivedAge;
      long responseDuration = receivedResponseMillis - sentRequestMillis;
      long residentDuration = nowMillis - receivedResponseMillis;
      return receivedAge + responseDuration + residentDuration;
    }
  • 1、 apparentReceivedAge 代表了客户端收到响应到服务器发出响应的一个时间差

seredData 是从缓存中获得的 Date 响应头对应的时间(服务器发出本响应的时间);
receivedResponseMillis 为本次响应对应的客户端发出请求的时间

  • 2、 receivedAge 是代表了客户端的缓存,在收到时就已经存在多久了

ageSeconds 是从缓存中获得的 Age 响应头对应的秒数 (本地缓存的响应是由服务器的缓存返回,这个缓存在服务器存在的时间)

ageSeconds 与上一步计算结果 apparentReceivedAge 的最大值为收到响应时,这个响应数据已经存在多久。

假设我们发出请求时,服务器存在一个缓存,其中 Data: 0点 。 此时,客户端在1小时候发起请求,此时由服务器在缓存中插入 Age: 1小时 并返回给客户端,此时客户端计算的 receivedAge 就是1小时,这就代表了客户端的缓存在收到时就已经存在多久了。(不代表到本次请求时存在多久了)

  • 3、 responseDuration 是缓存对应的请求,在发送请求与接收请求之间的时间差
  • 4、 residentDuration 是这个缓存接收到的时间到现在的一个时间差

receivedAge + responseDuration + residentDuration 所代表的意义就是:
缓存在客户端收到时就已经存在的时间 + 请求过程中花费的时间 + 本次请求距离缓存获得的时间,就是缓存真正存在了多久

6.2、缓存新鲜度(有效时间):freshMillis

long freshMillis = computeFreshnessLifetime();

    private long computeFreshnessLifetime() {
      CacheControl responseCaching = cacheResponse.cacheControl();
      if (responseCaching.maxAgeSeconds() != -1) {
        return SECONDS.toMillis(responseCaching.maxAgeSeconds());
      } else if (expires != null) {
        long servedMillis = servedDate != null
            ? servedDate.getTime()
            : receivedResponseMillis;
        long delta = expires.getTime() - servedMillis;
        return delta > 0 ? delta : 0;
      } else if (lastModified != null
          && cacheResponse.request().url().query() == null) {
        // As recommended by the HTTP RFC and implemented in Firefox, the
        // max age of a document should be defaulted to 10% of the
        // document's age at the time it was served. Default expiration
        // dates aren't used for URIs containing a query.
        long servedMillis = servedDate != null
            ? servedDate.getTime()
            : sentRequestMillis;
        long delta = servedMillis - lastModified.getTime();
        return delta > 0 ? (delta / 10) : 0;
      }
      return 0;
    }

缓存新鲜度(有效时长)的判定会有几种情况,按优先级排列如下:

  • 1、缓存响应包含 Cache-Control: max-age=[秒] 资源最大有效时间
  • 2、缓存响应包含 Expires: 时间 ,则通过 Data 或接收该响应时间计算资源有效时间
  • 3、缓存响应包含 Last-Modified: 时间 ,则通过 Data 或发送该响应对应请求的时间计算资源有效时间;并且根据建议以及在Firefox浏览器的实现,使用得到结果的10%来作为资源的有效时间。

6.3、缓存最小新鲜度:minFreshMillis

long minFreshMillis = 0;
if (requestCaching.minFreshSeconds() != -1) {
    minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds());
}

如果用户的请求头中包含 Cache-Control: min-fresh=[秒] ,代表用户认为这个缓存有效的时长。假设本身缓存新鲜度为: 100毫秒,而缓存最小新鲜度为:10毫秒,那么缓存真正有效时间为90毫秒。

6.4、缓存过期后仍有效时长:maxStaleMillis

long maxStaleMillis = 0;
if (!responseCaching.mustRevalidate() && requestCaching.maxStaleSeconds() != -1) {
    maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds());
}

这个判断中第一个条件为缓存的响应中没有包含 Cache-Control: must-revalidate (不可用过期资源),获得用户请求头中包含 Cache-Control: max-stale=[秒] 缓存过期后仍有效的时长

6.5、判定缓存是否有效

      if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
        Response.Builder builder = cacheResponse.newBuilder();
        //todo 如果已过期,但未超过 过期后继续使用时长,那还可以继续使用,只用添加相应的头部字段
        if (ageMillis + minFreshMillis >= freshMillis) {
          builder.addHeader("Warning", "110 HttpURLConnection \"Response is stale\"");
        }
        //todo 如果缓存已超过一天并且响应中没有设置过期时间也需要添加警告
        long oneDayMillis = 24 * 60 * 60 * 1000L;
        if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
          builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\"");
        }
        return new CacheStrategy(null, builder.build());
      }

最后利用上4步产生的值,只要缓存的响应未指定 no-cache 忽略缓存,如果:

缓存存活时间+缓存最小新鲜度 < 缓存新鲜度+过期后继续使用时长,代表可以使用缓存。

假设 缓存到现在存活了:100 毫秒; 用户认为缓存有效时间(缓存最小新鲜度)为:10 毫秒; 缓存新鲜度为: 100毫秒; 缓存过期后仍能使用: 0 毫秒; 这些条件下,首先缓存的真实有效时间为: 90毫秒,而缓存已经过了这个时间,所以无法使用缓存。

不等式可以转换为: 缓存存活时间 < 缓存新鲜度 - 缓存最小新鲜度 + 过期后继续使用时长,即 存活时间 < 缓存有效时间 + 过期后继续使用时间

总体来说,只要不忽略缓存并且缓存未过期,则使用缓存。

  • 7、缓存过期处理
      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); // No condition! Make a regular request.
      }
      //添加请求头
      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 ,则使用 If-None-Match交给服务器进行验证;如果存在 Last-Modified 或者 Data ,则使用 If-Modified-Since 交给服务器验证。服务器如果无修改则会返回304,这时候注意

由于是缓存过期而发起的请求(与第4个判断用户的主动设置不同),如果服务器返回304,那框架会自动更新缓存,所以此时 CacheStrategy 既包含 networkRequest 也包含 cacheResponse

  • 8、收尾
    至此,缓存的判定结束,拦截器中只需要判断 CacheStrategy 中 networkRequest 与 cacheResponse 的不同组合就能够判断是否允许使用缓存

但是需要注意的是,如果用户在创建请求时,配置了 onlyIfCached 这意味着用户这次希望这个请求只从缓存获得,不需要发起请求。那如果生成的 CacheStrategy 存在 networkRequest 这意味着肯定会发起请求,此时出现冲突!那会直接给到拦截器一个既没有 networkRequest 又没有 cacheResponse 的对象。拦截器直接返回用户 504 !

CacheInterceptor#intercept里面的缓存拦截器

    //缓存拦截器
    if (networkRequest == null && cacheResponse == null) {
      return new Response.Builder()
          .request(chain.request())
          .protocol(Protocol.HTTP_1_1)
          .code(504)
          .message("Unsatisfiable Request (only-if-cached)")
          .body(Util.EMPTY_RESPONSE)
          .sentRequestAtMillis(-1L)
          .receivedResponseAtMillis(System.currentTimeMillis())
          .build();
    }

缓存策略 get 方法

    public CacheStrategy get() {
      //获取当前的缓存策略
      CacheStrategy candidate = getCandidate();
     //如果是网络请求不为null并且请求里面的cacheControl是只用缓存
      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;
    }
  • 9、总结
  • 1、如果从缓存获取的 Response 是null,那就需要使用网络请求获取响应;
  • 2、如果是Https请求,但是又丢失了握手信息,那也不能使用缓存,需要进行网络请求;
  • 3、如果判断响应码不能缓存且响应头有 no-store 标识,那就需要进行网络请求;
  • 4、如果请求头有 no-cache 标识或者有 If-Modified-Since/If-None-Match ,那么需要进行网络请求;
  • 5、如果响应头没有 no-cache 标识,且缓存时间没有超过极限时间,那么可以使用缓存,不需要进行网络请求;
  • 6、如果缓存过期了,判断响应头是否设置 Etag/Last-Modified/Date ,没有那就直接使用网络请求否则需要考虑服务器返回304;

并且,只要需要进行网络请求,请求头中就不能包含 only-if-cached ,否则框架直接返回504!

缓存拦截器本身主要逻辑其实都在缓存策略中,拦截器本身逻辑非常简单,如果确定需要发起网络请求,则下一个拦截器为 ConnectInterceptor

三、Cache.java类

  final DiskLruCache cache;
  int writeSuccessCount;
  int writeAbortCount;
  private int networkCount;
  private int hitCount;
  private int requestCount;
  public Cache(File directory, long maxSize) {
    this(directory, maxSize, FileSystem.SYSTEM);
  }
  Cache(File directory, long maxSize, FileSystem fileSystem) {
    this.cache = DiskLruCache.create(fileSystem, directory, VERSION, ENTRY_COUNT, maxSize);
  }
  • 1、Cache对象拥有一个DiskLruCache引用。
  • 2、Cache构造器接受两个参数,意味着如果我们想要创建一个缓存必须指定缓存文件存储的目录和缓存文件的最大值

(1) ”增“操作——put()方法

CacheRequest put(Response response) {
    String requestMethod = response.request().method();
    //判断请求如果是"POST"、"PATCH"、"PUT"、"DELETE"、"MOVE"中的任何一个则调用DiskLruCache.remove(urlToKey(request));将这个请求从缓存中移除出去。
    if (HttpMethod.invalidatesCache(response.request().method())) {
      try {
        remove(response.request());
      } catch (IOException ignored) {
        // The cache cannot be written.
      }
      return null;
    }
    //判断请求如果不是Get则不进行缓存,直接返回null。官方给的解释是缓存get方法得到的Response效率高,其它方法的Response没有缓存效率低。通常通过get方法获取到的数据都是固定不变的的,因此缓存效率自然就高了。其它方法会根据请求报文参数的不同得到不同的Response,因此缓存效率自然而然就低了。
    if (!requestMethod.equals("GET")) {
      // Don't cache non-GET responses. We're technically allowed to cache
      // HEAD requests and some POST requests, but the complexity of doing
      // so is high and the benefit is low.
      return null;
    }
     //判断请求中的http数据包中headers是否有符号"*"的通配符,有则不缓存直接返回null
    if (HttpHeaders.hasVaryAll(response)) {
      return null;
    }
    //由Response对象构建一个Entry对象,Entry是Cache的一个内部类
    Entry entry = new Entry(response);
    //通过调用DiskLruCache.edit();方法得到一个DiskLruCache.Editor对象。
    DiskLruCache.Editor editor = null;
    try {
      editor = cache.edit(key(response.request().url()));
      if (editor == null) {
        return null;
      }
      //把这个entry写入
      //方法内部是通过Okio.buffer(editor.newSink(ENTRY_METADATA));获取到一个BufferedSink对象,随后将Entry中存储的Http报头数据写入到sink流中。
      entry.writeTo(editor);
      //构建一个CacheRequestImpl对象,构造器中通过editor.newSink(ENTRY_BODY)方法获得Sink对象
      return new CacheRequestImpl(editor);
    } catch (IOException e) {
      abortQuietly(editor);
      return null;
    }
  }

(2) ”删“操作——remove()方法

  void remove(Request request) throws IOException {
    cache.remove(key(request.url()));
  }
 //key()这个方法原来就说获取url的MD5和hex生成的key
  public static String key(HttpUrl url) {
    return ByteString.encodeUtf8(url.toString()).md5().hex();
  }

(3) ”改“操作——update()方法

void update(Response cached, Response network) {
    //用response构造一个Entry对象
    Entry entry = new Entry(network);
    //从命中缓存中获取到的DiskLruCache.Snapshot
    DiskLruCache.Snapshot snapshot = ((CacheResponseBody) cached.body()).snapshot;
    //从DiskLruCache.Snapshot获取DiskLruCache.Editor()对象
    DiskLruCache.Editor editor = null;
    try {
      editor = snapshot.edit(); // Returns null if snapshot is not current.
      if (editor != null) {
        //将entry写入editor中
        entry.writeTo(editor);
        editor.commit();
      }
    } catch (IOException e) {
      abortQuietly(editor);
    }
  }

(4) ”查“操作——get()方法

 Response get(Request request) {
    //获取url经过MD5和HEX的key
    String key = key(request.url());
    DiskLruCache.Snapshot snapshot;
    Entry entry;
    try {
     //根据key来获取一个snapshot,由此可知我们的key-value里面的value对应的是snapshot
      snapshot = cache.get(key);
      if (snapshot == null) {
        return null;
      }
    } catch (IOException e) {
      // Give up because the cache cannot be read.
      return null;
    }
    //利用前面的Snapshot创建一个Entry对象。存储的内容是响应的Http数据包Header部分的数据。snapshot.getSource得到的是一个Source对象 (source是okio里面的一个接口)
    try {
      entry = new Entry(snapshot.getSource(ENTRY_METADATA));
    } catch (IOException e) {
      Util.closeQuietly(snapshot);
      return null;
    }

    //利用entry和snapshot得到Response对象,该方法内部会利用前面的Entry和Snapshot得到响应的Http数据包Body(body的获取方式通过snapshot.getSource(ENTRY_BODY)得到)创建一个CacheResponseBody对象;再利用该CacheResponseBody对象和第三步得到的Entry对象构建一个Response的对象,这样该对象就包含了一个网络响应的全部数据了。
    Response response = entry.response(snapshot);
    //对request和Response进行比配检查,成功则返回该Response。匹配方法就是url.equals(request.url().toString()) && requestMethod.equals(request.method()) && OkHeaders.varyMatches(response, varyHeaders, request);其中Entry.url和Entry.requestMethod两个值在构建的时候就被初始化好了,初始化值从命中的缓存中获取。因此该匹配方法就是将缓存的请求url和请求方法跟新的客户请求进行对比。最后OkHeaders.varyMatches(response, varyHeaders, request)是检查命中的缓存Http报头跟新的客户请求的Http报头中的键值对是否一样。如果全部结果为真,则返回命中的Response。
    if (!entry.matches(request, response)) {
      Util.closeQuietly(response.body());
      return null;
    }

    return response;
  }

四、 DiskLruCache

1、Entry.class(DiskLruCache的内部类)

Entry内部类是实际用于存储的缓存数据的实体类,每一个url对应一个Entry实体

    final String key;
    /** 实体对应的缓存文件 */ 
    /** Lengths of this entry's files. */
    final long[] lengths; //文件比特数 
    final File[] cleanFiles;
    final File[] dirtyFiles;
    /** 实体是否可读,可读为true,不可读为false*/  
    /** True if this entry has ever been published. */
    boolean readable;

     /** 编辑器,如果实体没有被编辑过,则为null*/  
    /** The ongoing edit or null if this entry is not being edited. */
    Editor currentEditor;
    /** 最近提交的Entry的序列号 */  
    /** The sequence number of the most recently committed edit to this entry. */
    long sequenceNumber;
    //构造器 就一个入参 key,而key又是url,所以,一个url对应一个Entry
    Entry(String key) {
     
      this.key = key;
      //valueCount在构造DiskLruCache时传入的参数默认大小为2
      //具体请看Cache类的构造函数,里面通过DiskLruCache.create()方法创建了DiskLruCache,并且传入一个值为2的ENTRY_COUNT常量
      lengths = new long[valueCount];
      cleanFiles = new File[valueCount];
      dirtyFiles = new File[valueCount];

      // The names are repetitive so re-use the same builder to avoid allocations.
      StringBuilder fileBuilder = new StringBuilder(key).append('.');
      int truncateTo = fileBuilder.length();
      //由于valueCount为2,所以循环了2次,一共创建了4份文件
      //分别为key.1文件和key.1.tmp文件
      //           key.2文件和key.2.tmp文件
      for (int i = 0; i < valueCount; i++) {
        fileBuilder.append(i);
        cleanFiles[i] = new File(directory, fileBuilder.toString());
        fileBuilder.append(".tmp");
        dirtyFiles[i] = new File(directory, fileBuilder.toString());
        fileBuilder.setLength(truncateTo);
      }
    }

通过上述代码咱们知道了,一个url对应一个Entry对象,同时,每个Entry对应两个文件,key.1存储的是Response的headers,key.2文件存储的是Response的body

2、Snapshot (DiskLruCache的内部类)

  public final class Snapshot implements Closeable {
    private final String key;  //也有一个key
    private final long sequenceNumber; //序列号
    private final Source[] sources; //可以读入数据的流   这么多的流主要是从cleanFile中读取数据
    private final long[] lengths; //与上面的流一一对应  

    //构造器就是对上面这些属性进行赋值
    Snapshot(String key, long sequenceNumber, Source[] sources, long[] lengths) {
      this.key = key;
      this.sequenceNumber = sequenceNumber;
      this.sources = sources;
      this.lengths = lengths;
    }

    public String key() {
      return key;
    }
   //edit方法主要就是调用DiskLruCache的edit方法了,入参是该Snapshot对象的两个属性key和sequenceNumber.
    public Editor edit() throws IOException {
      return DiskLruCache.this.edit(key, sequenceNumber);
    }
    public Source getSource(int index) {
      return sources[index];
    }

    public long getLength(int index) {
      return lengths[index];
    }
    public void close() {
      for (Source in : sources) {
        Util.closeQuietly(in);
      }
    }
  }

这时候再回来看下Entry里面的snapshot()方法

    Snapshot snapshot() {
      //首先判断 线程是否有DiskLruCache对象的锁
      if (!Thread.holdsLock(DiskLruCache.this)) throw new AssertionError();
      //new了一个Souce类型数组,容量为2
      Source[] sources = new Source[valueCount];
      //clone一个long类型的数组,容量为2
      long[] lengths = this.lengths.clone(); // Defensive copy since these can be zeroed out.
       //获取cleanFile的Source,用于读取cleanFile中的数据,并用得到的souce、Entry.key、Entry.length、sequenceNumber数据构造一个Snapshot对象
      try {
        for (int i = 0; i < valueCount; i++) {
          sources[i] = fileSystem.source(cleanFiles[i]);
        }
        return new Snapshot(key, sequenceNumber, sources, lengths);
      } catch (FileNotFoundException e) {
        // A file must have been deleted manually!
        for (int i = 0; i < valueCount; i++) {
          if (sources[i] != null) {
            Util.closeQuietly(sources[i]);
          } else {
            break;
          }
        }
        try {
          removeEntry(this);
        } catch (IOException ignored) {
        }
        return null;
      }
    }

由上面代码可知Spapshot里面的key,sequenceNumber,sources,lenths都是一个entry,其实也就可以说一个Entry对象一一对应一个Snapshot对象

3、Editor.class(DiskLruCache的内部类)

  public final class Editor {
    final Entry entry;
    final boolean[] written;
    private boolean done;
    Editor(Entry entry) {
      this.entry = entry;
      this.written = (entry.readable) ? null : new boolean[valueCount];
    }
    /**
     *这里说一下detach方法,当编辑器(Editor)处于io操作的error的时候,或者editor正在被调用的时候而被清
     *除的,为了防止编辑器可以正常的完成。我们需要删除编辑器创建的文件,并防止创建新的文件。如果编
     *辑器被分离,其他的编辑器可以编辑这个Entry
     */
    void detach() {
      if (entry.currentEditor == this) {
        for (int i = 0; i < valueCount; i++) {
          try {
            fileSystem.delete(entry.dirtyFiles[i]);
          } catch (IOException e) {
            // This file is potentially leaked. Not much we can do about that.
          }
        }
        entry.currentEditor = null;
      }
    }

    /**
     * 获取cleanFile的输入流 在commit的时候把done设为true
     */
    public Source newSource(int index) {
      synchronized (DiskLruCache.this) {
       //如果已经commit了,不能读取了
        if (done) {
          throw new IllegalStateException();
        }
        //如果entry不可读,并且已经有编辑器了(其实就是dirty)
        if (!entry.readable || entry.currentEditor != this) {
          return null;
        }
        try {
         //通过filesystem获取cleanFile的输入流
          return fileSystem.source(entry.cleanFiles[index]);
        } catch (FileNotFoundException e) {
          return null;
        }
      }
    }

    /**
    * 获取dirty文件的输出流,如果在写入数据的时候出现错误,会立即停止。返回的输出流不会抛IO异常
     */
    public Sink newSink(int index) {
      synchronized (DiskLruCache.this) {
       //已经提交,不能操作
        if (done) {
          throw new IllegalStateException();
        }
       //如果编辑器是不自己的,不能操作
        if (entry.currentEditor != this) {
          return Okio.blackhole();
        }
       //如果entry不可读,把对应的written设为true
        if (!entry.readable) {
          written[index] = true;
        }
         //如果文件
        File dirtyFile = entry.dirtyFiles[index];
        Sink sink;
        try {
          //如果fileSystem获取文件的输出流
          sink = fileSystem.sink(dirtyFile);
        } catch (FileNotFoundException e) {
          return Okio.blackhole();
        }
        return new FaultHidingSink(sink) {
          @Override protected void onException(IOException e) {
            synchronized (DiskLruCache.this) {
              detach();
            }
          }
        };
      }
    }

    /**
     * 写好数据,一定不要忘记commit操作对数据进行提交,我们要把dirtyFiles里面的内容移动到cleanFiles里才能够让别的editor访问到
     */
    public void commit() throws IOException {
      synchronized (DiskLruCache.this) {
        if (done) {
          throw new IllegalStateException();
        }
        if (entry.currentEditor == this) {
          completeEdit(this, true);
        }
        done = true;
      }
    }
    public void abort() throws IOException {
      synchronized (DiskLruCache.this) {
        if (done) {
          throw new IllegalStateException();
        }
        if (entry.currentEditor == this) {
         //这个方法是DiskLruCache的方法在后面讲解
          completeEdit(this, false);
        }
        done = true;
      }
    }

    public void abortUnlessCommitted() {
      synchronized (DiskLruCache.this) {
        if (!done && entry.currentEditor == this) {
          try {
            completeEdit(this, false);
          } catch (IOException ignored) {
          }
        }
      }
    }
  }
  • abort()和abortUnlessCommitted()最后都会执行completeEdit(Editor, boolean) 这个方法这里简单说下:
  • success情况提交:dirty文件会被更名为clean文件,entry.lengths[i]值会被更新,DiskLruCache,size会更新(DiskLruCache,size代表的是所有整个缓存文件加起来的总大小),redundantOpCount++,在日志中写入一条Clean信息
  • failed情况:dirty文件被删除,redundantOpCount++,日志中写入一条REMOVE信息

四、 CacheControl

okhttp中建议用 CacheControl 这个类来进行缓存策略的制定。它内部有两个很重要的静态实例。

1、CacheControl.Builder 里面方法

2、请求头中的参数

  • Cache-Control: private
    Cache-control 是由服务器返回的 Response 中添加的头信息,它的目的是告诉客户端是要从本地读取缓存还是直接从服务器摘取消息。它有不同的值,每一个值有不同的作用。

  • max-age:这个参数告诉浏览器将页面缓存多长时间,超过这个时间后才再次向服务器发起请求检查页面是否有更新。对于静态的页面,比如图片、CSS、Javascript,一般都不大变更,因此通常我们将存储这些内容的时间设置为较长的时间,这样浏览器会不会向浏览器反复发起请求,也不会去检查是否更新了。

  • s-maxage:这个参数告诉缓存服务器(proxy,如Squid)的缓存页面的时间。如果不单独指定,缓存服务器将使用max-age。对于动态内容(比如文档的查看页面),我们可告诉浏览器很快就过时了(max-age=0),并告诉缓存服务器(Squid)保留内容一段时间(比如,s-maxage=7200)。一旦我们更新文档,我们将告诉Squid清除老的缓存版本。

  • must-revalidate:这告诉浏览器,一旦缓存的内容过期,一定要向服务器询问是否有新版本。

  • proxy-revalidate:proxy上的缓存一旦过期,一定要向服务器询问是否有新版本。

  • no-cache:不做缓存。

  • no-store:数据不在硬盘中临时保存,这对需要保密的内容比较重要。

  • public:告诉缓存服务器, 即便是对于不该缓存的内容也缓存起来,比如当用户已经认证的时候。所有的静态内容(图片、Javascript、CSS等)应该是public的。

  • private:告诉proxy不要缓存,但是浏览器可使用private cache进行缓存。一般登录后的个性化页面是private的。

  • no-transform: 告诉proxy不进行转换,比如告诉手机浏览器不要下载某些图片。

  • max-stale:指示客户机可以接收超出超时期间的响应消息。如果指定max-stale消息的值,那么客户机可以接收超出超时期指定值之内的响应消息。

在OKHttp开发中我们常见到的有下面几个:

  • max-age
  • no-cache
  • max-stale
  • expires

3、用法

        CacheControl cacheControl = new CacheControl.Builder()
                .maxStale(10, TimeUnit.SECONDS)
                .maxAge(10, TimeUnit.SECONDS)
                .build();

        Request request = new Request.Builder()
                .url("http://publicobject.com/helloworld.txt")
                .cacheControl(cacheControl)
                .build();
网络Response中的参数

总结

整个OkHttp功能的实现就在这五个默认的拦截器中,所以先理解拦截器模式的工作机制是先决条件。这五个拦截器分别为: 重试拦截器、桥接拦截器、缓存拦截器、连接拦截器、请求服务拦截器。每一个拦截器负责的工作不一样,就好像工厂流水线,最终经过这五道工序,就完成了最终的产品。

但是与流水线不同的是,OkHttp中的拦截器每次发起请求都会在交给下一个拦截器之前干一些事情,在获得了结果之后又干一些事情。整个过程在请求向是顺序的,而响应向则是逆序。

当用户发起一个请求后,会由任务分发起 Dispatcher 将请求包装并交给重试拦截器处理。

  • 1、重试拦截器在交出(交给下一个拦截器)之前,负责判断用户是否取消了请求;在获得了结果之后,会根据响应码判断是否需要重定向,如果满足条件那么就会重启执行所有拦截器。

  • 2、桥接拦截器在交出之前,负责将HTTP协议必备的请求头加入其中(如:Host)并添加一些默认的行为(如:GZIP压缩);在获得了结果后,调用保存cookie接口并解析GZIP数据。

  • 3、缓存拦截器顾名思义,交出之前读取并判断是否使用缓存;获得结果后判断是否缓存。

  • 4、连接拦截器在交出之前,负责找到或者新建一个连接,并获得对应的socket流;在获得结果后不进行额外的处理。

  • 5、请求服务器拦截器进行真正的与服务器的通信,向服务器发送数据,解析读取的响应数据。

在经过了这一系列的流程后,就完成了一次HTTP请求!

okhttp的缺点:

  • 用户网络请求的接口配置繁琐,尤其需要配置复杂请求body,请求头,参数的时候。
  • 数据解析过程需要用户手动拿到responsbody进行解析,不能复用
  • 无法适配自动进行线程的切换
  • 万一我们存在嵌套网络请求就会陷入回调陷阱
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 162,547评论 4 374
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,787评论 2 308
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 112,175评论 0 254
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,752评论 0 223
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,169评论 3 297
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 41,056评论 1 226
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,195评论 2 321
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,980评论 0 214
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,724评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,872评论 2 254
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,344评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,667评论 3 264
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,379评论 3 245
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,202评论 0 9
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,992评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,189评论 2 286
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,987评论 2 279