http快取中etag的生成原理
文章原文:https://www.cnblogs.com/yalong/p/15207547.html
說到http快取中的etag應該都知道, 但是etag具體是怎麼生成的,不太清楚,所以特意研究了下
原始碼是看的 koa-etag 這個npm包
先上總結, koa2中etag生成原理:
- 對於靜態檔案,比如html, css,js, png等這些,
etag
生成的方式就是檔案的size
加mtime
- 對於字串 或者
Buffer
型別的,etag
生成的方式就是 字串、Buffer
的長度 加上 對應的hash值
koa-etag使用方式
const conditional = require('koa-conditional-get'); const etag = require('koa-etag'); const Koa = require('koa'); const app = new Koa(); // etag works together with conditional-get app.use(conditional()); app.use(etag()); app.use(function (ctx) { ctx.body = 'Hello World'; }); app.listen(3000); console.log('listening on port 3000');
koa-etag的原始碼以及註釋如下:
'use strict' /** * Module dependencies. */ const calculate = require('etag') const Stream = require('stream') // 用於將老式的 Error first callback 轉換為 Promise 物件 const promisify = require('util').promisify const fs = require('fs') const stat = promisify(fs.stat) /** * Expose `etag`. * * Add ETag header field. * @param {object} [options] see https://github.com/jshttp/etag#options * @param {boolean} [options.weak] * @return {Function} * @api public */ module.exports = function etag (options) { // 返回的就是個中介軟體函式 return async function etag (ctx, next) { await next() // 獲取響應體 const entity = await getResponseEntity(ctx) setEtag(ctx, entity, options) } } async function getResponseEntity (ctx) { const body = ctx.body // !body -- 沒有 body 就不用設定 etag 了; // ctx.response.get('etag') -- 如果已經設定過etag了也不用再設定etag了 if (!body || ctx.response.get('etag')) return // 看下status是數字幾開頭的, 比如2xx, 4xx, 3xx const status = ctx.status / 100 | 0 // 如果不是2xx的 就相當於請求失敗,也不用設定了 if (status !== 2) return if (body instanceof Stream) { // 看body是不是流物件(Stream), 是的話, 根據對應的 path, 呼叫fs.stat 返回對應的stats if (!body.path) return return await stat(body.path) } else if ((typeof body === 'string') || Buffer.isBuffer(body)) { // 看body是不是 string 或 Buffer return body } else { // 一般是物件 return JSON.stringify(body) } } function setEtag (ctx, entity, options) { if (!entity) return // entity 沒有的話 就不用設定etag了, 對應 getResponseEntity 方法裡面的直接 return // 呼叫 etag 模組,計算並生成 etag ctx.response.etag = calculate(entity, options) }
核心就是 先呼叫 getResponseEntity
獲取響應實體
然後呼叫 etag
計算生成 etag
注意, ctx.body
有如下型別
string written
Buffer written
Stream piped
Object || Array json-stringified
null no content response
然後body大致可以分為三種
- body instanceof Stream // stream 型別,平常的css、js、html、png等 這些靜態資源 都是stream型別的
- typeof body === 'string' || Buffer.isBuffer(body) // 字串型別和Buffer型別, 比如檔案下載,一般就是返回二進位制的Buffer
- 其他型別
etag的原始碼以及註釋如下:
/*!
* etag
* Copyright(c) 2014-2016 Douglas Christopher Wilson
* MIT Licensed
*/
'use strict'
/**
* Module exports.
* @public
*/
module.exports = etag
/**
* Module dependencies.
* @private
*/
var crypto = require('crypto')
var Stats = require('fs').Stats
/**
* Module variables.
* @private
*/
var toString = Object.prototype.toString
/**
* Generate an entity tag.
*
* @param {Buffer|string} entity
* @return {string}
* @private
*/
// Buffer、String 型別生成 etag 依賴於 crypto 生成 hash
// hash 的生成主要依賴於sha1的加密方式
function entitytag (entity) {
if (entity.length === 0) {
// fast-path empty
return '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"'
}
// compute hash of entity
var hash = crypto
.createHash('sha1')
.update(entity, 'utf8')
.digest('base64')
.substring(0, 27)
// compute length of entity
var len = typeof entity === 'string'
? Buffer.byteLength(entity, 'utf8')
: entity.length
return '"' + len.toString(16) + '-' + hash + '"'
}
/**
* Create a simple ETag.
*
* @param {string|Buffer|Stats} entity
* @param {object} [options]
* @param {boolean} [options.weak]
* @return {String}
* @public
*/
function etag (entity, options) {
if (entity == null) {
throw new TypeError('argument entity is required')
}
// support fs.Stats object
var isStats = isstats(entity)
var weak = options && typeof options.weak === 'boolean'
? options.weak
: isStats
// validate argument
if (!isStats && typeof entity !== 'string' && !Buffer.isBuffer(entity)) {
throw new TypeError('argument entity must be string, Buffer, or fs.Stats')
}
// generate entity tag
var tag = isStats
? stattag(entity)
: entitytag(entity)
// 弱etag 比 強etag 多了個 W
return weak
? 'W/' + tag
: tag
}
/**
* Determine if object is a Stats object.
*
* @param {object} obj
* @return {boolean}
* @api private
*/
// 判斷obj 是不是 Stats 的例項
// 或者 如果 obj 裡面有 ctime mtime ino size 這幾個欄位 並且資料型別也對的上 也行
function isstats (obj) {
// genuine fs.Stats
if (typeof Stats === 'function' && obj instanceof Stats) {
return true
}
// quack quack
return obj && typeof obj === 'object' &&
'ctime' in obj && toString.call(obj.ctime) === '[object Date]' &&
'mtime' in obj && toString.call(obj.mtime) === '[object Date]' &&
'ino' in obj && typeof obj.ino === 'number' &&
'size' in obj && typeof obj.size === 'number'
}
/**
* Generate a tag for a stat.
*
* @param {object} stat
* @return {string}
* @private
*/
// 生成 stats 型別的 etag
function stattag (stat) {
var mtime = stat.mtime.getTime().toString(16)
var size = stat.size.toString(16)
return '"' + size + '-' + mtime + '"'
}
核心就這倆函式
// generate entity tag
var tag = isStats
? stattag(entity)
: entitytag(entity)
生成etag的原理:
stattag()
對應靜態檔案,etag
生成的方式就是檔案的size
加mtime
entitytag()
對應 字串 或者Buffer
,etag
生成的方式就是 字串 或者Buffer
的長度 加上 通過sha1
演算法生成的hash
串的前27位
關於強、弱Etag
這倆的生成方式如下
// 弱etag 比 強etag 多了個 W
return weak
? 'W/' + tag
: tag
在上述Etag方法裡 其實弱Etag 就是比強Etag多了個 字母 W
, 生成的原理都是一樣的
使用的時候強校驗的ETag匹配要求兩個資源內容的每個位元組需完全相同,包括所有其他實體欄位(如Content-Language)不發生變化。強ETag允許重新裝配和快取部分響應,以及位元組範圍請求。 弱校驗的ETag匹配要求兩個資源在語義上相等,這意味著在實際情況下它們可以互換,而且快取副本也可以使用。不過這些資源不需要每個位元組相同,因此弱ETag不適合位元組範圍請求。
當Web伺服器無法生成強ETag的時候,比如動態生成的內容,弱ETag就可能發揮作用了。
檔案系統中 mtime 和 ctime 指什麼,都有什麼不同
在 linux 中,
- mtime:
modified time
指檔案內容改變的時間戳 - ctime:
change time
指檔案屬性改變的時間戳,屬性包括mtime
。而在windows
上,它表示的是creation time
http
服務中靜態檔案的 Last-Modified
就是根據 mtime
什麼生成
Apache伺服器生成etag的方式
Apache
預設通過 FileEtag
中 INode
Mtime
Size
的配置自動生成ETag
(當然也可以通過使用者自定義的方式)
- INode: 檔案的索引節點(inode)數
- MTime: 檔案的最後修改日期及時間
- Size: 檔案的位元組數
面試題
1.為什麼大公司不太願意用etag?
因為大公司好多是使用web叢集或者說負載均衡,
在web伺服器只有一臺的情況,請求內容的唯一性可以由Etag
來確定,但是如果是多臺web伺服器在負載均衡裝置下提供對外服務,儘管各web伺服器上的元件內容完全一致,但是由於在不同的伺服器上Inode
是不同的,因此對應生成的Etag
也是不一樣的。
在這種情況下,儘管請求的是同一個未發生變化的元件,但是由於Etag
的不同,導致Apache
伺服器不再返回304 Not Modified
,而是返回了200 OK和實際的元件內容(儘管事實上內容不曾發生變化),大大浪費了頻寬。
所以有人建議使用WEB叢集時不要使用ETag
這個問題其實很好解決,因為多伺服器時,INode
不一樣,所以不同的伺服器生成的ETag
不一樣,所以使用者有可能重複下載(這時ETag
就會不準),
明白了上面的原理和設定後,解決方法也很容易,讓ETag
只用後面二個引數,MTime
和Size
就好了.只要ETag
的計算沒有INode
參於計算,就會很準了.
或者自定義Etag
的生成規則,只要避開那些因機器不同而導致差異的欄位就可以了
Koa2裡面的etag由於不涉及到Inode 以及其他受機器影響的欄位,所以在叢集模式下是可用的
2.koa2中協商快取是如何生效的?
在上面使用koa-etag
的時候,用到了 koa-conditional-get
, 而 koa-conditional-get
的原始碼如下:
module.exports = function conditional () {
return async function (ctx, next) {
await next()
if (ctx.fresh) {
ctx.status = 304
ctx.body = null
}
}
}
其實就用哪個呼叫了ctx.fresh 進行新鮮度檢測
可以看到Koa在request中的fresh方法如下:
狀態碼200-300之間以及304呼叫fresh方法,判斷該請求的資源是否新鮮。
fresh方法原始碼解讀:
只保留核心程式碼,可以自行去看fresh的原始碼。
var CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/
function fresh (reqHeaders, resHeaders) {
// 1. 如果這2個欄位,一個都沒有,不需要校驗
var modifiedSince = reqHeaders['if-modified-since']
var noneMatch = reqHeaders['if-none-match']
if (!modifiedSince && !noneMatch) {
console.log('not fresh')
return false
}
// 2. 給端對端測試用的,因為瀏覽器的Cache-Control: no-cache請求
// 是不會帶if條件的 不會走到這個邏輯
var cacheControl = reqHeaders['cache-control']
if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl)) {
return false
}
// 3. 比較 etag和if-none-match
if (noneMatch && noneMatch !== '*') {
var etag = resHeaders['etag']
if (!etag) {
return false
}
// 部分程式碼
if (match === etag) {
return true;
}
}
// 4. 比較if-modified-since和last-modified
if (modifiedSince) {
var lastModified = resHeaders['last-modified']
var modifiedStale = !lastModified || !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince))
if (modifiedStale) {
return false
}
}
return true
}
fresh的程式碼判斷邏輯總結如下,滿足3種條件之一,fresh為true。
3.關於koe-etag的使用方法
看下面的程式碼,為啥要先 app.use(conditional());
再 app.use(etag());
?
const conditional = require('koa-conditional-get');
const etag = require('koa-etag');
const Koa = require('koa');
const app = new Koa();
// etag works together with conditional-get
app.use(conditional());
app.use(etag());
答:
其實這個koa2的洋蔥模型原理,看下圖:
因為最後一步才進行新鮮度檢測, 所以 app.use(conditional());
要放在最前面
更多關於洋蔥模型可以參考: https://www.jianshu.com/p/4cf2d9792165
瀏覽器快取整體流程
1.發出請求後,會先在本地查詢快取。
2.沒有快取去服務端請求最新的資源,返回給客戶端(200),並重新進行快取。
3.查到有快取,要判斷快取本地是否過期(max-age等)。
4.沒有過期,直接返回給客戶端(200 from cache)。
5.如果快取過期了,看是否有配置協商快取(etag/last-modified),去服務端再驗證該資源是否更新,本地快取是否可以繼續使用。
6.如果發現資源可用,返回304,告知客戶端可以繼續使用快取,並根據max-age等更新快取時間,不需要返回資料,從而減少整體請求時間。
7.如果服務端再驗證失敗,請求最新的資源,返回給客戶端(200),並重新進行快取。
參考連結:
https://juejin.cn/post/6844904133024022536#heading-19
https://www.sohu.com/a/328853216_463987
https://www.jianshu.com/p/4cf2d9792165