URLSession HTTP 缓存机制初探

HTTP/HTTPS 可以通过 HTTP Response 中的 header 字段(如 Cache-ControlExpiresLast-Modified)告诉 client 是否需要对 response 进行缓存。有未过期的缓存,客户端可以使用缓存避免冗余的网络请求。

接下来探讨几个问题:

  1. HTTP 协议如何利用 header 控制缓存的?

  2. iOS(URLSession)对于非幂等的网络请求是否也支持本地缓存(如 POST)? 疑问来自于 rfc2616

    Responses to this method are not cacheable, unless the response
    includes appropriate Cache-Control or Expires header fields. However,
    the 303 (See Other) response can be used to direct the user agent to
    retrieve a cacheable resource.

  1. GET/POST 是否都会进行缓存
  2. 缓存由哪些 HTTP header 字段影响
  3. 边界场景
    1. response 大于磁盘空间的5%不会触发willCacheResponse 参考

HTTP 缓存机制

利用缓存机制,HTTP 发请求可以可以分为3种情况:

  1. client 不发送 HTTP 请求, 直接使用本地的缓存。
  2. client 发送请求,server 校验请求头中的字段(如 If-Modified-SinceIf-None-Match)并判断 client 缓存为有效,server 返回 304 状态码通知 client 使用本地缓存,304 的响应头也可以同时更新缓存文档的过期时间。
  3. 和场景2类似,如果如果 server 判断 client 的缓存已经失效会返回 200 状态码和新的响应数据,client 根据响应头更新本地的缓存。

控制缓存常用的 header 字段

1. Cache-Control

HTTP/1.1定义的 Cache-Control 头用来区分对缓存机制的支持情况, 请求头和响应头都支持这个属性。通过它提供的不同的值来定义缓存策略。

在请求中使用:

字段 说明
no-store 忽略本地缓存也就是拿到 response 不进行保存,每次 client 发起的请求都会下载完整的响应内容
no-cache 每次有请求发出时(即便是有未过期的本地缓存),带上相关字段 (如 If-Modified-SinceIf-None-Match),server 端会验证请求中所描述的缓存是否过期,若未过期则返回 304 告知 client 使用本地缓存
only-if-cached 告诉(代理)服务器,客户端希望获取缓存的内容(若有),而不用向源服务器发请求。
max-age max-age= 表示资源能够被缓存(保持新鲜)的最大时间,是距离请求发起的时间的秒数。

在响应中使用:

字段 说明
no-store 不直接使用缓存,要求想服务器发起(新鲜度)请求
no-cache 所有内容都不会被保存到缓存
must-revalidate 告诉 client,本地缓存过期前,可以使用缓存;本地缓存一旦过期,必须去 server 进行有效性校验。
only-if-cached 告诉(代理)服务器,客户端希望获取缓存的内容(若有),而不用向源服务器发请求。
max-age 告诉客户端该资源新鲜度的时间周期

另外一些如 privatepublic 字段比较少用,这里不再阐述。

2. Last-Modified

响应头可以作为一种弱校验器。说它弱是因为它只能精确到一秒。如果响应头里含有这个信息,客户端可以在后续的请求中带上 If-Modified-Since / If-Unmodified-Since 来验证缓存。

当向服务端发起缓存校验的请求时,服务端会返回 200 ok表示返回正常的结果或者 304 Not Modified(不返回body)表示浏览器可以使用本地缓存文件。304的响应头也可以同时更新缓存文档的过期时间。

  • If-Modified-Since : 是一个条件式请求首部,服务器只在所请求的资源在给定的日期时间之后对内容进行过修改的情况下才会将资源返回,状态码为 200 。如果请求的资源从那时起未经修改,那么返回一个不带有消息主体的 304 响应,而在 Last-Modified 首部中会带有上次修改时间。 不同于 If-Unmodified-Since, If-Modified-Since 只可以用在 GETHEAD 请求中。
  • If-Unmodified-Since: HTTP协议中的 If-Unmodified-Since 消息头用于请求之中,使得当前请求成为条件式请求:只有当资源在指定的时间之后没有进行过修改的情况下,服务器才会返回请求的资源,或是接受 POST 或其他 non-safe 方法的请求。如果所请求的资源在指定的时间之后发生了修改,那么会返回 412 (Precondition Failed) 错误。

