HTTP 协议中的 ETag 与 If-None-Match

ETag 是 HTTP 头的一个字段,出现在 Response Header 之中,用于标记一个资源的版本,是 HTTP 缓存策略的一种手段。

软件开发常常有版本号的概念,比如 1.0, 1.1, 2.0 等等。软件使用者通常会在客户端版本落后于服务器最新版本时才会去服务器获取新的软件。

这一理念也用于了浏览器缓存策略中。结合下面这张图为例,浏览器已经缓存了 foo.jpg 且已记录版本号为1.2 (这个版本号由服务器生成并告诉浏览器),那么当浏览器再次请求 foo.jpg 时,就会同时把版本号也放在请求头中。这样,服务器收到请求时,就知道了客户端已缓存的文件的版本。如果服务器中的 foo.jpg 版本也是 1.2,那么服务器就可以说 304 Not Modified,不用再将 foo.jpg 传给了浏览器,因此就节省了带宽。反之,如果服务器端的 foo.jpg 已经 1.3 了,那么就要将新的文件传给浏览器,也同时告诉它版本号为 1.3,浏览器收到后,缓存文件,并记录版本号为 1.3

ETag 是 Response Header 中的一个字段,而与之对应的一个出现在请求头中的字段为 If-None-MatchIf-None-Match 对应的值即为浏览器缓存的的文件的版本号。

代码演示

使用 Koa 框架为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const Koa = require('koa')
const koaLogger = require('koa-logger')
const fs = require('fs')

const app = new Koa()

app.use(koaLogger())

app.use(async (ctx, next) => {
if (ctx.get('if-none-match') && ctx.get('if-none-match') === 'foobar') {
ctx.status = 304
} else {
ctx.status = 200
ctx.type = 'json'
ctx.set('etag', 'foobar')
ctx.body = fs.createReadStream('package.json')
}
})

app.listen(5000)

这次我们把它标记为 foobar。注意版本号并不一定要像软件开发的版本号具有语义化,相反它可以是任意字符串,只要确保两个版本间的版本号不一样就行。

如图,第一次向 localhost:5000/package.json 发起请求,返回头中包含了 etag: foobar。因此是第一次请求,所以请求头之中没有 If-None-Match 字段。

刷新页面继续请求:

可以发现,这次请求头中多了 if-none-match 字段,其值就是 foobar。因此这次 foobar 和服务器版本相同,因此可以直接返回 304。

如果,修改本地服务器版本号,比如改成 package:

1
2
3
4
5
6
7
8
9
10
app.use(async (ctx, next) => {
if (ctx.get('if-none-match') && ctx.get('if-none-match') === 'package') {
ctx.status = 304
} else {
ctx.status = 200
ctx.type = 'json'
ctx.set('etag', 'package')
ctx.body = fs.createReadStream('package.json')
}
})

再次请求,

如图,这次因为版本号不同了,所以服务端要再次发送文件,并且通知浏览器更新版本号。

koa-static-cache

koa-static-cache 这个包中,也使用了 ETag 策略。

源码中有这么几行代码:

1
2
3
4
if (file.md5) ctx.response.etag = file.md5

if (ctx.fresh)
return ctx.status = 304

它的意思其实就是将文件的 md5 值作为版本号,因为文件内容一旦改变,那么它的 md5 也一定随之改变。

ctx.fresh 是 koa 实现的一个属性,可以参考文档 doc。而这个属性就是根据 If-None-Match / ETag, and If-Modified-Since and Last-Modified 判断缓存是否过期。更具体的,Koa 源码中对 fresh 字段的实现又是使用了 fresh 这个包,源码中可以观察这几行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// if-none-match
if (noneMatch && noneMatch !== '*') {
var etag = resHeaders['etag']

if (!etag) {
return false
}

var etagStale = true
var matches = parseTokenList(noneMatch)
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
}
}

其实现就是的原理就是比较 if-none-matchetag 的。

参考

  1. HTTP: The Definitive Guide
  2. MDN ETag