跳至主要內容
Skip to content

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/json

3.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 GMT

3.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

特性ETagLast-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)

進階挑戰

  1. 實作一個支援條件請求的 RESTful API,包含樂觀鎖定。
  2. 比較基於內容雜湊和版本號的 ETag 效能差異。
  3. 研究如何在分散式系統中保持 ETag 一致性。

延伸閱讀與資源