[note] HTTP Cache 快取
TL;DR
- 透過 HTTP Cache 是避免瀏覽器向伺服器發送不必要請求的第一道防線
- 瀏覽器自行判斷:
Cache-Control: max-age=...
的用法有機會讓瀏覽器完全不送出請求 - 搭配伺服器判斷:
Last-Modified
和eTag
的使用則會在瀏覽器產生對應的If-Modified-Since
和If-None-Match
標頭,伺服器在根據此標頭判斷是否要回傳新的檔案,或回傳304 Not Modified
即可。 - 使用
Cache-Control: no-store
瀏覽器將不會快取任何內容(永遠不要快取)。 - 使用
Cache-Control: no-cache
瀏覽器會快取所有內容,但每次都會發送請求向伺服器詢問是否有新內容要提供(永遠檢查快取)。
圖片來源:Prevent unnecessary network requests with the HTTP Cache
Cache 運作的原理
所有透過瀏覽器發送的 HTTP 請求,都會先經過瀏覽器的快取,在這裡會先檢查是否有有效的快取內容可作為回應,如果有的話就直接讀取快取的內容,以減少網路的延遲和傳輸造成的成本。
一般來說,瀏覽器會在發送請求的時候自動選擇最適合的快取方式,因此即使你不去設定 Cache-Control
的 header,瀏覽器依然會選擇最適合的方式。
在判斷資源有沒有過期時,會先看 Cache-Control: max-age="..."
,沒有的話才會去看 Expires
;如果沒有 max-age
也沒有 Expires
的話,才會再進一步去看 Last-modified
。
Expires(較舊的用法):時間未到不會向伺服器發送請求
// HTTP Response Header
Expires: Wed, 21 Oct 2020 09:10:00 GMT
- 瀏覽器會自動把這個資源給快取起來,接者比對每次發送請求的時間,如果發送請求的時間沒有超過 Expires 的時間,則不會真正向伺服器發送請求,而是從 cache 把資料拿出來。
- 這個做會碰到的問題是,使用者可以手動調整系統的時間,來讓這個快取的機制失效。
func ApplyExpires(ctx *gin.Context) {
expiredUntil := time.Now().Add(10 * time.Second).UTC().Format(http.TimeFormat)
ctx.Header("Expires", expiredUntil)
ctx.Next()
}
Cache-Control
Cache-Control @ MDN
- 值使用大小寫都可以(case-insensitive),但 MDN 建議用小寫。
- 多個項目時以逗號區隔。
快取「有版本」或(幾乎不會改變)的 URL
這種方式適合有版本的檔案(或 URL),例如有些檔案的名稱中會加上一串 hash app.34def12.css
,當檔案變更的時候,就會產生新的 Hash,使用者就會重新下載最新的檔案,否則就會直接使用快取的檔案:
Cache-Control: public, max-age=31536000, immutable
no-store:不要讓瀏覽器快取
Cache-Control: no-store
no-cache:快取,但每次請求前都先向伺服器檢查
如果使用了 no-cache
或 max-age=0
瀏覽器都會快取該資源,並且會在下一次使用時重新驗證,也就是說,每次都會發送 HTTP 請求,但如果當內容還是有效的話,就不會再下載 HTTP body:
Cache-Control: no-cache, max-age=0
max-age:時間未到不會向伺服器發送請求
// 以下面的例子來說,在接收 response 要超過 10 秒後,瀏覽器才會再次向伺服器發送請求
func ApplyCacheControl(ctx *gin.Context) {
ctx.Header("Cache-Control", "max-age=10")
ctx.Next()
}
cache-control
後使用max-age
,可以讓使用者收到 response 後的一定時間內(max-age
),瀏覽器不會重新發送請求,而是收到(Status code 200 (from memory cache)
)max-age
後接的是「秒數」- 如果同時設置了
max-age
和Expires
的話,會以max-age
為主,Expires
將會被忽略。
stale-while-revalidate
通常會搭配 max-age
一起用。例如:
Cache-Control: max-age=1, stale-while-revalidate=59
瀏覽器在發送請求前會先檢查 max-age
來判斷 cache 是否已經過期,如果還沒過期,就直接拿瀏覽器的快取(不會實際像 server 發出請求);但若它判斷已經過期,同時有設定 stale-while-revalidate
的時間(且在該時間區間內),會判定該 cache 已經過期,雖然如此,會先拿原本的 cache 來作為回應,但「同時」又在背景向伺服器發送請求(revalidation request),以此產生新的 cache 供未來使用。
swr 的重點是「先拿原本的 cache 來作為回應」,但「同時」又在背景向伺服器發送請求(revalidation request),以此產生新的 cache 供未來使用。因此瀏覽網頁的使用者總是「不需要等待伺服器產生頁面」的這段時間。
但若同時超過 max-age
和 stale-while-revalidate
的時間的話,則不會再使用瀏覽器內的 cache,會重新發送新的請求,並取得伺服器新的回應。
圖片來源:Keeping things fresh with stale-while-revalidate @ web.dev
- 0 - 1 秒時:由於使用
max-age=1
,因此在這段時間內向伺服器發送請求時,瀏覽器會直接取用保存過的 cache 當作回應(fulfill browser request),而不會實際向 server 發送請求。 - 1 - 60 秒時:由於超過了
max-age
定義的時間,因此 cache 已經過期,但因為有使用stale-while-revalidate
,因此瀏覽器仍會先以 cache 當作請求的回應(fulfill browser request),但同時在背景向伺服器查詢有無新的畫面。 - 60 秒之後:超過了
max-age
和stale-while-revalidate
所定義的時間,因此 cache 已經過期,也不會使用 swr 的機制,而是直接向伺服器發送請求,取得伺服器最新的回應。
Last-Modified 和 If-Modified-Since:會向伺服器發送請求
- 在 server 回傳
Last-Modified
的 HTTP Header 後,瀏覽器在後續的 Request Header 中都會自動帶入If-Modified-Since
的欄位,這個欄位的值會是上一次發送請求時Last-Modified
回傳的值。 - server 即可根據
If-Modified-Since
Header 的時間來決定要不要回傳新的內容給瀏覽器。
func ApplyHTTPCache(ctx *gin.Context) {
cacheSince := time.Now().UTC().Format(http.TimeFormat)
// 設定 Last-Modified 後
ctx.Header("Last-Modified", cacheSince)
// 在後續的瀏覽器請求中,將會收到 If-Modified-Since
fmt.Println("If-Modified-Since", ctx.GetHeader("If-Modified-Since"))
ctx.Next()
}
實作 Last-Modified Header
// 實作 HTTP Last-Modified Header
func ApplyLastModifiedHeader(ctx *gin.Context) {
// STEP 2:下一次瀏覽器收到請求是會帶有 If-Modified-Since 的 Header
if ifModifiedSince := ctx.GetHeader("If-Modified-Since"); ifModifiedSince != "" {
// STEP 3:判斷檔案有無變更,沒有變更的話可以回傳 302
// 這裡進行判斷...
ctx.AbortWithStatus(http.StatusNotModified)
}
// STEP 1:設定某檔案最後更新的時間
lastModified := time.Now().Add(time.Minute).UTC().Format(http.TimeFormat)
ctx.Header("Last-Modified", lastModified)
ctx.Next()
}
Etag 和 If-None-Match
- 在 server 回傳
Etag
的 HTTP Header 後,瀏覽器在後續的 Request Header 中都會自動帶入If-None-Match
的欄位,這個欄位的值會是上一次發送請求時Etag
回傳的值。 - server 即可根據
If-None-Match
Header 的值來決定要不要回傳新的內容給瀏覽器。
實作
func ApplyEtagCache(file []byte, ctx *gin.Context) bool {
etag := fmt.Sprintf("%x", md5.Sum(file)) //nolint:gosec
ctx.Header("ETag", etag)
ctx.Header("Cache-Control", "no-cache")
fmt.Println("Etag", etag)
fmt.Println("If-None-Match", ctx.GetHeader("If-None-Match"))
if match := ctx.GetHeader("If-None-Match"); match != "" {
if strings.Contains(match, etag) {
fmt.Println("Status Not Modified")
return true
}
}
return false
}
func main() {
router := gin.Default()
router.NoRoute(func(ctx *gin.Context) {
file, _ := ioutil.ReadFile("./../client/build/index.html")
isNotModified := ApplyEtagCache(file, ctx)
if isNotModified {
ctx.AbortWithStatus(http.StatusNotModified)
} else {
ctx.Data(http.StatusOK, "text/html; charset=utf-8", file)
}
})
}
參考
- 循序漸進理解 HTTP Cache 機制 @ TechBridge
- Prevent unnecessary network requests with the HTTP Cache @ web.dev
- HTTP caching @ MDN > Web technology for developers
- Make use of long-term caching @ Google Developers > Web Fundamentals > Performance
- Caching best practices & max-age gotchas