HTTP 中的 ETag 是如何生成的?
深入理解瀏覽器的緩存機(jī)制 這篇文章詳細(xì)介紹了瀏覽器緩存相關(guān)的內(nèi)容,本文阿寶哥將介紹如何利用 ETag 和 If-None-Match 來實(shí)現(xiàn)緩存控制。此外,阿寶哥還將介紹 HTTP 中的 ETag 是如何生成的。不過在此之前,我們得先來簡單介紹一下 ETag。
一、ETag 簡介
1.1 ETag 是什么
ETag(Entity Tag)是萬維網(wǎng)協(xié)議 HTTP 的一部分。它是 HTTP 協(xié)議提供的若干機(jī)制中的一種 Web 緩存驗(yàn)證機(jī)制,并且允許客戶端進(jìn)行緩存協(xié)商。這使得緩存變得更加高效,而且節(jié)省帶寬。如果資源的內(nèi)容沒有發(fā)生改變,Web 服務(wù)器就不需要發(fā)送一個(gè)完整的響應(yīng)。
1.2 ETag 的作用
ETag 是一個(gè)不透明的標(biāo)識符,由 Web 服務(wù)器根據(jù) URL 上的資源的特定版本而指定。如果 URL 上的資源內(nèi)容改變,一個(gè)新的不一樣的 ETag 就會(huì)被生成。ETag 可以看成是資源的指紋,它們能夠被快速地比較,以確定兩個(gè)版本的資源是否相同。
需要注意的是 ETag 的比較只對同一個(gè) URL 有意義 —— 不同 URL 上資源的 ETag 值可能相同也可能不同。
1.3 ETag 的語法
- ETag: W/"<etag_value>"
- ETag: "<etag_value>"
W/(可選):'W/'(大小寫敏感) 表示使用弱驗(yàn)證器。弱驗(yàn)證器很容易生成,但不利于比較。強(qiáng)驗(yàn)證器是比較的理想選擇,但很難有效地生成。相同資源的兩個(gè)弱 Etag 值可能語義等同,但不是每個(gè)字節(jié)都相同。
"<etag_value>
1.4 ETag 的使用
在大多數(shù)場景下,當(dāng)一個(gè) URL 被請求,Web 服務(wù)器會(huì)返回資源和其相應(yīng)的 ETag 值,它會(huì)被放置在 HTTP 響應(yīng)頭的 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"
然后,客戶端可以決定是否緩存這個(gè)資源和它的 ETag。以后,如果客戶端想再次請求相同的 URL,將會(huì)發(fā)送一個(gè)包含已保存的 ETag 和 If-None-Match 字段的請求。
- GET /index.js HTTP/1.1
- Host: localhost:3000
- Connection: keep-alive
- If-None-Match: W/"2c-1799c10ab70"
客戶端請求之后,服務(wù)器可能會(huì)比較客戶端的 ETag 和當(dāng)前版本資源的 ETag。如果 ETag 值匹配,這就意味著資源沒有改變,服務(wù)器便會(huì)發(fā)送回一個(gè)極短的響應(yīng),包含 HTTP “304 未修改” 的狀態(tài)。304 狀態(tài)碼告訴客戶端,它的緩存版本是最新的,可以直接使用它。
- HTTP/1.1 304 Not Modified
- Cache-Control: max-age=10
- ETag: W/"2c-1799c10ab70"
- Connection: keep-alive
二、ETag 實(shí)戰(zhàn)
2.1 創(chuàng)建 Koa 服務(wù)器
了解完 ETag 相關(guān)知識后,阿寶哥將基于 koa、koa-conditional-get、koa-etag 和 koa-static 這些庫來介紹一下,在實(shí)際項(xiàng)目中如何利用 ETag 響應(yīng)頭和 If-None-Match請求頭實(shí)現(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");
- });
在以上代碼中,我們使用了 koa-static 中間件來處理靜態(tài)資源,這些資源被保存在 public 目錄下。在該目錄下,阿寶哥創(chuàng)建了 index.html 和 index.js 兩個(gè)資源文件,文件中的內(nèi)容分別如下所示:
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("大家好,我是阿寶哥");
在啟動(dòng)完服務(wù)器之后,我們打開 Chrome 開發(fā)者工具并切換到 Network 標(biāo)簽欄,然后在瀏覽器地址欄輸入 http://localhost:3000/ 地址,接著多次訪問該地址(地址欄多次回車)。下圖是阿寶哥多次訪問的結(jié)果:
2.2 ETag 和 If-None-Match
下面阿寶哥將以 index.js 為例,來分析上圖中與之對應(yīng)的 HTTP 報(bào)文。對于 index.html 文件,感興趣的小伙伴可以自行分析一下。接下來我們先來分析首次請求 index.js 文件的報(bào)文:
2.2.1 首次請求 — 請求報(bào)文
- GET /index.js HTTP/1.1
- Host: localhost:3000
- Connection: keep-alive
- Pragma: no-cache
- Cache-Control: no-cache
- ...
2.2.2 首次請求 — 響應(yīng)報(bào)文
- HTTP/1.1 200 OK
- Content-Length: 44
- Cache-Control: max-age=10
- ETag: W/"2c-1799c10ab70"
- ...
在使用了 koa-static 和 koa-etag 中間件之后,index.js 文件首次請求的響應(yīng)報(bào)文中會(huì)包含 Cache-Control 和 ETag 的字段信息。
- Cache-Control 描述的是一個(gè)相對時(shí)間,在進(jìn)行緩存命中的時(shí)候,都是利用客戶端時(shí)間進(jìn)行判斷,所以相比較 Expires,Cache-Control 的緩存管理更有效,安全一些。
2.2.3 10s內(nèi) — 請求報(bào)文
- GET /index.js HTTP/1.1
- Host: localhost:3000
- Connection: keep-alive
- Pragma: no-cache
- Cache-Control: no-cache
- ...
2.2.4 10s內(nèi) — 響應(yīng)信息(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內(nèi) — 響應(yīng)信息(Response Headers)
- Cache-Control: max-age=10
- Connection: keep-alive
- Content-Length: 44
- ETag: W/"2c-1799c10ab70"
由于我們設(shè)置了 index.js 資源文件的最大緩存時(shí)間為 10s,所以在 10s 內(nèi)瀏覽器會(huì)直接從緩存中讀取文件的內(nèi)容。需要注意的是,此時(shí)的狀態(tài)碼為:Status Code: 200 OK (from memory cache)。
2.2.6 10s后 — 請求報(bào)文
- GET /index.js HTTP/1.1
- Host: localhost:3000
- Connection: keep-alive
- If-None-Match: W/"2c-1799c10ab70"
- Referer: http://localhost:3000/
- ...
因?yàn)?10s 之后,緩存已經(jīng)過期了,而且在 index.js 文件首次請求的響應(yīng)報(bào)文中也返回了 ETag 字段。所以此時(shí)瀏覽器會(huì)發(fā)起 If-None-Match 條件請求。這類請求可以用來驗(yàn)證緩存的有效性,省去不必要的控制手段。
2.2.7 10s后 — 響應(yīng)報(bào)文
- HTTP/1.1 304 Not Modified
- Cache-Control: max-age=10
- ETag: W/"2c-1799c10ab70"
- Connection: keep-alive
- ...
因?yàn)槲募膬?nèi)容未發(fā)生改變,所以 10s 后的響應(yīng)報(bào)文的狀態(tài)碼為 304 Not Modified。此外,響應(yīng)報(bào)文中也返回了 ETag 字段??吹竭@里,有一些小伙伴可能會(huì)有疑惑 —— ETag 到底是如何生成的?接下來,阿寶哥將帶大家一起來揭開 koa-etag 中間件背后的秘密。
三、如何生成 ETag
在前面的示例中,我們使用了 koa-etag 中間件來實(shí)現(xiàn)資源的緩存控制。其實(shí)該中間件的實(shí)現(xiàn)并不復(fù)雜,具體如下所示:
- // 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 中間件內(nèi)部會(huì)先通過 getResponseEntity 函數(shù)來獲取響應(yīng)實(shí)體對象,然后再調(diào)用 setETag 函數(shù)來生成 ETag。而 setETag 函數(shù)的實(shí)現(xiàn)很簡單,在 setETag 函數(shù)內(nèi)部,會(huì)通過 etag 這個(gè)第三方庫來生成 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 這個(gè)庫對外提供了一個(gè) etag 函數(shù)來創(chuàng)建 ETag,該函數(shù)的簽名如下:
- etag(entity, [options])
entity:用于生成 ETag 的實(shí)體,類型支持 Strings,Buffers 和 fs.Stats。除了 fs.Stats 對象之外,默認(rèn)將生成 strong ETag。
options:配置對象,支持通過 options.weak 屬性來配置生成 weak ETag。
了解完 etag 函數(shù)的參數(shù)之后,我們來看一下該函數(shù)的具體實(shí)現(xiàn):
- function etag (entity, options) {
- if (entity == null) {
- throw new TypeError('argument entity is required')
- }
- // 支持fs.Stats對象
- // isstats 函數(shù)的判斷規(guī)則:當(dāng)前對象是否包含ctime、mtime、ino和size這些屬性
- var isStats = isstats(entity)
- var weak = options && typeof options.weak === 'boolean'
- ? options.weak
- : isStats
- // 參數(shù)校驗(yàn)
- if (!isStats && typeof entity !== 'string' && !Buffer.isBuffer(entity)) {
- throw new TypeError('argument entity must be string, Buffer, or fs.Stats')
- }
- // 生成ETag標(biāo)簽
- var tag = isStats
- ? stattag(entity) // 處理fs.Stats對象
- : entitytag(entity)
- return weak
- ? 'W/' + tag
- : tag
- }
在 etag 函數(shù)內(nèi)部會(huì)根據(jù) entity 的類型,執(zhí)行不同的生成邏輯。如果 entity 是 fs.Stats 對象,則會(huì)調(diào)用 stattag 函數(shù)來創(chuàng)建 ETag。
- function stattag (stat) {
- // mtime:Modified Time,是在寫入文件時(shí)隨文件內(nèi)容的更改而更改,是指文件內(nèi)容最后一次被修改的時(shí)間。
- var mtime = stat.mtime.getTime().toString(16)
- var size = stat.size.toString(16)
- return '"' + size + '-' + mtime + '"'
- }
而如果 entity 參數(shù)非 fs.Stats 對象,則會(huì)調(diào)用 entitytag 函數(shù)來生成 ETag。其中 entitytag 函數(shù)的具體實(shí)現(xiàn)如下:
- function entitytag (entity) {
- if (entity.length === 0) {
- return '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"'
- }
- // 計(jì)算實(shí)體對象的哈希值
- var hash = crypto
- .createHash('sha1')
- .update(entity, 'utf8')
- .digest('base64')
- .substring(0, 27)
- // 計(jì)算實(shí)體對象的長度
- var len = typeof entity === 'string'
- ? Buffer.byteLength(entity, 'utf8')
- : entity.length
- return '"' + len.toString(16) + '-' + hash + '"'
- }
對于非 fs.Stats 對象來說,在 entitytag 函數(shù)內(nèi)部會(huì)使用 sha1 消息摘要算法來生成 hash 值并以 base64 格式輸出,而實(shí)際的生成的 hash 值會(huì)取前 27 個(gè)字符。此外,由以上代碼可知,最終的 ETag 將由實(shí)體的長度和哈希值兩部分組成。
需要注意的是,生成 ETag 的算法并不是固定的, 通常是使用內(nèi)容的散列、最后修改時(shí)間戳的哈希值或簡單地使用版本號。
四、ETag vs Last-Modified
其實(shí)除了 ETag 字段之外,大多數(shù)情況下,響應(yīng)頭中還會(huì)包含 Last-Modified 字段。它們之間的區(qū)別如下:
精確度上,Etag 要優(yōu)于 Last-Modified。Last-Modified 的時(shí)間單位是秒,如果某個(gè)文件在 1 秒內(nèi)被改變多次,那么它們的 Last-Modified 并沒有體現(xiàn)出來修改,但是 Etag 每次都會(huì)改變,從而確保了精度;此外,如果是負(fù)載均衡的服務(wù)器,各個(gè)服務(wù)器生成的 Last-Modified 也有可能不一致。
性能上,Etag 要遜于 Last-Modified,畢竟 Last-Modified 只需要記錄時(shí)間,而 ETag 需要服務(wù)器通過消息摘要算法來計(jì)算出一個(gè)hash 值。
優(yōu)先級上,在資源新鮮度校驗(yàn)時(shí),服務(wù)器會(huì)優(yōu)先考慮 Etag。即如果條件請求的請求頭同時(shí)攜帶 If-Modified-Since 和 If-None-Match 字段,則會(huì)優(yōu)先判斷資源的 ETag 值是否發(fā)生變化。
五、總結(jié)
本文阿寶哥首先介紹了 ETag 的相關(guān)基礎(chǔ)知識,然后以 Koa 為例詳細(xì)介紹了 ETag 和 If-None-Match 是如何實(shí)現(xiàn)緩存控制的。此外,阿寶哥還分析了 koa-etag 中間件內(nèi)部依賴的 etag 第三方庫是如何為指定的實(shí)體生成 ETag 對象。最后,阿寶哥列舉了 ETag 與 Last-Modified 之間的主要區(qū)別。
如果你還想進(jìn)一步了解瀏覽器的緩存機(jī)制,你可以閱讀 深入理解瀏覽器的緩存機(jī)制 這篇文章。在后續(xù)的文章中,阿寶哥將介紹如何實(shí)現(xiàn)資源的新鮮度檢測,感興趣的小伙伴不要錯(cuò)過喲。