跳至主要內容
Skip to content

內容協商機制:讓伺服器說你懂的語言

同一個 URL 可以有多種表示形式:不同語言、不同格式、不同編碼。內容協商(Content Negotiation)讓客戶端和伺服器協商出最適合的版本。


一、 什麼是內容協商?

1.1 問題場景

同一個資源可能有多種表示:

/users/123
  ├── JSON 格式
  ├── XML 格式
  ├── HTML 格式
  ├── 繁體中文
  ├── 英文
  └── 壓縮 vs 未壓縮

如何讓客戶端得到最適合的版本?

1.2 協商類型

類型描述機制
伺服器驅動伺服器根據請求標頭選擇Accept-* 標頭
客戶端驅動伺服器列出選項,客戶端選擇300 Multiple Choices
透明協商代理伺服器代理協商Vary 標頭

本篇主要討論伺服器驅動協商


二、 Accept 標頭家族

2.1 Accept:內容類型

http
Accept: application/json
Accept: text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8

常見 MIME 類型

MIME 類型說明
application/jsonJSON 格式
text/htmlHTML 網頁
text/plain純文字
application/xmlXML 格式
image/webpWebP 圖片
*/*任何類型

2.2 Accept-Language:語言

http
Accept-Language: zh-TW, zh;q=0.9, en-US;q=0.8, en;q=0.7

語言標籤格式

標籤說明
en英文
en-US美式英文
zh中文
zh-TW繁體中文(台灣)
zh-CN簡體中文(中國)
ja日文

2.3 Accept-Encoding:壓縮方式

http
Accept-Encoding: gzip, deflate, br
編碼說明
gzip最常見的壓縮
deflate較舊的壓縮
brBrotli,壓縮率更高
identity不壓縮

2.4 Accept-Charset:字符編碼

http
Accept-Charset: utf-8, iso-8859-1;q=0.5

NOTE

現代 Web 幾乎都使用 UTF-8,此標頭已較少使用。


三、 Quality Values(品質值)

3.1 語法

media-type;q=0.8
  • q 範圍:0.0 ~ 1.0
  • 預設值:1.0
  • 越高表示越偏好

3.2 解析範例

http
Accept: text/html, application/json;q=0.9, text/plain;q=0.8, */*;q=0.1

解析順序:

順序類型q 值
1text/html1.0(預設)
2application/json0.9
3text/plain0.8
4/0.1

3.3 選擇演算法

3.4 程式碼實作

javascript
function negotiateContentType(acceptHeader, supportedTypes) {
  // 解析 Accept 標頭
  const preferences = acceptHeader.split(",").map((part) => {
    const [type, ...params] = part.trim().split(";");
    const qParam = params.find((p) => p.trim().startsWith("q="));
    const q = qParam ? parseFloat(qParam.split("=")[1]) : 1.0;
    return { type: type.trim(), q };
  });

  // 按 q 值排序
  preferences.sort((a, b) => b.q - a.q);

  // 找到第一個支援的類型
  for (const pref of preferences) {
    if (pref.type === "*/*") {
      return supportedTypes[0];
    }
    if (supportedTypes.includes(pref.type)) {
      return pref.type;
    }
  }

  return null; // 無法協商
}

// 使用範例
const accept = "application/json;q=0.9, text/html;q=1.0";
const supported = ["application/json", "text/html"];
const result = negotiateContentType(accept, supported);
// result: 'text/html'

四、 伺服器回應

4.1 Content-Type

回應實際使用的類型:

http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"name": "John"}

4.2 Content-Language

回應使用的語言:

http
HTTP/1.1 200 OK
Content-Language: zh-TW
Content-Type: text/html; charset=utf-8

<html lang="zh-TW">...</html>

4.3 Content-Encoding

回應使用的壓縮:

http
HTTP/1.1 200 OK
Content-Encoding: gzip
Content-Type: text/html

(gzip 壓縮的內容)

4.4 Vary

告訴快取:根據哪些請求標頭區分快取:

http
HTTP/1.1 200 OK
Content-Type: application/json
Vary: Accept, Accept-Language

Vary 的重要性


五、 406 Not Acceptable

當伺服器無法提供客戶端可接受的內容時:

http
GET /api/users HTTP/1.1
Accept: application/xml

HTTP/1.1 406 Not Acceptable
Content-Type: application/json

