从头到尾实现一个 fresh 包来学习协商缓存过程

缘起

前不久我写了一篇从头到尾实现简易 Koa 框架的文章。不过为了突出核心,很多次要的功能都没有实现,其中一个就是 Koa request 对象上的 fresh 属性,即 ctx.request.fresh

ctx.freshctx.request.fresh 的一个别名。其含义是当前请求的内容是否还"新鲜"。如果内容新鲜(freshtrue)就返回 304 状态码,表示"内容未修改(Not Modified)",请求体空,浏览器会使用上一次的缓存内容;如果内容过期(freshfalse),那么就返回 200 状态码,请求体设置成新内容,浏览器使用并缓存。

下面是 Koa ctx.fresh 属性的一个使用 DEMO。

javascript 复制代码
// freshness check requires status 20x or 304
ctx.status = 200;
ctx.set('ETag', '123');

// cache is ok
if (ctx.fresh) {
  ctx.status = 304;
  return;
}

// cache is stale
// fetch new data
ctx.body = await db.find('something');

ctx.request.fresh 内部是使用一个叫 refresh 的 npm 包,通过缓存协商机制检查请求内容是否过期的。

本文会从头到尾写一个 fresh 包来学习协商缓存的过程。不过在此之前,我先带大家了解一下协商缓存,这样在后期实现时,思路更加清晰。

协商缓存

温馨提示:如果想要更加详细地了解浏览器缓存知识,可以查看文章《一文彻底弄懂浏览器缓存,只需动手操作一次》 学习。

浏览器缓存分 2 种:强缓存和协商缓存。如何判定呢?

可以像下面这样简单判断:

  1. 第一次访问页面时,如果响应头中包含 cache-control 字段,并且字段值等于 no-cache 时,代表该资源是协商缓存
  2. 第一次访问页面时,响应头中包含 cache-controlexpires 字段时,代表该资源是强缓存

网页中的 JS、CSS、图片等这类静态资源一般会配置成强缓存;而像 HTML 文档则会配置成协商缓存。

协商缓存的流程一般如下。

  1. 第一次访问页面,返回响应头 Cache-Control: no-cache,另外还有 Last-Modifiedetag 字段,并且响应内容被缓存
  2. 后续访问页面时,请求头中携带 If-Modified-SinceIf-None-Match 字段
    • If-None-Match 字段会携带上次请求返回的 etag
    • If-Modified-Since 字段会携带上次请求返回的 Last-Modified

If-None-Match If-Modified-Since 用于在后端校验内容是否过期。If-None-Match 的处理优先级更高。

如果校验没有过期,那么返回 304 状态码,浏览器继续使用之前缓存的内容;如果校验过期了,那么返回 200 状态码,浏览器使用新内容并缓存。

这就介绍完了协商缓存了。

接下来我们先分析一下 Koa 中 ctx.request.fresh 属性的实现。

Koa ctx.request.refresh 属性

Koa ctx.request.refresh 属性的源码实现如下。

javascript 复制代码
const fresh = require('fresh');

module.exports = {
  /**
   * Check if the request is fresh, aka
   * Last-Modified and/or the ETag
   * still match.
   *
   * @return {Boolean}
   * @api public
   */

  get fresh() {
    const method = this.method;
    const s = this.ctx.status;

    // GET or HEAD for weak freshness validation only
    if ('GET' !== method && 'HEAD' !== method) return false;

    // 2xx or 304 as per rfc2616 14.26
    if ((s >= 200 && s < 300) || 304 === s) {
      return fresh(this.header, this.response.header);
    }

    return false;
  }
}

Koa 的内容过期判断,是基于 fresh 包进行封装的:

  1. 所有 GET、HEAD 请求内容一律认为过期
  2. 所有返回状态码在 2xx、304 之外的请求内容一律认为过期
  3. 只有返回状态码介于 2xx、304 范围时,才使用 fresh 进行过期校验

接下来,再着手实现 fresh 包。

实现 npm 包 fresh

基本使用

fresh 包的使用比较简单,它会暴露一个 fresh() 方法,接收每次请求的请求头和响应头信息,返回一个布尔值告诉使用者内容是否过期。

javascript 复制代码
const isFresh = fresh(reqHeaders, resHeaders)

下面举 2 个案例来说明一下。

案例一:直接使用 API

javascript 复制代码
var reqHeaders = { 'if-none-match': '"foo"' }
var resHeaders = { etag: '"bar"' }
fresh(reqHeaders, resHeaders)
// => false

var reqHeaders = { 'if-none-match': '"foo"' }
var resHeaders = { etag: '"foo"' }
fresh(reqHeaders, resHeaders)
// => true

第一个场景,因为前后 etag 值不同,所以返回 false;第二个场景,因为前后 etag 值不同,所以返回 false

案例二:与 Node.js http server 结合使用

javascript 复制代码
var fresh = require('fresh')
var http = require('http')

var server = http.createServer(function (req, res) {
  // perform server logic
  // ... including adding ETag / Last-Modified response headers

  if (isFresh(req, res)) {
    // client has a fresh copy of resource
    res.statusCode = 304
    res.end()
    return
  }

  // send the resource
  res.statusCode = 200
  res.end('hello, world!')
})

function isFresh (req, res) {
  return fresh(req.headers, {
    etag: res.getHeader('ETag'),
    'last-modified': res.getHeader('Last-Modified')
  })
}

server.listen(3000)

