http強制緩存、協商緩存、指紋ETag詳解

每個瀏覽器都有一個自己的緩存區,使用緩存區的數據有諸多好處,減少冗餘的數據傳輸,節省網絡傳輸。減少服務器負擔, 提高網站的性能。加快客戶端加載網頁的速度等,而這裡指的緩存,指代的靜態文件的緩存,動態數據緩存需要走redis。今天我們使用node搭建服務,簡單演示一下幾種緩存的設置及配合使用。

緩存分為disk cachememory cache兩種,瀏覽器自行處理,代碼層面無法控制。而我們一般在用的時候都是在nginx層做處理,但核心是一樣的,都是設置header

簡單說一下,Chrome瀏覽器的緩存文件位置在哪,感興趣的同學可以自己找一找:

  1. chrome瀏覽器地址欄中輸入:chrome://version/

  2. 找到個人資料路徑(我的是):C:\Users\Lenovo\AppData\Local\Google\Chrome\User Data\Default

  3. 計算機中找到對應的目錄,可以在這個目錄下查看到CacheCode Cache目錄,這個就是緩存文件目錄進入對應的目錄,可以進行手動刪除

實操目錄及步驟

初始化package.jsonnpm init -y

下載第三方模塊:npm i mime

.
│
└─cache                
    ├─node_modules             
    ├─public            // 靜態文件目錄              
        ├─1.js          // 請求的文件資源  
        ├─index.html    
    ├─1.cache.js          // 強制緩存 完整代碼案例
    ├─2.cache.js          // 協商緩存 完整代碼案例
    ├─3.cache.js          // 指紋對比 完整代碼案例

緩存分類

  1. 強制緩存:直接緩存至瀏覽器中,不會再次向服務器發送請求;
  2. 對比緩存:也叫協商緩存,客服各執一份文件修改時間,相互對比,若相同用客戶端緩存
  3. 指紋Etag:為解決對比緩存存在的一些問題,客服各執一份文件簽名,相互對比,若相同用客戶端緩存

強制緩存

  1. 服務器與瀏覽器約定一個緩存的最大存活時間,如10s,那麼10s內,瀏覽器請求相同的資源便不會在請求服務器,會默認走瀏覽器的緩存區,並且響應碼依然為200

  2. 如果返回的是一個html,其中又引用了其他資源,還會繼續向服務器發送請求。

  3. 不對首次訪問的路徑做處理,也就是第一次訪問時,不走強制緩存的,必然會請求到服務器端,因為如果連首頁都走緩存了,那麼在斷網或服務器宕機的情況下也可以訪問該網站,顯然是不合理的

  4. 可以根據不同的文件後綴,設置不同的強制緩存的時間

  5. 在緩存數據生效期間,可以直接使用緩存數據,在沒有緩存數據時,瀏覽器向服務器請求數據,服務器會將數據和緩存規則一併返回,緩存規則信息包含在響應頭中。

  6. 強制緩存常用的兩種響應頭設置

    // 10s 表示當前時間 + 10s,屬於相對時間 (用於新版瀏覽器)
    res.setHeader('Cache-Control', 'max-age=10'); 
    
    // 設置 絕對時間 (用於舊版瀏覽器或IE老版本 或 http1.0)
    // 設置header的值只能是數字,字符串或數組,不能為對象,new Date()返回的是對象,所以需要轉一下。
    res.setHeader('Expires', new Date(Date.now() + 10 * 1000).toUTCString()); 
    
  7. 完整代碼:

    const http = require('http')
    const url = require('url')
    const path = require('path');
    const fs = require('fs');
    const mime = require('mime');
    
    const server = http.createServer((req, res) => {
      let { pathname } = url.parse(req.url, true)
      let filepath = path.join(__dirname, 'public', pathname);   // 訪問路徑拼接 public
    
      // 10s 表示當前時間 + 10s,屬於相對時間 (用於新版瀏覽器)
      res.setHeader('Cache-Control', 'max-age=10'); 
      // 設置 絕對時間 (用於舊版瀏覽器或IE老版本 或 http1.0)
      // 設置header的值只能是數字,字符串或數組,不能為對象,new Date()返回的是對象,所以需要轉一下。
      res.setHeader('Expires', new Date(Date.now() + 10 * 1000).toUTCString()); 
      
      fs.stat(filepath, function (err, statObj) {
        if (err) {    // 獲取文件信息報錯,則則響應 404
          res.statusCode = 404;
          res.end('Not Found!')
        } else {
          // 如果是文件,設置對應類型的響應頭,並返響應文件內容
          if (statObj.isFile()) {
            res.setHeader('Content-Type', mime.getType(filepath) + ';charset=utf-8');
            fs.createReadStream(filepath).pipe(res);
          } else {
            // 如果是目錄,需要找目錄下的 index.html
            let htmlPath = path.join(filepath, 'index.html')   // 拼接路徑
            fs.access(htmlPath, function (err) {
              if (err) {    // 查看文件的可訪問性,如不能訪問則響應 404
                res.statusCode = 404;
                res.end('Not Found!')
              } else {
                res.setHeader('Content-Type', 'text/html;charset=utf-8');
                fs.createReadStream(htmlPath).pipe(res)
              }
            })
          }
        }
      })
    });
    
    // 服務監聽 3000 端口
    server.listen(3000, function () {
      console.log('server is running....');
    })
    

