HTTP缓存机制及其在iOS中的应用

2,858 阅读14分钟

一、 什么是缓存

Web 缓存是可以保存文档副本的HTTP设备。

HTTP缓存一般为两种,本地缓存和代理缓存。本地缓存就是客户端设备中的缓存,代理缓存就是缓存代理服务器,常见的就 是 CDN。

二、缓存机制

1. 缓存机制

缓存的机制是针对客户端-缓存设备-源站的交互而言的,缓存的处理机制如下: 缓存机制

如上图所示,一般而言,缓存是否新鲜采用 Cache-Control/Expires 进行判断,也叫做强制缓存。服务器的再验证一般采用 If-None-Match + ETag 或者 If-Modified-Since + Last-Modified 的“条件get”请求来判断,也叫做对比缓存。

2. 本地缓存的特殊之处

当存在多个缓存设备时,比如客户端设备中有缓存,CDN 中也有缓存,此时就有两个缓存了。只要客户端本地有缓存,那么客户端就是一个通常意义上的 Web缓存设备,只不过客户端-服务器的距离几乎为 0 而已。

缓存的概念相当重要,缓存是一个设备。所有缓存相关的逻辑都是按照三个关键点来进行的,这三个关键点就是:客户端、缓存、源服务器,当同时存在客户端缓存和代理缓存时,其情况可能是:

多个缓存设备

  • 本地缓存的特殊之处就在于在判断缓存未失效时可以直接使用缓存而不用发送 Request。

因此,请求一个 HTTP 时,先查询客户端本地的缓存,检查是否有缓存,如果有缓存再根据 Cache-Control: max-age=xxx 来判断缓存是否新鲜,如果足够新鲜,也就是第二次请求在 Cache-Control 时间内,此时可以直接使用本地保存的 Reponse + data,完全不需要进行请求。

如果不够新鲜了,就不能使用本地缓存了,而是应该发起正式的请求。请求到了缓存服务器,缓存服务器会发送带条件的再验证请求到源站,也就是使用 If-Modified-Since 等方法来进行再验证。如果验证通过,缓存未过期,更新 Cache-Control/Expires 的值,重新计算时间,整合 Reponse 之后返回给客户端,返回的实体中不包含 data,此时状态码为 304。如果缓存失效,那么返回的状态码为200,实体中包含全部 data。

其时序图如下: 客户端-本地缓存-代理缓存-源站

三、缓存过期

意义:当存在缓存时,使用过期验证的机制来验证缓存是否可以使用,这一机制也有很多人称之为强制缓存

这一步一般是在本地缓存或者代理缓存中进行,通过 Cache-Control 或者是 Expires 进行验证。

1. Expires

老式的 HTTP1.0协议使用 Expires字段来表示文档的过期日期,比如:

Expires:Thu,15 Apr  2010  20:00:00  GMT

**意义:**这个字段可以使用一个组件的当前副本,直到指定的时间为止。

缺陷:

  1. 客户端和服务端的时钟必须严格一致;
  2. 时间到期之后服务器需要重新设置;

所以就有了第二种方式:

2. Cache-Control:max-age

Cache-Control:max-age 是对 Expires的优化处理,比如:

Cathe-Control:max-age=315360000

**意义:**从请求开始在max-age时间都可以使用缓存,之外的使用请求。

如此,就可以消除 Expires 时间统一的限制。

**总结:**现在强制缓存一般都采用 Cache-Control: max-age=xxx 来设置。

备注:Cache-Control 还有很多其他的可选值,后文会介绍。

四、服务器再验证

意义: 即使缓存过期了,也不意味着缓存文件和原始服务器上的文件不一致,这只是意味着要进行时间核对来确认缓存是否仍然可以使用。这个情况叫做服务器再验证。

**验证机制:**HTTP 允许客户端向服务器发送一个“条件GET”,根据条件判断,只有当服务器中的文档和缓存不一样时,服务器才会在 Response 主体中包含全部的内容,否则返回 304,Response 中不包含资源。

条件语句有很多种,常用的有两种:

1. If-Modified-Since 和 Last-Modified

