具體說明 HTTP 標頭(header) ETag 是如何運作的
Posted on Jun 9, 2024 in HTTP headers by Amo Chen ‐ 5 min read
談到瀏覽器的快取,最眾所皆知的是 Cache-Control 。
但其實還有 1 個也經常被提到,那就是 ETag 。
可真要解釋 ETag 具體在做什麼、是如何運作的,以及為什麼需要 ETag, 卻又很難講得清楚(偏偏面試又會被問)。
本文將從實際的範例出發,帶你徹底了解 ETag 並提供 Python 程式碼,讓你在家也能玩!
本文環境
- Python 3
- Flask
$ pip install flask
HTTP header ETag 簡介
ETag 是 HTTP 回應標頭,用來標誌資源的版本。它可以使快取機制更有效率並節省頻寬, 畢竟資料沒有變動的話,伺服器就不需要再次傳回整份資料。
——摘自 ETag - HTTP | MDN
MDN 將 ETag 的解釋很清楚,不過舉個例子說明會更有感覺,假設客戶端向伺服器發出 GET /image.png
的要求(request):
伺服器端可以在回應(response)加上 ETag 的 header, 告訴客戶端 /image.png
的版本編號,例如 "abc"
:
客戶端接收回應之後,可以把 /image.png
與 ETag 值的映對存起來,當下次又需要 /image.png
時,在 HTTP request 加入 1 個 If-None-Match: "abc"
的 header, 藉此告訴伺服器「客戶端現在存有版本編號為 "abc"
的 /image.png
」:
p.s. 通常 ETag 的值會使用 MD5, SHA1 之類的 hash ,這個 hash 可以使用 response 的內容產生,但是沒有規定該如何產生,只要伺服器端能夠產生並且認得自己產生的 ETag 即可
伺服器收到帶有 If-None-Match: "abc"
header 的 HTTP request 就需要,檢查 "abc"
的值是否跟伺服器端 /image.png
的 ETag 值相同,如果兩者相同就直接回應 304 Not Modified 狀態碼:
客戶端一旦收到 304 狀態碼,就知道可以繼續沿用已經存好的 /image.png
。
假設 /image.png
大小是 20MB 。
在沒有使用 ETag 的情況下,連續 2 次一模一樣的 HTTP requests, 伺服器端會傳輸 40MB 的流量出去。
若使用 ETag 的情況下,連續 2 次一模一樣的 HTTP requests,僅有第 1 次需要傳輸 20MB 的流量出去,第 2 次則是透過 304 狀態碼直接告訴客戶端繼續使用客戶端的 /image.png
,省下第 2 次傳輸的流量。
這就是 MDN 文件中提到的 ETag 能改善快取效率與節省頻寬的作用。
但如果客戶端第 2 次請求時, /image.png
已經更新了,伺服器端則需要回應新的 ETag 標頭,並將更新後的內容放到 HTTP response body 之中:
客戶端收到新的 ETag 值之後,就更新 /image.png
與相對應的 ETag 值,達成更新。
這就是為何 ETag 能夠用來標示資源(resource)的版本,因為伺服器端可以透過 ETag 標頭告訴客戶端所要求的資源版本編號為何,而客戶端可以用該版本編號詢問伺服器端有沒有新版本。
為什麼需要 ETag ?
接觸過 HTTP 協定的人可能會疑問一件事情:
「咦?既然是快取(cache)在客戶端,那不是使用 Cache-Control 標頭就好了嗎?」
伺服器可以回應 Cache-Control 標頭藉此讓客戶端快取回應「特定時間」,但重點在於「特定時間」內客戶端都會直接使用快取,萬一「特定時間」內,伺服器端早已更新資料的話,客戶端將無法更新,直到經過「特定時間」。
單純使用 Cache-Control 很難處理「既要有快取,又要能夠持續更新」的情況。
使用 ETag 就能夠滿足「既要有快取,又要能夠持續更新」的需求,但它的缺點是 HTTP request 仍然會發往伺服器端;使用 Cache-Control 則可以讓發往伺服器端的 HTTP request 都省掉。
總結一下,在同樣都需要快取的情況下:
- Cache-Control 適合對更新無關緊要的應用場景,伺服器端壓力相對小
- ETag 適合需要持續更新的應用場景,然而伺服器端一樣會收到 request, 仍須承擔一定的壓力
ETag 與 Cache-Control 可以一起用嗎?
答案是「可以」!
以下是實際從 GitHub 側錄到的 HTTP response, 從圖片中可以看到伺服器端同時回應 ETag 與 Cache-Control:
相信大家應該會對 2 個同時使用的情況產生困惑,同時使用 ETag 與 Cache-Control 的情況下,到底要怎麼解讀?
ETag 與 Cache-Control 同時使用的情況下,會先看 Cache-Control, 如果 Cache-Control 還未過期,客戶端會直接使用快取,等到 Cache-Control 過期之後,才會用 If-None-Match
header 詢問伺服器端是否可繼續使用快取。
可以實際在瀏覽器測試 ETag 與 Cache-Control 同時使用的情況,以下模擬 Cache-Control 60 秒與 ETag 同時使用的情況。
第 1 次載入 image.png 時,可以看到伺服器端同時回應 Cache-Control 與 ETag headers:
如果在第 1 次載入完成之後的 60 秒內,第 2 次載入 image.png 的話,可以看到瀏覽器選擇使用記憶體中的 cache:
等超過 60 秒之後,再試圖載入 image.png 的話,就可以看到瀏覽器送出 If-None-Match
標頭詢問是否可繼續使用快取了:
看到此處,可能有人會疑問「那為什麼 GitHub Cache-Control 設定這麼久(31536000 秒 = 1 年)?這樣客戶端不就 1 年都不會更新快取?」
之所以使用 31536000 是因為這是業界最佳實務,根據 Google 官方文件建議 static assets (圖片、CSS 檔、 JavaScript 檔等等)最好使用 Cache-Control: max-age=31536000
。
至於客戶端會不會 1 年都無法更新快取則不是太大的問題,因為瀏覽器的快取是使用 URL ,如果 URL 有任何一丁點的變化,就會被視為不同的資源,瀏覽器就會向伺服器發出要求,這也是為什麼我們經常看到有些 URL 後面經常有 1 連串代號的緣故,例如下列 URL 的 819c71711fe6
部分:
https://github.githubassets.com/assets/chunk-app_components_reactions_reactions-menu-element_ts-819c71711fe6.js
這是為了讓客戶端能夠棄用原有快取的作法,只要伺服器端有任何更新,就只要在網頁內更新檔案名稱內的代號即可讓客戶端載入全新資源,例如:
<script src="script-<新代號>.js"></script>
如此一來, Cache-Control 就算設定為 1 年也沒有關係。
實際玩一遍 ETag
以下為本文實驗 ETag 的 Python 程式碼,該範例以 Flask 實作。
範例資料夾結構:
.
├── image.png
├── templates
│ └── index.html
└── server.py
server.py
import hashlib
import os
from flask import Flask, make_response, render_template, request, send_file
app = Flask(__name__)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/image.png')
def image():
# Path to the image file
image_path = './image.png'
if not os.path.exists(image_path):
return 'Image not found', 404
with open(image_path, 'rb') as f:
image_content = f.read()
etag = hashlib.md5(image_content).hexdigest()
if_none_match = request.headers.get('If-None-Match')
if if_none_match == etag:
return '', 304
response = make_response(send_file(image_path, mimetype='image/png'))
response.headers['ETag'] = etag
return response
if __name__ == "__main__":
app.run(debug=True, host='0.0.0.0', port=65432)
上述 Python 程式碼只有 2 個 endpoints:
/
/image.png
其中 /image.png
會使用 MD5 作為 image.png
圖檔的 ETag, 並與圖檔內容一起回傳給客戶端。如果客戶端的要求含有 If-None-Match
標頭,則檢查該標頭的值是否與圖檔的 ETag 值相同,如果相同則僅回覆 304 狀態碼給客戶端。簡單來說,是 1 個典型的有實作 ETag 的伺服器。
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ETag Demo</title>
</head>
<body>
<h1>ETag Demo</h1>
<div>
<img src="/image.png" style="width: 300px;" />
</div>
</body>
</html>
image.png
可以自己準備一張同名的圖。
執行的指令如下:
$ python server.py
執行成功的話,可以使用瀏覽器輸入網址 http://127.0.0.1:65432
,並且用開發者工具觀察的話,將可以看到下列畫面,伺服器端回應 ETag 標頭:
以及重新整理頁面之後,瀏覽器會送出 If-None-Match
標頭:
有興趣的人可以玩看看!
p.s. 實驗的時候記得清瀏覽器快取,詳見 Chrome Hard Reload
總結
在開發能處理龐大流量的系統時,除了需要注意伺服器本身的效能之外,其實還有很多細節要處理,這些細節本質說穿了不外乎是利用、是省,怎麼能利用更多的資源,怎麼省下不必要的開支(request 數、頻寬等等),而這些細節本質仰賴對 1 項技術的根本理解,只有對技術本質足夠瞭解,才能不斷突破向前。
以上!
Enjoy!
References
ETags: What they are, and how to use them
Serve static assets with an efficient cache policy | Lighthouse | Chrome for Developers