为什么 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 请求头实现资源的缓存控制。

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

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

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

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

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

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

二、为何返回 304 状态码

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

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

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

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

  1. // https://github.com/koajs/conditional-get/blob/master/index.js 
  2. module.exports = function conditional () { 
  3.   return async function (ctx, next) { 
  4.     await next() 
  5.     if (ctx.fresh) { 
  6.       ctx.status = 304 
  7.       ctx.body = null 
  8.     } 
  9.   } 

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

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

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

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

  1. // node_modules/koa/lib/request.js 
  2. module.exports = { 
  3.   // 省略部分代码 
  4.   get fresh() { 
  5.     const method = this.method; // 获取请求方法 
  6.     const s = this.ctx.status; // 获取状态码 
  7.  
  8.     if ('GET' !== method && 'HEAD' !== method) return false
  9.  
  10.     // 2xx or 304 as per rfc2616 14.26 
  11.     if ((s >= 200 && s < 300) || 304 === s) { 
  12.       return fresh(this.header, this.response.header); 
  13.     } 
  14.     return false
  15.   }, 

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 判断是否条件请求

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

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

3.2 判断 cache-control 请求头

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

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

3.3 检测 ETag 是否匹配

  1. // https://github.com/jshttp/fresh/blob/master/index.js 
  2. function fresh (reqHeaders, resHeaders) { 
  3.   var modifiedSince = reqHeaders['if-modified-since']  
  4.   var noneMatch = reqHeaders['if-none-match'
  5.    
  6.   // 省略部分代码 
  7.   if (noneMatch && noneMatch !== '*') { 
  8.     var etag = resHeaders['etag'] // 获取响应头中的etag字段的值 
  9.  
  10.     if (!etag) { // 响应头未设置etag,则直接返回false 
  11.       return false 
  12.     } 
  13.  
  14.     var etagStale = true // stale:不新鲜 
  15.     var matches = parseTokenList(noneMatch) // 解析noneMatch 
  16.     for (var i = 0; i < matches.length; i++) { // 执行循环匹配操作 
  17.       var match = matches[i] 
  18.       if (match === etag || match === 'W/' + etag || 'W/' + match === etag) { 
  19.         etagStale = false 
  20.         break 
  21.       } 
  22.     } 
  23.  
  24.     if (etagStale) { 
  25.       return false 
  26.     } 
  27.   } 
  28.   return true 

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

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

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

3.4 判断 Last-Modified 是否过期

  1. // https://github.com/jshttp/fresh/blob/master/index.js 
  2. function fresh (reqHeaders, resHeaders) { 
  3.   var modifiedSince = reqHeaders['if-modified-since'] // 获取请求头中的修改时间 
  4.   var noneMatch = reqHeaders['if-none-match'
  5.  
  6.   // if-modified-since 
  7.   if (modifiedSince) { 
  8.     var lastModified = resHeaders['last-modified'] // 获取响应头中的修改时间 
  9.     var modifiedStale = !lastModified || !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince)) 
  10.  
  11.     if (modifiedStale) { 
  12.       return false 
  13.     } 
  14.   } 
  15.  
  16.   return true 

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 缓存示例:

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

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

4.1 koa-conditional-get

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

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

4.2 koa-etag

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

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

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

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

4.3 koa-static

  1. // https://github.com/koajs/static/blob/master/index.js 
  2. function serve (root, opts) { 
  3.   opts = Object.assign(Object.create(null), opts) 
  4.   // 省略部分代码 
  5.   return async function serve (ctx, next) { 
  6.     await next() 
  7.     if (ctx.method !== 'HEAD' && ctx.method !== 'GET'return 
  8.     // response is already handled 
  9.     if (ctx.body != null || ctx.status !== 404) return 
  10.     try { 
  11.       await send(ctx, ctx.path, opts) 
  12.     } catch (err) { 
  13.       if (err.status !== 404) { 
  14.         throw err 
  15.       } 
  16.     } 
  17.   } 

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

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

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

五、总结

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

六、参考资源

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

 

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

2022-03-30 08:21:57

合并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

2016-12-22 18:38:49

JavaAndroid

2012-08-17 10:01:07

云计算

2012-05-02 10:08:51

桌面Linux微软

2012-03-26 10:26:43

openstackeucalyptus

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

2023-03-22 09:10:18

IT文档语言

2015-12-07 10:49:43

卸载App用户体验

2021-01-25 07:14:53

Cloud DevOps云计算

2022-04-13 20:53:15

Spring事务管理

2022-05-11 08:22:54

IO负载NFSOS

2019-04-24 08:00:00

HTTPSHTTP前端

2023-10-16 08:57:52

点赞
收藏

51CTO技术栈公众号