If-Modified-Since 客户端使用,在请求头中添加。Last-Modified 服务端使用,在响应头中返回。两个配合使用来验证资源是否真的发生了改变。如果改变了,状态码为200,响应主体中包含所有内容,如果为改变,状态码为304,响应实体中不包含主体,只包含头部。

举个栗子🌰:

第一次请求的 Response 如下: Response

从图中可以看出,Response 中包含 Cache-Control: max-age=10,表示在10秒内可以直接使用缓存,超过10秒就需要进行再验证。同时 Response 中带有一个 Last-Modified:

第二次进行请求的请求头和响应头: 再验证通过

上图表示10秒后进行了缓存再验证且验证通过,所以返回的是304。

2. If-None-Match 和 ETag

**意义:**有些资源会周期性重写,但是内容却未发生变化,此时 If-Modified-Since 就不能满足要求,而是需要一个实体标签。

客户端记录服务端在响应头中的 ETag 并在请求通中使用 If-None-Match 字段提交给服务器。同理,如果 ETag 一致,表示缓存的资源在源站中未发生改变,所以此时会返回 304,表示缓存可用。否则就会返回 200,在返回的主体中包含完整的内容。同时,Response 中会返回最新的 ETag。

举个栗子,缓存再验证通过: ETag缓存再验证通过

4. 强弱验证

**意义:**有些文档被修改了,但是修改的内容不重要,比如注释,此时需要一个强弱标签来告诉使用者,什么情况下缓存还可以继续使用。

仍然类似于 Git 上的代码管理。每次提交代码,都会在对应的分支上生成一个索引值,但是并不是每次提交都会修改版本号的,更不是每次更新都会生成一个大的版本号。

缓存也存在这种情况,因为资源的某些无伤大雅的修改并不影响原先副本的继续使用,比如注释。所以存在强弱验证的情况:

**弱验证:**内容的主要含义发生变化时,弱验证器才会发生变化。 **强验证:**只要内容发生变化,强验证器就会发生变化。

例如:

Etag:w/"2.6"
If-None-Match:w/"2.6"

不带 w/ 就是强验证,例如:

Etag:"2.6"
If-None-Match:"2.6"

5. If-None-Match的多值情况

If-None-Match 可以有多个值,表示这些版本的副本在缓存中都存在,如图: If-None-Match

6. 先后问题

因为缓存控制的原因,对缓存的使用会分很多情况。比如 Cache-Control 为 no-cache 时,表示必须进行再验证通过后,才能使用缓存。而 Cache-Control 为 max-age=xxx 时表示在收到 Response 之后的这个时间内都可以使用缓存。

另外,除了 Cache-Control 对缓存的控制,还会有试探性过期的机制,因此缓存的使用与否的逻辑并不是简单的 Yes or No,而是需要根据多重条件进行综合判断,后文会有存在 Cache-Control 和不存在 Cache-Control 情况下的常见逻辑。

因此,If-Modified-Since/If-None-Match 和 Last-Modified/ETag 两者的先后关系不确定。其一种常见的作用机制是服务端返回 Last-Modified 字段,客户端进行缓存,如果需要进行缓存的再验证(比如max-age过期了),那么就将存储的值作为 If-Modified-Since 的值添加在请求头中发送给服务器。

7. 特别注意

  1. 如果服务器返回一个实体标签(ETag),HTTP/1.1,客户端就必须使用实体标签;
  2. 如果服务器只回送了一个 Last-Modified 值,客户端就可以使用 If-Modified-Since 验证,非必须;
  3. 如果两者都提供了,那么就需要使用两种验证方案,这样就可以兼容 HTTP/1.0 和 HTTP/1.1,但不是必须;
  4. 如果客户端的头部中既包含实体标签又包含最后修改日期,那么服务端只有在两个条件都验证通过时才能返回 304;

如图: 再验证

再验证

正因为如此,才有了试探性过期的缓存策略。

五、缓存控制

1. Cache-Control之于代理缓存

  • no-store

缓存中不得存储任何关于客户端请求和服务端响应的内容。每次由客户端发起的请求都会下载完整的响应内容。

  • no-cache

