http強制快取、協商快取、指紋ETag詳解

Echoyya、發表於2021-06-30

每個瀏覽器都有一個自己的快取區,使用快取區的資料有諸多好處,減少冗餘的資料傳輸,節省網路傳輸。減少伺服器負擔, 提高網站的效能。加快客戶端載入網頁的速度等,而這裡指的快取,指代的靜態檔案的快取,動態資料快取需要走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();
    

相關文章