對比緩存

  1. 瀏覽器首次請求資源時,服務器會將緩存標識(文件修改時間)與資源一同返回給瀏覽器。

  2. 再次請求時,客戶端請求頭會攜帶緩存標識(If-Modified-Since),並在服務端對比兩個時間

  3. 若相等,直接返回304狀態碼,讀取瀏覽器的緩存中對應緩存文件;

  4. 若不相等,返回最新內容,並給文件設置新的修改時間。

  5. 對比緩存不管是否生效,都需要與服務端發生交互

  6. 強制緩存和對比緩存可以配合使用,如10s內強制緩存,超過10s走對比緩存,同時在設置10s的強制緩存

  7. 響應頭設置

    // no-cache: 需要使用對比緩存驗證數據,會向服務器發送請求,且數據會存到瀏覽器的緩存中 
    res.setHeader('Cache-Control', 'no-cache'); 
    
    // 設置響應頭,文件的最後修改時間
    res.setHeader('Last-Modified',ctime)
    
  8. Last-Modify & If-Modified-Since

  9. 完整代碼:

    const http = require('http')
    const url = require('url')
    const path = require('path');
    const fs = require('fs');
    const mime = require('mime');
    
    const server = http.createServer((req, res) => {
      let { pathname } = url.parse(req.url, true)
      let filepath = path.join(__dirname, 'public', pathname);
      // 強制緩存和對比緩存配合使用,10s內走強制緩存,超過10s會走對比緩存,同時在設置10s的強制緩存
      // res.setHeader('Cache-Control', 'max-age=10');  
      
      res.setHeader('Cache-Control', 'no-cache'); 
     
      fs.stat(filepath, function (err, statObj) {
        if (err) {
          res.statusCode = 404;
          res.end('Not Found!')
        } else {
          // 如果是文件
          if (statObj.isFile()) {
            const ctime = statObj.ctime.toGMTString();
            // 判斷請求頭存儲的時間與服務器端文件的最後修改時間是否相等
            if(req.headers['if-modified-since'] === ctime){
              res.statusCode = 304;  // 設置響應狀態碼,瀏覽器默認會自動解析,從緩存中讀取對應文件
              res.end();  // 表示此時服務器沒有響應結果
            }else{
              // 設置響應頭,文件的最後修改時間
              res.setHeader('Last-Modified',ctime)
              // 設置對應類型的響應頭,並返響應文件內容
              res.setHeader('Content-Type', mime.getType(filepath) + ';charset=utf-8');
              fs.createReadStream(filepath).pipe(res);
            }
          } else {
            // 如果是目錄,需要找目錄下的index.html
            let htmlPath = path.join(filepath, 'index.html')    // 拼接路徑
            fs.access(htmlPath, function (err) {
              if (err) {  // 查看文件的可訪問性,如不能訪問則響應 404
                res.statusCode = 404;
                res.end('Not Found!')
              } else {
                res.setHeader('Content-Type', 'text/html;charset=utf-8');
                fs.createReadStream(htmlPath).pipe(res)
              }
            })
          }
        }
      })
    });
    
    // 服務監聽 3000 端口
    server.listen(3000, function () {
      console.log('server is running....');
    })
    

指紋 Etag

在講指紋之前,還需要介紹一下摘要算法加密算法crypto 是node中提供好的用於加密的模塊,各種摘要算法和加密算法。

摘要及加密算法

MD5:常見的MD5算法,也叫hash算法或者摘要算法,具有以下特點:

  • 不能反解,不可逆,
  • 相同的內容,摘要出的結果相同
  • 不同的內容,摘要出長度是相同的
  • 不同的內容,摘要的結果完全不同 (也稱雪崩效應,有一點不一樣,結果就完全不一樣)
  • 網上在線解密MD5其實只是通常意義的撞庫
  • 撞庫不叫解密,為了安全,可以將一個md5值多次加密,一般三次以上就無法破解了md5(md5(md5(xxx))),

sah1/sha256:加鹽算法,是真正的加密算法,設定一個鹽值(秘鑰)內容一致,鹽值不同,結果不同

const crypto = require('crypto');
/** md5*/
//                                    摘要的內容     摘要的格式
let r1 = crypto.createHash('md5').update('abcd').digest('base64');
//                                   分開摘要, 如果內部使用了流,可以讀一點摘要一點
let r2 = crypto.createHash('md5').update('a').update('b').update('cd').digest('base64');
console.log(r1, r2);

/** sha256 */
const crypto = require('crypto');
let r3 = crypto.createHmac('sha256','n').update('ab')..update('cd').digest('base64');
let r4 = crypto.createHmac('sha256','h').update('a')..update('bcd').digest('base64');
console.log(r3, r4);