代理缓存可以存储缓存,但是必须在和源站进行验证之后才能提供给客户端。

  • private

只能用于私有缓存(客户端缓存),中间人不能缓存,默认为private;

  • public

公共缓存,可以用于中间人(代理缓存、CDN);

  • must-revalidate

如果过期,那么必须验证后才能使用或者提供给客户端,比 no-cache 稍微宽松,no-cache 不管是否过期都要验证;

  • max-age

过期时间,从服务器将资源传来之时,资源处于新鲜状态的秒数;

  • pragma

是HTTP/1.0标准中定义的一个 header 属性,请求中包含Pragma 的效果跟在头信息中定义Cache-Control: no-cache相同,但是HTTP的响应头没有明确定义这个属性,所以它不能拿来完全替代HTTP/1.1中定义的Cache-control头。通常定义Pragma以向后兼容基于HTTP/1.0的客户端。

2. Cache-Control之于客户端

客户端也可以在请求中添加 Cache-Control 请求首部,完整的意义如下: Cache-Control请求指令

其中最常用的有两种:

  • Cache-Control: no-cache + Pragma: no-cache

表示代理缓存必须对缓存进行验证,验证通过后才能提供缓存。Pragma 是为了支持 HTTP1.0;

  • Cache-Control: no-store

表示代理缓存不能提供缓存,且代理缓存中的的缓存资源应该删除;

六、试探性过期

1. 定义

如果响应中没有 Cache-Control 也没有 Expires ,那么缓存就可以计算出一个试探性最大使用期。

最大使用期的计算可以使用任意算法,但是如果得到的最大试用期大于24小时,就应该想响应首部添加一个试探性过期,但是这种方式的使用很少,常用的是 LM-factor 算法。

2. LM-factor 算法

使用条件:

    1. 响应中没有 Cache-Control 也没有 Expires;
    1. 响应中存在 Last-Modified;

计算方法:

试探性最大使用期 = rate * (请求时间 - 最后修改时间)

举个栗子🌰: LM-factor 算法

3. 特别提示

《HTTP权威指南》中特别指出:

试探性过期特别注意

而实际上,safari 就对试探性过期进行了实现。

七、iOS中的系统实现的缓存策略

iOS 中的 NSURLSession 中使用到了 NSURLRequestCachePolicy ,这个枚举就是 Apple 遵循 HTTP 协议,将 iPhone 作为一个本地缓存设备,实现了和协议对应的缓存逻辑,但是其逻辑使用到了试探性过期,其判断逻辑大致如下:

iOS中的NSURLRequestCachePolicy

验证:

iOS 中,会存在这样一种情况: 如果 Response 存在 ETag,Request头部中会自动添加 If-None-Matched,但是如果同时存在 Last-Modified,却不会主动添加If-Modified-Since,而是直接存储后使用,在第二次使用时不请求网络直接使用缓存,此时试探性过期机制生效,缓存未过期。

原始的响应头(省略了一些内容):

HTTP/1.1 200 OK
Date	Wed, 19 Feb 2020 08:09:14 GMT
Content-Type	image/jpeg
Server	openresty/1.11.2.5
Content-MD5	c6090671ef82012e7e71b6dc938dc706
ETag	51cf999237cf860b7fd92e6986fc4767
Last-Modified	Wed, 12 Feb 2020 12:01:36 Asia/Shanghai

再次请求就抓不到包了,却得到了 200 的响应,且包含 data,此时就是试探性过期机制生效,直接使用的本地的 Reponse 和 data,并没有进行请求。

其实,可以使用 charles 断点,对这个 Response 进行几种操作:

  1. 去掉 Last-Modified
  2. 修改 Last-Modified 为和请求时间相近
  3. 添加 Cache-Control

1. 去掉Last-Modified

意义: Response 中只有 ETag,所以客户端下一次请求时不触发试探性过期,也不会触发强制缓存,而是直接请求进行缓存的再验证;

操作如下:

去掉Last-Modified ,然后,通过 charles 断点 修改响应头,去掉了 Last-Modified 字段,最终的响应头如下:

