本次博文并不贴具体实现代码,只讲方案和流程,因为涉及的SQL、SP查询和文件缓存都是一些基本操作,只是额外结合了一点Http协议的东西,具体还请结合自身项目框架实现。
为了提高App的网络请求响应速度和减轻服务器的请求压力,比如某些接口的数据更新的并不频繁,没必要每次都去服务器请求数据下来,接口缓存是一个非常棒的解决方案,那么App内的接口缓存机制如何实现呢?首先,这个缓存机制要满足:
1、接口的正常请求和资源的正常获取
2、如再次请求时,资源未发生更新,则不必再次返回资源
3、如再次请求时,资源已有更新,则按常规请求返回
可以看出这个缓存机制的核心是请求资源有无发生更新,那么我们如何知道存在服务器的资源是否有无更新呢?这就得搬出Http协议的Cache-Control了,浏览器对于网页的缓存就是通过它来实现的,我们App要实现同样的功能也少不了它的参与。
Cache-Control验证
众所周知,网页的缓存是由HTTP消息头中的“Cache-Control”来控制的,常见的取值有private、no-cache、max-age、must-revalidate等,默认为private。其作用根据不同的重新浏览方式分为以下几种情况。
Cache-directive | 说明 |
public | 所有内容都将被缓存(客户端和代理服务器都可缓存) |
private | 内容只缓存到私有缓存中(仅客户端可以缓存,代理服务器不可缓存) |
no-cache | 必须先与服务器确认返回的响应是否被更改,然后才能使用该响应来满足后续对同一个网址的请求。因此,如果存在合适的验证令牌 (ETag),no-cache 会发起往返通信来验证缓存的响应,如果资源未被更改,可以避免下载。 |
no-store | 所有内容都不会被缓存到缓存或 Internet 临时文件中 |
must-revalidation/proxy-revalidation | 如果缓存的内容失效,请求必须发送到服务器/代理以进行重新验证 |
max-age=xxx (xxx is numeric) | 缓存的内容将在 xxx 秒后失效, 这个选项只在HTTP 1.1可用, 并如果和Last-Modified一起使用时, 优先级较高 |
以上六种属性全部是针对浏览器缓存而设置的,而今天要实现的App接口缓存只需要用到no-cache做处理。no-cache并不是不使用缓存的意思,只是每次使用前需要跟服务器端进行验证,询问请求的资源是否更改过。
那么当Cache-Control为no-cache时,我们如何与服务器验证呢?
Last-Modified
顾名思义,Last-Modified表示资源最后的更新时间。服务端在返回资源时,会将该资源的最后更改时间通过Last-Modified字段返回给客户端。客户端下次请求时通过If-Modified-Since(If-Unmodified-Since不作讨论)带上Last-Modified,服务端检查该时间是否与服务器的最后修改时间一致:如果一致,则返回304状态码,不返回资源;如果不一致则返回200和修改后的资源,并带上新的时间。
比如我请求本地一个文件,把整个请求信息打印出看看。
thread {
val url = URL("http://127.0.0.1:8080/test/aaa.json")
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.setRequestProperty("Cache-Control", "no-cache")
connection.connect()
println("响应码 = ${connection.responseCode}")
println("响应头:")
connection.headerFields.forEach { t, u ->
println("$t : $u")
}
println("请求内容:${is2String(connection.inputStream)}")
}.run()
响应码 = 200
响应头:
Accept-Ranges : [bytes]
null : [HTTP/1.1 200]
ETag : [W/"173-1566976351219"]
Accept-Ranges : [bytes]
Last-Modified : [Wed, 28 Aug 2019 07:12:31 GMT]
Content-Length : [173]
Date : [Wed, 28 Aug 2019 07:03:26 GMT]
Content-Type : [application/json]
请求内容:{"code":200,"data":{"installPackage":"","lastVersion":"","message":"已经是最新版本","needUpdate":false,"updateContent":"","updateType":null},"flag":true,"message":""}
请求成功,返回200并且附带了请求的资源和Last-Modified字段。
接着我们再次发起请求并在请求头带上Last-Modified值。
connection.setRequestProperty("If-Modified-Since", "Wed, 28 Aug 2019 07:12:31 GMT")
响应码 = 304
响应头:
null : [HTTP/1.1 304]
ETag : [W/"173-1566976351219"]
Last-Modified : [Wed, 28 Aug 2019 07:12:31 GMT]
Date : [Wed, 28 Aug 2019 07:14:36 GMT]
请求内容:
这次返回了304并且没有附带资源,这种情况下我们就可以针对不同场景使用本地缓存或者不作处理了。
ETag
ETag又称实体标签(Entity Tag)。只是以修改时间来判断还是有缺陷,比如文件的最后修改时间变了,但内容没变。对于这样的情况,ETag提供了更加有效的验证方式。
服务器通过某个算法对资源进行计算,取得一串值(类似于文件的md5值),之后将该值通过ETag返回给客户端,客户端下次请求时通过If-None-Match(If-Match不作讨论)带上该值,服务器对该值进行对比校验:如果一致则返回304,否则返回200和最新的资源。
接着上面的那个请求,第一次响应返回了200、ETag和资源。
响应码 = 200
响应头:
Accept-Ranges : [bytes]
null : [HTTP/1.1 200]
ETag : [W/"173-1566976351219"]
Accept-Ranges : [bytes]
Last-Modified : [Wed, 28 Aug 2019 07:12:31 GMT]
Content-Length : [173]
Date : [Wed, 28 Aug 2019 07:03:26 GMT]
Content-Type : [application/json]
请求内容:{"code":200,"data":{"installPackage":"","lastVersion":"","message":"已经是最新版本","needUpdate":false,"updateContent":"","updateType":null},"flag":true,"message":""}
在请求头带上ETag再来一次试试。
connection.setRequestProperty("If-None-Match", "W/\"173-1566976351219\"")
响应码 = 304
响应头:
null : [HTTP/1.1 304]
ETag : [W/"173-1566976351219"]
Last-Modified : [Wed, 28 Aug 2019 07:12:31 GMT]
Date : [Wed, 28 Aug 2019 07:17:13 GMT]
请求内容:
Etag和Last-Modified都可以用于对资源进行验证,我们把这两者都成为验证器(Validators),不同的是,Etag属于强验证(Strong Validation),因为它期望的是资源字节级别的一致;而Last-Modified属于弱验证(Weak Validation),只要资源的主要内容一致即可,允许例如页底的广告,页脚不同。
根据RFC 2616标准中的13.3.4小节,一个使用HTTP 1.1标准的服务端应该同时发送Etag和Last-Modified字段。同时一个支持HTTP 1.1的客户端,比如浏览器,如果服务端有提供Etag的话,必须首先对Etag进行Conditional Request(If-None-Match头信息);如果两者都有提供,那么应该同时对两者进行Conditional Request(If-Modified-Since头信息)。如果服务端对两者的验证结果不一致,例如通过一个条件判断资源发生了更改,而另一个判定资源没有发生更改,则不允许返回304状态。但话说回来,是否返回还是通过服务端编写的实际代码决定的,所以具体使用哪种验证方式可根据自身方便使用选择。
验证Last-Modified和ETag
修改aaa.json文件,添加删除一个字符,相对于文件修改过但内容没有改变,这时在通过发送之前的Etag和Last-Modified来验证'验证'是否可用。
Last-Modified
connection.setRequestProperty("If-Modified-Since", "Wed, 28 Aug 2019 07:12:31 GMT")
响应码 = 200
响应头:
Accept-Ranges : [bytes]
null : [HTTP/1.1 200]
ETag : [W/"173-1566977335527"]
Last-Modified : [Wed, 28 Aug 2019 07:28:55 GMT]
Content-Length : [173]
Date : [Wed, 28 Aug 2019 08:15:05 GMT]
Content-Type : [application/json]
请求内容:{"code":200,"data":{"installPackage":"","lastVersion":"","message":"已经是最新版本","needUpdate":false,"updateContent":"","updateType":null},"flag":true,"message":""}
Last-Modified可用✔️
ETag
connection.setRequestProperty("If-None-Match", "W/\"173-1566976351219\"")
响应码 = 200
响应头:
Accept-Ranges : [bytes]
null : [HTTP/1.1 200]
ETag : [W/"173-1566977335527"]
Last-Modified : [Wed, 28 Aug 2019 07:28:55 GMT]
Content-Length : [173]
Date : [Wed, 28 Aug 2019 08:19:12 GMT]
Content-Type : [application/json]
请求内容:{"code":200,"data":{"installPackage":"","lastVersion":"","message":"已经是最新版本","needUpdate":false,"updateContent":"","updateType":null},"flag":true,"message":""}
我擦嘞,不对啊,说好的内容不变返回304呢?
这个问题就是网上有人搜索的ETag不起作用,那为啥呢?维基百科是这样说的:
ETag机制同时支持强校验和弱校验。它们通过ETag标识符的开头是否存在“W/”来区分,如:
"123456789" -- 一个强ETag验证符
W/"123456789" -- 一个弱ETag验证符
强校验的ETag匹配要求两个资源内容的每个字节需完全相同,包括所有其他实体字段(如Content-Language
)不发生变化。
弱校验的ETag匹配要求两个资源在语义上相等,不需要每个字节相同。
并且ETag的生成规则没有严格规定包括,可以是文档内容的hash值,对最后修改时间的hash值,甚至是约定的版本号。
上面的ETag没有按预期完成就是因为它是对最后修改时间作的hash值,只要把它修改为对内容作hash值就行了。
App接口缓存流程
这个流程看起来蛮简单的,但是其中隐藏的问题可不少。比如,如果并不是所有的接口需要缓存,那么如何区分需要缓存的接口呢?区分的地方又在哪里?我又如何找到对应接口的缓存?
1、对于每次请求返回的ETag或Last-Modified,可以使用数据库存储或者选择使用SP存储,使用URl作为标识,每次请求时先去查询对应接口的ETag或Last-Modified。
2、在Header设置ETag或Last-Modified,推荐在一个统一的地方设置,比如拦截器,如果需要针对部分接口采取缓存,可以在Application类中创建一个缓存URL列表,在拦截器中判断请求的URL是否在这个列表添加Header。
![](https://i-blog.csdnimg.cn/blog_migrate/e9eee3c7f384f4ac602939e649b10994.png)
3、请求成功后的数据,对于ETag和Last-Modified还是使用SQL或SP存储。真正的数据则需要针对304和200做不同处理。
返回200:
将数据进行本地持久化处理,采用文件存储,储存在统一的缓存文件夹,文件名称可以采用URL+后缀的形式。
返回304:
使用本地存储数据,进入缓存文件夹打开URL+后缀的文件,读入数据并返回。
总结
为什么我只讲方案不贴具体代码呢,因为不好搞(狗头.jpg),其实上面的缓存步骤,又可以分为分散处理和统一处理,分散处理你只需在你需要进行缓存的请求回调中进行上面的缓存操作。统一处理既可以在拦截器中也可以在统一封装的接口回调中进行缓存操作。因为每个人的项目框架和结构都不相同,很难讲一个例子,涵盖所有情况,如果我只是把我项目中的情况拿出来讲,可能对你根本没什么帮助,所以还不如讲一个大概方案,再结合个人实际选择符合自己需求的最优解。而对于那些根本没头绪的人来说,相信本次例子也是一个很不错的参考。
如果你喜欢我的分享的话,还请收藏、点赞、多多留言~