用程式碼來實踐Web快取
阿新 • • 發佈:2021-02-28
> Web快取是可以自動儲存常見文件副本的HTTP裝置。當Web請求抵達快取時,如果本地有“已快取的副本”,就可以從本地儲存裝置而不是原始伺服器中提取這個文件。
上面是《HTTP權威指南》中對Web快取的定義,快取的好處主要有以下幾點:
1. 減少了冗餘資料的傳輸;
2. 減少了客戶端的網路請求,也降低了原始伺服器的壓力;
3. 降低了時延,頁面載入更快。
總結一下就是省流量,省頻寬,還賊快。那麼快取是如何工作的呢?客戶端和服務端是如何協調快取的時效性的呢?下面我們用程式碼來一步一步揭曉快取的工作原理。
### 一、瀏覽器快取
當我們在瀏覽器位址列敲入`localhost:8080/test.txt`並回車時,我們是向指定的服務端發起對`text.txt`檔案的請求,
服務端在接收到這個請求之後,找到了這個檔案並準備返回給客戶端,並通過設定`Cache-Control`和`Expires`兩個`response header`告訴客戶端這個檔案要快取下來,在過期之前別跟我要了。
首先我們看一下專案目錄:
```
|-- Cache
|-- index.js
|-- assets
|-- index.html
|-- test.txt
```
具體實現程式碼如下:
```html
...
test.txt
...
```
```javascript
// index.js
const http = require('http');
const path = require('path');
const fs = require('fs');
http.createServer((req, res) => {
const requestUrl = path.join(__dirname, '/assets', path.normalize(req.url));
fs.stat(requestUrl, (err, stats) => {
if (err || !stats.isFile) {
res.writeHead(404, 'Not Found');
res.end();
} else {
const readStream = fs.createReadStream(requestUrl);
const maxAge = 10;
const expireDate = new Date(
new Date().getTime() + maxAge * 1000
).toUTCString();
res.setHeader('Cache-Control', `max-age=${maxAge}, public`);
res.setHeader('Expires', expireDate);
readStream.pipe(res);
}
});
}).listen(8080);
```
那`Cache-Control`和`Expires`這個兩個response header又代表什麼意思呢?`Cache-Control:max-age=500`表示設定快取儲存的最大週期為500秒,超過這個時間快取被認為過期。`Expires:Tue, 23 Feb 2021 01:23:48 GMT`表示在`Tue, 23 Feb 2021 01:23:48 GMT`這個日期之後文件過期。
啟動server後,在瀏覽器訪問`localhost:8080/index.html`,這時是第一次訪問,沒有快取,所以伺服器返回完整的資源。
![](https://img2020.cnblogs.com/blog/2249414/202102/2249414-20210227133621244-1586533805.png)
我們點選超連結訪問`test.txt`:
![](https://img2020.cnblogs.com/blog/2249414/202102/2249414-20210227133801460-1450802209.png)
因為是第一次訪問,所以沒有快取,這個時候我們點選返回按鈕回到`index.html`:
![](https://img2020.cnblogs.com/blog/2249414/202102/2249414-20210227133947159-1308099227.png)
發現不同了嗎?這個時候NetWork中Size已經變成了`disk cache`,說明命中了瀏覽器快取,也就是強快取,這個時候再點選超連結訪問`test.txt`,如果在設定的過期時間10s以內,就能看到命中瀏覽器快取,如果超過10s,就會重新從伺服器獲取資源。
這裡說明一點,瀏覽器的前進後退按鈕會一直從快取中讀取資源,而忽略設定的快取規則。也就是說剛才如果我從`localhost:8080/test.txt`頁面通過瀏覽器返回按鈕回到`localhost:8080/index.html`頁面,會發現不管過多久Network都是`disk cache`,同樣再點選瀏覽器前進按鈕進入`localhost:8080/test.txt`頁面,哪怕超過設定的過期時間也還是from disk cache。
> **注意**:`Cache-Control`的優先順序大於`Expires`,因為時差原因還有服務端時間和客戶端時間可能不一致會導致`Expires`判斷快取有效性不準確。但是`Expires`相容http1.0,`Cache-Control`相容到http1.1,所以一般還是兩個都設定。
### 二、協商快取
上面我們設定過快取時限後,如果快取過期了怎麼辦呢?你可能會說,過期了就重新從服務端獲取資源啊。但是也有可能快取時間過期了,但是資源並沒有變化,所以我們還要引入其他的策略來處理這種情況,那就是協商快取也就是弱快取。
我們梳理一下協商快取的流程:
當服務端第一次返回資源時,除了設定`Cache-Control`和`Expires`響應頭之外,還會設定`Last-Modified`(資源更新時間)和`ETag`(資源摘要或資源版本)兩個響應頭,分別代表資源的最近一次變更時間和實體標籤。當客戶端沒有命中強快取時,會重新像服務端發起請求,並攜帶`If-modified-Since`和`If-None-Match`兩個請求頭,服務端拿到這兩個請求頭會跟之前設定的`Last-Modified`和`ETag`作比較,如果不匹配,說明快取不可用,重新返回資源,反之說明快取有效,返回`304`響應碼,告知快取可以繼續使用,並更新快取有效時間。
下面我們看一下具體程式碼實現:
```javascript
const http = require('http');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
// 生成entity digest
function generateDigest(requestUrl) {
let hash = '2jmj7l5rSw0yVb/vlWAYkK/YBwk';
let len = 0;
fs.readFile(requestUrl, (err, data) => {
if (err) {
console.error(error);
throw new Error(err);
} else {
len = Buffer.byteLength(data, 'utf8');
hash = crypto
.createHash('sha1')
.update(data, 'utf-8')
.digest('base64')
.substring(0, 27);
}
});
return '"' + len.toString(16) + '-' + hash + '"';
}
// 響應檔案
function responseFile(requestUrl, stats, res) {
const readStream = fs.createReadStream(requestUrl);
const maxAge = 10;
const expireDate = new Date(
new Date().getTime() + maxAge * 1000
).toUTCString();
res.setHeader('Cache-Control', `max-age=${maxAge}, public`);
res.setHeader('Expires', expireDate);
res.setHeader('Last-Modified', stats.mtime);
res.setHeader('ETag', generateDigest(requestUrl));
readStream.pipe(res);
}
// 判斷新鮮度
function isFresh(requestUrl, stats, req) {
const ifModifiedSince = req.headers['if-modified-since'];
const ifNoneMatch = req.headers['if-none-match'];
if (!ifModifiedSince && !ifNoneMatch) {
//如果沒有相應的請求頭,應該返回全新的資源
return false;
} else if (ifNoneMatch && ifNoneMatch !== generateDigest(requestUrl)) {
//如果ETag不匹配(資源內容發生改變),表示快取不新鮮
return false;
} else if (ifModifiedSince && ifModifiedSince !== stats.mtime.toString()) {
//如果資源更新時間不匹配,表示快取不新鮮
return false;
}
return true;
}
http.createServer((req, res) => {
const requestUrl = path.join(__dirname, '/assets', path.normalize(req.url));
fs.stat(requestUrl, (err, stats) => {
if (err || !stats.isFile) {
res.writeHead(404, 'Not Found');
res.end();
} else {
if (isFresh(requestUrl, stats, req)) {
// 快取新鮮,告知客戶端沒有快取可用,不返回響應實體
res.writeHead(304, 'Not Modified');
res.end();
} else {
// 快取不新鮮,重新返回資源
responseFile(requestUrl, stats, res);
}
}
});
}).listen(8080);
```
從程式碼中可以看到`ETag`和`Last-Modified`都是用於協商快取的校驗的,`ETag`基於實體標籤,一般可以通過版本號,或者資源摘要來指定;`Last-Modified`則是基於資源的最後修改時間。
這時訪問`localhost:8080/test.txt`檔案,當命中強快取後,等待10s鍾,再次訪問,伺服器返回`304`,而非`200`,表明協商快取生效。
![](https://img2020.cnblogs.com/blog/2249414/202102/2249414-20210227224710834-489732848.png)
此時修改test.txt檔案,再次訪問,伺服器返回`200`,頁面展示最新的`test.txt`檔案內容。
![](https://img2020.cnblogs.com/blog/2249414/202102/2249414-20210227224715730-1249091081.png)
總結一下:
1. `ETag`能更精確地判斷資源到底有沒有變化,且優先順序高於`Last-Modified`;
2. 基於摘要實現的`ETag`相對較慢,更佔資源;
3. `Last-Modified`精確到秒,對亞秒級的資源更新的快取新鮮度判斷無能為力;
4. `ETag`相容到`http1.1`,`Last-Modified`相容到`http1.0`。
> 注意:本文中通過超連結訪問`test.txt`是因為,如果直接在位址列訪問該資源,瀏覽器會在`request headers`中設定`cache-control:max-age=0`,這樣永遠不會命中瀏覽器快取。
>
> 本文測試瀏覽器:Chrome 版本 88.0.4324.192
### 參考:
1. 《HTTP權威指南》
2. [HTTP快取](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Caching)
3. [etag](https://github.com/jshtt