HTTP快取機制

sevencui發表於2019-02-16

前言

Http簡介

瀏覽器和伺服器之間通訊是通過HTTP協議,HTTP協議永遠都是客戶端發起請求,伺服器回送響應。模型如下:

clipboard.png

HTTP報文就是瀏覽器和伺服器間通訊時傳送及響應的資料塊。瀏覽器向伺服器請求資料,傳送請求(request)報文;伺服器向瀏覽器返回資料,返回響應(response)報文。報文資訊主要分為兩部分:

報文頭部:一些附加資訊(cookie,快取資訊等),與快取相關的規則資訊,均包含在頭部中
資料主體部分:HTTP請求真正想要傳輸的資料內容

快取的作用

我們為什麼使用快取,是因為快取可以給我們的 Web 專案帶來以下好處,以提高效能和使用者體驗。

加快了瀏覽器載入網頁的速度;
減少了冗餘的資料傳輸,節省網路流量和頻寬;
減少伺服器的負擔,大大提高了網站的效能。

由於從本地快取讀取靜態資源,加快瀏覽器的網頁載入速度是一定的,也確實的減少了資料傳輸,就提高網站效能來說,可能一兩個使用者的訪問對於減小伺服器的負擔沒有明顯效果,但如果這個網站在高併發的情況下,使用快取對於減小伺服器壓力和整個網站的效能都會發生質的變化。

原始模型(不是用快取)

搭建一個Express的伺服器,不加任何快取資訊頭:

const express = require(`express`);
const app = express();
const port = 8080;
const fs = require(`fs`);
const path = require(`path`);

app.get(`/`,(req,res) => {
    res.send(`<!DOCTYPE html>
    <html lang="en">
    <head>
        <title>Document</title>
    </head>
    <body>
        Http Cache Demo
        <script src="/demo.js"></script>
    </body>
    </html>`)
})

app.get(`/demo.js`,(req, res)=>{
    let jsPath = path.resolve(__dirname,`./static/js/demo.js`);
    let cont = fs.readFileSync(jsPath);
    res.end(cont)
})

app.listen(port,()=>{
    console.log(`listen on ${port}`)    
})

我們可以看到請求結果如下:

clipboard.png

請求過程如下:

  1. 瀏覽器請求靜態資源demo.js
  2. 伺服器讀取磁碟檔案demo.js,返給瀏覽器
  3. 瀏覽器再次請求,伺服器又重新讀取磁碟檔案 demo.js,返給瀏覽器。

迴圈請求。。

  看得出來這種請求方式的流量與請求次數有關,同時,缺點也很明顯:

  • 浪費使用者流量
  • 浪費伺服器資源,伺服器要讀磁碟檔案,然後傳送檔案到瀏覽器
  • 瀏覽器要等待js下載並且執行後才能渲染頁面,影響使用者體驗

快取規則

為了方便理解,我們認為瀏覽器存在一個快取資料庫,用於儲存快取資訊(實際上靜態資源是被快取到了記憶體和磁碟中),在瀏覽器第一次請求資料時,此時快取資料庫沒有對應的快取資料,則需要請求伺服器,伺服器會將快取規則和資料返回,瀏覽器將快取規則和資料儲存進快取資料庫。

clipboard.png

當瀏覽器位址列輸入地址後請求的 index.html 是不會被快取的,但 index.html 內部請求的其他資源會遵循快取策略,HTTP 快取有多種規則,根據是否需要向伺服器傳送請求主要分為兩大類,強制快取和協商快取。

Http快取的分類

Http快取可以分為兩大類,強制快取(也稱強快取)和協商快取。兩類快取規則不同,強制快取在快取資料未失效的情況下,不需要再和伺服器發生互動;而協商快取,顧名思義,需要進行比較判斷是否可以使用快取。
  兩類快取規則可以同時存在,強制快取優先順序高於協商快取,也就是說,當執行強制快取的規則時,如果快取生效,直接使用快取,不再執行協商快取規則。

強制快取

強制快取是第一次訪問伺服器獲取資料後,在有效時間內不會再請求伺服器,而是直接使用快取資料,強制快取的流程如下:

clipboard.png

強制快取分為兩種情況,Expires和Cache-Control。

Expires

Expires的值是伺服器告訴瀏覽器的快取過期時間(值為GMT時間,即格林尼治時間),即下一次請求時,如果瀏覽器端的當前時間還沒有到達過期時間,則直接使用快取資料。下面通過我們的Express伺服器來設定一下Expires響應頭資訊。

