HTTP 中的 ETag 是如何生成的?
本文將介紹如何利用 ETag 和 If-None-Match 來實現緩存控制。此外,還將介紹 HTTP 中的 ETag 是如何生成的。不過在此之前,我們得先來簡單介紹一下 ETag。
一、ETag 簡介
1.1 ETag 是什么
ETag(Entity Tag)是萬維網協議 HTTP 的一部分。它是 HTTP 協議提供的若干機制中的一種 Web 緩存驗證機制,并且允許客戶端進行緩存協商。這使得緩存變得更加高效,而且節省帶寬。如果資源的內容沒有發生改變,Web 服務器就不需要發送一個完整的響應。
1.2 ETag 的作用
ETag 是一個不透明的標識符,由 Web 服務器根據 URL 上的資源的特定版本而指定。如果 URL 上的資源內容改變,一個新的不一樣的 ETag 就會被生成。ETag 可以看成是資源的指紋,它們能夠被快速地比較,以確定兩個版本的資源是否相同。
需要注意的是 ETag 的比較只對同一個 URL 有意義 —— 不同 URL 上資源的 ETag 值可能相同也可能不同。
1.3 ETag 的語法
- ETag: W/"<etag_value>"
- ETag: "<etag_value>"
- W/(可選):'W/'(大小寫敏感) 表示使用弱驗證器。弱驗證器很容易生成,但不利于比較。強驗證器是比較的理想選擇,但很難有效地生成。相同資源的兩個弱 Etag 值可能語義等同,但不是每個字節都相同。
- "<etag_value>":實體標簽唯一地表示所請求的資源。它們是位于雙引號之間的 ASCII 字符串(如 “2c-1799c10ab70” )。沒有明確指定生成 ETag 值的方法。通常是使用內容的散列、最后修改時間戳的哈希值或簡單地使用版本號。比如,MDN 使用 wiki 內容的十六進制數字的哈希值。
1.4 ETag 的使用
在大多數場景下,當一個 URL 被請求,Web 服務器會返回資源和其相應的 ETag 值,它會被放置在 HTTP 響應頭的 ETag 字段中:
- HTTP/1.1 200 OK
- Content-Length: 44
- Cache-Control: max-age=10
- Content-Type: application/javascript; charset=utf-8
- ETag: W/"2c-1799c10ab70"
然后,客戶端可以決定是否緩存這個資源和它的 ETag。以后,如果客戶端想再次請求相同的 URL,將會發送一個包含已保存的 ETag 和 If-None-Match 字段的請求。
- GET /index.js HTTP/1.1
- Host: localhost:3000
- Connection: keep-alive
- If-None-Match: W/"2c-1799c10ab70"
客戶端請求之后,服務器可能會比較客戶端的 ETag 和當前版本資源的 ETag。如果 ETag 值匹配,這就意味著資源沒有改變,服務器便會發送回一個極短的響應,包含 HTTP “304 未修改” 的狀態。304 狀態碼告訴客戶端,它的緩存版本是最新的,可以直接使用它。
- HTTP/1.1 304 Not Modified
- Cache-Control: max-age=10
- ETag: W/"2c-1799c10ab70"
- Connection: keep-alive
二、ETag 實戰
2.1 創建 Koa 服務器
了解完 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");
- });
在以上代碼中,我們使用了 koa-static 中間件來處理靜態資源,這些資源被保存在 public 目錄下。在該目錄下,創建了 index.html 和 index.js 兩個資源文件,文件中的內容分別如下所示:
2.1.1 public/index.html
- <!DOCTYPE html>
- <html lang="zh-cn">
- <head>
- <meta charset="UTF-8">
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>ETag 使用示例</title>
- <script src="/index.js"></script>
- </head>
- <body>
- <h3>ETag 使用示例</h3>
- </body>
- </html>
2.1.2 public/index.js
console.log("大家好,");
在啟動完服務器之后,我們打開 Chrome 開發者工具并切換到 Network 標簽欄,然后在瀏覽器地址欄輸入 http://localhost:3000/ 地址,接著多次訪問該地址(地址欄多次回車)。下圖是多次訪問的結果:
2.2 ETag 和 If-None-Match
下面將以 index.js 為例,來分析上圖中與之對應的 HTTP 報文。對于 index.html 文件,感興趣的小伙伴可以自行分析一下。接下來我們先來分析首次請求 index.js 文件的報文:
2.2.1 首次請求 — 請求報文
- GET /index.js HTTP/1.1
- Host: localhost:3000
- Connection: keep-alive
- Pragma: no-cache
- Cache-Control: no-cache
- ...
2.2.2 首次請求 — 響應報文
- HTTP/1.1 200 OK
- Content-Length: 44
- Cache-Control: max-age=10
- ETag: W/"2c-1799c10ab70"
- ...
在使用了 koa-static 和 koa-etag 中間件之后,index.js 文件首次請求的響應報文中會包含 Cache-Control 和 ETag 的字段信息。
Cache-Control 描述的是一個相對時間,在進行緩存命中的時候,都是利用客戶端時間進行判斷,所以相比較 Expires,Cache-Control 的緩存管理更有效,安全一些。
2.2.3 10s內 — 請求報文
- GET /index.js HTTP/1.1
- Host: localhost:3000
- Connection: keep-alive
- Pragma: no-cache
- Cache-Control: no-cache
- ...
2.2.4 10s內 — 響應信息(General)
- Request URL: http://localhost:3000/index.js
- Request Method: GET
- Status Code: 200 OK (from memory cache)
- Remote Address: [::1]:3000
- Referrer Policy: strict-origin-when-cross-origin
2.2.5 10s內 — 響應信息(Response Headers)
- Cache-Control: max-age=10
- Connection: keep-alive
- Content-Length: 44
- ETag: W/"2c-1799c10ab70"
由于我們設置了 index.js 資源文件的最大緩存時間為 10s,所以在 10s 內瀏覽器會直接從緩存中讀取文件的內容。需要注意的是,此時的狀態碼為:Status Code: 200 OK (from memory cache)。
2.2.6 10s后 — 請求報文
- GET /index.js HTTP/1.1
- Host: localhost:3000
- Connection: keep-alive
- If-None-Match: W/"2c-1799c10ab70"
- Referer: http://localhost:3000/
- ...
因為 10s 之后,緩存已經過期了,而且在 index.js 文件首次請求的響應報文中也返回了 ETag 字段。所以此時瀏覽器會發起 If-None-Match 條件請求。這類請求可以用來驗證緩存的有效性,省去不必要的控制手段。
2.2.7 10s后 — 響應報文
- HTTP/1.1 304 Not Modified
- Cache-Control: max-age=10
- ETag: W/"2c-1799c10ab70"
- Connection: keep-alive
- ...
因為文件的內容未發生改變,所以 10s 后的響應報文的狀態碼為 304 Not Modified。此外,響應報文中也返回了 ETag 字段。看到這里,有一些小伙伴可能會有疑惑 —— ETag 到底是如何生成的?接下來,將帶大家一起來揭開 koa-etag 中間件背后的秘密。
三、如何生成 ETag
在前面的示例中,我們使用了 koa-etag 中間件來實現資源的緩存控制。其實該中間件的實現并不復雜,具體如下所示:
- // https://github.com/koajs/etag/blob/master/index.js
- const calculate = require('etag');
- // 省略部分代碼
- module.exports = function etag (options) {
- return async function etag (ctx, next) {
- await next()
- const entity = await getResponseEntity(ctx)
- setEtag(ctx, entity, options)
- }
- }
由以上代碼可知,在 koa-etag 中間件內部會先通過 getResponseEntity 函數來獲取響應實體對象,然后再調用 setETag 函數來生成 ETag。而 setETag 函數的實現很簡單,在 setETag 函數內部,會通過 etag 這個第三方庫來生成 ETag。
- // https://github.com/koajs/etag/blob/master/index.js
- function setEtag (ctx, entity, options) {
- if (!entity) return
- ctx.response.etag = calculate(entity, options)
- }
etag 這個庫對外提供了一個 etag 函數來創建 ETag,該函數的簽名如下:
- etag(entity, [options])
- entity:用于生成 ETag 的實體,類型支持 Strings,Buffers 和 fs.Stats。除了 fs.Stats 對象之外,默認將生成 strong ETag。
- options:配置對象,支持通過 options.weak 屬性來配置生成 weak ETag。
了解完 etag 函數的參數之后,我們來看一下該函數的具體實現:
- function etag (entity, options) {
- if (entity == null) {
- throw new TypeError('argument entity is required')
- }
- // 支持fs.Stats對象
- // isstats 函數的判斷規則:當前對象是否包含ctime、mtime、ino和size這些屬性
- var isStats = isstats(entity)
- var weak = options && typeof options.weak === 'boolean'
- ? options.weak
- : isStats
- // 參數校驗
- if (!isStats && typeof entity !== 'string' && !Buffer.isBuffer(entity)) {
- throw new TypeError('argument entity must be string, Buffer, or fs.Stats')
- }
- // 生成ETag標簽
- var tag = isStats
- ? stattag(entity) // 處理fs.Stats對象
- : entitytag(entity)
- return weak
- ? 'W/' + tag
- : tag
- }
在 etag 函數內部會根據 entity 的類型,執行不同的生成邏輯。如果 entity 是 fs.Stats 對象,則會調用 stattag 函數來創建 ETag。
- function stattag (stat) {
- // mtime:Modified Time,是在寫入文件時隨文件內容的更改而更改,是指文件內容最后一次被修改的時間。
- var mtime = stat.mtime.getTime().toString(16)
- var size = stat.size.toString(16)
- return '"' + size + '-' + mtime + '"'
- }
而如果 entity 參數非 fs.Stats 對象,則會調用 entitytag 函數來生成 ETag。其中 entitytag 函數的具體實現如下:
- function entitytag (entity) {
- if (entity.length === 0) {
- return '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"'
- }
- // 計算實體對象的哈希值
- var hash = crypto
- .createHash('sha1')
- .update(entity, 'utf8')
- .digest('base64')
- .substring(0, 27)
- // 計算實體對象的長度
- var len = typeof entity === 'string'
- ? Buffer.byteLength(entity, 'utf8')
- : entity.length
- return '"' + len.toString(16) + '-' + hash + '"'
- }
對于非 fs.Stats 對象來說,在 entitytag 函數內部會使用 sha1 消息摘要算法來生成 hash 值并以 base64 格式輸出,而實際的生成的 hash 值會取前 27 個字符。此外,由以上代碼可知,最終的 ETag 將由實體的長度和哈希值兩部分組成。
需要注意的是,生成 ETag 的算法并不是固定的, 通常是使用內容的散列、最后修改時間戳的哈希值或簡單地使用版本號。
四、ETag vs Last-Modified
其實除了 ETag 字段之外,大多數情況下,響應頭中還會包含 Last-Modified 字段。它們之間的區別如下:
- 精確度上,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 值是否發生變化。
五、總結
本文首先介紹了 ETag 的相關基礎知識,然后以 Koa 為例詳細介紹了 ETag 和 If-None-Match 是如何實現緩存控制的。此外,還分析了 koa-etag 中間件內部依賴的 etag 第三方庫是如何為指定的實體生成 ETag 對象。最后,列舉了 ETag 與 Last-Modified 之間的主要區別。