ETag 與條件請求:304 優化實戰
ETag(Entity Tag,實體標籤)是 HTTP 協商快取的核心機制。本篇將詳細解析 ETag 的原理與實戰應用。
一、 什麼是 ETag?
1.1 概念
ETag 是資源的「指紋」,用於識別資源是否變更:
http
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Content-Type: application/json
{"name": "John", "age": 30}1.2 工作流程
二、 ETag 類型
2.1 強 ETag
http
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"- 位元組級別相同
- 適用於精確比對
2.2 弱 ETag
http
ETag: W/"33a64df551425fcc55e4d42a148795d9f25f89d4"- 語義級別相同
- 允許微小差異(如空白、格式)
javascript
// 弱 ETag 範例:格式可能不同但內容相同
const data1 = '{"name":"John"}';
const data2 = '{ "name": "John" }';
// 強 ETag 不同,弱 ETag 可能相同三、 條件請求標頭
3.1 If-None-Match
搭配 ETag 使用:
http
# 請求
GET /api/data HTTP/1.1
If-None-Match: "abc123"
# 回應(未變更)
HTTP/1.1 304 Not Modified
ETag: "abc123"
# 回應(已變更)
HTTP/1.1 200 OK
ETag: "def456"
Content-Type: application/json3.2 If-Modified-Since
搭配 Last-Modified 使用:
http
# 請求
GET /api/data HTTP/1.1
If-Modified-Since: Wed, 21 Oct 2024 07:28:00 GMT
# 回應(未變更)
HTTP/1.1 304 Not Modified
# 回應(已變更)
HTTP/1.1 200 OK
Last-Modified: Thu, 22 Oct 2024 10:00:00 GMT3.3 If-Match(用於寫入)
確保更新的是預期版本:
http
# 更新請求
PUT /api/user/123 HTTP/1.1
If-Match: "abc123"
Content-Type: application/json
{"name": "John Updated"}
# 回應(版本匹配)
HTTP/1.1 200 OK
# 回應(版本不匹配)
HTTP/1.1 412 Precondition Failed這可以防止「丟失更新」問題。
四、 生成 ETag
4.1 基於內容雜湊
javascript
const crypto = require("crypto");
function generateETag(content) {
return crypto.createHash("md5").update(content).digest("hex");
}
app.get("/api/data", (req, res) => {
const data = JSON.stringify({ name: "John" });
const etag = `"${generateETag(data)}"`;
res.set("ETag", etag);
if (req.headers["if-none-match"] === etag) {
return res.status(304).end();
}
res.json(JSON.parse(data));
});4.2 使用 etag 套件
javascript
const etag = require("etag");
app.get("/api/data", (req, res) => {
const data = JSON.stringify({ name: "John" });
const etagValue = etag(data);
res.set("ETag", etagValue);
res.set("Cache-Control", "no-cache");
if (req.headers["if-none-match"] === etagValue) {
return res.status(304).end();
}
res.type("application/json").send(data);
});4.3 基於版本號
javascript
// 適用於資料庫記錄
app.get("/api/user/:id", async (req, res) => {
const user = await User.findById(req.params.id);
const etag = `"${user.id}-${user.updatedAt.getTime()}"`;
res.set("ETag", etag);
res.set("Cache-Control", "no-cache");
if (req.headers["if-none-match"] === etag) {
return res.status(304).end();
}
res.json(user);
});五、 Express 完整實作
5.1 中介軟體
javascript
const etag = require("etag");
function conditionalGet(getData) {
return async (req, res) => {
try {
const data = await getData(req);
const body = JSON.stringify(data);
const etagValue = etag(body);
res.set("ETag", etagValue);
res.set("Cache-Control", "no-cache");
res.set("Content-Type", "application/json");
// 檢查條件
if (req.headers["if-none-match"] === etagValue) {
return res.status(304).end();
}
res.send(body);
} catch (err) {
res.status(500).json({ error: err.message });
}
};
}
// 使用
app.get(
"/api/config",
conditionalGet(async () => {
return await Config.findOne();
})
);
app.get(
"/api/user/:id",
conditionalGet(async (req) => {
return await User.findById(req.params.id);
})
);5.2 樂觀鎖定更新
javascript
app.put("/api/user/:id", async (req, res) => {
const ifMatch = req.headers["if-match"];
if (!ifMatch) {
return res.status(428).json({
error: "If-Match header required",
});
}
const user = await User.findById(req.params.id);
const currentEtag = `"${user.id}-${user.updatedAt.getTime()}"`;
if (ifMatch !== currentEtag) {
return res.status(412).json({
error: "Resource has been modified",
currentEtag,
});
}
// 更新資料
Object.assign(user, req.body);
await user.save();
const newEtag = `"${user.id}-${user.updatedAt.getTime()}"`;
res.set("ETag", newEtag);
res.json(user);
});六、 前端處理
6.1 Fetch API
javascript
// 帶 ETag 的請求
let cachedData = null;
let cachedEtag = null;
async function fetchData() {
const headers = {};
if (cachedEtag) {
headers["If-None-Match"] = cachedEtag;
}
const response = await fetch("/api/data", { headers });
if (response.status === 304) {
console.log("使用快取");
return cachedData;
}
cachedEtag = response.headers.get("ETag");
cachedData = await response.json();
return cachedData;
}6.2 Axios 攔截器
javascript
const cache = new Map();
axios.interceptors.request.use((config) => {
const cached = cache.get(config.url);
if (cached?.etag) {
config.headers["If-None-Match"] = cached.etag;
}
return config;
});
axios.interceptors.response.use(
(response) => {
const etag = response.headers["etag"];
if (etag) {
cache.set(response.config.url, {
data: response.data,
etag,
});
}
return response;
},
(error) => {
if (error.response?.status === 304) {
const cached = cache.get(error.config.url);
return { ...error.response, data: cached.data, status: 200 };
}
return Promise.reject(error);
}
);七、 ETag vs Last-Modified
| 特性 | ETag | Last-Modified |
|---|---|---|
| 精確度 | 內容級別 | 秒級時間 |
| 計算成本 | 需要雜湊 | 只需時間戳 |
| 分散式問題 | 可能不一致 | 時間需同步 |
| 優先級 | 高 | 低 |
八、 常見問題
8.1 分散式系統 ETag 不一致
javascript
// 問題:不同伺服器生成不同 ETag
// 解決方案 1:使用統一的版本號
const etag = `"${resource.version}"`;
// 解決方案 2:弱 ETag
const etag = `W/"${hash}"`;
// 解決方案 3:確定性雜湊
const etag = crypto
.createHash("sha256")
.update(JSON.stringify(sortedData))
.digest("hex");8.2 動態內容的 ETag
javascript
// 問題:每次生成內容都不同
// 解決:基於有意義的識別碼
app.get("/api/feed", async (req, res) => {
const lastPost = await Post.findOne().sort({ createdAt: -1 });
const postCount = await Post.countDocuments();
// 基於最新文章和總數
const etag = `"${lastPost.id}-${postCount}"`;
// ...
});總結
| 概念 | 說明 |
|---|---|
| ETag | 資源指紋 |
| If-None-Match | 驗證 ETag(讀取) |
| If-Match | 驗證 ETag(寫入) |
| 304 | 資源未變更 |
| 412 | 前提條件失敗 |
> **使用時機**:
- 動態內容需要驗證新鮮度
- 需要防止並發更新衝突
- 需要節省流量(304 無 body)
進階挑戰
- 實作一個支援條件請求的 RESTful API,包含樂觀鎖定。
- 比較基於內容雜湊和版本號的 ETag 效能差異。
- 研究如何在分散式系統中保持 ETag 一致性。