为什么 HTTP 请求会返回 304?

网络 网络管理
本文阿宝哥基于 Koa 的缓存示例,介绍了 HTTP 304 状态码和 fresh 模块中的 fresh 函数是如何实现资源新鲜度检测的。希望阅读完本文后,你对 HTTP 和浏览器的缓存机制有更深入的理解。

[[402402]]

相信大多数 Web 开发者,对 HTTP 304 状态码都不会陌生。本文阿宝哥将基于 Koa 缓存的示例,为大家介绍 HTTP 304 状态码和 fresh 模块中的 fresh 函数是如何实现资源新鲜度检测的。如果你对浏览器的缓存机制还不了解的话,建议你先阅读 深入理解浏览器的缓存机制 这篇文章。

一、304 状态码

在 HTTP 中的 ETag 是如何生成的? 这篇文章中,介绍了 ETag 是如何生成的。在 ETag 实战环节,阿宝哥基于 koa、koa-conditional-get、koa-etag 和 koa-static 这些库,演示了在实际项目中如何利用 ETag 响应头和 If-None-Match 请求头实现资源的缓存控制。

// server.js 
const Koa = require("koa"); 
const path = require("path"); 
const serve = require("koa-static"); 
const etag = require("koa-etag"); 
const conditional = require("koa-conditional-get"); 
 
const app = new Koa(); 
 
app.use(conditional()); // 使用条件请求中间件 
app.use(etag()); // 使用etag中间件 
app.use( // 使用静态资源中间件 
  serve(path.join(__dirname, "/public"), { 
    maxage: 10 * 1000, // 设置缓存存储的最大周期,单位为秒 
  }); 
); 
 
app.listen(3000, () => { 
  console.log("app starting at port 3000"); 
}); 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.

在启动完服务器之后,我们打开 Chrome 开发者工具并切换到 Network 标签栏,然后在浏览器地址栏输入 http://localhost:3000/ 地址,接着多次访问该地址(地址栏多次回车)。

上图是阿宝哥多次访问的结果,在图中我们可以看到 200 和 304 状态码。其中 304 状态码表示资源在由请求头中的 If-Modified-Since 或 If-None-Match 参数指定的这一版本之后,未曾被修改。在这种情况下,由于客户端仍然具有以前下载的副本,因此不需要重新传输资源。

下面我们以 index.js 资源为例,来近距离观察一下 304 响应报文:

HTTP/1.1 304 Not Modified 
Last-Modified: Sat, 29 May 2021 02:24:53 GMT 
Cache-Control: max-age=10 
ETag: W/"29-179b5f04654" 
Date: Sat, 29 May 2021 02:25:26 GMT 
Connection: keep-alive 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

对于以上的响应报文,在响应头中包含了 Last-Modified、Cache-Control 和 ETag 这些与缓存相关的字段。如果你对这些字段的作用还不熟悉的话,可以阅读 深入理解浏览器的缓存机制 和 HTTP 中的 ETag 是如何生成的? 这两篇文章。接下来,阿宝哥将跟大家一起来探索一下为什么 10s 后,请求 index.js 资源会返回 304 ?

二、为何返回 304 状态码

在前面的示例中,我们通过使用 app.use 方法注册了 3 个中间件:

app.use(conditional()); // 使用条件请求中间件 
app.use(etag()); // 使用etag中间件 
app.use( // 使用静态资源中间件 
  serve(path.join(__dirname, "/public"), { 
    maxage: 10 * 1000, // 设置缓存存储的最大周期,单位为秒 
  }) 
); 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

首先注册的是 koa-conditional-get 中间件,该中间件用于处理 HTTP 条件请求。在这类请求中,请求的结果,甚至请求成功的状态,都会随着验证器与受影响资源的比较结果的变化而变化。HTTP 条件请求可以用来验证缓存的有效性,省去不必要的控制手段。

其实 koa-conditional-get 中间件的实现很简单,具体如下所示:

// https://github.com/koajs/conditional-get/blob/master/index.js 
module.exports = function conditional () { 
  return async function (ctx, next) { 
    await next() 
    if (ctx.fresh) { 
      ctx.status = 304 
      ctx.body = null 
    } 
  } 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

由以上代码可知,当请求上下文对象的 fresh 属性为 true 时,就会设置响应的状态码为 304。因此,接下来我们的重点就是分析 ctx.fresh 值的设置条件。

通过阅读 koa/lib/context.js 文件的源码,我们可知当访问上下文对象的 fresh 属性时,实际上是访问 request 对象的 fresh 属性。

// 代理request对象 
delegate(proto, 'request'
   // 省略其它代理 
  .getter('fresh'
  .getter('ips'
  .getter('ip'); 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

而 request 对象上的 fresh 属性是通过 getter 方式来定义的,具体如下所示:

// node_modules/koa/lib/request.js 
module.exports = { 
  // 省略部分代码 
  get fresh() { 
    const method = this.method; // 获取请求方法 
    const s = this.ctx.status; // 获取状态码 
 
    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
  }, 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.

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; },}

在 fresh 方法中,仅当请求为 GET/HEAD 请求且状态码为 2xx 或 304 才会执行新鲜度检测。而对应的新鲜度检测逻辑被封装在 fresh 模块中,所以接下来我们来分析该模块是如何检测新鲜度?

三、如何检测新鲜度

fresh 模块对外提供了 fresh 函数,该函数支持 2 个参数:reqHeaders 和 resHeaders。在该函数内部,新鲜度检测的逻辑可以分为以下 4 个部分:

3.1 判断是否条件请求

// https://github.com/jshttp/fresh/blob/master/index.js 
function fresh (reqHeaders, resHeaders) { 
  var modifiedSince = reqHeaders['if-modified-since']  
  var noneMatch = reqHeaders['if-none-match'
 
  // 非条件请求 
  if (!modifiedSince && !noneMatch) { 
    return false 
  } 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

如果请求头未包含 if-modified-since 和 if-none-match 字段,则直接返回 false。

3.2 判断 cache-control 请求头

// https://github.com/jshttp/fresh/blob/master/index.js 
var CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/ 
 
function fresh (reqHeaders, resHeaders) { 
  var modifiedSince = reqHeaders['if-modified-since']  
  var noneMatch = reqHeaders['if-none-match'
   
  // Always return stale when Cache-Control: no-cache 
  // to support end-to-end reload requests 
  // https://tools.ietf.org/html/rfc2616#section-14.9.4 
  var cacheControl = reqHeaders['cache-control'
  if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl)) { 
    return false 
  } 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

当 cache-control 请求头的值为 no-cache 时,则返回 false,以支持端到端的重载请求。需要注意的是,no-cache 并不是表示不缓存,而是表示资源被缓存,但是立即失效,下次会发起请求验证资源是否过期。 如果你不缓存任何响应,需要设置 cache-control 的值为 no-store。

3.3 检测 ETag 是否匹配

// https://github.com/jshttp/fresh/blob/master/index.js 
function fresh (reqHeaders, resHeaders) { 
  var modifiedSince = reqHeaders['if-modified-since']  
  var noneMatch = reqHeaders['if-none-match'
   
  // 省略部分代码 
  if (noneMatch && noneMatch !== '*') { 
    var etag = resHeaders['etag'] // 获取响应头中的etag字段的值 
 
    if (!etag) { // 响应头未设置etag,则直接返回false 
      return false 
    } 
 
    var etagStale = true // stale:不新鲜 
    var matches = parseTokenList(noneMatch) // 解析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 
    } 
  } 
  return true 

  • 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.

在以上代码中 parseTokenList 函数的作用,是为了处理 'if-none-match': ' "bar" , "foo"' 这种情形。在解析的过程中,会去掉多余的空格,并且还会拆分使用逗号分隔符做分隔的 etag 值。而执行循环匹配的目的,也是为了支持以下测试用例:

// https://github.com/jshttp/fresh/blob/master/test/fresh.js     
describe('when at least one matches'function () { 
  it('should be fresh'function () { 
    var reqHeaders = { 'if-none-match'' "bar" , "foo"' } 
    var resHeaders = { 'etag''"foo"' } 
    assert.ok(fresh(reqHeaders, resHeaders)) 
   }) 
}) 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

此外,以上代码中的 W/(大小写敏感) 表示使用弱验证器。弱验证器很容易生成,但不利于比较。而如果 etag 中不包含 W/,则表示强验证器,它是比较理想的选择,但很难有效地生成。相同资源的两个弱 etag 值可能语义等同,但不是每个字节都相同。

3.4 判断 Last-Modified 是否过期

// https://github.com/jshttp/fresh/blob/master/index.js 
function fresh (reqHeaders, resHeaders) { 
  var modifiedSince = reqHeaders['if-modified-since'] // 获取请求头中的修改时间 
  var noneMatch = reqHeaders['if-none-match'
 
  // if-modified-since 
  if (modifiedSince) { 
    var lastModified = resHeaders['last-modified'] // 获取响应头中的修改时间 
    var modifiedStale = !lastModified || !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince)) 
 
    if (modifiedStale) { 
      return false 
    } 
  } 
 
  return true 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

Last-Modified 的判断逻辑很简单,当响应头未设置 last-modified 字段信息或者响应头中 last-modified 的值大于请求头 if-modified-since 字段对应的修改时间时,则新鲜度的检测结果为 false,即表示资源已被修改过,已经不新鲜了。

了解完 fresh 函数的具体实现之后,我们再来回顾一下 Last-Modified 和 ETag 之间的区别:

  • 精确度上,Etag 要优于 Last-Modified。Last-Modified 的时间单位是秒,如果某个文件在 1 秒内被改变多次,那么它们的 Last-Modified 并没有体现出来修改,但是 Etag 每次都会改变,从而确保了精度;此外,如果是负载均衡的服务器,各个服务器生成的 Last-Modified 也有可能不一致。
  • 性能上,Etag 要逊于 Last-Modified,毕竟 Last-Modified 只需要记录时间,而 ETag 需要服务器通过消息摘要算法来计算出一个hash 值。
  • 优先级上,在资源新鲜度校验时,服务器会优先考虑 Etag。 即如果条件请求的请求头同时携带 If-Modified-Since 和 If-None-Match 字段,则会优先判断资源的 ETag 值是否发生变化。

看到这里相信你对示例中 index.js 资源请求返回 304 的原因,应该有了大致的理解。如果你对 koa-etag 中间件是如何生成 ETag 感兴趣的话,可以阅读 HTTP 中的 ETag 是如何生成的? 这篇文章。

四、缓存机制

强缓存优先于协商缓存进行,若强缓存(Expires 和 Cache-Control)生效则直接使用缓存,若不生效则进行协商缓存(Last-Modified/If-Modified-Since 和 Etag/If-None-Match),协商缓存由服务器决定是否使用缓存,若协商缓存失效,那么代表该请求的缓存失效,返回 200,重新返回资源和缓存标识,再存入浏览器缓存中;生效则返回 304,继续使用缓存。

具体的缓存机制如下图所示:

为了让大家能够更好地理解缓存机制,我们再来简单分析一下前面的介绍 Koa 缓存示例:

// server.js 
const Koa = require("koa"); 
const path = require("path"); 
const serve = require("koa-static"); 
const etag = require("koa-etag"); 
const conditional = require("koa-conditional-get"); 
 
const app = new Koa(); 
 
app.use(conditional()); // 使用条件请求中间件 
app.use(etag()); // 使用etag中间件 
app.use( // 使用静态资源中间件 
  serve(path.join(__dirname, "/public"), { 
    maxage: 10 * 1000, // 设置缓存存储的最大周期,单位为秒 
  }); 
); 
 
app.listen(3000, () => { 
  console.log("app starting at port 3000"); 
}); 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.

以上示例使用了 koa-conditional-get、koa-etag 和 koa-static 这 3 个中间件。它们的具体定义分别如下:

4.1 koa-conditional-get

// https://github.com/koajs/conditional-get/blob/master/index.js 
module.exports = function conditional () { 
  return async function (ctx, next) { 
    await next() 
    if (ctx.fresh) { // 资源未更新,则返回304 Not Modified  
      ctx.status = 304 
      ctx.body = null 
    } 
  } 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

koa-conditional-get 中间件的实现很简单,如果资源是新鲜的,则直接返回 304 状态码并设置响应体为 null。

4.2 koa-etag

// https://github.com/koajs/etag/blob/master/index.js 
module.exports = function etag (options) { 
  return async function etag (ctx, next) { 
    await next() 
    const entity = await getResponseEntity(ctx) // 获取响应实体对象 
    setEtag(ctx, entity, options) 
  } 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

在 koa-etag 中间件内部,当获取到响应实体对象之后,会调用 setEtag 函数来设置 ETag。setEtag 函数的定义如下:

// https://github.com/koajs/etag/blob/master/index.js 
const calculate = require('etag'
 
function setEtag (ctx, entity, options) { 
  if (!entity) return 
  ctx.response.etag = calculate(entity, options) 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

很明显在 koa-etag 中间件内部是通过 etag 这个库,来为响应实体生成对应的 etag的。

4.3 koa-static

// https://github.com/koajs/static/blob/master/index.js 
function serve (root, opts) { 
  opts = Object.assign(Object.create(null), opts) 
  // 省略部分代码 
  return async function serve (ctx, next) { 
    await next() 
    if (ctx.method !== 'HEAD' && ctx.method !== 'GET'return 
    // response is already handled 
    if (ctx.body != null || ctx.status !== 404) return 
    try { 
      await send(ctx, ctx.path, opts) 
    } catch (err) { 
      if (err.status !== 404) { 
        throw err 
      } 
    } 
  } 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.

对于 koa-static 中间件来说,当请求方法不是 GET 或 HEAD 请求(不应包含响应体)时,则直接返回。而静态资源的处理能力,实际是交由 send 这个库来实现的。

最后为了让小伙伴们能够更好地理解以上中间件的处理逻辑,阿宝哥带大家来简单回顾一下洋葱模型:

在上图中,洋葱内的每一层都表示一个独立的中间件,用于实现不同的功能,比如异常处理、缓存处理等。每次请求都会从左侧开始一层层地经过每层的中间件,当进入到最里层的中间件之后,就会从最里层的中间件开始逐层返回。因此对于每层的中间件来说,在一个 请求和响应 周期中,都有两个时机点来添加不同的处理逻辑。

五、总结

本文阿宝哥基于 Koa 的缓存示例,介绍了 HTTP 304 状态码和 fresh 模块中的 fresh 函数是如何实现资源新鲜度检测的。希望阅读完本文后,你对 HTTP 和浏览器的缓存机制有更深入的理解。此外,本文只是简单介绍了 Koa 的洋葱模型,如果你对洋葱模型感兴趣,可以继续阅 如何更好地理解中间件和洋葱模型 这篇文章。

六、参考资源

  • MDN - HTTP 条件请求
  • HTTP 中的 ETag 是如何生成的?

 

责任编辑:姜华 来源: 全栈修仙之路
相关推荐

2022-03-30 08:21:57

合并HTTP

2024-12-26 09:05:18

HTTP状态码数据

2020-08-16 11:29:12

Python函数开发

2020-10-12 14:31:16

HTTP之200还是3

2022-05-30 10:23:59

HTTPHTTP 1.1TCP

2021-10-30 19:57:00

HTTP2 HTTP

2012-05-02 10:08:51

桌面Linux微软

2012-03-26 10:26:43

openstackeucalyptus

2012-08-17 10:01:07

云计算

2020-03-30 15:05:46

Kafka消息数据

2021-07-09 09:24:06

NanoID UUID软件开发

2021-06-02 10:52:01

HTTP3Linux

2014-03-05 14:58:00

苹果CarPlayiOS

2021-01-25 07:14:53

Cloud DevOps云计算

2022-04-13 20:53:15

Spring事务管理

2023-03-22 09:10:18

IT文档语言

2022-05-11 08:22:54

IO负载NFSOS

2015-12-07 10:49:43

卸载App用户体验

2019-04-24 08:00:00

HTTPSHTTP前端

2023-10-16 08:57:52

点赞
收藏

51CTO技术栈公众号