If-Modified-SinceIf-Unmodified-Since 的区别是:

  1. If-Modified-Since 告诉服务器如果时间一致,返回状态码 304
  2. If-Unmodified-Since 告诉服务器如果时间不一致,返回状态码 412

3. Etag

ETag HTTP响应头是资源的特定版本的标识符,类似于 hash 值。HTTP响应包会通过 Etag 把标识符告诉客户端。客户端下次请求时通过 If-None-MatchIf-Match 带上该值,服务器对该值进行对比校验:如果一致则不要返回资源。

If-None-MatchIf-Match 的区别是:

  1. If-None-Match:告诉服务器如果一致,返回状态码 304,不一致则返回资源
  2. If-Match:告诉服务器如果不一致,返回状态码 412

有了 Last-Modified 为什么还需要 Etag ?

ETag 是为了解决 Last-modified 的一些缺陷:

  • 一些资源的最后修改时间变了,但是内容没有改变,造成荣誉的请求

  • Last-modified 只能精确到秒级,如果一秒钟文件被多次修改,可能会导致客户端不能及时更新缓存数据。

Etag 是服务器自动生成或者由开发者生成的对应资源在服务器端的唯一标识符,能够更加准确的控制缓存。Last-ModifiedETag 是可以一起使用的,服务器会优先验证 ETag,一致的情况下,才会继续比对 Last-Modified,最后才决定是否返回 304

URLSession 的缓存表现

URLSession 提供的缓存机制有一下几种,可以通过 NSURLSessionConfiguration/NSRequest 对象进行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef NS_ENUM(NSUInteger, NSURLRequestCachePolicy)
{
// 默认的缓存策略,也是按照HTTP协议,根据不同字段进行标准的缓存策略
NSURLRequestUseProtocolCachePolicy = 0,

// 忽略系统本地缓存,直接向服务器请求
NSURLRequestReloadIgnoringLocalCacheData = 1,

// 忽略本地缓存,并告诉代理服务器不使用缓存,直接向源服务器请求数据
NSURLRequestReloadIgnoringLocalAndRemoteCacheData = 4,
NSURLRequestReloadIgnoringCacheData = NSURLRequestReloadIgnoringLocalCacheData,

// 不管过期与否都使用本地缓存,若没有则向服务器请求
NSURLRequestReturnCacheDataElseLoad = 2,
// 不管过期与否都使用本地缓存,若没有不向服务器请求,返回错误给上层
NSURLRequestReturnCacheDataDontLoad = 3,

// 无论本地缓存过期与否,都先向服务器验证缓存的有效性
NSURLRequestReloadRevalidatingCacheData = 5,
};

本文目的是验证 iOS 的 post 请求是否也会进行缓存,所以这里使用默认的缓存策略,也就是 NSURLRequestUseProtocolCachePolicy

验证步骤分2步:

  1. app 使用 get 请求,回包分别返回 Cache-controlLast-ModifiedETag 的表现
  2. app 使用 post 请求,回包分别返回 Cache-controlLast-ModifiedETag 的表现

为了方便验证,服务端使用 beego 搭建本地简易 server

URLSession GET 请求缓存

客户端主要代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
- (void)viewDidLoad {
[super viewDidLoad];

NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
config.requestCachePolicy = 0;
self.session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:NSOperationQueue.mainQueue];
}

- (IBAction)GET:(id)sender {
NSURL *URL = [NSURL URLWithString:@"http://127.0.0.1:8080/get"];
NSURLRequest *request = [NSURLRequest requestWithURL:URL cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:15];
NSURLSessionTask *task = [self.session dataTaskWithRequest:request];
[task resume];
}

