跳至主要内容

[note] HTTP Cache 快取

TL;DR

  • 透過 HTTP Cache 是避免瀏覽器向伺服器發送不必要請求的第一道防線,一般來說我們只需要用到 cache-control(做 cache) 和 etag(做 revalidate)。
  • 瀏覽器自行判斷: 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

  • 不需要檢查有沒有更新的情況下,瀏覽器可以重複使用這個回應多久
  • Cache 是避免 client 和 sever 提出請求的一種方式
  • cache-control 針對的就是 cache

Revalidate

  • 當快取過期後,要如何確認這個 response 有沒有新的版本( ⚠️ 也就是說,如果 cache 沒有過期,就沒有所謂 revalidate 的問題)
  • 如果 cache 沒有過期,revalidation 是不會發生的
  • etag 針對的就是 revalidate
  • 邏輯上來說,即使沒有做 revalidate 的動作也不會怎麼樣,頂多就是重新 fetch 一次檔案,但因為這個檔案有可能是一樣的,所以如果不做 revalidate 的話,就可能會浪費了傳輸的流量
  • 內容不會改變的檔案,完全不需要 revalidate

Fresh & Stale

當 response 已經在 cache 中後:

  • fresh:只能表示 cache 還沒過期,⚠️ 但並不表示 response 是最新的
  • stale:cache 已經過期

HTTP Status Code

對應到 Server 回應的 HTTP Status Code:

  • 200(OK):只是表示成功得到了一個完整的 response,這個 response 有可能從 server 來,但也有可能從 browser cache 來⚠️ 無法從 200 判斷瀏覽器是否用了 cache)
  • 304(Not Modified):server 檢查後向瀏覽器表示可以繼續使用原本 cache 中的 response,通常會發生在 cache stale 後,向 server 訊問後得到的回應

Cache 運作的原理

所有透過瀏覽器發送的 HTTP 請求,都會先經過瀏覽器的快取,在這裡會先檢查是否有有效的快取內容可作為回應,如果有的話就直接讀取快取的內容,以減少網路的延遲和傳輸造成的成本。

一般來說,瀏覽器會在發送請求的時候自動選擇最適合的快取方式,因此即使你不去設定 Cache-Control 的 header,瀏覽器依然會選擇最適合的方式。

在判斷資源有沒有過期時,會先看 Cache-Control: max-age="...",沒有的話才會去看 Expires;如果沒有 max-age 也沒有 Expires 的話,才會再進一步去看 Last-modified

推薦:Cache-Control(控制 cache)

Cache-Control @ MDN

  • Directives 使用大小寫都可以(case-insensitive),但 MDN 建議用小寫。
  • 多個項目時以逗號區隔。
沒有設定 cache-control

如果沒有設定 cache-control 的話,等於沒有告訴瀏覽器要採用的快取機制,導致瀏覽器把檔案快取起來後,不知道什麼時候才能向 server 詢問有沒有新的檔案。這時候如果要讓瀏覽器詢問 server 有沒有最新的檔案,除非使用者手動點擊「重新整理」,瀏覽器會自動帶上 cache-control: no-cache 在 request header 中(force reloading a page);或者,瀏覽器會透過自己的 heuristic(每個瀏覽器實作可能不同),決定問問看(heuristic caching)。由於沒加 cache-control 會導致我們沒辦法掌握 cache 過期的時間,因此一般還是會建議根據實際的需求,選擇適當的 cache-control 機制。

快取檔名中「有版本」的檔案

這種方式適合有版本的檔案(或 URL),例如有些檔案的名稱中會加上一串 hash app.34def12.css這個檔案內容一定不會有變更,因為當檔案變更的時候,就會產生新的 hash,使用者就會重新下載最新的檔案,否則就會直接使用快取的檔案(cache-busting pattern):

Cache-Control: public, max-age=31536000, immutable

no-store:這個 response 能不能被 cache

  • 可以:Cache-Control: max-age=3600
  • 不可以: Cache-Control: no-store

使用 no-store 告訴瀏覽器不要 cache 這個檔案或 response

Cache-Control: no-store

no-cache:這個 response 是不是一定得確認用的是最新版

  • 是: Cache-Control: no-cache
  • 不是: Cache-Control: max-age=3600

no-cache 指的是「要快取」,但每次在使用 cache 前,都要先向伺服器檢查(revalidate)。

如果使用了 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 將會被忽略。

must-revalidate:即使這個檔案過期了,能不能把它給 offline 的使用者使用

  • 能: cache-control: max-age=3600。如此,即使該檔案已經過期,users 在 offline 的情況下一樣能使用
  • 不能: cache-control: max-age=3600, must-revalidate,如果希望不使用任何 stale 的資料,要加上 must-revalidate,表示如果檔案過期後,瀏覽器一定要先向 server 詢問。

stale-while-revalidate:在執行 revalidation 時,能不能稍微忍受過期的 response

  • 可以: cache-control: max-age=3600, stale-while-revalidate=600。表示在這 600 秒內,你可以重複使用這個過期的內容,但同時從背景做 revalidate 的動作。適合用在不想要 blocking 使用者瀏覽的情況,所以使用者這次會看到舊的 response,再下一次才會拿到新的。
  • 不可以: cache-control: max-age=3600

通常會搭配 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 的機制,而是直接向伺服器發送請求,取得伺服器最新的回應。

public/private:這個 response 能不能被不同 users 共享

  • 能: cache-control: public, max-age=3600public 適合用在大家看到的是同樣的 image,大家看到的是相同的樣式(stylesheet),一般並不需要明確指名是 public。
  • 不能: cache-control: private, max-age=3600private 適合用在個人登入資訊。

s-maxage:我們需要根據 CDN 或 browser 進行不同的設定嗎?

  • 需要: cache-control: max-age=3600, s-maxage=86400s-maxages 表示 shared 的意思,因此會針對 CDN 進行設定。如此,針對 client(browser) 的有效時間會是一小時,但 CDN 則會是一天。
  • 不需要: cache-control: max-age=3600

immutable:檔案名稱是不是帶有 hash,例如 app.1af3ef.js

  • 有: cache-control: max-age=2147483648, immutableimmutable 表示的是這個檔案不可能會改變 ,所以完全不需要 revalidate
  • 沒有: cache-control: max-age=3600
Cache-Busting Pattern

對於靜態資源,當今最佳做法是在其 URL 中包含版本/hash,後續就不再修改這個檔案。後續要更新時,就使用具有新版本號/hash 的檔案,使其 URL 不同。這就是所謂的 Cache-Busting Pattern。

推薦:Etag 和 If-None-Match(控制 revalidate)

  • 在 server 回傳 Etag 的 HTTP Header 後,瀏覽器在後續的 Request Header 中都會自動帶入 If-None-Match 的欄位,這個欄位的值會是上一次發送請求時 Etag 回傳的值。
  • server 即可根據 If-None-Match Header 的值來決定要不要回傳新的內容給瀏覽器。
  • 如果是 true 的話(需要更新),server 會回傳 200 並提供新的 response;如果是 false(還可以繼續使用),server 則會回傳 304,請 Client 繼續使用 cached response
If-None-Match: "<etag_value>"
If-None-Match: "bfc13a64729c4290ef5b2c2730249c88ca92d82d"
If-None-Match: W/"67ab43", "54ed21", "7892dd"
If-None-Match: *
提示

不需要 revalidate 帶有檔名中已經包含有 hashed 的檔案,因為這些檔案永遠不會改變,所以不需要特別用 etag 來 revalidate 它們。

實作 Etag

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)
}
})
}

中立:Last-Modified 和 If-Modified-Since:會向伺服器發送請求

  • 在 server 回傳 Last-Modified 的 HTTP Header 後,瀏覽器在後續的 Request Header 中都會自動帶入 If-Modified-Since 的欄位,這個欄位的值會是上一次發送請求時 Last-Modified 回傳的值。
  • server 即可根據 If-Modified-Since Header 的時間來決定要不要回傳新的內容給瀏覽器。
  • 可能的話使用 etag 會比 last-modified 更好,原因是有可能過了 last-modified 的時間,但其實並沒有新的檔案需要 fetch,如果是 etag 的話,可以完全依照檔案是否有變更來決定要不要 refetch。
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()
}

不建議: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

參考