我们基于 fresh() 方法,抽象了一个 isFresh() 函数。函数内部会将响应头的 etaglast-modified 字段传入进行比较。内容新鲜就返回 304 状态码,内容过期就返回 200 状态码。

代码实现

依据 fresh v0.5.2 版本

首先声明 fresh() 函数。

javascript 复制代码
/**
 * Check freshness of the response using request and response headers.
 *
 * @param {Object} reqHeaders
 * @param {Object} resHeaders
 * @return {Boolean}
 * @public
 */

function fresh (reqHeaders, resHeaders) {
  // ...
}

首先,获取请求头中的 if-none-matchif-modified-since 字段。如果 2 个字段都没有,直接视为过期。

javascript 复制代码
function fresh (reqHeaders, resHeaders) {
  // fields
  const noneMatch = reqHeaders['if-none-match']
  const modifiedSince = reqHeaders['if-modified-since']

  // unconditional request
  if (!modifiedSince && !noneMatch) {
    return false
  }
  
  // ...
}

其次,如果请求头中包含 Cache-Control: no-cache,直接视内容过期。

javascript 复制代码
/**
 * RegExp to check for no-cache token in Cache-Control.
 * @private
 */

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

function fresh (reqHeaders, resHeaders) {
  // ...
  
  var cacheControl = reqHeaders['cache-control']
  if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl)) {
    return false
  }
  
  // ...
}

然后,再优先进行 if-none-match/etag 字段值比较。

javascript 复制代码
function fresh (reqHeaders, resHeaders) {
  // ...
  
  // if-none-match
  if (noneMatch && noneMatch !== '*') {
    const etag = resHeaders['etag']

    if (!etag) {
      return false
    }
    
    let etagStale = true
    const matches = parseTokenList(noneMatch)
    for (let i = 0; i < matches.length; i++) {
      const match = matches[i]
      if (match === etag || match === 'W/' + etag || 'W/' + match === etag) {
        etagStale = false
        break
      }
    }

    if (etagStale) {
      return false
    }
  }

  // ...
}

如果 if-none-match 有值(并且不是特殊字符 *(匹配一切)),就获取响应头里的 etag 字段:

  • 没有值就视内容过期,直接返回
  • 有值并且跟(处理后的)if-none-match 值不等,就视内容过期(etagStaletrue),直接返回

注意:parseTokenList() 用于格式化请求头 etag 字段,其实现不在本文的范围内。有兴趣的同学可以参照源码学习。

如果 if-none-match/etag 字段值比较后发现相等,还并不能确认内容是新鲜的,因为还没有比较 if-modified-since/last-modified 字段值

接着,我们比较 if-modified-since/last-modified 字段值。

javascript 复制代码
function fresh (reqHeaders, resHeaders) {
  // ...

  // if-modified-since
  if (modifiedSince) {
    const lastModified = resHeaders['last-modified']
    const modifiedStale = !lastModified || !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince))

    if (modifiedStale) {
      return false
    }
  }

  return true
}

/**
 * Parse an HTTP Date into a number.
 *
 * @param {string} date
 * @private
 */

function parseHttpDate (date) {
  var timestamp = date && Date.parse(date)

  // istanbul ignore next: guard against date.js Date.parse patching
  return typeof timestamp === 'number'
    ? timestamp
    : NaN
}

如果 if-modified-since 有值,就获取响应头里的 last-modified 字段。

我们有两个条件来判定内容是过期的。

  1. last-modified 字段没有值(!lastModified
  2. last-modified 字段有值,但是这个时间比请求里面的时间要新(即 !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince))

以上,我们将所有可能的过期条件(return false)都做完了。

走到最后,说明内容是新鲜的。

javascript 复制代码
function fresh (reqHeaders, resHeaders) {
  // ...

  return true
}

到这里就完成了 refresh() 函数的所有逻辑。

refresh 流程图

为了更好理解,我们再画一张流程图说明。

总结

本文我们先讨论协商缓存的流程。当用户浏览器首次接收 Cache-Control: no-cache 时,就表示启用协商缓存。后续请求时会携带 if-none-matchif-modified-since 字段在后端进行校验。

Koa 框架内部采用 refresh 包进行新鲜度校验,本文我们探讨了它的基本使用和实现过程,最后还画了一张流程图进行感性说明。

希望本文能加深大家对协商缓存的理解。感谢阅读,再见。

相关推荐
真的很上进3 小时前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
滚雪球~5 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语5 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
m0_748234526 小时前
前端Vue3字体优化三部曲(webFont、font-spider、spa-font-spider-webpack-plugin)
前端·webpack·node.js
噢,我明白了7 小时前
同源策略:为什么XMLHttpRequest不能跨域请求资源?
javascript·跨域
sanguine__7 小时前
APIs-day2
javascript·css·css3
关你西红柿子7 小时前
小程序app封装公用顶部筛选区uv-drop-down
前端·javascript·vue.js·小程序·uv
济南小草根7 小时前
把一个Vue项目的页面打包后再另一个项目中使用
前端·javascript·vue.js
小木_.8 小时前
【python 逆向分析某有道翻译】分析有道翻译公开的密文内容,webpack类型,全程扣代码,最后实现接口调用翻译,仅供学习参考
javascript·python·学习·webpack·分享·逆向分析
Aphasia3118 小时前
一次搞懂 JS 对象转换,从此告别类型错误!
javascript·面试