从 django 到 flask 再到 fastapi,一篇讲透 python web 国际化的正确姿势。
前言
上周接到一个需求:公司内部的工单系统要支持英文和日文。
我打开代码一看,好家伙——
return {"message": "操作成功", "data": result}
全项目 300 多处 硬编码中文字符串。
那一刻我悟了:国际化不是"加个翻译",而是一种架构思维。
今天这篇文章,我会从底层原理到三大主流框架的实战,把 python web 项目的国际化讲透。建议收藏 🌟,早晚用得上。
一、先搞懂:i18n 到底是什么?
i18n = internationalization(首尾字母 + 中间 18 个字母)
核心思想就一句话:代码里不写死任何自然语言文本,所有用户可见的字符串都通过"翻译系统"动态获取。
没有 i18n 的痛
# ❌ 硬编码
def create_order(user):
if not user.is_verified:
raise bizerror("请先完成实名认证")
return {"msg": "下单成功"}
想加英文?改代码。想加日文?再改。想改一个措辞?全局搜索替换,祈祷别改错。
有 i18n 的爽
# ✅ 国际化
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 一个最小示例
目录结构:
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:
msgid "hello, {name}!"
msgstr "你好,{name}!"
msgid "you have {count} new messages."
msgstr "你有 {count} 条新消息。"编译 .po → .mo:
msgfmt locale/zh_cn/lc_messages/messages.po \
-o locale/zh_cn/lc_messages/messages.mo代码中使用:
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:
# 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 代码中标记翻译
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 模板中使用
{% 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 提取 & 编译翻译
# 提取所有待翻译字符串 → 生成 .po 文件 python manage.py makemessages -l zh_hans python manage.py makemessages -l ja # 翻译完成后,编译 python manage.py compilemessages
3.5 前端切换语言
# 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
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 使用翻译
@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 去哪里找字符串):
[python: app/**.py] [jinja2: templates/**.html] extensions=jinja2.ext.autoescape,jinja2.ext.with_
提取 → 初始化 → 翻译 → 编译:
# 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 模板
<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 项目结构
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 模块
# 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 中间件:自动识别语言
# 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 在路由中使用
# 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 工具链:
# 提取 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
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 中可以封装一个工具函数:
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 是编译产物,不应入库(或入库,看团队规范) |
最佳实践
1. msgid 统一用英文,作为"唯一标识符"
2. 所有用户可见文本都过 _(),包括错误码对应的消息
3. 翻译文件交给专业翻译 / 翻译平台(如 crowdin、lokalise)
4. ci/cd 中加一步 pybabel compile,防止忘编译
5. 写单元测试验证每种语言的翻译完整性
6. 数字、日期、货币用 babel,别自己 format
翻译完整性检查脚本
# 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 web项目国际化(i18n)的完全指南的详细内容,更多关于python国际化i18n的资料请关注代码网其它相关文章!
发表评论