🌍 别再硬编码中文了!Python Web 项目国际化(i18n)完全指南

从 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开发 #后端

相关推荐
二月龙14 小时前
Spring 事务失效的 8 种场景,很多老手依然频繁踩雷
后端
掘金酱15 小时前
「TRAE Work 实战帮」征文启动!你沉淀的经验,值得被看见!
前端·人工智能·后端
颜酱15 小时前
14 | 验证并修正 LLM 生成的 SQL
人工智能·python
长大198815 小时前
MyBatis 常见性能陷阱:N+1 查询、一级缓存踩坑解决方案
后端
用户18615580086015 小时前
MinIO Java 对接试用:从连接、上传到下载的完整示例
后端
颜酱15 小时前
13 | 使用 LangChain 生成 SQL
人工智能·python·langchain
爱勇宝15 小时前
DeepSeek V4-Flash 更新:代码与 Agent 能力全面增强
前端·后端·deepseek
极客悟道15 小时前
SDKMAN vs jEnv vs JetTUI,JDK 版本管理到底选哪个
后端