內容協商機制:讓伺服器說你懂的語言
同一個 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/json | JSON 格式 |
text/html | HTML 網頁 |
text/plain | 純文字 |
application/xml | XML 格式 |
image/webp | WebP 圖片 |
*/* | 任何類型 |
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 | 較舊的壓縮 |
br | Brotli,壓縮率更高 |
identity | 不壓縮 |
2.4 Accept-Charset:字符編碼
http
Accept-Charset: utf-8, iso-8859-1;q=0.5NOTE
現代 Web 幾乎都使用 UTF-8,此標頭已較少使用。
三、 Quality Values(品質值)
3.1 語法
media-type;q=0.8q範圍: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 值 |
|---|---|---|
| 1 | text/html | 1.0(預設) |
| 2 | application/json | 0.9 |
| 3 | text/plain | 0.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-LanguageVary 的重要性:
五、 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優先級(由高到低):
text/html;level=1(最具體)text/htmltext/**/*(最通用)
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 能智慧地返回最適合客戶端的內容版本。
進階挑戰
- 實作一個支援 JSON、XML、HTML 三種格式的 API 端點,使用 Accept 標頭進行內容協商。
- 建立一個多語言網站,使用 Accept-Language 自動選擇語言,並正確設置 Vary 標頭。
- 思考:為什麼 GraphQL API 通常不需要內容協商?