進入正題,對比緩存使用的最後修改時間方案也存在一定的問題:

  • 某些服務器不能精確得到文件的最後修改時間, 這樣就無法通過最後修改時間來判斷文件是否更新了。
  • 某些文件的修改非常頻繁,在秒以下的時間內進行多次修改,而Last-Modified只能精確到秒。
  • 一些文件的最後修改時間改變了,但是內容並未改變(典型吃了吐)。 因此不希望被認為是修改。
  • 如果同樣的一個文件位於多個CDN服務器,內容雖然一樣,修改時間不一樣。

Etag的出現,可以在一定程度上解決這個問題,但不能說完全解決,他也存在他的問題,接下來分析一下他的實現原理:

  1. ETag(實體標籤),根據摘要算法將實體內容生成的一段hash字符串,文件改變,ETag也隨之改變

  2. 但是對於大文件,不會直接全量比對,可以用文件的大小,開頭、或某一段生成一個指紋

  3. 瀏覽器首次請求資源時,服務器會將ETag與資源一同返回給瀏覽器。

  4. 再次請求時,客戶端請求頭會攜帶簽名標識(If-None-Match),並在服務端對比兩個簽名

  5. 若相等,直接返回304狀態碼,讀取瀏覽器的緩存中對應緩存文件;

  6. 若不相等,返回最新內容,並給文件設置新的修改時間。

  7. ETag不管是否生效,都需要與服務端發生交互

  8. 響應頭設置

    // no-cache: 需要使用對比緩存驗證數據,會向服務器發送請求,且數據會存到瀏覽器的緩存中 
    res.setHeader('Cache-Control', 'no-cache'); 
    
    // 設置響應頭,文件的最後修改時間
    res.setHeader('Last-Modified',ctime)
    
  9. ETag & If-None-Match

  10. 完整代碼:

    const http = require('http')
    const url = require('url')
    const path = require('path');
    const fs = require('fs');
    const mime = require('mime');
    const crypto = require('crypto');
    
    const server = http.createServer((req, res) => {
      let { pathname } = url.parse(req.url, true)
      let filepath = path.join(__dirname, 'public', pathname);
      
      fs.stat(filepath, function (err, statObj) {
        if (err) {
          res.statusCode = 404;
          res.end('Not Found!')
        } else {
        // 如果是文件
          if (statObj.isFile()) {
            let content = fs.readFileSync(filepath);
            let etag = crypto.createHash('md5').update(content).digest('base64');
            // 判斷請求頭存儲的簽名與服務端文件的生成的簽名是否相等
            if(req.headers['if-none-match'] === etag){
              res.statusCode = 304; // 設置響應狀態碼,瀏覽器默認會自動解析,從緩存中讀取對應文件
              res.end()  // 表示此時服務器沒有響應結果
            }else{
              // 設置響應頭,簽名
              res.setHeader('Etag',etag)
              // 設置對應類型的響應頭,並返響應文件內容
              res.setHeader('Content-Type', mime.getType(filepath) + ';charset=utf-8');
              fs.createReadStream(filepath).pipe(res);
            }
          } else {
            // 如果是目錄,需要找目錄下的index.html
            let htmlPath = path.join(filepath, 'index.html')   // 拼接路徑
            fs.access(htmlPath, function (err) {  
              if (err) {   // 查看文件的可訪問性,如不能訪問則響應 404
                res.statusCode = 404;
                res.end('Not Found!')
              } else {
                res.setHeader('Content-Type', 'text/html;charset=utf-8');
                fs.createReadStream(htmlPath).pipe(res)
              }
            })
          }
        }
      })
    });
    
    // 服務監聽 3000 端口
    server.listen(3000, function () {
      console.log('server is running....');
    })
    

緩存總結

  • 強制緩存如果生效,不會再和服務器發生交互而對比緩存不管是否生效,都需要與服務端發生交互

  • 緩存規則可以同時存在,強制緩存優先級高於對比緩存,也就是說,當強制緩存規則生效時,直接使用緩存,不再執行對比緩存規則

  • 可以設置不同的匹配規則,採用不同的緩存方式

  • 重要代碼:

    // 第一次發送文件,先設置強制緩存,在執行強制緩存時,默認不會執行對比緩存,因為不走服務器
    res.setHeader('Cache-Control','max-age=10');
    res.setHeader('Expires',new Date(Date.now() + 10 * 1000).toGMTString());
    
    // 每次強制緩存時間到了,就會走對比緩存,然後在變成強制緩存
    const lastModified = statObj.ctime.toGMTString();
    const etag = crypto.createHash('md5').update(readFileSync(requestFile)).digest('base64');
    res.setHeader('Last-Modified',lastModified);
    res.setHeader('Etag',etag);
    
    let ifModifiedSince = req.headers['if-modified-since'];
    let ifNoneMatch = req.headers['if-none-match'];
    // 如果文件修改時間不一樣,就直接返回最新的
    if(lastModified !== ifModifiedSince){    // 有可能時間一樣,但是內容不一樣
        return createReadStream(requestFile).pipe(res);;
    }
    if(etag !== ifNoneMatch){ // 一般情況,指紋生成不會是根據文件全量生成,有可能只是根據文件大小等
        return createReadStream(requestFile).pipe(res);;
    }
    res.statusCode = 304;
    return res.end();