//其他程式碼...
const moment = require(`moment`);

app.get(`/demo.js`,(req, res)=>{
    let jsPath = path.resolve(__dirname,`./static/js/demo.js`);
    let cont = fs.readFileSync(jsPath);
    res.setHeader(`Expires`, getGLNZ()) //2分鐘
    res.end(cont)
})

function getGLNZ(){
    return moment().utc().add(2,`m`).format(`ddd, DD MMM YYYY HH:mm:ss`)+` GMT`;
}
//其他程式碼...

我們在demo.js中新增了一個Expires響應頭,不過由於是格林尼治時間,所以通過momentjs轉換一下。第一次請求的時候還是會向伺服器發起請求,同時會把過期時間和檔案一起返回給我們;但是當我們重新整理的時候,才是見證奇蹟的時刻:

clipboard.png

可以看出檔案是直接從快取(memory cache)中讀取的,並沒有發起請求。我們在這邊設定過期時間為兩分鐘,兩分鐘過後可以重新整理一下頁面看到瀏覽器再次傳送請求了。

  雖然這種方式新增了快取控制,節省流量,但是還是有以下幾個問題的:

  • 由於瀏覽器時間和伺服器時間不同步,如果瀏覽器設定了一個很後的時間,過期時間一直沒有用
  • 快取過期後,不管檔案有沒有發生變化,伺服器都會再次讀取檔案返回給瀏覽器

不過Expires 是HTTP 1.0的東西,現在預設瀏覽器均預設使用HTTP 1.1,所以它的作用基本忽略。

Cache-Control

針對瀏覽器和伺服器時間不同步,加入了新的快取方案;這次伺服器不是直接告訴瀏覽器過期時間,而是告訴一個相對時間Cache-Control=10秒,意思是10秒內,直接使用瀏覽器快取。

Cache-Control各個值的含義:

**private**:客戶端可以快取;
**public**:客戶端和代理伺服器都可以快取(對於前端而言,可以認為與 private 效果相同);
**max-age=xxx**:快取的內容將在 xxx 秒後過期(相對時間,秒為單位);
**no-cache**:需要使用協商快取(後面介紹)來驗證資料是否過期;
**no-store**:所有內容都不會快取,強制快取和協商快取都不會觸發。

app.get(`/demo.js`,(req, res)=>{
    let jsPath = path.resolve(__dirname,`./static/js/demo.js`);
    let cont = fs.readFileSync(jsPath);
    res.setHeader(`Cache-Control`, `public,max-age=120`) //2分鐘
    res.end(cont)
})

clipboard.png

其實快取的儲存是記憶體和磁碟兩個位置,由當前瀏覽器本身的策略決定,比較隨機,從記憶體的快取中取出的資料會顯示 (from memory cache),從磁碟的快取中取出的資料會顯示 (from disk cache)。

協商快取

強制快取的弊端很明顯,即每次都是根據時間來判斷快取是否過期;但是當到達過期時間後,如果檔案沒有改動,再次去獲取檔案就有點浪費伺服器的資源了。

協商快取又叫對比快取,設定協商快取後,第一次訪問伺服器獲取資料時,伺服器會將資料和快取標識一起返回給瀏覽器,客戶端會將資料和標識存入快取資料庫中,下一次請求時,會先去快取中取出快取標識傳送給伺服器進行詢問,當伺服器資料更改時會更新標識,所以伺服器拿到瀏覽器發來的標識進行對比,相同代表資料未更改,響應瀏覽器通知資料未更改,瀏覽器會去快取中獲取資料,如果標識不同,代表伺服器更改過資料,所以會將新的資料和新的標識返回瀏覽器,瀏覽器會將新的資料和標識存入快取中,協商快取的流程如下:

clipboard.png

協商快取和強制快取不同的是,協商快取每次請求都需要跟伺服器通訊,而且命中快取伺服器返回狀態碼不再是 200,而是 304。

協商快取有兩組報文結合使用:

  1. Last-Modified和If-Modified-Since
  2. ETag和If-None-Match

clipboard.png

Last-Modified

