OkHttp源码之深度解析(五)——CacheInterceptor详解:缓存机制

OkHttp源码之深度解析(五)——CacheInterceptor详解:缓存机制

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

前言

OkHttp源码系列文章:

为了节省带宽流量和提高响应的效率,OkHttp在缓存拦截器CacheInterceptor中实现了一套缓存机制,支持响应缓存复用。CacheInterceptor在拦截器责任链中位于桥接拦截器和连接拦截器之间,走到CacheInterceptor这个节点的时候会进行一系列的判定,根据缓存策略来决定是执行后续的拦截器走网络请求还是直接使用缓存。本文将详细分析缓存拦截器CacheInterceptor的源码,探究OkHttp缓存机制的原理。

在分析源码之前,先来简单了解一下两个概念,HTTP的缓存可以分为两种:

  1. 强制缓存:在请求数据时,查看缓存响应的header中的Expires字段或者Cache-Control字段带的max-age信息,由客户端去计算缓存是否过期,如果没有过期则直接使用缓存数据,不会发起网络请求。
  2. 协商缓存:也叫对比缓存,在没有命中强制缓存的情况下才会走协商缓存的策略,协商缓存的实现需要服务器支持,而且必然会发起网络请求,服务器收到请求后根据header中的If-None-Match字段值或者If-Modify-Since字段值自行判断资源在服务器上有没有更新过,如果没有更新过就会返回状态码304并且body为空,客户端收到304就会取出本地缓存数据来使用,否则服务器就会下发最新的资源。

这里先剧透一下,OkHttp中的缓存策略也是分为上述的两种,整个缓存机制是基于HTTP的缓存原理来实现的,如果对HTTP的缓存流程不清楚的话可以自行查阅相关的内容,本文就不详细介绍了,话不多说马上开始分析OkHttp的缓存机制!

PS:本文基于OkHttp3版本4.9.3

使用缓存机制

OkHttp中的缓存机制默认是关闭的,如果需要使用缓存机制,则在初始化OkHttpClient的时候需要传入一个Cache实例,配置缓存的路径、大小等,下面是一个简单的使用示例:

val cacheDirectory = File("/.../...") //路径
val cacheSize = 10 * 1024 * 1024L //大小 10MB
val client = OkHttpClient.Builder()
    .cache(Cache(cacheDirectory, cacheSize))
    .build()
复制代码

通过OkHttpClient设置的缓存会在全局生效,如果想要对某个请求禁用缓存、指定缓存策略等等,则可以通过CacheControl API对单个Request进行特殊配置,例如:

val request = Request.Builder()
    .cacheControl(CacheControl.Builder().onlyIfCached().build()) //设置强制使用缓存
    .build()
复制代码

CacheInterceptor.intercept方法

缓存拦截器CacheInterceptor作为一个拦截器,它实现缓存机制的核心逻辑就是在intercept方法中,下面来看看intercept方法的源码:

