為什么 HTTP 請求會返回 304?
相信大多數(shù) Web 開發(fā)者,對 HTTP 304 狀態(tài)碼都不會陌生。本文阿寶哥將基于 Koa 緩存的示例,為大家介紹 HTTP 304 狀態(tài)碼和 fresh 模塊中的 fresh 函數(shù)是如何實現(xiàn)資源新鮮度檢測的。如果你對瀏覽器的緩存機(jī)制還不了解的話,建議你先閱讀 深入理解瀏覽器的緩存機(jī)制 這篇文章。
一、304 狀態(tài)碼
在 HTTP 中的 ETag 是如何生成的? 這篇文章中,介紹了 ETag 是如何生成的。在 ETag 實戰(zhàn)環(huán)節(jié),阿寶哥基于 koa、koa-conditional-get、koa-etag 和 koa-static 這些庫,演示了在實際項目中如何利用 ETag 響應(yīng)頭和 If-None-Match 請求頭實現(xiàn)資源的緩存控制。
- // 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( // 使用靜態(tài)資源中間件
- serve(path.join(__dirname, "/public"), {
- maxage: 10 * 1000, // 設(shè)置緩存存儲的最大周期,單位為秒
- });
- );
- app.listen(3000, () => {
- console.log("app starting at port 3000");
- });
在啟動完服務(wù)器之后,我們打開 Chrome 開發(fā)者工具并切換到 Network 標(biāo)簽欄,然后在瀏覽器地址欄輸入 http://localhost:3000/ 地址,接著多次訪問該地址(地址欄多次回車)。
上圖是阿寶哥多次訪問的結(jié)果,在圖中我們可以看到 200 和 304 狀態(tài)碼。其中 304 狀態(tài)碼表示資源在由請求頭中的 If-Modified-Since 或 If-None-Match 參數(shù)指定的這一版本之后,未曾被修改。在這種情況下,由于客戶端仍然具有以前下載的副本,因此不需要重新傳輸資源。
下面我們以 index.js 資源為例,來近距離觀察一下 304 響應(yīng)報文:
- 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
對于以上的響應(yīng)報文,在響應(yīng)頭中包含了 Last-Modified、Cache-Control 和 ETag 這些與緩存相關(guān)的字段。如果你對這些字段的作用還不熟悉的話,可以閱讀 深入理解瀏覽器的緩存機(jī)制 和 HTTP 中的 ETag 是如何生成的? 這兩篇文章。接下來,阿寶哥將跟大家一起來探索一下為什么 10s 后,請求 index.js 資源會返回 304 ?
二、為何返回 304 狀態(tài)碼
在前面的示例中,我們通過使用 app.use 方法注冊了 3 個中間件:
- app.use(conditional()); // 使用條件請求中間件
- app.use(etag()); // 使用etag中間件
- app.use( // 使用靜態(tài)資源中間件
- serve(path.join(__dirname, "/public"), {
- maxage: 10 * 1000, // 設(shè)置緩存存儲的最大周期,單位為秒
- })
- );
首先注冊的是 koa-conditional-get 中間件,該中間件用于處理 HTTP 條件請求。在這類請求中,請求的結(jié)果,甚至請求成功的狀態(tài),都會隨著驗證器與受影響資源的比較結(jié)果的變化而變化。HTTP 條件請求可以用來驗證緩存的有效性,省去不必要的控制手段。
其實 koa-conditional-get 中間件的實現(xiàn)很簡單,具體如下所示:
- // 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
- }
- }
- }
由以上代碼可知,當(dāng)請求上下文對象的 fresh 屬性為 true 時,就會設(shè)置響應(yīng)的狀態(tài)碼為 304。因此,接下來我們的重點就是分析 ctx.fresh 值的設(shè)置條件。
通過閱讀 koa/lib/context.js 文件的源碼,我們可知當(dāng)訪問上下文對象的 fresh 屬性時,實際上是訪問 request 對象的 fresh 屬性。
- // 代理request對象
- delegate(proto, 'request')
- // 省略其它代理
- .getter('fresh')
- .getter('ips')
- .getter('ip');
而 request 對象上的 fresh 屬性是通過 getter 方式來定義的,具體如下所示:
- // node_modules/koa/lib/request.js
- module.exports = {
- // 省略部分代碼
- get fresh() {
- const method = this.method; // 獲取請求方法
- const s = this.ctx.status; // 獲取狀態(tài)碼
- 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;
- },
- }
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 方法中,僅當(dāng)請求為 GET/HEAD 請求且狀態(tài)碼為 2xx 或 304 才會執(zhí)行新鮮度檢測。而對應(yīng)的新鮮度檢測邏輯被封裝在 fresh 模塊中,所以接下來我們來分析該模塊是如何檢測新鮮度?
三、如何檢測新鮮度
fresh 模塊對外提供了 fresh 函數(shù),該函數(shù)支持 2 個參數(shù):reqHeaders 和 resHeaders。在該函數(shù)內(nèi)部,新鮮度檢測的邏輯可以分為以下 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
- }
- }
如果請求頭未包含 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
- }
- }
當(dāng) cache-control 請求頭的值為 no-cache 時,則返回 false,以支持端到端的重載請求。需要注意的是,no-cache 并不是表示不緩存,而是表示資源被緩存,但是立即失效,下次會發(fā)起請求驗證資源是否過期。 如果你不緩存任何響應(yīng),需要設(shè)置 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'] // 獲取響應(yīng)頭中的etag字段的值
- if (!etag) { // 響應(yīng)頭未設(shè)置etag,則直接返回false
- return false
- }
- var etagStale = true // stale:不新鮮
- var matches = parseTokenList(noneMatch) // 解析noneMatch
- for (var i = 0; i < matches.length; i++) { // 執(zhí)行循環(huán)匹配操作
- var match = matches[i]
- if (match === etag || match === 'W/' + etag || 'W/' + match === etag) {
- etagStale = false
- break
- }
- }
- if (etagStale) {
- return false
- }
- }
- return true
- }
在以上代碼中 parseTokenList 函數(shù)的作用,是為了處理 'if-none-match': ' "bar" , "foo"' 這種情形。在解析的過程中,會去掉多余的空格,并且還會拆分使用逗號分隔符做分隔的 etag 值。而執(zhí)行循環(huán)匹配的目的,也是為了支持以下測試用例:
- // 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))
- })
- })
此外,以上代碼中的 W/(大小寫敏感) 表示使用弱驗證器。弱驗證器很容易生成,但不利于比較。而如果 etag 中不包含 W/,則表示強(qiáng)驗證器,它是比較理想的選擇,但很難有效地生成。相同資源的兩個弱 etag 值可能語義等同,但不是每個字節(jié)都相同。
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'] // 獲取響應(yīng)頭中的修改時間
- var modifiedStale = !lastModified || !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince))
- if (modifiedStale) {
- return false
- }
- }
- return true
- }
Last-Modified 的判斷邏輯很簡單,當(dāng)響應(yīng)頭未設(shè)置 last-modified 字段信息或者響應(yīng)頭中 last-modified 的值大于請求頭 if-modified-since 字段對應(yīng)的修改時間時,則新鮮度的檢測結(jié)果為 false,即表示資源已被修改過,已經(jīng)不新鮮了。
了解完 fresh 函數(shù)的具體實現(xiàn)之后,我們再來回顧一下 Last-Modified 和 ETag 之間的區(qū)別:
- 精確度上,Etag 要優(yōu)于 Last-Modified。Last-Modified 的時間單位是秒,如果某個文件在 1 秒內(nèi)被改變多次,那么它們的 Last-Modified 并沒有體現(xiàn)出來修改,但是 Etag 每次都會改變,從而確保了精度;此外,如果是負(fù)載均衡的服務(wù)器,各個服務(wù)器生成的 Last-Modified 也有可能不一致。
- 性能上,Etag 要遜于 Last-Modified,畢竟 Last-Modified 只需要記錄時間,而 ETag 需要服務(wù)器通過消息摘要算法來計算出一個hash 值。
- 優(yōu)先級上,在資源新鮮度校驗時,服務(wù)器會優(yōu)先考慮 Etag。 即如果條件請求的請求頭同時攜帶 If-Modified-Since 和 If-None-Match 字段,則會優(yōu)先判斷資源的 ETag 值是否發(fā)生變化。
看到這里相信你對示例中 index.js 資源請求返回 304 的原因,應(yīng)該有了大致的理解。如果你對 koa-etag 中間件是如何生成 ETag 感興趣的話,可以閱讀 HTTP 中的 ETag 是如何生成的? 這篇文章。
四、緩存機(jī)制
強(qiáng)緩存優(yōu)先于協(xié)商緩存進(jìn)行,若強(qiáng)緩存(Expires 和 Cache-Control)生效則直接使用緩存,若不生效則進(jìn)行協(xié)商緩存(Last-Modified/If-Modified-Since 和 Etag/If-None-Match),協(xié)商緩存由服務(wù)器決定是否使用緩存,若協(xié)商緩存失效,那么代表該請求的緩存失效,返回 200,重新返回資源和緩存標(biāo)識,再存入瀏覽器緩存中;生效則返回 304,繼續(xù)使用緩存。
具體的緩存機(jī)制如下圖所示:
為了讓大家能夠更好地理解緩存機(jī)制,我們再來簡單分析一下前面的介紹 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( // 使用靜態(tài)資源中間件
- serve(path.join(__dirname, "/public"), {
- maxage: 10 * 1000, // 設(shè)置緩存存儲的最大周期,單位為秒
- });
- );
- app.listen(3000, () => {
- console.log("app starting at port 3000");
- });
以上示例使用了 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
- }
- }
- }
koa-conditional-get 中間件的實現(xiàn)很簡單,如果資源是新鮮的,則直接返回 304 狀態(tài)碼并設(shè)置響應(yīng)體為 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) // 獲取響應(yīng)實體對象
- setEtag(ctx, entity, options)
- }
- }
在 koa-etag 中間件內(nèi)部,當(dāng)獲取到響應(yīng)實體對象之后,會調(diào)用 setEtag 函數(shù)來設(shè)置 ETag。setEtag 函數(shù)的定義如下:
- // 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)
- }
很明顯在 koa-etag 中間件內(nèi)部是通過 etag 這個庫,來為響應(yīng)實體生成對應(yīng)的 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
- }
- }
- }
- }
對于 koa-static 中間件來說,當(dāng)請求方法不是 GET 或 HEAD 請求(不應(yīng)包含響應(yīng)體)時,則直接返回。而靜態(tài)資源的處理能力,實際是交由 send 這個庫來實現(xiàn)的。
最后為了讓小伙伴們能夠更好地理解以上中間件的處理邏輯,阿寶哥帶大家來簡單回顧一下洋蔥模型:
在上圖中,洋蔥內(nèi)的每一層都表示一個獨立的中間件,用于實現(xiàn)不同的功能,比如異常處理、緩存處理等。每次請求都會從左側(cè)開始一層層地經(jīng)過每層的中間件,當(dāng)進(jìn)入到最里層的中間件之后,就會從最里層的中間件開始逐層返回。因此對于每層的中間件來說,在一個 請求和響應(yīng) 周期中,都有兩個時機(jī)點來添加不同的處理邏輯。
五、總結(jié)
本文阿寶哥基于 Koa 的緩存示例,介紹了 HTTP 304 狀態(tài)碼和 fresh 模塊中的 fresh 函數(shù)是如何實現(xiàn)資源新鮮度檢測的。希望閱讀完本文后,你對 HTTP 和瀏覽器的緩存機(jī)制有更深入的理解。此外,本文只是簡單介紹了 Koa 的洋蔥模型,如果你對洋蔥模型感興趣,可以繼續(xù)閱 如何更好地理解中間件和洋蔥模型 這篇文章。
六、參考資源
- MDN - HTTP 條件請求
- HTTP 中的 ETag 是如何生成的?