Accept-Language 头处理与语言协商
核心问题:用户浏览器发来的
Accept-Language头怎么解析?后端如何决定返回哪种语言?
真实场景
用户使用繁体中文浏览器访问你的 API,浏览器发送 Accept-Language: zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7。你的 API 需要按优先级匹配支持的语言,返回最合适的内容。
HTTP 语言协商机制
Accept-Language 头格式
Accept-Language: zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7,*;q=0.5
zh-TW:quality factor 默认为1.0,最优先zh;q=0.9:中文(不指定地区),权重 0.9en-US;q=0.8:英语(美国),权重 0.8*;q=0.5:接受任何语言,权重 0.5
语言协商算法
flowchart TD
A[解析 Accept-Language 头] --> B[按 q 值降序排列]
B --> C{精确匹配?\nzh-TW in supportedLocales?}
C -->|是| D[返回 zh-TW 内容]
C -->|否| E{语言前缀匹配?\nzh in supportedLocales?}
E -->|是| F[返回 zh-CN 或其他 zh-* 内容]
E -->|否| G{下一个语言?}
G -->|有| C
G -->|无| H[返回默认语言]
Express.js 实现
安装依赖
npm install accept-language-parser
语言解析中间件
// src/middleware/i18n.ts
import { Request, Response, NextFunction } from 'express';
import acceptLanguageParser from 'accept-language-parser';
const SUPPORTED_LOCALES = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP'] as const;
type SupportedLocale = typeof SUPPORTED_LOCALES[number];
const DEFAULT_LOCALE: SupportedLocale = 'zh-CN';
// 语言前缀到默认地区的映射
const LANGUAGE_FALLBACK: Record<string, SupportedLocale> = {
'zh': 'zh-CN',
'en': 'en-US',
'ja': 'ja-JP',
};
function negotiateLocale(acceptLanguageHeader: string | undefined): SupportedLocale {
if (!acceptLanguageHeader) return DEFAULT_LOCALE;
const parsed = acceptLanguageParser.parse(acceptLanguageHeader);
for (const lang of parsed) {
const tag = lang.region
? `${lang.code}-${lang.region}` // e.g. zh-TW
: lang.code; // e.g. zh
// 1. 精确匹配
if (SUPPORTED_LOCALES.includes(tag as SupportedLocale)) {
return tag as SupportedLocale;
}
// 2. 语言前缀匹配(zh-HK → zh-CN)
const fallback = LANGUAGE_FALLBACK[lang.code];
if (fallback) return fallback;
}
return DEFAULT_LOCALE;
}
export function i18nMiddleware(req: Request, res: Response, next: NextFunction) {
// 优先级:URL 参数 > Cookie > Accept-Language > 默认
const localeFromQuery = req.query.locale as string;
const localeFromCookie = req.cookies?.locale;
const localeFromHeader = negotiateLocale(req.headers['accept-language']);
const locale = (
SUPPORTED_LOCALES.includes(localeFromQuery as SupportedLocale) ? localeFromQuery :
SUPPORTED_LOCALES.includes(localeFromCookie as SupportedLocale) ? localeFromCookie :
localeFromHeader
) as SupportedLocale;
req.locale = locale;
// 响应头:告知客户端内容使用的语言
res.setHeader('Content-Language', locale);
// Vary 头:告知 CDN 按 Accept-Language 分别缓存
res.setHeader('Vary', 'Accept-Language');
next();
}
// 扩展 Express Request 类型
declare global {
namespace Express {
interface Request {
locale: SupportedLocale;
}
}
}
在路由中使用
// src/app.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import { i18nMiddleware } from './middleware/i18n';
const app = express();
app.use(cookieParser());
app.use(i18nMiddleware);
// 产品 API:返回本地化内容
app.get('/api/products/:id', async (req, res) => {
const { id } = req.params;
const { locale } = req; // 由中间件注入
const product = await db.products.findUnique({
where: { id },
// 按 locale 获取翻译内容
include: {
translations: {
where: { locale },
},
},
});
if (!product) {
return res.status(404).json({
error: t('errors.notFound', locale),
});
}
res.json({
id: product.id,
name: product.translations[0]?.name ?? product.name,
description: product.translations[0]?.description ?? product.description,
price: product.price, // 价格以数字形式返回,前端格式化
currency: 'CNY',
});
});
FastAPI(Python)实现
# main.py
from fastapi import FastAPI, Request, Depends
from typing import Literal
import re
app = FastAPI()
SUPPORTED_LOCALES = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP']
DEFAULT_LOCALE = 'zh-CN'
LANGUAGE_FALLBACK = {
'zh': 'zh-CN',
'en': 'en-US',
'ja': 'ja-JP',
}
def parse_accept_language(accept_language: str | None) -> str:
"""解析 Accept-Language 头,返回最佳匹配的 locale"""
if not accept_language:
return DEFAULT_LOCALE
# 解析格式:zh-TW,zh;q=0.9,en-US;q=0.8
parts = []
for item in accept_language.split(','):
item = item.strip()
if ';q=' in item:
lang, q = item.split(';q=')
parts.append((lang.strip(), float(q)))
else:
parts.append((item, 1.0))
# 按 q 值降序排列
parts.sort(key=lambda x: x[1], reverse=True)
for lang, _ in parts:
# 精确匹配
if lang in SUPPORTED_LOCALES:
return lang
# 语言前缀匹配
prefix = lang.split('-')[0]
if prefix in LANGUAGE_FALLBACK:
return LANGUAGE_FALLBACK[prefix]
return DEFAULT_LOCALE
async def get_locale(request: Request) -> str:
"""依赖注入:从请求中解析 locale"""
# 优先级:查询参数 > Cookie > Accept-Language
locale_from_query = request.query_params.get('locale')
if locale_from_query in SUPPORTED_LOCALES:
return locale_from_query
locale_from_cookie = request.cookies.get('locale')
if locale_from_cookie in SUPPORTED_LOCALES:
return locale_from_cookie
return parse_accept_language(request.headers.get('accept-language'))
@app.get("/api/products/{product_id}")
async def get_product(
product_id: str,
locale: str = Depends(get_locale)
):
product = await db.get_product(product_id, locale=locale)
return {
"id": product.id,
"name": product.get_name(locale),
"locale": locale,
}
语言协商的优先级设计
graph TD
A[请求进入] --> B{URL 路径中有语言?\n/en-US/products}
B -->|有| C[使用路径中的语言]
B -->|无| D{查询参数?\n?locale=zh-CN}
D -->|有| E[使用查询参数]
D -->|无| F{Cookie?\nlang=zh-TW}
F -->|有| G[使用 Cookie]
F -->|无| H[解析 Accept-Language]
H --> I{匹配到支持的语言?}
I -->|是| J[使用匹配到的语言]
I -->|否| K[使用默认语言 zh-CN]
语言偏好持久化
// 用户登录后保存语言偏好到数据库
app.post('/api/user/locale', authenticate, async (req, res) => {
const { locale } = req.body;
if (!SUPPORTED_LOCALES.includes(locale)) {
return res.status(400).json({ error: 'Invalid locale' });
}
await db.users.update({
where: { id: req.user.id },
data: { preferredLocale: locale },
});
// 同时设置 Cookie(未登录状态下使用)
res.cookie('locale', locale, {
maxAge: 365 * 24 * 60 * 60 * 1000, // 1 年
httpOnly: false, // 前端 JS 可读
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
res.json({ locale });
});
// 获取用户语言偏好(登录用户用数据库,未登录用 Cookie)
async function getUserLocale(req: Request): Promise<SupportedLocale> {
if (req.user) {
const user = await db.users.findUnique({ where: { id: req.user.id } });
if (user?.preferredLocale) return user.preferredLocale as SupportedLocale;
}
return req.locale; // 由中间件注入的协商结果
}
CDN 缓存与 Vary 头
# Nginx 配置:按语言分别缓存
location /api/ {
proxy_pass http://backend;
proxy_set_header Accept-Language $http_accept_language;
# 缓存:按 URI + Accept-Language 分别存储
proxy_cache_key "$scheme$request_method$host$request_uri$http_accept_language";
proxy_cache_valid 200 10m;
}
// 设置正确的缓存响应头
res.setHeader('Vary', 'Accept-Language, Accept-Encoding');
res.setHeader('Cache-Control', 'public, max-age=300'); // 5分钟
res.setHeader('Content-Language', locale);
⚠️ 注意:设置
Vary: Accept-Language会使 CDN 为每种语言单独缓存,增加缓存条目数。对于高流量接口,评估是否值得。静态内容(商品名称等)建议在构建时生成多语言版本,而非每次请求时动态协商。
常见问题
Q:API 返回的语言和前端 URL 中的语言不一致怎么办?
A:在 API 请求时显式传递 locale(通过查询参数或 X-Locale 自定义头),不依赖 Accept-Language 头自动协商。
// 前端:在所有 API 请求中附带 locale
const api = axios.create({
baseURL: '/api',
});
api.interceptors.request.use((config) => {
config.params = {
...config.params,
locale: getCurrentLocale(),
};
return config;
});
Q:GraphQL API 如何处理多语言?
# 在 Query 中传入 locale 参数
query GetProduct($id: ID!, $locale: Locale!) {
product(id: $id, locale: $locale) {
id
name
description
}
}
下一节:前端和 API 层的语言协商搞定了,接下来处理数据库里的多语言内容——商品名称、描述等应该如何存储和查询?