跳至主要内容

[note] HTTP Cache 快取

TL;DR

  • 透過 HTTP Cache 是避免瀏覽器向伺服器發送不必要請求的第一道防線
  • 瀏覽器自行判斷: Cache-Control: max-age=... 的用法有機會讓瀏覽器完全不送出請求
  • 搭配伺服器判斷:Last-ModifiedeTag 的使用則會在瀏覽器產生對應的 If-Modified-SinceIf-None-Match 標頭,伺服器在根據此標頭判斷是否要回傳新的檔案,或回傳 304 Not Modified 即可。
  • 使用 Cache-Control: no-store 瀏覽器將不會快取任何內容(永遠不要快取)。
  • 使用 Cache-Control: no-cache 瀏覽器會快取所有內容,但每次都會發送請求向伺服器詢問是否有新內容要提供(永遠檢查快取)。

Imgur

圖片來源: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()
}

Imgur

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-cachemax-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-ageExpires 的話,會以 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-agestale-while-revalidate 的時間的話,則不會再使用瀏覽器內的 cache,會重新發送新的請求,並取得伺服器新的回應。

swr

圖片來源: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-agestale-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)
}
})
}

參考