override fun intercept(chain: Interceptor.Chain): Response {
    val call = chain.call()
    val cacheCandidate = cache?.get(chain.request())

    val now = System.currentTimeMillis()

    val strategy = CacheStrategy.Factory(now, chain.request(), cacheCandidate).compute()
    val networkRequest = strategy.networkRequest
    val cacheResponse = strategy.cacheResponse

    cache?.trackResponse(strategy)
    val listener = (call as? RealCall)?.eventListener ?: EventListener.NONE

    //⑴
    if (cacheCandidate != null && cacheResponse == null) {
      cacheCandidate.body?.closeQuietly()
    }

    //⑵
    if (networkRequest == null && cacheResponse == null) {
      return Response.Builder()
          .request(chain.request())
          .protocol(Protocol.HTTP_1_1)
          .code(HTTP_GATEWAY_TIMEOUT)
          .message("Unsatisfiable Request (only-if-cached)")
          .body(EMPTY_RESPONSE)
          .sentRequestAtMillis(-1L)
          .receivedResponseAtMillis(System.currentTimeMillis())
          .build().also {
            listener.satisfactionFailure(call, it)
          }
    }

    //⑶
    if (networkRequest == null) {
      return cacheResponse!!.newBuilder()
          .cacheResponse(stripBody(cacheResponse))
          .build().also {
            listener.cacheHit(call, it)
          }
    }

    //⑷
    if (cacheResponse != null) {
      listener.cacheConditionalHit(call, cacheResponse)
    } else if (cache != null) {
      listener.cacheMiss(call)
    }

    //⑸
    var networkResponse: Response? = null
    try {
      networkResponse = chain.proceed(networkRequest)
    } finally {
      if (networkResponse == null && cacheCandidate != null) {
        cacheCandidate.body?.closeQuietly()
      }
    }

    //⑹
    if (cacheResponse != null) {
      if (networkResponse?.code == HTTP_NOT_MODIFIED) {
        val 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()
        cache!!.trackConditionalCacheHit()
        cache.update(cacheResponse, response)
        return response.also {
          listener.cacheHit(call, it)
        }
      } else {
        cacheResponse.body?.closeQuietly()
      }
    }

    val response = networkResponse!!.newBuilder()
        .cacheResponse(stripBody(cacheResponse))
        .networkResponse(stripBody(networkResponse))
        .build()

    //⑺
    if (cache != null) {
      if (response.promisesBody() && CacheStrategy.isCacheable(response, networkRequest)) {
        val cacheRequest = cache.put(response)
        return cacheWritingResponse(cacheRequest, response).also {
          if (cacheResponse != null) {
            listener.cacheMiss(call)
          }
        }
      }

      if (HttpMethod.invalidatesCache(networkRequest.method)) {
        try {
          cache.remove(networkRequest)
        } catch (_: IOException) {
        }
      }
    }

    return response
  }
复制代码

这里解释一下intercept方法里声明的几个主要的成员变量:

  • cacheCandidate: Response,根据Request获取缓存中对应的Response
  • strategy: CacheStrategy,缓存策略对象,用来判定是使用缓存还是进行网络请求
  • networkRequest: Request,网络请求,如果为null的话则不进行网络请求
  • cacheResponse: Response,缓存响应,如果为null的话则不使用缓存
  • listener: EventListener,用于监控Call的生命周期

了解完几个主要成员变量的用处,接下来就逐步分析缓存拦截器实现缓存机制的逻辑:

  • 读取缓存中对应的响应数据cacheCandidate和构建出一个缓存策略对象strategy,调用Cache.trackResponse根据strategy来统计网络和缓存的使用情况;
  • ⑴处检查缓存候选cacheCandidate和缓存响应cacheResponse,如果存在缓存但缓存策略没有通过,也就是这个缓存不适用,则关闭缓存响应体;
  • ⑵处如果网络被禁用而且也没有缓存可用的异常情况,则构建一个code为504的Response并返回;
  • ⑶处走强制缓存策略,流程走到这里如果网络被禁用并且有缓存可用,此时命中缓存,则直接返回缓存中的数据,这种情况下不会发起网络请求,后续的拦截器也不会被触发;
  • ⑷处为缓存增加监听;
  • ⑸处调用Chain.proceed方法继续执行后续的拦截器,通过发起网络请求从服务器获取Response,最后如果请求失败拿不到Response并且有缓存时,就关闭缓存响应体;
  • ⑹处走协商缓存策略,在缓存可用的情况下,如果网络请求返回的响应码是304,也就是说服务器的资源没有更新,此时命中缓存,那么则取出本地的缓存数据作为Response返回,同时更新缓存的命中情况以及更新缓存;如果网络请求返回的响应码不是304,则关闭缓存响应体,这种情况会使用服务器返回的最新数据;
  • ⑺处如果cache不为空,也就是初始化OkHttpClient时有开启缓存机制,那么就根据请求方式、网络请求返回的响应码以及Cache-Control字段是否设置了no-store来判断是否可以缓存,如果可以则将响应数据写入缓存;调用HttpMethod.invalidatesCache来根据请求方式去判断缓存的有效性,只有GET请求的缓存是有效的,如果是其他请求方式则从缓存中移除;
  • 最后如果没有在上述的过程中命中缓存的话,则返回网络请求的结果。

