Django 對於 internationalization 與 localization 已提供成熟的解決方案,官方 文件 也寫得十分詳細。

而 Flask 作為一個輕量化的 Web Framework ,並沒有內建 i18n & l10n 的功能,不過只要安裝 Flask-Babel 也可以做到一樣的事情,剛好整合過程也十分簡單,特此紀錄一篇。

本文環境

專案結構

以下為本文的專案結構,用以模擬 1 個很簡單的網站,最後完成會長這樣子:

├── app.py
├── babel.cfg
├── locales
│   ├── en
│   │   └── LC_MESSAGES
│   │       ├── messages.mo
│   │       └── messages.po
│   └── zh_TW
│       └── LC_MESSAGES
│           ├── messages.mo
│           └── messages.po
└── templates
    └── index.html

其中 babel.cfglocales/ 是本文的主角。

babel.cfg 存放著 Babel 的設定,讓 Babel 套件知道如何從檔案中取得所有需要的字串,讓我們能夠將這些字串交給翻譯人員進行翻譯。

locales 則用以存放翻譯完成後的 messages.po 檔,以及經過 Babel 編譯後的 messages.mo 檔,供 Flask 載入使用。

從頭來過

首先,建立個資料夾存放 Flask 專案,並進入該資料夾中:

$ mkdir i18n-demo; cd i18n-demo

接著新增 1 個檔案 app.py

$ touch app.py

假設此網站支援 英文(en) 與 繁體中文(zh_TW) 2 種語系,因此需要 1 個資料夾存放 2 種語系的翻譯字串,Babel 預設用 translations 作為資料夾名稱(而本文用 locales 作為資料夾名稱),另外該資料夾底下的子資料夾必須用語系名稱作為資料夾名稱,每個語系底下也都必須有個名稱為 LC_MESSAGES 的資料夾,如此才能讓 Babel 載入相對應的語系。

因此用以下指令一次新增 2 個語系的資料夾:

$ mkdir -pv ./i18n/{en,zh_TW}/LC_MESSAGES/

p.s. 語系名稱請不要含有 - 字元,該字元會導致 Babel 執行錯誤(因此本文用 zh_TW 取代 zh-TW 作為語系名稱)

簡單的 app.py

以下是 app.py 的內容:

# -*- coding: utf-8 -*-
from flask import Flask
from flask import request
from flask import g

from babel.support import Translations

from flask import Flask
from flask import render_template
from flask_babel import Babel


def create_app():
    flask_app = Flask(__name__)
    flask_app.config['BABEL_TRANSLATION_DIRECTORIES'] = 'locales'
    return flask_app


app = create_app()
babel = Babel(app)


@babel.localeselector
def get_locale():
    return getattr(g, 'LOCALE', 'en')


@app.route('/<locale>', methods=['GET'])
def index(locale):

    if locale not in ('en', 'zh_TW'):
        locale = 'en'

    g.LOCALE = locale
    request.babel_translations = Translations.load('locales', [locale])

    return render_template('index.html')

由於上述 Flask APP 用 index.html 作為樣板,因此新增 1 個 templates 資料夾存放 index.html 樣板:

$ mkdir templates
$ touch templates/index.html

接著在 index.html 中放入以下內容:

{{ _('Hello World!') }}

{% raw %} {{ }} {% endraw %}Jinja2 語法,而 _('Hello World!') 為我們多國語系的字串,其中底線 _ 其實是一個名稱為 _ 的函數,該函數對應的真正函數為 gettext ,多國語系就是透過此函數在執行時將字串換成翻譯後的字串。

至於 Flask 的運作就不多加贅述,以下僅解說相對重要的幾行程式:

flask_app.config['BABEL_TRANSLATION_DIRECTORIES'] = 'locales'

由於本文並非用 translations 作為存放多國語系翻譯的資料夾名稱,因此在 config 中將 BABEL_TRANSLATION_DIRECTORIES 設定改為 locales 代表多國語系翻譯的資料夾名稱改為 locales

顯示多國語系字串時,我們需要告訴 Babel 用何種語系顯示給使用者,因此需要實作 babel.localeselector ,讓 Babel 在執行時知道使用何種語系(以下程式也可以是情況改為讀取資料庫等方式):