{
  "error": "Not Acceptable",
  "message": "This API only supports application/json",
  "supportedTypes": ["application/json"]
}

六、 實戰範例

6.1 Express.js 內容協商

javascript
const express = require("express");
const app = express();

app.get("/users/:id", (req, res) => {
  const user = { id: 1, name: "John", email: "john@example.com" };

  // Express 內建的內容協商
  res.format({
    "application/json": () => {
      res.json(user);
    },
    "text/html": () => {
      res.send(`<h1>${user.name}</h1><p>${user.email}</p>`);
    },
    "text/plain": () => {
      res.send(`${user.name} <${user.email}>`);
    },
    default: () => {
      res.status(406).send("Not Acceptable");
    },
  });
});

app.listen(3000);

6.2 語言協商

javascript
const messages = {
  en: { greeting: "Hello" },
  "zh-TW": { greeting: "你好" },
  ja: { greeting: "こんにちは" },
};

app.get("/greeting", (req, res) => {
  // 解析 Accept-Language
  const acceptLang = req.headers["accept-language"] || "en";
  const langs = acceptLang.split(",").map((l) => l.split(";")[0].trim());

  // 找到支援的語言
  const lang = langs.find((l) => messages[l]) || "en";

  res.setHeader("Content-Language", lang);
  res.json(messages[lang]);
});

6.3 壓縮協商

javascript
const zlib = require("zlib");

app.get("/data", (req, res) => {
  const data = JSON.stringify({ large: "...很大的資料..." });
  const acceptEncoding = req.headers["accept-encoding"] || "";

  if (acceptEncoding.includes("br")) {
    res.setHeader("Content-Encoding", "br");
    res.send(zlib.brotliCompressSync(data));
  } else if (acceptEncoding.includes("gzip")) {
    res.setHeader("Content-Encoding", "gzip");
    res.send(zlib.gzipSync(data));
  } else {
    res.send(data);
  }
});

七、 API 設計中的協商

7.1 URL vs Accept 標頭

兩種風格的 API 設計:

# 風格 1:URL 後綴
GET /users.json
GET /users.xml

# 風格 2:Accept 標頭
GET /users
Accept: application/json
面向URL 後綴Accept 標頭
明確性✅ 一目了然❌ 需要看標頭
RESTful❌ 資源有多個 URL✅ 一個資源一個 URL
快取✅ 簡單⚠️ 需要 Vary
實用性✅ 易於測試⚠️ 需要設定標頭

7.2 實務建議

javascript
// 同時支援兩種方式
app.get("/users.:format?", (req, res) => {
  const format = req.params.format || (req.accepts("json") ? "json" : "html");

  const users = [{ id: 1, name: "John" }];

  if (format === "json") {
    res.json(users);
  } else if (format === "xml") {
    res.type("xml").send(toXML(users));
  } else {
    res.render("users", { users });
  }
});

八、 常見陷阱

8.1 萬用符號的優先級

http
Accept: text/*, text/html, */*, text/html;level=1

優先級(由高到低):

  1. text/html;level=1 (最具體)
  2. text/html
  3. text/*
  4. */* (最通用)

8.2 q=0 的意義

q=0 表示明確拒絕

http
Accept: text/*, text/plain;q=0

意思:接受任何 text/*,但不要 text/plain

8.3 不要忘記 Vary

如果基於 Accept 標頭返回不同內容,一定要設置 Vary:

http
# ❌ 錯誤:沒有 Vary,快取可能返回錯誤格式
HTTP/1.1 200 OK
Content-Type: application/json

# ✅ 正確:有 Vary
HTTP/1.1 200 OK
Content-Type: application/json
Vary: Accept

總結

標頭用途範例
Accept內容類型偏好application/json, text/html;q=0.9
Accept-Language語言偏好zh-TW, en;q=0.8
Accept-Encoding壓縮偏好gzip, br
Content-Type回應類型application/json
Content-Language回應語言zh-TW
Vary快取區分依據Accept, Accept-Language

> **記住**:內容協商讓同一個 URL 能智慧地返回最適合客戶端的內容版本。


進階挑戰

  1. 實作一個支援 JSON、XML、HTML 三種格式的 API 端點,使用 Accept 標頭進行內容協商。
  2. 建立一個多語言網站,使用 Accept-Language 自動選擇語言,並正確設置 Vary 標頭。
  3. 思考:為什麼 GraphQL API 通常不需要內容協商?

延伸閱讀與資源