HTTP/1.1 200 OK
Date	Wed, 19 Feb 2020 08:09:14 GMT
Content-Type	image/jpeg
Server	openresty/1.11.2.5
Content-MD5	c6090671ef82012e7e71b6dc938dc706
ETag	51cf999237cf860b7fd92e6986fc4767

再次进行请求, charles 能够抓到包,证明没有触发强制缓存,也没有触发试探性过期,请求头和响应头如下: 缓存再验证成功

由图可知:客户端发送了一个条件GET进行缓存验证,且验证成功。

2. 修改 Last-Modified 为和请求时间相近

因为不存在 Cache-Control 但是却存在 Last-Modified,所以 iOS 会触发试探性过期。

未修改之前这个值相对较老,试探性过期触发之后会判断短时间内缓存不会过期,所以不会发送请求,直接使用缓存。

但是如果修改 Last-Modified 为和请求时间相近,那么试探性过期的计算结果为缓存很快就会过期,资源变动比较频繁,所以此时 iOS 会发送一个一个条件请求进行缓存再验证。

修改后的 Reponse 如下:

HTTP/1.1 200 OK
Date: Wed, 19 Feb 2020 09:06:27 GMT
Content-Type: image/jpeg
Content-Length: 3444
Connection: keep-alive
Server: openresty/1.11.2.5
Content-MD5: c6090671ef82012e7e71b6dc938dc706
ETag: 51cf999237cf860b7fd92e6986fc4767
Last-Modified: Wed, 19 Feb 2020 09:06:27 GMT

再次进行请求的请求头和响应头如下: 试探性过期判断结果为缓存过期

3. 添加 Cache-Control

**意义:**添加 Cache-Control 后,就按照正常逻辑走了,也就是说不会触发试探性缓存了。

因为 Cache-Control 相对复杂,这里直接使用 max-age=5 和 max-age=36500 和 来作为示例;

修改后的响应头为:

HTTP/1.1 200 OK
Date: Wed, 19 Feb 2020 09:13:05 GMT
Content-Type: image/jpeg
Content-Length: 3444
Connection: keep-alive
Server: openresty/1.11.2.5
Content-MD5: c6090671ef82012e7e71b6dc938dc706
Cache-Control: max-age=5
ETag: 51cf999237cf860b7fd92e6986fc4767
Last-Modified: Wed, 12 Feb 2020 12:01:36 Asia/Shanghai

过5秒之后再次请求肯定不会触发强制缓存,而是在强制缓存失效之后,进行正常的进行缓存再验证: 强制缓存判断缓存失效后再验证成功

同理,如果 max-age=36500,短期内强制缓存生效,肯定是直接使用本地缓存而不进行请求。

附上测试代码:

// 图片
NSURL *url = [NSURL URLWithString:@"http://cms-bucket.ws.126.net/2020/0212/51cf9992j00q5kluo00bmc000tj00tjc.jpg?imageView&thumbnail=140y88"];

NSMutableURLRequest *request = [NSMutableURLRequest new];
request.HTTPMethod = @"GET";
request.URL = url;

// 查询是否有缓存
NSCachedURLResponse *cacheReponse = [[NSURLCache sharedURLCache] cachedResponseForRequest:request];
if (cacheReponse) {
    NSLog(@"本地存在缓存");
} else {
    NSLog(@"本地无缓存");
}

NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
    NSLog(@"%@",request.allHTTPHeaderFields);
    NSHTTPURLResponse *httpReponse = (NSHTTPURLResponse *)response;
    NSLog(@"statusCode:%li",httpReponse.statusCode);

     if (data.length > 0) {
        NSLog(@"响应有数据");
    } else {
        NSLog(@"响应无数据");
    }
}];
[task resume];

总结

几个知识点再总结下: 总结

结尾

正常来讲,iOS 中使用默认的缓存机制,然后服务端按照 HTTP/1.1 协议正确配置好缓存过期字段(Expires/Cache-Control)和条件验证字段(If-Modified-Since/If-None-Match),基本上就能满足大部分的需求,而且将缓存更新与否的决定权交给了服务端,也就是 H5 页面可以控制 App 中的页面是否更新,并不用发包。

更多文章