总的来说,缓存拦截器就是根据缓存策略返回的结果来决定是否用缓存的,其intercept方法整体的业务逻辑可以用下面的表格来简单总结:

网络(networkRequest)缓存(cacheResponse)说明
不可用不可用返回504
不可用可用直接使用缓存,不会进行网络请求
可用不可用发起网络请求,直接拿服务器返回的数据
可用可用发起网络请求,如果返回响应码304则使用缓存并更新缓存

可以看到,只有网络可用的情况下才会且一定会向服务器发起请求,另外当networkRequest为空而cacheResponse不为空时使用的是强制缓存策略,当两者都不为空时使用的是协商缓存策略,而且强制缓存优先于协商缓存。

在整个缓存机制中涉及到缓存的有两个关键的类:

  • Cache类,里面封装了对缓存的处理,如写入、读取、删除等操作;
  • CacheStrategy类,里面封装了对缓存策略的判定,职责就是用来生成缓存策略。

下面就分析一下这两个类的具体逻辑。

缓存处理:Cache类

CacheInterceptor中对缓存的操作都是在Cache类中实现的,比如从缓存中拿响应、记录缓存使用情况、更新缓存等等,本文主要分析缓存的获取和存储逻辑。

Cache.get:缓存获取

  internal fun get(request: Request): Response? {
    val key = key(request.url)
    val snapshot: DiskLruCache.Snapshot = try {
      cache[key] ?: return null //这里的cache是一个DiskLruCache实例
    } catch (_: IOException) {
      return null
    }

    val entry: Entry = try {
      Entry(snapshot.getSource(ENTRY_METADATA))
    } catch (_: IOException) {
      snapshot.closeQuietly()
      return null
    }

    val response = entry.response(snapshot)
    if (!entry.matches(request, response)) {
      response.body?.closeQuietly()
      return null
    }

    return response
  }
复制代码

缓存获取的流程大致如下:

  • 对请求的url进行md5加密生成这个请求的key;
  • 根据当前请求的key从DiskLruCache中获取对应的缓存快照数据snapshot;
  • 读取缓存快照里index为0的缓存资源流,经过Okio的转换输出缓存数据Entry对象;
  • 根据缓存数据构建Response,如果这个Response跟Request不匹配的话就关闭响应体并返回null,合法则返回这个Response。

Cache.put:缓存存储

  internal fun put(response: Response): CacheRequest? {
    val requestMethod = response.request.method

    if (HttpMethod.invalidatesCache(response.request.method)) {
      try {
        remove(response.request)
      } catch (_: IOException) {
      }
      return null
    }

    if (requestMethod != "GET") {
      return null
    }

    if (response.hasVaryAll()) {
      return null
    }

    val entry = Entry(response)
    var editor: DiskLruCache.Editor? = null
    try {
      editor = cache.edit(key(response.request.url)) ?: return null
      entry.writeTo(editor)
      return RealCacheRequest(editor)
    } catch (_: IOException) {
      abortQuietly(editor)
      return null
    }
  }
复制代码

从这段代码中不难看出,以下的情况OkHttp是不支持缓存的:

  1. 非GET请求不支持缓存,源码中给出的解释是HEAD请求和POST请求在技术上也是可以支持缓存的,但是实现起来很复杂而受益不大,所以不进行缓存。这样的做法很好理解,GET请求就是用来获取数据的,比较适合使用缓存,而其他的请求方式使用缓存的意义不大,所以对非GET请求不作处理。如果缓存里有存储非GET请求的数据,则需要将这些无效数据清除掉。
  2. 请求头中包含vary:*头信息的不支持缓存。

对于可以缓存的情况,会以进行md5加密过的请求的url作为key从DiskLruCache中获取对应的编辑器Editor对象,与此同时在DiskLruCache的内部会将这个key及其对应的DiskLruCache.Entry对象存储在一个map中,并把生成的Editor对象关联给Entry。拿到了Editor对象之后则调用writeTo方法,通过Okio将数据写入本地缓存,最后返回一个CacheRequest。