- (IBAction)POST:(id)sender {
NSURL *URL = [NSURL URLWithString:@"http://127.0.0.1:8080/post"];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:URL cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:15];
request.HTTPMethod = @"POST";
NSURLSessionTask *task = [self.session dataTaskWithRequest:request];
[task resume];
}

#pragma mark - NSURLSessionDataDelegate

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
didReceiveResponse:(NSURLResponse *)response
completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler {
NSLog(@"%s|response allHeaderFields=%@",
__func__, [(NSHTTPURLResponse *)response allHeaderFields]);
if (completionHandler) {
completionHandler(NSURLSessionResponseAllow);
}
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
//NSString *responseStr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
NSLog(@"%s|data length=%@",
__func__, @(data.length));
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
willCacheResponse:(NSCachedURLResponse *)proposedResponse
completionHandler:(void (^)(NSCachedURLResponse * _Nullable cachedResponse))completionHandler {
NSLog(@"%s|proposedResponse=%@", __func__, proposedResponse);
if (completionHandler) {
completionHandler(proposedResponse);
}
}
  1. Cache-Control

    • 服务端代码,设置回包数据的新鲜度为 60 秒
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    func (c *GetController) Get() {
    for key, strings := range c.Ctx.Request.Header {
    beego.Debug(key, "=", strings)
    }

    // Cache-Control: max-age=60
    interval := 60
    maxAge := "max-age=" + strconv.Itoa(interval)
    c.Ctx.Output.Header("Cache-Control",maxAge)
    c.Data["json"] = &jsonData{
    10086,
    "message",
    }
    c.ServeJSON()
    }

    func (c *PostController) Post() {
    for key, strings := range c.Ctx.Request.Header {
    beego.Debug(key, "=", strings);
    }
    interval := 60
    maxAge := "max-age=" + strconv.Itoa(interval)
    c.Ctx.Output.Header("Cache-Control",maxAge)
    c.Data["json"] = &jsonData{
    10010,
    "message",
    }
    c.ServeJSON()
    }
    • 请求结果
      1. GET:60秒内客户端重复触发请求,从第二次开始,会使用本地缓存,不会发出网络请求
      2. POST:客户端每次会发出网络请求,不会进行缓存
  1. Last-Modified:

    • 服务端代码,设置回包数据的修改时间为 1609430400 秒(2021/1/1 00:00:00)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      func (c *GetController) Get() {
      // ...略

      // 这里简单处理,客户端如果带上If-Modified-Since,则都返回304告诉其用本地缓存
      ims := c.Ctx.Request.Header.Get("If-Modified-Since")
      if len(ims) > 0 {
      c.Ctx.ResponseWriter.WriteHeader(304)
      return
      }

      // 标记资源过期时间为2020/01/01 00:00:00
      c.Ctx.Output.Header("Last-Modified", "Fri, 01 Jan 2021 00:00:00 GMT")

      // ...略
      }

      func (c *PostController) Post() {
      // ...略

      // 这里简单处理,客户端如果带上If-Modified-Since,则都返回304告诉其用本地缓存
      ims := c.Ctx.Request.Header.Get("If-Modified-Since")
      if len(ims) > 0 {
      c.Ctx.ResponseWriter.WriteHeader(304)
      return
      }

      // 标记资源过期时间为2020/01/01 00:00:00
      c.Ctx.Output.Header("Last-Modified", "Fri, 01 Jan 2021 00:00:00 GMT")

      // ...略
      }
    • 请求结果:

      1. GET:客户端每次都会发出网路请求;第一次响应收到 server 返回的 Last-Modified, 之后的请求中会通过 If-Modified-Since 客户端缓存的修改日期给到 server 端;若 server 端返回 304,则客户端使用本地的缓存,并且 URLSession 向上返回 200 的状态码。
      2. POST: 和 GET 表现一致
  2. ETag

    • 服务端代码:利用 ETag 把资源进行标记

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      func (c *MainController) Get() {
      //...略

      ims := c.Ctx.Request.Header.Get("If-Modified-Since")
      if len(ims) > 0 {
      c.Ctx.ResponseWriter.WriteHeader(304)
      return
      }

      inm := c.Ctx.Request.Header.Get("If-None-Match")
      if len(inm) > 0 {
      c.Ctx.ResponseWriter.WriteHeader(304)
      } else {
      c.Ctx.Output.Header("ETag", "ETagValueGet")
      }

      c.Data["json"] = &jsonData{
      10086,
      "etag-message-get"+inm,
      }
      c.ServeJSON()
      }

      func (c *PostController) Post() {
      inm := c.Ctx.Request.Header.Get("If-None-Match")
      if len(inm) > 0 {
      c.Ctx.ResponseWriter.WriteHeader(304)
      } else {
      c.Ctx.Output.Header("ETag", "ETagValuePost")
      }

      c.Data["json"] = &jsonData{
      10010,
      "etag-message-post"+inm,
      }
      c.ServeJSON()
      }
    • 请求结果:

      1. GET: 客户端每次都会发出网路请求;第一次响应收到 server 返回的 ETag, 之后的请求中会通过 If-None-Match 客户端缓存的标记给到 server 端;若 server 端返回 304,则客户端使用本地的缓存,并且 URLSession 向上返回 200 的状态码。
      2. POST: 和 GET 表现一致

结论

URLSession 使用 NSURLRequestUseProtocolCachePolicy 策略的缓存表现可以概括为:

  1. 如果 server 端通过 Cache-Control 告诉客户端资源新鲜度,则客户端在资源未过期之前,不会发出网络请求,直接使用本地缓存,只作用于 GET 请求。
  2. server 端台利用 Last-Modified 标记资源修改时间或者利用 ETag 标记资源版本,下次客户端重新发出网络请求,携带上 If-Modified-Since / If-None-Match ,server 端会通过 HTTP 状态码 200 / 304 告诉客户端是否可以使用本地的缓存,GET / POST 请求表现一致。

回到文章开头的问题:(URLSession)非幂等的网络请求是否也支持缓存(如 POST)?

从上文中简单的测试表现看,结论是不支持的,和 RFC 文档描述存在差异。于是网上搜索相关问题

How to cache an HTTP POST response?

Is it possible to cache POST methods in HTTP?

问题底下的回复其中提及到:POST 请求支持缓存,但是客户端如 Firefox 浏览器实现中不支持

Note, however many browsers, including current Firefox 3.0.10, will not cache POST response regardless of the headers. IE behaves more smartly in this respect.

然后再去查阅 mozilla 的相关文档,POST 请求利用 Content-Location 字段也是可以进行资源新鲜度描述并进行缓存,但是一般很少应用支持。Cacheable

The method used in the request is itself cacheable, that is either a GET or a HEAD method. A response to a POST or PATCH request can also be cached if freshness is indicated and the Content-Location header is set, but this is rarely implemented. (For example, Firefox does not support it per https://bugzilla.mozilla.org/show_bug.cgi?id=109553.) Other methods, like PUT or DELETE are not cacheable and their result cannot be cached.

Content-Location 定义是:

Content-Location 首部指定的是要返回的数据的地址选项。最主要的用途是用来指定要访问的资源经过内容协商后的结果的URL。

结合我们的测试表现,只有通过 GET 请求响应包携带的 Cache-Control 才能实现让 URLSession 不发出请求直接使用本地缓存。

参考文章:

  1. HTTP 缓存
  2. Caching HTTP POST Requests and Responses
  3. Is it possible to cache POST methods in HTTP?
  4. web性能优化之:no-cache与must-revalidate深入探究
  5. iOS NSCache & NSURLCache 机制原理探究 (二)