前言
CacheInterceptor主要处理http请求过程中的缓存逻辑,默认只支持GET类型的请求,当第一次请求时,由于本地没有缓存,会先请求服务器,拿到响应后再将返回内容缓存到本地,再次请求时,满足条件情况下直接使用缓存,减少网络请求的发送,降低对流量的消耗。使okhttp支持缓存,我们首先需要主动的开启缓存配置。
java
OkHttpClient client = new OkHttpClient().newBuilder()
.cache(new Cache(new File(getCacheDir(),"okhttp_cache"),10000))
.build();
intercept
同样,直接进入拦截器的intercept方法,看看这个缓存拦截器到底是如何实现的。缓存的逻辑是先存再取,所以我们不按照代码顺序来看了,直接按照使用的顺序,先看看缓存是怎么存下来的,从拿到服务端响应开始入手。
缓存写入
networkResponse就是从服务端拿到的数据,用它构建一个新的response对象,并将其成员cacheResponse和networkResponse的body都置空。
scss
val response = networkResponse!!.newBuilder()
.cacheResponse(stripBody(cacheResponse))
.networkResponse(stripBody(networkResponse))
.build()
接来下开始进入缓存操作逻辑。这里的cache就是我们给OkHttpClient对象设置的那个cache对象,
scss
if (cache != null) {
//见1.判断是否有响应内容返回;并且是否可以缓存,见2
if (response.promisesBody() && CacheStrategy.isCacheable(response, networkRequest)) {
// 满足缓存的条件,则准备开始缓存,见4
val cacheRequest = cache.put(response)
//cacheRequest不为null就说明要成功了,具体写入缓存的细节就不分析了。
return cacheWritingResponse(cacheRequest, response).also {
if (cacheResponse != null) {
listener.cacheMiss(call)
}
}
}
1.promisesBody
方法用于判断响应是否有内容。
kotlin
fun Response.promisesBody(): Boolean {
// 不管响应头是什么,HEAD请求都不会返回内容。
if (request.method == "HEAD") {
return false
}
//http状态码204(无内容)服务器成功处理了请求,但没有返回任何内容。
//http状态码304 自从上次请求后,请求的网页未修改过。服务器返回此响应时,不会返回网页内容。
//所以这两种情况下没有响应正文返回
val responseCode = code
if ((responseCode < HTTP_CONTINUE || responseCode >= 200) &&
responseCode != HTTP_NO_CONTENT &&
responseCode != HTTP_NOT_MODIFIED) {
return true
}
//走到这里说明code值有问题,但是响应头却提示有内容返回,说明二者之一总有一个是错的
//但是为了最大的兼容性,则尊重响应头的提示,认为有内容返回。
//Content-Length值不为-1表示有内容,Transfer-Encoding表示分块响应,也是有内容的
if (headersContentLength() != -1L ||
"chunked".equals(header("Transfer-Encoding"), ignoreCase = true)) {
return true
}
return false
}
2.isCacheable
响应是否可以缓存取决于它的响应码和响应头。
kotlin
fun isCacheable(response: Response, request: Request): Boolean {
when (response.code) {
HTTP_OK,
HTTP_NOT_AUTHORITATIVE,
HTTP_NO_CONTENT,
HTTP_MULT_CHOICE,
HTTP_MOVED_PERM,
HTTP_NOT_FOUND,
HTTP_BAD_METHOD,
HTTP_GONE,
HTTP_REQ_TOO_LONG,
HTTP_NOT_IMPLEMENTED,
StatusLine.HTTP_PERM_REDIRECT -> {
//这些code下响应都是可以被缓存的
}
HTTP_MOVED_TEMP,
StatusLine.HTTP_TEMP_REDIRECT -> {
// 当响应码为302或307时,见3.
if (response.header("Expires") == null &&
response.cacheControl.maxAgeSeconds == -1 &&
!response.cacheControl.isPublic &&
!response.cacheControl.isPrivate) {
return false
}
}
else -> {
// 其他code都不能被缓存
return false
}
}
//最后,请求和响应头中都没有设置Cache-control:no-store,才能缓存,否则不行
return !response.cacheControl.noStore && !request.cacheControl.noStore
}
3.临时重定向响应302、307
当响应码为302或307时,表示资源被临时重定向到其他地址了。这里提到了几个响应头,我们来了解下这几个头的含义。
vbnet
Expires:表示资源过期的时间,格式如:Expires: Sat, 18 Nov 2028 06:17:41 GMT
maxAgeSeconds:表示的是响应头cache-control中的方法:max-age,如"cache-control: 'max-age=43200'",定义了资源可以在客户端缓存多久,单位是秒。43200秒等于12小时
isPublic:表明该资源可以被任何用户缓存,比如客户端,代理服务器等都可以缓存资源。
isPrivate:表明该资源只能被单个用户缓存,当指定private指令后,响应只以特定的用户作为对象。
所以从okhttp对这几个响应头的判断方式来看,针对这种被临时重定向的资源,响应头必须至少包含上边四个取值之一,才能被缓存,否则无法缓存。
4.put
put方法就是准备将响应内容写入缓存了,不过方法内部仍有一些判断,不满足还是会被退回。
kotlin
internal fun put(response: Response): CacheRequest? {
val requestMethod = response.request.method
//判断若请求方法是POST、PATCH、PUT、DELETE、MOVE时,废弃掉此缓存,从缓存移除,见5
if (HttpMethod.invalidatesCache(response.request.method)) {
try {
remove(response.request)
} catch (_: IOException) {
// The cache cannot be written.
}
return null
}
if (requestMethod != "GET") {
// 不要缓存非GET响应。从技术上讲,可以缓存HEAD请求和一些POST请求,但这样做的复杂性很高,收益很低。
return null
}
//看看响应头中有没有Vary,并且Vary的值是不是Vary: *,如果是,则不能缓存。见6
//Vary: *的含义可以理解为,就是告诉缓存服务器(okhttp也算是缓存服务器),不要缓存这个内容
//必须请求源服务器
if (response.hasVaryAll()) {
return null
}
//走到这里一定是满足缓存的条件的,构建一个Entry交给DiskLruCache去写入缓存
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
}
}
5.invalidatesCache
当请求方法是POST、PATCH、PUT、DELETE、MOVE时,不支持缓存。
ini
fun invalidatesCache(method: String): Boolean = (method == "POST" ||
method == "PATCH" ||
method == "PUT" ||
method == "DELETE" ||
method == "MOVE") // WebDAV
6.Vary头
Vary头是和缓存相关的一个响应头,如Vary: Accept-Encoding。在 HTTP 缓存的过程中,缓存是按照 URL 作为唯一标识来进行缓存的,也就是说,如果两个请求的 URL 是一致的,那么就会使用缓存,不会重新向服务器请求一份新的数据。
但是,如果两个相同URL的请求使用了不同的请求头,那么此时使用相同的缓存将会出现问题。因为缓存是按照 URL 来判断缓存的,如果两个请求的请求头不同,就会导致两个请求产生不同的响应结果,缓存就无法共享。这时候,Vary 头就可以解决这个问题。
Vary 头部的作用就是在响应头中返回一个 Vary 字段,其中包含一组可能影响该响应结果的请求头名称,如"Vary: Accept-Encoding"。当客户端发起一个新的请求,比如使用不同的 Accept-Encoding 头,那么服务器就可以根据 Vary 头部的信息来判断该响应是否可以被缓存。
缓存读取
前边我们分析了缓存写入的逻辑,缓存写入之后,下次请求该url地址,就会先去读取该地址下保存的缓存,并验证是否满足使用的条件。
ini
val cacheCandidate = cache?.get(chain.request())
kotlin
internal fun get(request: Request): Response? {
//url转md5
val key = key(request.url)
//从DiskLruCache中读取缓存,为空则直接返回,第一次请求时为空
val snapshot: DiskLruCache.Snapshot = try {
cache[key] ?: return null
} catch (_: IOException) {
return null // Give up because the cache cannot be read.
}
//缓存不为空,构建一个Entry
val entry: Entry = try {
Entry(snapshot.getSource(ENTRY_METADATA))
} catch (_: IOException) {
snapshot.closeQuietly()
return null
}
//从entry中读取到response返回
val response = entry.response(snapshot)
if (!entry.matches(request, response)) {
response.body?.closeQuietly()
return null
}
return response
}
继续往后看拿到缓存之后做了什么?
ini
//计算得到一个strategy,strategy对象中包含一个networkRequest和cacheResponse
//最终将根据这两个值来决定是使用缓存还是再次请求网络服务器。
//我们来看下compute方法,见7
val strategy = CacheStrategy.Factory(now, chain.request(), cacheCandidate).compute()
val networkRequest = strategy.networkRequest
val cacheResponse = strategy.cacheResponse
上边得到了CacheStrategy之后,就开始根据CacheStrategy的networkRequest和cacheResponse两个成员来判断究竟是直接使用缓,还是直接请求网络,或者请求网络询问服务器是否使用缓存。
csharp
/如果本地有缓存,但是缓存策略返回的cacheResponse说明缓存不可用,关闭这个cacheCandidate
if (cacheCandidate != null && cacheResponse == null) {
cacheCandidate.body?.closeQuietly()
}
networkRequest、cacheResponse两个都为空,表示缓存不可用,但是又要强制使用缓存,只能返回一个空的结果。
erlang
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)
}
}
继续往下,此时networkRequest为空,但是cacheResponse不为空,于是返回缓存即可。
scss
if (networkRequest == null) {
return cacheResponse!!.newBuilder()
.cacheResponse(stripBody(cacheResponse))
.build().also {
listener.cacheHit(call, it)
}
}
经过上边的一次判断,可以知道此时networkRequest不为空,cacheResponse可能为空也可能不为空。networkRequest不为空说明,需要询问服务器,那么就要问下服务器究竟能不能使用缓存了。
csharp
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()
}
}
服务器返回结果,304表示本地缓存和服务端的数据一致,没有发生过改变,那么此时是可以直接使用这个缓存的。
scss
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()
// header发生改变了(combine方法合并的header),更新缓存
cache!!.trackConditionalCacheHit()
cache.update(cacheResponse, response)
//返回缓存
return response.also {
listener.cacheHit(call, it)
}
} else {
cacheResponse.body?.closeQuietly()
}
}
最后,走到这里说明cacheResponse为空,需要直接请求服务器拿到结果。
scss
val response = networkResponse!!.newBuilder()
.cacheResponse(stripBody(cacheResponse))
.networkResponse(stripBody(networkResponse))
.build()
//更新缓存的逻辑省略
return response
代码逻辑走到这里,缓存拦截器的主流程逻辑我们就分析完了。
7.compute
kotlin
fun compute(): CacheStrategy {
//见8
val candidate = computeCandidate()
// 虽然客观条件不让使用缓存,但是调用方强制设置必须使用缓存,那么只能设置两个null,响应失败
if (candidate.networkRequest != null && request.cacheControl.onlyIfCached) {
return CacheStrategy(null, null)
}
return candidate
}
8.computeCandidate
根据读到的缓存的具体情况来判断缓存是否可用,然后返回一个CacheStrategy对象,CacheStrategy中包含两个成员,一个是netWorkRequest,一个是cacheResponse,假如netWorkRequest非空,cacheResponse为空,则表示要发起网络请求;假如netWorkRequest为空,cacheResponse非空,则表示缓存可用。
kotlin
private fun computeCandidate(): CacheStrategy {
// 如果本地就没有缓存,那么直接将CacheStrategy中缓存cacheResponse设置为null,
// 表示要请求网络
if (cacheResponse == null) {
return CacheStrategy(request, null)
}
// 对于https请求,如果其握手信息为空,则缓存也不能使用,握手信息包含加密方式,tls版本信息等等
if (request.isHttps && cacheResponse.handshake == null) {
return CacheStrategy(request, null)
}
// 根据响应码决定是否满足缓存的条件,这个方法我们在缓存写入的分析中提到过
// 缓存写入前也会调用此方法判断响应能否缓存,不能则设置cache为null返回。见上边写入缓存中2
if (!isCacheable(cacheResponse, request)) {
return CacheStrategy(request, null)
}
//如果请求中cacheControl设置了noCache,这种情况肯定是不能使用缓存,所以cacheResponse设置为null
//针对第二种情况,是判断请求头中,是否包含If-Modified-Since或If-None-Match,假如包含
//则表示虽然本地有缓存,但是也要和服务器确认缓存是否可用,见9,
val requestCaching = request.cacheControl
if (requestCaching.noCache || hasConditions(request)) {
return CacheStrategy(request, null)
}
//接下来开始对缓存中的cacheControl做判断了
val responseCaching = cacheResponse.cacheControl
//计算缓存的年龄,也就是缓存用了多久了,见10.
val ageMillis = cacheResponseAge()
//计算缓存的有效期,见11
var freshMillis = computeFreshnessLifetime()
//maxAgeSeconds != -1表示本次请求时,指定了缓存的有效时间
//Cache-Control:max-age[秒] :资源最大有效时间
if (requestCaching.maxAgeSeconds != -1) {
//所以从指定的有效值和实际的有效值中取最小值作为有效时间
freshMillis = minOf(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds.toLong()))
}
//请求中有没有强制指定最小有效期时间,
//min-fresh=[秒] :(请求)缓存最小新鲜度(用户认为这个缓存有效的时长)
var minFreshMillis: Long = 0
if (requestCaching.minFreshSeconds != -1) {
minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds.toLong())
}
var maxStaleMillis: Long = 0
//mustRevalidate(must-revalidate)(响应)不允许使用过期缓存
//max-stale(请求)缓存过期后多久内仍然有效
//这里表示调用方指定了不能使用过期缓存,所以要在缓存有效期内才能使用
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
//缓存响应超过24小时,需要附加警告。
if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
builder.addHeader("Warning", "113 HttpURLConnection "Heuristic expiration"")
}
//返回缓存
return CacheStrategy(null, builder.build())
}
val conditionName: String
val conditionValue: String?
when {
//缓存响应头中包含ETag标识,则请求中添加If-None-Match头,询问服务器资源是否存在
etag != null -> {
conditionName = "If-None-Match"
conditionValue = etag
}
//缓存响应头中包含Last-Modified标识,则请求中添加If-Modified-Since头,询问服务器资源是否改变
lastModified != null -> {
conditionName = "If-Modified-Since"
conditionValue = lastModifiedString
}
//缓存响应头中包含Date标识,请求中添加If-Modified-Since头,询问服务器资源是否改变
servedDate != null -> {
conditionName = "If-Modified-Since"
conditionValue = servedDateString
}
//如果上边这些都没有,直接发送一个常规请求
else -> return CacheStrategy(request, null)
}
//将上边指定的header添加到request中
val conditionalRequestHeaders = request.headers.newBuilder()
conditionalRequestHeaders.addLenient(conditionName, conditionValue!!)
val conditionalRequest = request.newBuilder()
.headers(conditionalRequestHeaders.build())
.build()
//返回最终的缓存策略
return CacheStrategy(conditionalRequest, cacheResponse)
}
9.hasConditions
kotlin
private fun hasConditions(request: Request): Boolean =
request.header("If-Modified-Since") != null || request.header("If-None-Match") != null
If-Modify-Since:
和Last-Modified关联使用,服务器下发一次资源,会同时下发资源的最后修改时间(Last-Modified),当客户端再次请求这个资源时,把上一次保存的最后修改时间带给服务器(通过请求头If-Modify-Since把Last-Modified带给服务器),服务器会把这个时间与服务器上实际文件的最后修改时间进行比较。如果时间一致,那么返回HTTP状态码304(不返回文件内容),客户端接到之后,就直接使用本地缓存文件;如果时间不一致,就返回HTTP状态码200和新的文件内容,客户端接到之后,会丢弃旧文件,把新文件缓存起来。
If-None-Match
和Etag关联使用,第一次发起http请求资源时,服务器会返回一个Etag(假设Etag:abcdefg1234567),在第二次发起同一个请求时,客户端在请求头同时发送一个If-None-Match,而它的值就是Etag的值(If-None-Match:abcdefg1234567),这个请求头需要客户端自己设置。然后服务器收到后会对比客户端发送过来的Etag是否与服务器的相同,如果相同,就将If-None-Match的值设为false,返回状态为304,客户端继续使用本地缓存;如果不相同,就将If-None-Match的值设为true,返回状态为200,客户端重新解析服务器返回的数据
10.cacheResponseAge
kotlin
private fun cacheResponseAge(): Long {
//servedDate指的就是当前缓存从服务器请求下来时,请求头中Date的时间值,
//表示的是服务端返回这个资源时的时间。
val servedDate = this.servedDate
//receivedResponseMillis是客户端记录的请求被接收到的时间值
//所以receivedResponseMillis - servedDate.time得到的是请求到这个资源时,
//资源的寿命已经有多长了,如我断点得到的403,已经403毫秒了
val apparentReceivedAge = if (servedDate != null) {
maxOf(0, receivedResponseMillis - servedDate.time)
} else {
0
}
//响应头"Age"的值,如果没有这个头,则使用上边我们计算出的apparentReceivedAge赋值
val receivedAge = if (ageSeconds != -1) {
maxOf(apparentReceivedAge, SECONDS.toMillis(ageSeconds.toLong()))
} else {
apparentReceivedAge
}
//请求的时长减响应的时长,是请求到响应消耗的时长
val responseDuration = receivedResponseMillis - sentRequestMillis
//当前时间减去响应时间,得到的就是响应之后已经过了多久
val residentDuration = nowMillis - receivedResponseMillis
//所以最终缓存的年龄计算得到的就是这个资源被使用多久了
return receivedAge + responseDuration + residentDuration
}
11.computeFreshnessLifetime
计算缓存的有效期
kotlin
private fun computeFreshnessLifetime(): Long {
val responseCaching = cacheResponse!!.cacheControl
//缓存有没有指定有效期,如果maxAgeSeconds != -1表示指定了,那么返回这个时间
if (responseCaching.maxAgeSeconds != -1) {
return SECONDS.toMillis(responseCaching.maxAgeSeconds.toLong())
}
//缓存有没有指定过期时间,如果指定了,expires != null,
val expires = this.expires
if (expires != null) {
//用过期时间减去接收到缓存的时间,得到的就是缓存在本地的有效期
val servedMillis = servedDate?.time ?: receivedResponseMillis
val delta = expires.time - servedMillis
return if (delta > 0L) delta else 0L
}
//如果缓存响应头中包含了上次修改时间,并且请求这个缓存时的get请求中没有添加参数(query == null,见12)
if (lastModified != null && cacheResponse.request.url.query == null) {
// 用服务端响应的时间减去上次修改的时间,就是资源已经存在的时间
val servedMillis = servedDate?.time ?: sentRequestMillis
val delta = servedMillis - lastModified!!.time
return if (delta > 0L) delta / 10 else 0L
}
return 0L
}
12.query指什么
okhttp中有明确的注释,若get请求中添加了请求参数,则query不为空,query就是参数部分。
ruby
* | URL | `query()` |
* | :-------------------------------- | :--------------------- |
* | `http://host/` | null |
* | `http://host/?` | `""` |
* | `http://host/?a=apple&k=key+lime` | `"a=apple&k=key lime"` |
* | `http://host/?a=apple&a=apricot` | `"a=apple&a=apricot"` |
* | `http://host/?a=apple&b` | `"a=apple&b"` |
总结
这篇文章缓存拦截器逻辑的分析耽误了很久,主要在写文章之前我希望先通过demo将流程完整的再验证一遍,虽然之前也分析过java版本的okhttp源码,但主要还是抱着浅尝则止的态度,一些问题没有深究,所以这次中间遇到了一些小问题导致迟迟无法往下推进,到今天总算完成了,研究或者分析问题,应该抱着剥洋葱的态度,一层一层,由外及里,才能有更深刻的认识,本文虽然相对于之前深入了一些细节,但还是有很多没有聊到的,因为篇幅有限,欢迎讨论。