分析完缓存的获取和存储不难发现,OkHttp是使用Okio来实现文件读写操作的,而在Cache类的内部,对缓存数据进行读取、插入、删除等等的操作实际上是委托DiskLruCache去完成的,DiskLruCache是Android上常用的缓存框架,使用LRU作为缓存淘汰算法基于磁盘进行缓存数据,由此也可以知道,OkHttp的缓存方式是磁盘缓存,不支持内存缓存。

缓存策略:CacheStrategy类

OkHttp中缓存策略的具体实现类是CacheStrategy类,CacheStrategy类里主要定义了两个成员变量networkRequest和cacheResponse以及一个Factory类,CacheStrategy对象是通过工厂模式去构造的,缓存策略的判定逻辑都是在Factory类中实现,在上面分析缓存拦截器的intercept方法时可以知道,缓存策略对象是通过调用CacheStrategy.Factory.compute去创建的,下面就来分析compute方法的源码。

CacheStrategy.Factory.compute方法

fun compute(): CacheStrategy {
  val candidate = computeCandidate()

  if (candidate.networkRequest != null && request.cacheControl.onlyIfCached) {
    return CacheStrategy(null, null)
  }
  return candidate
}
复制代码

这段代码块的逻辑很简单,通过computeCandidate方法去生成一个CacheStrategy实例,如果说生成的CacheStrategy是存在networkRequest的而请求又配置了only-if-cached,也就是说用户是希望强制使用缓存而禁用网络的,然而networkRequest不为空的话就会发起网络请求,这样一来就会发生冲突了,此时就会返回一个networkRequest和cacheResponse均为空的CacheStrategy实例,也就是对应上文提到返回504的异常情况。

这段代码块生成缓存策略的核心逻辑都是由computeCandidate方法去实现的,下面继续进入computeCandidate方法分析源码。

CacheStrategy.Factory.computeCandidate方法

    private fun computeCandidate(): CacheStrategy {
      
      if (cacheResponse == null) {
        return CacheStrategy(request, null)
      }

      if (request.isHttps && cacheResponse.handshake == null) {
        return CacheStrategy(request, null)
      }

      if (!isCacheable(cacheResponse, request)) {
        return CacheStrategy(request, null)
      }

      val requestCaching = request.cacheControl
      if (requestCaching.noCache || hasConditions(request)) {
        return CacheStrategy(request, null)
      }

      val responseCaching = cacheResponse.cacheControl

      val ageMillis = cacheResponseAge()
      var freshMillis = computeFreshnessLifetime()

      if (requestCaching.maxAgeSeconds != -1) {
        freshMillis = minOf(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds.toLong()))
      }

      var minFreshMillis: Long = 0
      if (requestCaching.minFreshSeconds != -1) {
        minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds.toLong())
      }

      var maxStaleMillis: Long = 0
      if (!responseCaching.mustRevalidate && requestCaching.maxStaleSeconds != -1) {
        maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds.toLong())
      }

      if (!responseCaching.noCache && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
        val builder = cacheResponse.newBuilder()
        if (ageMillis + minFreshMillis >= freshMillis) {
          builder.addHeader("Warning", "110 HttpURLConnection \"Response is stale\"")
        }
        val oneDayMillis = 24 * 60 * 60 * 1000L
        if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
          builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\"")
        }
        return CacheStrategy(null, builder.build())
      }

      val conditionName: String
      val conditionValue: String?
      when {
        etag != null -> {
          conditionName = "If-None-Match"
          conditionValue = etag
        }

        lastModified != null -> {
          conditionName = "If-Modified-Since"
          conditionValue = lastModifiedString
        }

        servedDate != null -> {
          conditionName = "If-Modified-Since"
          conditionValue = servedDateString
        }

        else -> return CacheStrategy(request, null)
      }

      val conditionalRequestHeaders = request.headers.newBuilder()
      conditionalRequestHeaders.addLenient(conditionName, conditionValue!!)

      val conditionalRequest = request.newBuilder()
          .headers(conditionalRequestHeaders.build())
          .build()
      return CacheStrategy(conditionalRequest, cacheResponse)
    }
