最近在看面試題的時候總會看到有一些關於Http快取的題目,但是總是一知半解,不甚理解;尤其是Http頭資訊中有一大堆的欄位,什麼if-modified-since,什麼if-none-match,真是令人頭疼。後來突然想到,要是能通過自己構建一個伺服器,自己新增頭資訊,然後看實現的效果,不就更好了麼。說幹就幹,在網上各種找資料,然後再使用expressjs新增各種頭資訊,就能夠很好的理解Http快取了。
個人部落格瞭解下謝小飛的部落格
Http簡介
瀏覽器和伺服器之間通訊是通過HTTP協議,HTTP協議永遠都是客戶端發起請求,伺服器回送響應。模型如下:
HTTP報文就是瀏覽器和伺服器間通訊時傳送及響應的資料塊。瀏覽器向伺服器請求資料,傳送請求(request)報文;伺服器向瀏覽器返回資料,返回響應(response)報文。報文資訊主要分為兩部分:
- 報文頭部:一些附加資訊(cookie,快取資訊等),與快取相關的規則資訊,均包含在頭部中
- 資料主體部分:HTTP請求真正想要傳輸的資料內容
本文用到的一些報文頭如下:
欄位名稱 | 欄位所屬 |
---|---|
Pragma | 通用頭 |
Expires | 響應頭 |
Cache-Control | 通用頭 |
Last-Modified | 響應頭 |
If-Modified-Sice | 請求頭 |
ETag | 響應頭 |
If-None-Match | 請求頭 |
Http快取的分類
Http快取可以分為兩大類,強制快取(也稱強快取)和協商快取。兩類快取規則不同,強制快取在快取資料未失效的情況下,不需要再和伺服器發生互動;而協商快取,顧名思義,需要進行比較判斷是否可以使用快取。
兩類快取規則可以同時存在,強制快取優先順序高於協商快取,也就是說,當執行強制快取的規則時,如果快取生效,直接使用快取,不再執行協商快取規則。
原始模型
我們先簡單搭建一個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}`)
})
複製程式碼
我們可以看到請求結果如下:
請求過程如下:
- 瀏覽器請求靜態資源demo.js
- 伺服器讀取磁碟檔案demo.js,返給瀏覽器
- 瀏覽器再次請求,伺服器又重新讀取磁碟檔案 a.js,返給瀏覽器。
- 迴圈請求。。
看得出來這種請求方式的流量與請求次數有關,同時,缺點也很明顯:
- 浪費使用者流量
- 浪費伺服器資源,伺服器要讀磁碟檔案,然後傳送檔案到瀏覽器
- 瀏覽器要等待js下載並且執行後才能渲染頁面,影響使用者體驗
接下來我們開始在頭資訊中新增快取資訊。
一、強制快取
強制快取分為兩種情況,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轉換一下。第一次請求的時候還是會向伺服器發起請求,同時會把過期時間和檔案一起返回給我們;但是當我們重新整理的時候,才是見證奇蹟的時刻:
可以看出檔案是直接從快取(memory cache)中讀取的,並沒有發起請求。我們在這邊設定過期時間為兩分鐘,兩分鐘過後可以重新整理一下頁面看到瀏覽器再次傳送請求了。
雖然這種方式新增了快取控制,節省流量,但是還是有以下幾個問題的:
- 由於瀏覽器時間和伺服器時間不同步,如果瀏覽器設定了一個很後的時間,過期時間一直沒有用
- 快取過期後,不管檔案有沒有發生變化,伺服器都會再次讀取檔案返回給瀏覽器
不過Expires 是HTTP 1.0的東西,現在預設瀏覽器均預設使用HTTP 1.1,所以它的作用基本忽略。
Cache-Control
針對瀏覽器和伺服器時間不同步,加入了新的快取方案;這次伺服器不是直接告訴瀏覽器過期時間,而是告訴一個相對時間Cache-Control=10秒,意思是10秒內,直接使用瀏覽器快取。
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)
})
複製程式碼
二、協商快取
強制快取的弊端很明顯,即每次都是根據時間來判斷快取是否過期;但是當到達過期時間後,如果檔案沒有改動,再次去獲取檔案就有點浪費伺服器的資源了。協商快取有兩組報文結合使用:
- Last-Modified和If-Modified-Since
- ETag和If-None-Match
Last-Modified
為了節省伺服器的資源,再次改進方案。瀏覽器和伺服器協商,伺服器每次返回檔案的同時,告訴瀏覽器檔案在伺服器上最近的修改時間。請求過程如下:
- 瀏覽器請求靜態資源demo.js
- 伺服器讀取磁碟檔案demo.js,返給瀏覽器,同時帶上檔案上次修改時間 Last-Modified(GMT標準格式)
- 當瀏覽器上的快取檔案過期時,瀏覽器帶上請求頭
If-Modified-Since
(等於上一次請求的Last-Modified)請求伺服器 - 伺服器比較請求頭裡的
If-Modified-Since
和檔案的上次修改時間。如果果一致就繼續使用本地快取(304),如果不一致就再次返回檔案內容和Last-Modified。 - 迴圈請求。。
程式碼實現過程如下:
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)
}
})
複製程式碼
我們多次重新整理頁面,可以看到請求結果如下:
雖然這個方案比前面三個方案有了進一步的優化,瀏覽器檢測檔案是否有修改,如果沒有變化就不再傳送檔案;但是還是有以下缺點:
- 由於Last-Modified修改時間是GMT時間,只能精確到秒,如果檔案在1秒內有多次改動,伺服器並不知道檔案有改動,瀏覽器拿不到最新的檔案
- 如果伺服器上檔案被多次修改了但是內容卻沒有發生改變,伺服器需要再次重新返回檔案。
ETag
為了解決檔案修改時間不精確帶來的問題,伺服器和瀏覽器再次協商,這次不返回時間,返回檔案的唯一標識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);
}
})
複製程式碼
請求結果如下:
一些額外的東西
在報文頭的表格中我們可以看到有一個欄位叫Pragma,這是一段塵封的歷史....
在“遙遠的”http1.0時代,給客戶端設定快取方式可通過兩個欄位--Pragma和Expires。雖然這兩個欄位早可拋棄,但為了做http協議的向下相容,你還是可以看到很多網站依舊會帶上這兩個欄位。
關於Pragma
當該欄位值為no-cache
的時候,會告訴瀏覽器不要對該資源快取,即每次都得向伺服器發一次請求才行。
res.setHeader('Pragma', 'no-cache') //禁止快取
res.setHeader('Cache-Control', 'public,max-age=120') //2分鐘
複製程式碼
通過Pragma來禁止快取,通過Cache-Control設定兩分鐘快取,但是重新訪問我們會發現瀏覽器會再次發起一次請求,說明了Pragma的優先順序高於Cache-Control
關於Cache-Control
我們看到Cache-Control中有一個屬性是public,那麼這代表了什麼意思呢?其實Cache-Control不光有max-age,它常見的取值private、public、no-cache、max-age,no-store,預設值為private,各個取值的含義如下:
- private: 客戶端可以快取
- public: 客戶端和代理伺服器都可快取
- max-age=xxx: 快取的內容將在 xxx 秒後失效
- no-cache: 需要使用對比快取來驗證快取資料
- no-store: 所有內容都不會快取,強制快取,對比快取都不會觸發
所以我們在重新整理頁面的時候,如果只按F5只是單純的傳送請求,按Ctrl+F5會發現請求頭上多了兩個欄位Pragma: no-cache和Cache-Control: no-cache。
快取的優先順序
上面我們說過強制快取的優先順序高於協商快取,Pragma的優先順序高於Cache-Control,那麼其他快取的優先順序順序怎麼樣呢?網上查閱了資料得出以下順序(PS:有興趣的童鞋可以驗證一下正確性告訴我):
Pragma > Cache-Control > Expires > ETag > Last-Modified
如果覺得寫得還不錯,請關注我的掘金主頁。更多文章請訪問謝小飛的部落格
參考資料: