缘起
前不久我写了一篇从头到尾实现简易 Koa 框架的文章。不过为了突出核心,很多次要的功能都没有实现,其中一个就是 Koa request 对象上的 fresh 属性,即 ctx.request.fresh
。
ctx.fresh
是 ctx.request.fresh
的一个别名。其含义是当前请求的内容是否还"新鲜"。如果内容新鲜(fresh
为 true
)就返回 304
状态码,表示"内容未修改(Not Modified)",请求体空,浏览器会使用上一次的缓存内容;如果内容过期(fresh
为 false
),那么就返回 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 种:强缓存和协商缓存。如何判定呢?
可以像下面这样简单判断:
- 第一次访问页面时,如果响应头中包含
cache-control
字段,并且字段值等于no-cache
时,代表该资源是协商缓存 - 第一次访问页面时,响应头中包含
cache-control
或expires
字段时,代表该资源是强缓存
网页中的 JS、CSS、图片等这类静态资源一般会配置成强缓存;而像 HTML 文档则会配置成协商缓存。
协商缓存的流程一般如下。
- 第一次访问页面,返回响应头
Cache-Control: no-cache
,另外还有Last-Modified
、etag
字段,并且响应内容被缓存 - 后续访问页面时,请求头中携带
If-Modified-Since
和If-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 包进行封装的:
- 所有 GET、HEAD 请求内容一律认为过期
- 所有返回状态码在 2xx、304 之外的请求内容一律认为过期
- 只有返回状态码介于 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()
函数。函数内部会将响应头的 etag
和 last-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-match
和 if-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
值不等,就视内容过期(etagStale
为true
),直接返回
注意: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
字段。
我们有两个条件来判定内容是过期的。
last-modified
字段没有值(!lastModified
)last-modified
字段有值,但是这个时间比请求里面的时间要新(即!(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince))
)
以上,我们将所有可能的过期条件(return false
)都做完了。
走到最后,说明内容是新鲜的。
javascript
function fresh (reqHeaders, resHeaders) {
// ...
return true
}
到这里就完成了 refresh()
函数的所有逻辑。
refresh 流程图
为了更好理解,我们再画一张流程图说明。
总结
本文我们先讨论协商缓存的流程。当用户浏览器首次接收 Cache-Control: no-cache
时,就表示启用协商缓存。后续请求时会携带 if-none-match
和 if-modified-since
字段在后端进行校验。
Koa 框架内部采用 refresh 包进行新鲜度校验,本文我们探讨了它的基本使用和实现过程,最后还画了一张流程图进行感性说明。
希望本文能加深大家对协商缓存的理解。感谢阅读,再见。