HTTP 1.0 版本中:
為了節省伺服器的資源,再次改進方案。瀏覽器和伺服器協商,伺服器每次返回檔案的同時,告訴瀏覽器檔案在伺服器上最近的修改時間。請求過程如下:

  1. 瀏覽器請求靜態資源demo.js
  2. 伺服器讀取磁碟檔案demo.js,返給瀏覽器,同時帶上檔案上次修改時間 Last-Modified(GMT標準格式)
  3. 當瀏覽器上的快取檔案過期時,瀏覽器帶上請求頭If-Modified-Since(等於上一次請求的Last-Modified)請求伺服器
  4. 伺服器比較請求頭裡的If-Modified-Since和檔案的上次修改時間。如果果一致就繼續使用本地快取(304),如果不一致就再次返回檔案內容和Last-Modified。
  5. 迴圈請求。。

程式碼實現過程如下:

app.get(`/demo.js`,(req, res)=>{
    let jsPath = path.resolve(__dirname,`./static/js/demo.js`)
    let cont = fs.readFileSync(jsPath);
    let status = fs.statSync(jsPath)

    let lastModified = status.mtime.toUTCString()
    if(lastModified === req.headers[`if-modified-since`]){
        res.writeHead(304, `Not Modified`)
        res.end()
    } else {
        res.setHeader(`Cache-Control`, `public,max-age=5`)
        res.setHeader(`Last-Modified`, lastModified)
        res.writeHead(200, `OK`)
        res.end(cont)
    }
})

clipboard.png

雖然這個方案比前面三個方案有了進一步的優化,瀏覽器檢測檔案是否有修改,如果沒有變化就不再傳送檔案;但是還是有以下缺點:

  • 由於Last-Modified修改時間是GMT時間,只能精確到秒,如果檔案在1秒內有多次改動,伺服器並不知道檔案有改動,瀏覽器拿不到最新的檔案
  • 如果伺服器上檔案被多次修改了但是內容卻沒有發生改變,伺服器需要再次重新返回檔案。

ETag

HTTP 1.1 版本中:
為了解決檔案修改時間不精確帶來的問題,伺服器和瀏覽器再次協商,這次不返回時間,返回檔案的唯一標識ETag。只有當檔案內容改變時,ETag才改變。請求過程如下:

  • 瀏覽器請求靜態資源demo.js
  • 伺服器讀取磁碟檔案demo.js,返給瀏覽器,同時帶上檔案的唯一標識ETag
  • 當瀏覽器上的快取檔案過期時,瀏覽器帶上請求頭If-None-Match(等於上一次請求的ETag)請求伺服器
  • 伺服器比較請求頭裡的If-None-Match和檔案的ETag。如果一致就繼續使用本地快取(304),如果不一致就再次返回檔案內容和ETag。
  • 迴圈請求。。
const md5 = require(`md5`);

app.get(`/demo.js`,(req, res)=>{
    let jsPath = path.resolve(__dirname,`./static/js/demo.js`);
    let cont = fs.readFileSync(jsPath);
    let etag = md5(cont);

    if(req.headers[`if-none-match`] === etag){
        res.writeHead(304, `Not Modified`);
        res.end();
    } else {
        res.setHeader(`ETag`, etag);
        res.writeHead(200, `OK`);
        res.end(cont);
    }
})

請求結果如下:

clipboard.png

總結

為了使快取策略更加健壯、靈活,HTTP 1.0 版本 和 HTTP 1.1 版本的快取策略會同時使用,甚至強制快取和協商快取也會同時使用,對於強制快取,伺服器通知瀏覽器一個快取時間,在快取時間內,下次請求,直接使用快取,超出有效時間,執行協商快取策略,對於協商快取,將快取資訊中的 Etag 和 Last-Modified 通過請求頭 If-None-Match 和 If-Modified-Since 傳送給伺服器,由伺服器校驗同時設定新的強制快取,校驗通過並返回 304 狀態碼時,瀏覽器直接使用快取,如果協商快取也未命中,則伺服器重新設定協商快取的標識。

關於Pragma

當該欄位值為no-cache的時候,會告訴瀏覽器不要對該資源快取,即每次都得向伺服器發一次請求才行:

res.setHeader(`Pragma`, `no-cache`) //禁止快取
res.setHeader(`Cache-Control`, `public,max-age=120`) //2分鐘

通過Pragma來禁止快取,通過Cache-Control設定兩分鐘快取,但是重新訪問我們會發現瀏覽器會再次發起一次請求,說明了Pragma的優先順序高於Cache-Control

快取的優先順序

Pragma > Cache-Control > Expires > ETag > Last-Modified

相關文章