@babel.localeselector
def get_locale():
    return getattr(g, 'LOCALE', 'en')

上述的程式則是將需要顯示的語系放在 Flask 的 g 中,如果沒有 g.LOCALE 這個值,我們就用 en 語系顯示給使用者。

也因此,我們必須將語系放到 g 中:

@app.route('/<locale>', methods=['GET'])
def index(locale):

    if locale not in ('en', 'zh_TW'):
        locale = 'en'

    g.LOCALE = locale
    request.babel_translations = Translations.load('locales', [locale])

    return render_template('index.html')

因此在 index 這個 view 接到 request 時,我們會用 g.LOCALE = locale 將語系資料先存到 g 中,後續 Babel 自然就能夠取得語系資料。

知道要用何種語系之後, Babel 仍須載入對應的翻譯,而 Flask-Babel 預設會讀取 request.babel_translations 中的翻譯,因此用 Translations.load('locales', [locale]) 事先為其載入,如此一來就整合好多國語系的功能了。

執行 app.py

上述 Flask APP 僅包含 1 個 view ,可用以下指令執行:

$ env FLASK_APP=app.py flask run

執行之後,可以用瀏覽器瀏覽以下 2 個網址看不同語系的字串:

此時會發現,上述 2 個網址顯示的字串都是 Hello World! ,是還未翻譯前的字串。這是因為尚未將各語系需要的 messages.mo 檔編譯好的緣故。

接下來進行 messages.mo 的編譯。

編譯 messages.mo

編譯 messages.mo 的過程大致上分為 3 步驟:

  1. 擷取程式碼中的字串
  2. 翻譯字串
  3. 將翻譯編譯成 messages.mo

擷取程式碼中的字串

字串並不是手動ㄧ句一句整理而成的,而是透過程式擷取,擷取之前需要透過設定檔,告訴 Babel 哪些檔案可以擷取字串,所以我們新增 Babel 的設定檔 babel.cfg ,並填入以下內容:

[jinja2: templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_
keywords=_,gettext,ngettext,lazy_gettext

上述的內容指的是從 templates/**.html 中用 jinja2 解析方式取得字串,使用 extensions jinja2.ext.autoescapejinja2.ext.with_ ,並且將 _ , gettext , ngettext , lazy_gettext 等關鍵字中的字串擷取出來。

所以 {% raw %} {{ _('Hello World!') }} {% endraw %} 中的 Hello World! 將會被擷取。

設定檔完成之後,可以用以下指令開始擷取字串:

$ pybabel extract -F babel.cfg -o messages.pot .

上述指令會將所有字串擷取到 messages.pot 中,以下為 messages.pot 的內容,可以看到 msgid "Hello World!" 被擷取成功:

# Translations template for PROJECT.
# Copyright (C) 2018 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2018.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2018-12-08 16:28+0800\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.6.0\n"

#: templates/index.html:1
msgid "Hello World!"
msgstr ""

翻譯字串

有了 messages.pot 之後,用以下指令分別將字串再編成各語系資料夾內的 messages.po

$ pybabel init -l zh_TW -d ./locales -i messages.pot
$ pybabel init -l en -d ./locales -i messages.pot

如果是初次新增 messages.po 請用 pybabel init ,如果是後續的字串更新則使用 pybabel update

$ pybabel update -l zh_TW -d ./locales -i messages.pot
$ pybabel update -l en -d ./locales -i messages.pot

成功後可以在各語系的 LC_MESSAGES 資料夾內發現 messages.po 檔,接著用 POEDIT 打開這些檔案,開始進行翻譯:

編譯 messages.mo

完成翻譯後,最後就能編譯 messages.mo 囉!

用以下指令進行編譯:

pybabel compile -f -d ./locales

成功執行後,就可以在各語系的 LC_MESSAGES 資料夾內發現 messages.mo 檔。

接著打開 http://127.0.0.1:5000/zh_TW 就會出現翻譯字串囉!

結語

基本上 Python 的 i18n 實作都是大同小異,只要實際做過一次,換到什麼 Framework 應該都能夠很快上手。

以上, Happy Coding!

References

http://babel.pocoo.org/en/latest/index.html

https://docs.python.org/3/library/gettext.html#module-gettext

https://pythonhosted.org/Flask-Babel/