具體說明 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):

etag-example-01.png

伺服器端可以在回應(response)加上 ETag 的 header, 告訴客戶端 /image.png 的版本編號,例如 "abc"

etag-example-02.png

客戶端接收回應之後,可以把 /image.png 與 ETag 值的映對存起來,當下次又需要 /image.png 時,在 HTTP request 加入 1 個 If-None-Match: "abc" 的 header, 藉此告訴伺服器「客戶端現在存有版本編號為 "abc"/image.png 」:

etag-example-03.png

p.s. 通常 ETag 的值會使用 MD5, SHA1 之類的 hash ,這個 hash 可以使用 response 的內容產生,但是沒有規定該如何產生,只要伺服器端能夠產生並且認得自己產生的 ETag 即可

伺服器收到帶有 If-None-Match: "abc" header 的 HTTP request 就需要,檢查 "abc" 的值是否跟伺服器端 /image.png 的 ETag 值相同,如果兩者相同就直接回應 304 Not Modified 狀態碼:

etag-example-04.png

客戶端一旦收到 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-example-05.png

客戶端收到新的 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:

etag-example-06.png

相信大家應該會對 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:

etag-cache-control-01.png

如果在第 1 次載入完成之後的 60 秒內,第 2 次載入 image.png 的話,可以看到瀏覽器選擇使用記憶體中的 cache:

etag-cache-control-02.png

等超過 60 秒之後,再試圖載入 image.png 的話,就可以看到瀏覽器送出 If-None-Match 標頭詢問是否可繼續使用快取了:

etag-cache-control-03.png

看到此處,可能有人會疑問「那為什麼 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 標頭:

python-etag-01.png

以及重新整理頁面之後,瀏覽器會送出 If-None-Match 標頭:

python-etag-02.png

有興趣的人可以玩看看!

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

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!