為什麼 HTTP 請求會返回 304?

2021-12-10 全棧修仙之路

相信大多數 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");
});

在啟動完伺服器之後,我們打開 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

對於以上的響應報文,在響應頭中包含了 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, // 設置緩存存儲的最大周期,單位為秒
  })
);

首先註冊的是 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
    }
  }
}

由以上代碼可知,當請求上下文對象的 fresh 屬性為 true 時,就會設置響應的狀態碼為 304。因此,接下來我們的重點就是分析 ctx.fresh 值的設置條件。

通過閱讀 koa/lib/context.js 文件的源碼,我們可知當訪問上下文對象的 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; // 獲取狀態碼

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

在 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
  }
}

如果請求頭未包含 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
  }
}

當 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
}

在以上代碼中 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))
   })
})

此外,以上代碼中的 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
}

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");
});

以上示例使用了 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 中間件的實現很簡單,如果資源是新鮮的,則直接返回 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)
  }
}

在 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)
}

很明顯在 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
      }
    }
  }
}

對於 koa-static 中間件來說,當請求方法不是 GET 或 HEAD 請求(不應包含響應體)時,則直接返回。而靜態資源的處理能力,實際是交由 send 這個庫來實現的。

最後為了讓小夥伴們能夠更好地理解以上中間件的處理邏輯,阿寶哥帶大家來簡單回顧一下洋蔥模型:

在上圖中,洋蔥內的每一層都表示一個獨立的中間件,用於實現不同的功能,比如異常處理、緩存處理等。每次請求都會從左側開始一層層地經過每層的中間件,當進入到最裡層的中間件之後,就會從最裡層的中間件開始逐層返回。因此對於每層的中間件來說,在一個 請求和響應 周期中,都有兩個時機點來添加不同的處理邏輯。

五、總結

本文阿寶哥基於 Koa 的緩存示例,介紹了 HTTP  304 狀態碼和 fresh 模塊中的 fresh 函數是如何實現資源新鮮度檢測的。希望閱讀完本文後,你對 HTTP 和瀏覽器的緩存機制有更深入的理解。此外,本文只是簡單介紹了 Koa 的洋蔥模型,如果你對洋蔥模型感興趣,可以繼續閱 如何更好地理解中間件和洋蔥模型 這篇文章。

六、參考資源

