Skip to content

HTTP缓存 #18

@qingzhou729

Description

@qingzhou729

一、缓存

缓存技术一直一来在WEB技术体系中扮演非常重要角色,是快速且有效地提升性能的手段。
缓存
如上图,在网页展示出来的过程中,各个层面都可以进行缓存。

之前在学习缓存的过程中,一直没有实践过,有些概念经常会忘记。
今天主要通过实战的方式学习浏览器缓存,顺便分析一下Koa处理缓存的源码。

二、浏览器缓存

首先我们看一下浏览器请求缓存过程。
缓存的处理过程

  • 发出请求后,会先在本地查找缓存。

  • 查到有缓存,要判断缓存是否新鲜(是否过期)。

  • 没有过期,直接返回给客户端。

  • 如果缓存过期了,就要再次去服务器请求最新的资源,返回给客户端,并重新进行缓存。

三、新鲜度检测

可能很多同学看其他博客,提到都是“强缓存/协商缓存”等说法,这个我会放到后面讲。
上图中新鲜一词比较少见,来自《HTTP权威指南》。

因为HTTP会将资源缓存一段时间,在这个时间内,这个缓存就是“新鲜的”。
所以检查缓存是否过期就被称为,新鲜度检测

那么接下来就通过Node.js来实战一下,看看:

  1. 浏览器是如何进行缓存的?
  2. 如何进行新鲜度检测?

四、Node实战

上述提到缓存一段时间,那么HTTP提供了通用首部字段(就是请求报文和响应报文都能用上的字段),来控制缓存时间。

1. Pragma/Expires介绍

1.0

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

Expires会返回一个绝对时间,如果请求时间在Expires指定的时间之前,就能命中缓存。但是因为客户端可以修改本地时间,会和和服务器时间不一致,容易出现差错,不推荐使用。
Pragma/Expires

2. Cache-Control介绍

1.1

Cache-Control是现在常见的缓存方式,上述字段很多,初学者可以只看max-age,避免混乱,也是最有意义的属性。

max-age
Cache-Control描述的是一个相对时间,在进行缓存命中的时候,都是利用客户端时间进行判断,所以相比较ExpiresCache-Control的缓存管理更有效,安全一些。

3. Cache-Control实战

通过Koa框架,简单搭建一个Node服务。并通过koa-static管理静态资源。

代码结构参考如下,maxage缓存设置10秒。
代码
启动服务,就可以看到自己的页面了~

node index.js // server is starting at port 8001

代码目录
在代码截图上,可以看到给koa-static传了maxage: 10 * 1000

koa-static源码中引入了koa-send库。截取部分koa-send源码,只要传入maxage,就会设置Cache-Controlmax-age。为符合前端开发者习惯传入为毫秒,实际上是用秒为单位的。


通过NetWork可以观察到已经成功设置Cache-Control: max-age=10
缓存设置10s

访问测试如下图:

缓存验证

  1. 在10s内再次请求,可以看到js/css均来自缓存memory cache

  2. 10s后缓存过期,不走缓存,便再次从服务器获取。

4. HTML为何如此特殊?

4.1 现象

经过上面的实验可以看出,在Js/Css都走本地缓存的时候,HTML是依旧从服务端获取的。
HTML为何如此特殊
查看请求信息之后,发现请求头中默认加上了Cache-Control: max-age=0
HTML为何如此特殊
经过测试,发现如果单独请求Js资源,也会出现此类现象。因此得出结论,这个是浏览器默认加的,应该是为了保证直接请求的资源最新
客户端的缓存限制

4.2 原因

针对request请求,如果有Cache-Control限制,那么缓存系统就会先校验Cache-Control。不符合规则就直接请求服务端,具体规则如下:
客户端的新鲜度限制
客户端的缓存限制

同理浏览器中Network中的disable-cache也是如此,发出请求时,表示不需要走缓存,一定要服务端最新的。
浏览器disable cache
no-cache

5. 服务端再验证(新鲜度检测)

上述无论是http1.0还是1.1的方案,都是在本地缓存中存放一段时间。过期后就需要去服务端重新请求一遍。这个也被称之为强缓存

但是,缓存中过期并不意味服务端资源改变

因此请求发现本地缓存过期,可以去服务端咨询一下,这个资源还新鲜吗?还可以继续使用吗?常见的方法就是携带字段If-Modified-SinceIf-None-Match。如果验证资源是新鲜的,没有改变。那只需要返回一个标识,也就是我们常说的304,不需要返回数据,加速请求时间。

这个过程就是新鲜度检测,那实现这个缓存的方式就是我们常说的协商缓存

下面看下Node实战协商缓存。

6. Last-Modified 和 If-Modified-Since

6.1 代码验证

每个请求返回时,response中可以携带字段Last-Modified,资源修改的最后日期。用上面强缓存的例子便可以验证:


是因为我们使用的koa-static会默认给我们的返回头加上Last-Modified

发出的请求也会自动携带If-Modified-Since

但是验证后,发现10s后并没有返回304,还是200。

6.2 koa-conditional-get