复制代码

注意:computeCandidate方法里的cacheResponse是Factory类的入参而不是CacheStrategy的成员变量,在分析intercept方法的时候我们已经知道了这里的cacheResponse实际上传进来的是缓存中的Response,整个方法的逻辑就是基于HTTP的缓存判定流程来的,主要逻辑如下:

  • 如果没有缓存可用,则直接进行网络请求;

  • 如果缓存中的响应缺失握手信息,则扔掉缓存,直接进行网络请求;

  • 如果这个响应不可缓存,则直接进行网络请求;

  • 如果请求头中的Cache-Control字段值指定为no-cache,或者请求头中含有If-Modified-Since字段或If-None-Match字段,说明不直接用缓存,则直接进行网络请求;

  • 获取缓存中的响应的存活时长ageMillis(从创建到现在的时间)和有效时长freshMillis(从创建到过期的时间,也就是有效期);

  • 如果缓存中的请求有设置有效时长,则缓存响应的freshMillis取两者的最小值;

  • 如果缓存请求有配置更新缓存的最小时长,则获取这个值更新给minFreshMillis;

  • 如果没必要向服务器确认缓存响应并且缓存请求有配置缓存过期后仍允许继续使用的时长(也就是缓存的腐败时间),则获取这个值更新给maxStaleMillis;

  • 如果不需要向服务器验证缓存响应的有效性并且缓存响应的存活时长+刷新缓存的最小时长<缓存响应的有效时长+缓存过期后仍可使用的时长,也就是这时候缓存还没有失效,还是可以使用的,则按情况给缓存响应加header:

    • 如果缓存响应的存活时长+刷新缓存的最小时长不小于缓存响应的有效时长,这时候缓存已经过期了,虽然缓存不新鲜但还可以继续使用,就好比一块已经过了保质期的面包,但是还没有腐败,就可以认为还能继续吃,只是说这块面包已经不新鲜了,那么这时候就往header添加缓存已不新鲜的警告信息;
    • 如果缓存已经存活超过一天了,而且响应中没有配置缓存的有效期,那么就往header添加相应的警告。

    最后返回强制缓存的缓存策略,不需要进行网络请求;

  • 如果流程走到了这里,也就是上述的步骤都没有返回缓存策略,那就说明缓存已经失效了,需要发起网络请求,接下来就是根据缓存响应的header来判断是否走协商缓存策略:

    • 判断缓存响应的header中是否有ETag字段,有则将会往请求头添加If-None-Match并将其赋值为ETag的值,否则继续往下走;
    • 判断缓存响应的header中是否有Last-Modified字段,有则将会往请求头添加If-Modified-Since并将其赋值为Last-Modified的值,否则继续往下走;
    • 判断缓存响应的header中是否有Date字段,有则将会往请求头添加If-Modified-Since并将其赋值为Data的值,否则继续往下走;
    • 如果响应头中没有上述字段,则直接返回一个cacheResponse为空的缓存策略对象,此时不走协商缓存策略而走网络请求重新获取数据。
  • 根据上面获取到的header数据构建新的Request,最后返回协商缓存的缓存策略,向服务器发起请求,服务器会把请求的数据跟自身的资源进行对比,要是数据并没有修改过就返回304,那么这时还是会使用缓存的,否则就会下发最新的数据。

总的来说,缓存策略是根据用户的配置、由用户创建的请求的header和缓存中的响应的header包含的相关信息等自动生成的,整个工作流程不需要外界去手动干预。

总结

到这里本文对OkHttp的缓存机制分析完毕,整个缓存机制有以下几点需要注意的:

  • 只缓存GET请求;
  • 命中强制缓存的话不会走网络请求,如果走协商缓存策略则不管最后是否命中缓存,都会发起网络请求,强制缓存优先于协商缓存;
  • 只支持磁盘缓存,不支持内存缓存。

最后是OkHttp缓存机制的大致工作流程:

缓存机制流程图.png

PS:本文仅代表个人见解,如有理解偏差的地方欢迎各位大佬指出。

分类:
Android
标签: