怎麼網頁改完還是錯的?- 一次搞懂 HTTP Cache 機制

smalltown
Starbugs Weekly 星巴哥技術專欄
11 min readMar 16, 2022

Background

相信大家都有這樣的一個經驗,就是修改要給使用者存取的頁面資源後,例如各式各樣的 .js, .css, .html, .img …檔案,但是發現在瀏覽器呈現的結果不符合預期,反覆查找後發現因為是瀏覽器暫存住先前頁面使用的檔案在本地端,所以沒有再次去下載修改完後的最新版本檔案

因此常常在辦公室就會聽到,那是 Cache 啦,你再重新整理一遍看看,你要 Disable Cache 啦,你先開無痕試試看啦!… 等各種要使用者先把暫存清乾淨的聲音,不過對於那些真的在現實生活中的使用者來說,要他們具備怎麼自己清除暫存的知識與能力實在是太奢侈了,搞懂 HTTP Cache 的機制,讓使用者獲得正確的呈現結果才是最佳解

一個網站的維護者透過暫存功能最希望達成的兩個目標就是當使用者第二次來拜訪網站時

👍 確保使用者可以獲得最新版本的檔案,假如有任何的檔案被修改時,他們要可以馬上獲得修改完的檔案

👍 在達成上面目標的同時,盡可能從網路下載越少的東西越好

如此一來可以節省頻寬與時間,提升使用者體驗,因此底下文章想要來探討一下在 Client 端如何透過 HTTP Cache Header 去達成上面的目標?而且要怎麼設定會比較好?

How to Check HTTP Header

正所謂工欲善其事必先利其器,所以先介紹一下要怎麼去獲得這些 HTTP Header 的方式,自己常用的方式有兩個,假設我們要去拜訪 Google 首頁好了,要怎麼獲得 Google Server 傳送給 Browser,以及 Browser 傳送給 Google Server 的 HTTP Header 呢?自己常透過以下的兩種方式

📚 Browser Developer Mode

最隨手可得的就是各大瀏覽器自己內建的開發者模式,打開之後選擇自己想要查看的網頁資源,例如這邊可以看到去存取 favicon.ico 檔案時的 Status Code 是 200 (from disk cache),然後用到的 Cache-Control Header 內容為 public, max-age=691200,晚點再來詳細解釋這個什麼意思

📚 cURL

另外一個方式就是透過好用的 CLI Tool cURL,他除了可以獲得從 Server 端傳送而來的 HTTP Header 之外,也可以自己取組出想要送到 Server 端的 HTTP Header

HTTP Caching Strategy

其實控制 HTTP Cache 的行為就是透過這些 HTTP Header 來達成的,但在更詳細地了解 HTTP Cache Header 的設定細節前,先讓我們來看看各種跟暫存有關的策略,這樣等等研究各種設定值時可以更清楚的知道為什麼會有這麼樣的設定值,以及要設定哪一種比較好

📚 Cache Strategy

首先要知道該檔案究竟有沒有要被暫存?假如沒有的話,那就是使用者每一次都會去 Server 完整的抓取該檔案,不需要理會本地端有沒有暫存,這時候就會從 Server 拿到 HTTP Response Code 200;假如該檔案需要被暫存的話,那就要考慮他需要被暫存多久?

📚 Cache Expiration Strategy

一年,一天,一小時,一分鐘?暫存多久比較好?這個答案當然沒有一定,要看 Server 端的需求,假如還在 Cache 的期限內,那麼瀏覽器就不會去 Server 重新抓取檔案,可以直接從 Cache 拿出來給使用者就好,這時候就會從 Server 拿到 HTTP Reponse Code 200 (from disk cache)

📚 Cache Validation Strategy

假如設定的暫存時間超過了,就直接重新抓取一次檔案嗎?其實也不用,可以先去跟 Server 驗證看看該檔案是不是真的有被改變了,假如有的話就直接從抓,這時候就會從 Server 拿到 HTTP Response Code 200,沒有的話就繼續使用 Cache 內的,這時候就會從 Server 拿到 HTTP Response Code 304 Not Modified,這兩個動作所花費的網路頻寬在檔案越大的狀況,差距當然是越懸殊的

HTTP Caching Behavior

了解完 HTTP Cache 的策略之後,接著來看如何透過 HTTP Cache Header 來達成這些策略,讓大家可以知其然而知其所以然!

Cache-Control 此 Header 是在 HTTP 1.1 增加來讓 Server 端告訴 Client 端正要存取的這個檔案使用什麼樣的暫存策略,讓我們透過上面的簡易狀態轉移圖,來說明幾種常見的暫存策略,展示 Cache-Control 是如何發揮效用的

📚 Re-Use?

首先是此檔案可不可以被 Client 端重複使用,假如不行的話,那就是需要每次都去跟 Server 端重新下載一次,這時候 Cache-Control 的值就可以設定為 no-store

📚 Versioned URL?

再來是這個檔案的名稱是否有先被動過手腳?為什麼要做這種事情呢?因為從 Server 端獲取的檔案中有不少是 CSS 或是 Javascript 檔案,假如 Client 端沒有取得最新版本的檔案,可能會導致網頁顯示結果不符合預期;所以可以透過將檔案的 hash 值或是版本號直接加在檔案名稱中,例如 script-jfweof.js 或是 script.v101.js,確保不同的檔案內容就會有不一樣的檔案名稱

而 Cache-Control 的 max-age 值是用來告訴 Client 端這個檔案可以被暫存多久,假如已經確定檔案內容一改,檔案名稱就會不同,如此一來就可以設定很長接近永久的 max-age 值,因為 Client 可以一直使用同一個檔案,當檔案內容變更時,再從 Server 端根據新的檔案名稱下載新的檔案即可

📚 Re-Validate Anyway? No

假設 max-age 為 600 秒,也就是說這個檔案可以在 Client 端被暫存 600 秒的時間,在此期間內不需要去 Server 端抓取新的檔案,那假如超過 600 秒時會發生什麼事情?這時候 Client 端就要去問問看 Server 端這個檔案究竟有沒有被更改過,可不可以再繼續被使用,驗證的方式有兩種:

🧰 Modified Time: 假如要根據時間做驗證的話, Server 端會多傳送一個 Header Last-Modified,他負責記載該檔案的最後修改時間,所以 Client 端可以使用它與 max-age 相加起來,將獲得的時間填在 Header If-Modified-Since 回傳給 Server,請 Server 將這個時間之後有修改的檔案傳給 Client,藉此獲得最新的檔案內容;這個方法的小毛病在於有可能檔案內容沒有任何改變,但是檔案的修改時間改變了,導致 Client 端還是耗費時間跟網路頻寬去重新下載檔案

🧰 File Content: 假如要根據檔案內容做驗證的話,Server 端會多傳送一個 Header Etag,他負責記載類似該檔案 Hash 值的東西,理論上會確保不一樣的檔案內容有不同的 Etag 值,因此當該暫存檔案過期時,Client 端就可以將該值透過 If-None-Match 傳送給 Server 端問問看該檔案是不是被修改過了,假如有的話,就去下載新的版本回來 Cache;這個方法必須要注意的點在於 Server 端要真的確保同樣的檔案內容就算是不同台 Server 也回傳一樣的 ETag 值

📚 Re-Validate Anyway? Yes

假如我希望做到每次想要存取該檔案時都跟 Server 詢問該檔案是不是有做改動,而不是等到 max-age 的時間超過時才去做驗證,要怎麼做呢?這時候可以設定 Cache-Control 值為 no-cache 或是 max-age=0,這樣一來的話,每次 Client 要存取該檔案時就會透過上面提到的最後修改時間或是檢查檔案內容來決定是否該重新從 Server 下載該檔案

📚 Misc

假如 Server 同時回傳 Last-Modified 與 ETag 的話,那就必須兩個驗證方式都通過,才會去決定要不要下載新的檔案回來,但理論上通常選擇一種即可;而除了 Cache-Control 之外,在 HTTP 1.0 還有 Header Expires 也可以用來做類似的事情,但是他無法預防 Client 端時間錯誤的問題;Header Pragma: no-cache 是在 HTTP 1.0 用來告知 Client 端此檔案一定需要到 Server 端重新驗證後才能確定是不是要使用暫存的檔案,其實跟 Cache-Control: no-cache 類似,當前都是以 Cache-Control 的設定值為主,Expires 和 Pragma 應該也比較少人在使用了,所以在最後這邊稍微提一下而已

Better Practices

從上面的說明中可以理解到 Client 和 Server 端是如何一起合作處理暫存問題,用以達成文章一開始提到的兩個目標,但假如想要避免 Client 沒有取得最新檔案導致網頁呈現不如預期的現象發生,有沒有什麼 Cache-Control 的傻瓜設定方式?有的!就是把 Server 檔案的 Cache-Control 都依照下面兩條規則去做設定 (其實在上面的說明都有隱含提到)

👍 Immutable Content + Long max-age

當檔案名稱可以對應到特定的檔案內容時,例如: script.v101.js, script.v102.js…等,其實 Client 去跟 Server 下載的這個檔案內容永遠也不會改變才對,所以就可以大膽的設定很長很長的 max-age 在 Cache-Control 裏面,例如上圖中舉例的 Cache-Control: max-age=31536000,這個功能在不同語言應該都有類似的函示庫可以拿來直接使用了,但假如要是不能在檔案名稱動手腳呢?

👍 Mutable content + Always Server Revalidated

這時候就是採用 Cache-Control: no-cache 要求 Client 每次都去跟 Server 做檔案驗證 (使用檔案修改時間或是檔案的 ETag),雖然這個方式會導致每次都需要跟 Server 建立連線,但可以免除 Client 沒有取得最新版本檔案的問題

🤔 Cannot Use max-age=xxx ?

所以就不能夠使用像是 Cache-Control: max-age=600 這種規則了嗎?前面有先提到上面這兩條是傻瓜規則,可以讓你不需要煩惱太多事情就確保 Client 有從 Server 取得最新版本的檔案內容,但要是該檔案暫時沒有被使用者取得最新版本無所謂,或是你自己想要從 Service Worker 做更多的事情,那當然還是可以去使用 max-age 去設定某一小段時間區間,讓 Client 不需要去跟 Server 確認檔案到底是不是最新的版本

Conclusion

這篇文章主要是把 Cache-Control 比較基本的概念給筆記下來,並沒有把 Cache-Control 所有可以設定的值都拿出來看過一次,其實還有更多值得探討的設定,例如 public & private 在什麼樣的情況下需要去設定?使用 stale-while-revalidate 讓 Client 再重新驗證成功之前先使用已經過期的暫存檔案用以改善使用者體驗…等,有興趣的人可以直接細細品嚐 Cache-Control 的文件

Reference

--

--

smalltown
Starbugs Weekly 星巴哥技術專欄

原來只是一介草 QA,但開始研究自動化維運雲端服務後,便一頭栽進 DevOps 的世界裏,熱愛鑽研各種可以提升雲端服務品質及增進團隊開發效率的開源技術