原因是需要配置中间件koa-conditional-get
配置中间件koa-conditional-get
接下来看下koa-conditional-get做了什么,让协商缓存生效。
可以看出源码非常简单,判断是否新鲜即可。fresh如何计算,会在后面讲。
koa-conditional-get源码

重启服务测试:

  1. 在10s内请求
  2. 10s过期后请求
    304

304
测试结果,10s内Js/Css走强缓存。HTML由于请求默认加max-age为0,走协商缓存返回304,不需要返回数据,Size由484B降至163B。10s后Js/Css缓存到期,全部走协商缓存,由于Last-Modified一直没有改变,均返回304,不需要返回数据,Size降至163B。

返回304后,会重置max-age,10s内请求无需请求服务器。

  1. 修改Js内容



测试结果,Css没有修改依旧返回304。Js修改导致Last-Modified大于请求中的If-Modified-Since,资源不够新鲜,返回200并返回最新数据。

6.3 总结

Last-Modified工作流程如下:

一般来说,在没有调整服务器时间和篡改客户端缓存的情况下,这两个header配合起来管理协商缓存是非常可靠的,但是有时候也会服务器上资源其实有变化,但是最后修改时间却没有变化的情况,而这种问题又很不容易被定位出来,而当这种情况出现的时候,就会影响协商缓存的可靠性。所以就有了另外一对header来管理协商缓存,这对header就是【ETag、If-None-Match】

7. ETag 和 If-None-Match

7.1 ETag

这个header是服务器根据当前请求的资源生成的一个唯一标识,这个唯一标识是一个字符串,只要资源有变化这个串就不同,所以能很好的补充Last-Modified的问题。
避免干扰,可以注释Last-modified逻辑。


ETag的验证也非常简单,只需要再加入一个中间件koa-etag,重启服务测试。

发出请求,response已有Etag:

下一次请求也会携带If-None-Match为缓存中的Etag值:

修改JS资源测试:


测试结果:
Css返回304


Js修改返回200


304

fresh

  1. 是否新鲜计算必须同时要有if-modified-sinceif-none-match
var modifiedSince = reqHeaders['if-modified-since']
var noneMatch = reqHeaders['if-none-match']

// 不是带条件的请求
if (!modifiedSince && !noneMatch) {
    return false
}
  1. 比较etagif-none-match是否相等
if (noneMatch && noneMatch !== '*') {
    // 获取etag
    var etag = resHeaders['etag']
    if (!etag) {
        return false
    }
    
    var etagStale = true; // 资源不是最新的
    var matches = parseTokenList(noneMatch)
    
    // 处理一些复杂的情况
    // 但是最终目的就是为了比较`etag`和`if-none-match`是否相等
    // 看源码不要太抠细节
    for (var i = 0; i < matches.length; i++) {
        var match = matches[i]
        if (match === etag || match === 'W/' + etag || 'W/' + match === etag) {
            etagStale = false // 不是陈旧的
            break
        }
    }
    // 资源需要更新,不是新鲜的 直接返回
    if (etagStale) {
        return false
    }
  }
  1. 比较last-modifiedif-modified-since是否相等,并返回fresh
if (modifiedSince) {
    // 获取请求头中的last-modified
    var lastModified = resHeaders['last-modified']
    // lastModified是否大于缓存中的if-modified-since
    // 大于说明文件修改了
    var modifiedStale = !lastModified || !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince))

    if (modifiedStale) {
      return false
    }
}
  1. 两项校验都通过才会证明,该资源是新鲜的
return true

五、新鲜度检测(Koa源码解读)

1. koa-conditional-get

在前面看到koa-conditional-get可以让协商缓存生效,原因是对资源新鲜度做了304返回的处理。
koa-conditional-get源码
那么重点来看下ctx.fresh是如何处理的?

2. koa

可以看到Koa在request中的fresh方法如下:

状态码200-300之间以及304调用fresh方法

fresh方法源码解读

只保留核心代码,可以自行去看fresh的源码。

var CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/

function fresh (reqHeaders, resHeaders) {
   // 如果这2个字段,一个都没有,不需要校验
  var modifiedSince = reqHeaders['if-modified-since']
  var noneMatch = reqHeaders['if-none-match']
  if (!modifiedSince && !noneMatch) {
    console.log('not fresh')
    return false
  }

  // 给端对端测试用的,因为浏览器的Cache-Control: no-cache请求
  // 是不会带if条件的 不会走到这个逻辑
  var cacheControl = reqHeaders['cache-control']
  if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl)) {
    return false
  }

  // 比较 etag和if-none-match
  if (noneMatch && noneMatch !== '*') {
    var etag = resHeaders['etag']

    if (!etag) {
      return false
    }
    // 部分代码
    if (match === etag) {
        return true;
    }
  }
  
  // 比价if-modified-since和last-modified
  if (modifiedSince) {
    var lastModified = resHeaders['last-modified']
    var modifiedStale = !lastModified || !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince))
    if (modifiedStale) {
      return false
    }
  }
  
  return true
}

fresh的代码判断逻辑总结如下,满足3种条件之一,freshtrue.
fresh代码

总结

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions