[note] HTTP Cache 快取
TL;DR
- 透過 HTTP Cache 是避免瀏覽器向伺服器發送不必要請求的第一道防線,一般來說我們只需要用到
cache-control
(做 cache) 和etag
(做 revalidate)。 - 瀏覽器自行判斷:
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
- 在不需要檢查有沒有更新的情況下,瀏覽器可以重複使用這個回應多久
- 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 的話,等於沒有告訴瀏覽器要採用的快取機制,導致瀏覽器把檔案快取起來後,不知道什麼時候才能向 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-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
將會被忽略。
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-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 的機制,而是直接向伺服器發送請求,取得伺服器最新的回應。
public/private:這個 response 能不能被不同 users 共享
- 能:
cache-control: public, max-age=3600
。public
適合用在大家看到的是同樣的 image,大家看到的是相同的樣式(stylesheet),一般並不需要明確指名是 public。 - 不能:
cache-control: private, max-age=3600
。private
適合用在個人登入資訊。
s-maxage:我們需要根據 CDN 或 browser 進行不同的設定嗎?
- 需要:
cache-control: max-age=3600, s-maxage=86400
,s-maxage
的s
表示 shared 的意思,因此會針對 CDN 進行設定。如此,針對 client(browser) 的有效時間會是一小時,但 CDN 則會是一天。 - 不需要:
cache-control: max-age=3600
immutable:檔案名稱是不是帶有 hash,例如 app.1af3ef.js
- 有:
cache-control: max-age=2147483648, immutable
,immutable
表示的是這個檔案不可能會改變 ,所以完全不需要 revalidate - 沒有:
cache-control: max-age=3600
對於靜態資源,當今最佳做法是在其 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)
}
})
}