http緩存中etag的生成原理


文章原文:https://www.cnblogs.com/yalong/p/15207547.html

說到http緩存中的etag應該都知道, 但是etag具體是怎么生成的,不太清楚,所以特意研究了下

源碼是看的 koa-etag 這個npm包

先上總結, koa2中etag生成原理:

  • 對於靜態文件,比如html, css,js, png等這些,etag 生成的方式就是文件的 sizemtime
  • 對於字符串 或者 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大致可以分為三種

  1. body instanceof Stream // stream 類型,平常的css、js、html、png等 這些靜態資源 都是stream類型的
  2. typeof body === 'string' || Buffer.isBuffer(body) // 字符串類型和Buffer類型, 比如文件下載,一般就是返回二進制的Buffer
  3. 其他類型

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 生成的方式就是文件的 sizemtime
  • entitytag() 對應 字符串 或者 Bufferetag 生成的方式就是 字符串 或者 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默認通過 FileEtagINode Mtime Size 的配置自動生成ETag(當然也可以通過用戶自定義的方式)

  • INode: 文件的索引節點(inode)數
  • MTime: 文件的最后修改日期及時間
  • Size: 文件的字節數

面試題

1.為什么大公司不太願意用etag?

因為大公司好多是使用web集群或者說負載均衡,
在web服務器只有一台的情況,請求內容的唯一性可以由Etag來確定,但是如果是多台web服務器在負載均衡設備下提供對外服務,盡管各web服務器上的組件內容完全一致,但是由於在不同的服務器上Inode是不同的,因此對應生成的Etag也是不一樣的 (關於 Inode 詳細信息可以看這個 https://www.ruanyifeng.com/blog/2011/12/inode.html)

在這種情況下,盡管請求的是同一個未發生變化的組件,但是由於Etag的不同,導致Apache服務器不再返回304 Not Modified,而是返回了200 OK和實際的組件內容(盡管事實上內容不曾發生變化),大大浪費了帶寬。

所以有人建議使用WEB集群時不要使用ETag

這個問題其實很好解決,因為多服務器時,INode不一樣,所以不同的服務器生成的ETag不一樣,所以用戶有可能重復下載(這時ETag就會不准),
明白了上面的原理和設置后,解決方法也很容易,讓ETag只用后面二個參數,MTimeSize就好了.只要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的洋蔥模型原理,看下圖:

image
因為最后一步才進行新鮮度檢測, 所以 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
https://www.cnblogs.com/MrZhujl/p/15070866.html
https://www.ruanyifeng.com/blog/2011/12/inode.html


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM