从 Django 到 Flask 再到 FastAPI,一篇讲透 Python Web 国际化的正确姿势。
📌 前言
上周接到一个需求:公司内部的工单系统要支持英文和日文。
我打开代码一看,好家伙------
kotlin
return {"message": "操作成功", "data": result}
全项目 300 多处 硬编码中文字符串。
那一刻我悟了:国际化不是"加个翻译",而是一种架构思维。
今天这篇文章,我会从底层原理到三大主流框架的实战,把 Python Web 项目的国际化讲透。建议收藏 🌟,早晚用得上。
一、先搞懂:i18n 到底是什么?
i18n = I nternationalization(首尾字母 + 中间 18 个字母)
核心思想就一句话:
代码里不写死任何自然语言文本,所有用户可见的字符串都通过"翻译系统"动态获取。
没有 i18n 的痛
python
# ❌ 硬编码
def create_order(user):
if not user.is_verified:
raise BizError("请先完成实名认证")
return {"msg": "下单成功"}
想加英文?改代码。想加日文?再改。想改一个措辞?全局搜索替换,祈祷别改错。
有 i18n 的爽
python
# ✅ 国际化
def create_order(user):
if not user.is_verified:
raise BizError(_("error.need_verification"))
return {"msg": _("order.create_success")}
翻译文件单独维护,代码永远不用动。这才是正确的打开方式。
二、Python 国际化的基石:gettext
不管你用什么框架,底层几乎都是 gettext。它是 Python 标准库自带的模块,也是 GNU 翻译工具链的核心。
2.1 核心概念
| 概念 | 说明 |
|---|---|
.pot 文件 |
模板文件,从代码中提取的所有待翻译字符串 |
.po 文件 |
翻译文件,翻译人员编辑的就是它 |
.mo 文件 |
编译后的二进制文件,程序运行时读取的是它 |
msgid |
原始字符串(通常是英文) |
msgstr |
翻译后的字符串 |
2.2 一个最小示例
目录结构:
markdown
project/
├── app.py
└── locale/
├── en/
│ └── LC_MESSAGES/
│ ├── messages.po
│ └── messages.mo
└── zh_CN/
└── LC_MESSAGES/
├── messages.po
└── messages.mo
locale/zh_CN/LC_MESSAGES/messages.po:
arduino
msgid "Hello, {name}!"
msgstr "你好,{name}!"
msgid "You have {count} new messages."
msgstr "你有 {count} 条新消息。"
编译 .po → .mo:
bash
msgfmt locale/zh_CN/LC_MESSAGES/messages.po \
-o locale/zh_CN/LC_MESSAGES/messages.mo
代码中使用:
ini
import gettext
# 加载翻译
zh = gettext.translation('messages', localedir='locale', languages=['zh_CN'])
zh.install()
_ = zh.gettext
print(_("Hello, {name}!").format(name="张三"))
# 输出: 你好,张三!
💡 划重点 :
gettext是标准库,零依赖。但实际 Web 项目中,我们通常用框架封装好的方案。
三、Django:开箱即用的 i18n 全家桶 🏗️
Django 的国际化是最完善的,没有之一。从中间件、模板标签到 ORM 错误信息,全链路支持。
3.1 配置(3 步搞定)
settings.py:
ini
# 1. 开启国际化
USE_I18N = True
USE_L10N = True
# 2. 支持的语言
LANGUAGES = [
('zh-hans', '简体中文'),
('en', 'English'),
('ja', '日本語'),
]
# 3. 翻译文件目录
LOCALE_PATHS = [BASE_DIR / 'locale']
# 4. 添加中间件(放在 CommonMiddleware 之后)
MIDDLEWARE = [
...
'django.middleware.locale.LocaleMiddleware',
...
]
3.2 代码中标记翻译
python
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _lazy
# 视图函数中
def order_view(request):
return JsonResponse({
"message": _("Order created successfully"),
"total": 99.9
})
# 模型中(必须用 lazy 版本!)
class Product(models.Model):
name = models.CharField(
max_length=100,
help_text=_lazy("The display name of the product")
)
⚠️ 坑点提醒 :在模型定义、表单类等模块级别 的代码中,必须用
gettext_lazy而不是gettext,否则翻译会在服务启动时就固化,切换语言无效。
3.3 模板中使用
erlang
{% load i18n %}
<h1>{% trans "Welcome to our store" %}</h1>
<p>{% blocktrans count counter=items|length %}
You have {{ counter }} item.
{% plural %}
You have {{ counter }} items.
{% endblocktrans %}
3.4 提取 & 编译翻译
bash
# 提取所有待翻译字符串 → 生成 .po 文件
python manage.py makemessages -l zh_Hans
python manage.py makemessages -l ja
# 翻译完成后,编译
python manage.py compilemessages
3.5 前端切换语言
javascript
# urls.py
from django.conf.urls.i18n import i18n_patterns
urlpatterns = i18n_patterns(
path('orders/', order_view),
# URL 会变成: /zh-hans/orders/ 或 /en/orders/
)
也可以通过 Accept-Language 请求头或 Cookie 自动识别。
四、Flask:轻量但够用 ☕
Flask 本身不带 i18n,但 Flask-Babel 插件做得非常成熟。
4.1 安装 & 初始化
pip install flask-babel
python
from flask import Flask, g, request
from flask_babel import Babel, gettext as _, lazy_gettext as _lazy
app = Flask(__name__)
app.config['BABEL_DEFAULT_LOCALE'] = 'zh_Hans_CN'
app.config['BABEL_TRANSLATION_DIRECTORIES'] = 'translations'
babel = Babel(app)
@babel.localeselector
def get_locale():
"""决定当前请求使用哪种语言"""
# 优先级:URL参数 > 用户设置 > 浏览器偏好
lang = request.args.get('lang')
if lang in ['zh_Hans_CN', 'en', 'ja']:
return lang
return request.accept_languages.best_match(['zh_Hans_CN', 'en', 'ja'])
4.2 使用翻译
less
@app.route('/api/order', methods=['POST'])
def create_order():
user = g.current_user
if not user.is_verified:
return jsonify({
"code": 403,
"message": _("Please complete identity verification first.")
}), 403
return jsonify({
"code": 200,
"message": _("Order created successfully.")
})
4.3 翻译工作流
创建 babel.cfg(告诉 Babel 去哪里找字符串):
ini
[python: app/**.py]
[jinja2: templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_
提取 → 初始化 → 翻译 → 编译:
csharp
# 1. 提取
pybabel extract -F babel.cfg -o messages.pot .
# 2. 初始化语言(首次)
pybabel init -i messages.pot -d translations -l zh_Hans_CN
pybabel init -i messages.pot -d translations -l en
pybabel init -i messages.pot -d translations -l ja
# 3. 翻译人员编辑 translations/zh_Hans_CN/LC_MESSAGES/messages.po
# 4. 编译
pybabel compile -d translations
# 5. 后续更新(新增字符串后)
pybabel update -i messages.pot -d translations
4.4 Jinja2 模板
less
<h1>{{ _('Welcome back') }}, {{ user.name }}</h1>
<p>{{ ngettext(
'You have %(count)d notification.',
'You have %(count)d notifications.',
count=notifications|length
) }}</p>
五、FastAPI:异步时代的 i18n 方案 ⚡
FastAPI 没有官方 i18n 插件,但这不代表不能优雅地做。社区主流方案是 gettext + 自定义中间件。
5.1 项目结构
bash
fastapi_app/
├── main.py
├── i18n.py # 国际化核心模块
├── locale/
│ ├── en/LC_MESSAGES/messages.mo
│ ├── zh_CN/LC_MESSAGES/messages.mo
│ └── ja/LC_MESSAGES/messages.mo
└── routers/
└── order.py
5.2 核心:i18n 模块
python
# i18n.py
import gettext
from contextvars import ContextVar
from pathlib import Path
# 用 ContextVar 保证异步安全(每个请求独立的语言上下文)
_current_locale: ContextVar[str] = ContextVar('locale', default='zh_CN')
LOCALE_DIR = Path(__file__).parent / 'locale'
SUPPORTED_LOCALES = {'zh_CN', 'en', 'ja'}
# 预加载所有翻译对象,避免每次请求都读磁盘
_translations: dict[str, gettext.GNUTranslations] = {}
def _load_translations():
for lang in SUPPORTED_LOCALES:
try:
_translations[lang] = gettext.translation(
'messages', localedir=str(LOCALE_DIR), languages=[lang]
)
except FileNotFoundError:
_translations[lang] = gettext.NullTranslations()
_load_translations()
def set_locale(locale: str):
if locale in SUPPORTED_LOCALES:
_current_locale.set(locale)
def get_locale() -> str:
return _current_locale.get()
def _(msgid: str) -> str:
"""翻译函数,根据当前请求的语言返回对应文本"""
locale = _current_locale.get()
return _translations[locale].gettext(msgid)
def ngettext(singular: str, plural: str, n: int) -> str:
locale = _current_locale.get()
return _translations[locale].ngettext(singular, plural, n)
5.3 中间件:自动识别语言
python
# main.py
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from i18n import set_locale, SUPPORTED_LOCALES
app = FastAPI()
class LocaleMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# 优先级:查询参数 > 请求头 > 默认值
locale = (
request.query_params.get('lang')
or request.headers.get('Accept-Language', 'zh_CN')[:5]
)
# 标准化:zh-CN → zh_CN
locale = locale.replace('-', '_')
if locale not in SUPPORTED_LOCALES:
locale = 'zh_CN'
set_locale(locale)
response = await call_next(request)
return response
app.add_middleware(LocaleMiddleware)
5.4 在路由中使用
python
# routers/order.py
from fastapi import APIRouter, Depends
from i18n import _, ngettext
router = APIRouter()
@router.post("/api/orders")
async def create_order():
# 业务逻辑...
return {
"code": 200,
"message": _("Order created successfully."),
}
@router.get("/api/notifications")
async def get_notifications(count: int = 3):
msg = ngettext(
"You have {n} new notification.",
"You have {n} new notifications.",
count
).format(n=count)
return {"message": msg}
5.5 翻译文件管理
和前面一样,用 pybabel 工具链:
csharp
# 提取
pybabel extract -F babel.cfg -o messages.pot .
# 初始化
pybabel init -i messages.pot -d locale -l zh_CN
pybabel init -i messages.pot -d locale -l en
# 编译(改完 .po 后必须执行!)
pybabel compile -d locale
💡 FastAPI 特别注意 :因为用了
ContextVar,在async环境下每个请求的语言设置是隔离的,不会串。但如果你用了run_in_threadpool跑同步代码,ContextVar也能正确传播,放心用。
六、进阶:数字、日期、货币的本地化 📊
翻译了文字还不够。1,234,567.89 在德国是 1.234.567,89,日期 07/31/2026 在英国是 31/07/2026。
这时候需要 Babel 库:
pip install babel
ini
from babel.numbers import format_currency, format_decimal
from babel.dates import format_datetime
from datetime import datetime
# 货币
format_currency(99.9, 'CNY', locale='zh_CN') # '¥99.90'
format_currency(99.9, 'USD', locale='en_US') # '$99.90'
format_currency(99.9, 'JPY', locale='ja_JP') # '¥99'
# 数字
format_decimal(1234567.89, locale='de_DE') # '1.234.567,89'
# 日期
now = datetime(2026, 7, 31, 14, 30)
format_datetime(now, format='long', locale='zh_CN') # '2026年7月31日 14:30:00'
format_datetime(now, format='long', locale='en_US') # 'Jul 31, 2026, 2:30:00 PM'
在 FastAPI 中可以封装一个工具函数:
python
from babel.numbers import format_currency
from i18n import get_locale
def localize_price(amount: float, currency: str = 'CNY') -> str:
return format_currency(amount, currency, locale=get_locale())
七、踩坑清单 & 最佳实践 🚨
写了这么多,把我踩过的坑总结成清单,建议截图保存:
❌ 常见错误
| 坑 | 说明 |
|---|---|
用 gettext 而不是 gettext_lazy |
在类定义、模块级别使用会导致翻译固化 |
忘记编译 .mo 文件 |
改了 .po 不编译,线上完全不生效 |
msgid 用中文 |
一旦要改措辞,所有翻译文件都要跟着改 |
| 字符串拼接 | _("Hello") + name ❌ → _("Hello, {name}").format(name=name) ✅ |
| 忽略复数规则 | 英文 1 条 vs 2 条,俄语有 3 种复数形式!用 ngettext |
翻译文件没加 .gitignore |
.mo 是编译产物,不应入库(或入库,看团队规范) |
✅ 最佳实践
python
1. msgid 统一用英文,作为"唯一标识符"
2. 所有用户可见文本都过 _(),包括错误码对应的消息
3. 翻译文件交给专业翻译 / 翻译平台(如 Crowdin、Lokalise)
4. CI/CD 中加一步 pybabel compile,防止忘编译
5. 写单元测试验证每种语言的翻译完整性
6. 数字、日期、货币用 Babel,别自己 format
翻译完整性检查脚本
python
# scripts/check_translations.py
import polib
from pathlib import Path
def check(locale_dir: str):
pot = polib.pofile(f'{locale_dir}/messages.pot')
source_ids = {e.msgid for e in pot}
for po_file in Path(locale_dir).rglob('*.po'):
po = polib.pofile(str(po_file))
translated = {e.msgid for e in po.translated_entries()}
missing = source_ids - translated
if missing:
print(f"⚠️ {po_file.parent.parent.name} 缺少 {len(missing)} 条翻译:")
for m in list(missing)[:5]:
print(f" - {m}")
else:
print(f"✅ {po_file.parent.parent.name} 翻译完整")
check('locale')
八、三大框架对比总结
| 维度 | Django | Flask | FastAPI |
|---|---|---|---|
| i18n 支持 | ⭐⭐⭐⭐⭐ 内置全家桶 | ⭐⭐⭐⭐ Flask-Babel 插件 | ⭐⭐⭐ 需自行封装 |
| 上手难度 | 低(配置即用) | 中(需装插件+配置) | 中高(需理解 ContextVar) |
| 模板翻译 | {% trans %} 标签 |
{{ _() }} |
通常前后端分离,不涉及 |
| 复数支持 | blocktrans + plural |
ngettext() |
ngettext() |
| 异步安全 | N/A(同步框架) | N/A(同步为主) | ✅ ContextVar 天然支持 |
| 适用场景 | 全栈项目、CMS | 中小型 API / 传统 Web | 高性能 API、微服务 |
九、写在最后
国际化这件事,越早做成本越低。
项目初期加一个 _() 包裹,成本几乎为零。但等到上线后再回头改,那就是几百个文件的"考古工程"。
记住这个原则:
任何用户能看到的文字,都不应该出现在代码里。
如果这篇文章对你有帮助,点个赞 👍 收藏 ⭐ 就是对我最大的鼓励。有问题欢迎评论区交流,我们下期见!
#Python #国际化 #i18n #Django #Flask #FastAPI #Web开发 #后端