相關焦點

  • HTTP 304狀態分析
    今天在查看web伺服器日誌的時候看到有很多304的http狀態,為什麼會返回304而不是200呢?
  • HTTP之200還是304?
    如果是用瀏覽器刷新的,那麼瀏覽器不會去判斷max-age了,直接去伺服器拿,如果伺服器判斷資源沒變過,則還是會返回304,和上面是一樣的,所以刷新一下,其實很可怕,等於把所有的資源都要去伺服器請求一邊,問問伺服器我過期了沒有。
  • (總結)HTTP常見錯誤返回代碼
    HTTP返回狀態代碼當用戶試圖通過HTTP或FTP協議訪問一臺運行主機上的內容時,Web伺服器返回一個表示該請求的狀態的數字代碼
  • 304 Not Modified詳解
    第一次訪問 200 滑鼠點擊二次訪問 (Cache) 按F5刷新 304 按Ctrl+F5強制刷新 200        在客戶端向服務端發送http請求時,若返回狀態碼為304 Not Modified 則表明此次請求為條件請求。
  • ETag和304
    HTTP請求示例在HTTP響應報文中,會返回一個三位數字。這個數字被稱之為HTTP狀態碼,英文全稱是HTTP Status Code,它用來表示HTTP響應報文的各種狀態。在上圖中紅色方框圈出來的位置,便有多個返回403狀態的HTTP響應報文,意思是該Url對象禁止被訪問。
  • http請求和響應的全過程
    為什麼伺服器一定要重定向而不是直接發送用戶想看的網頁內容呢?其中一個原因跟搜尋引擎排名有關。如果一個頁面有兩個地址,就像http://www.igoro.com/和http://igoro.com/,搜尋引擎會認為它們是兩個網站,結果造成每個搜索連結都減少從而降低排名。而搜尋引擎知道301永久重定向是什麼意思,這樣就會把訪問帶www的和不帶www的地址歸到同一個網站排名下。
  • 前端為什麼要了解HTTP?
    範圍請求響應頭部會返回Access-Range: bytes來表示當前伺服器支持範圍請求。瀏覽器在請求時,可以指定請求頭中的 range 來設定要請求一個大文件中的哪一部分內容。304 Not Modified如果客戶端發送了一個帶條件的 GET 請求且該請求已被允許,而文檔的內容(自上次訪問以來或者根據請求的條件)並沒有改變,則伺服器應當返回這個狀態碼。304 響應禁止包含消息體,因此始終以消息頭後的第一個空行結尾。
  • 通用爬蟲名稱,POST的類型,request請求返回狀態碼
    204 (No Content無內容)伺服器成功處理了請求,但沒有返回任何內容205 (Reset Content重置內容)伺服器成功處理了請求,但沒有返回任何內容206 (Partial Content部分內容)伺服器成功處理了部分get請求207: ('multi_status', 'multiple_status', 'multi_stati
  • 阿里面試官問「說一下從 url 輸入到返回請求的過程」的難度就是不一樣!
    先說為什麼url要解析(也就是編碼)我回答大概內容是:因為網絡標準規定了URL只能是字母和數字,還有一些其它特殊符號(-_.~ ! * ' ( ) ; : @ & = + $ , / ? # [ ],特殊符號是我下來查的資料,實在背不住這麼多,比較常見的就是不包括百分號和雙引號),而且如果不轉義會出現歧義,比如http:www.baidu.com?
  • 阿里面試官的「說一下從url輸入到返回請求的過程」問的難度就是不一樣!
    先說為什麼url要解析(也就是編碼)我回答大概內容是:因為網絡標準規定了URL只能是字母和數字,還有一些其它特殊符號(-_.~ ! * ' ( ) ; : @ & = + $ , / ? # [ ],特殊符號是我下來查的資料,實在背不住這麼多,比較常見的就是不包括百分號和雙引號),而且如果不轉義會出現歧義,比如http:www.baidu.com?
  • HTTP的網絡請求狀態代碼詳解
    使用此狀態碼不是必須的,而且只有在響應不使用此狀態碼便會返回200 OK的情況下才是合適的。204 No Content 無內容伺服器成功處理了請求,但不需要返回任何實體內容,並且希望返回更新了的元信息。響應可能通過實體頭部的形式,返回新的或更新後的元信息。
  • 常見的301、404、200、304等HTTP狀態說明
    300(多種選擇)針對請求,伺服器可執行多種操作。伺服器可根據請求者 (user agent) 選擇一項操作,或提供操作列表供請求者選擇。301(永久移動)請求的網頁已永久移動到新位置。伺服器返回此響應(對 GET 或 HEAD 請求的響應)時,會自動將請求者轉到新位置。您應使用此代碼告訴 Googlebot 某個網頁或網站已永久移動到新位置。
  • jmeter(五)HTTP請求
    這裡解釋一下為什麼要添加http信息頭管理器: JMeter不是瀏覽器,因此其行為並不和瀏覽器完全一致。建議使用一個有意義的名稱2)注釋:對於測試沒任何影響,僅用來記錄用戶可讀的注釋信息3)伺服器名稱或IP:http請求發送的目標伺服器名稱或者IP位址,比如http://www.baidu.com4)埠號:目標伺服器的埠號,默認值為80,可不填5)協議:向目標伺服器發送http請求時的協議,http/https,大小寫不敏感,默認http
  • 詳解Http緩存機制
    伺服器在Http返回的header中帶上Expires、Cache-Control、Last-Modified、Etag等欄位,瀏覽器把返回的資源或數據緩存到本地。下次需要用到相同資源的時候,通過這些欄位來判斷是直接從緩存中獲取數據,還是重新發起請求。強制緩存使用http response header中的Expires和Cache-Control標註資源的有效期。
  • HTTP狀態碼404是啥意思?
    3.瀏覽器會自動再發送一個新的 HTTP 請求-去訪問  http://49.233.108.117:3000/signin狀態碼301和302在語法上是一模一樣的,都是在 Location 中返回新的URL.兩者的34第3章 HTTP 協議請求方法和狀態碼區別在於:1.301表示舊地址的資源已經被永久地移除了(這個資源不可訪問了), 搜尋引擎會把權重算到新地址
  • 前端需要了解的HTTP協議
    HTTP持久連接指的並不是一個HTTP連接保持不間斷,而是多個HTTP連接可以使用一個持久的TCP連接請求數據。HTTP/1.1默認採用的是持久連接,即請求頭和返回頭都設置Connection: Keep-Alive. Chrome瀏覽器中,請求都遵循HTTP/1.1協議,每個host的TCP最大連接數是6.
  • 使用java實現HTTP的GET請求
    http的get請求。ip,8888是伺服器接收請求的埠,輸入URL點擊後,瀏覽器會接收到請求回應並展現如下:使用它的好處在於足夠簡單,並且它有文件上傳功能,於是後面我們還可以用來實現POST請求,接下來我們使用代碼模擬客戶端向它發送GET請求,首先實現的是http數據包組裝和解析功能:上面給定的類用於負責組裝http請求的方法行,同時將http請求的頭部欄位和對應信息放入到一個
  • JavaScript 中的 HTTP 跨域請求
    OPTIONS 請求會將當前的跨域請求所使用的特殊 HTTP 請求頭和 HTTP 請求方法發送給伺服器端,如 Access-Control-Request-Method 和 Access-Control-Request-Headers 。伺服器端接收到 OPTIONS 請求後返回相應的響應頭。瀏覽器根據返回的響應頭再來判斷該跨域請求是否被允許的。
  • 如何用 HTTP Caching 優化網站
    十分有必要深入了解下 http 的 caching 協議。先來看下請求/響應過程:1、用 Last-Modified 頭在第一次請求的響應頭返回 Last-Modified 內容,時間格式如:Wed, 22 Jul 2009 07:08:07 GMT。
  • 接口測試基礎知識HTTP和HTTPS的區別,8種HTTP請求方式:GET/POST/DELETE……
    伺服器返回此響應(作為對 GET 或 HEAD 請求的響應)時,會自動將請求者轉到新位置。您應使用此代碼通知 檢測工具 某個網頁或網站已被永久移動到新位置302(臨時移動) 伺服器目前正從不同位置的網頁響應請求,但請求者應繼續使用原有位置來進行以後的請求。此代碼與響應 GET 和 HEAD 請求的 301 代碼類似,會自動將請求者轉到不同的位置。