Flask 教程 第十三章:國際化和本地化
這是Flask Mega-Tutorial系列的第十三部分,我將告訴你如何擴充套件Microblog應用以支援多種語言。 作為其中的一部分,你還將學習如何為flask命令建立自己的CLI擴充套件。
本章的主題是國際化和本地化,通常縮寫為I18n和L10n。 為了使我的應用對不會英語的人更加友好,我將在語言翻譯機制的幫助下,實施翻譯工作流程,來使用多種語言向用戶提供服務。
本章的GitHub連結為:Browse, Zip, Diff.
Flask-Babel簡介
你猜對了,Flask-Babel正是用於簡化翻譯工作的。可以使用pip命令安裝它:
(venv) $ pip install flask-babel
Flask-Babel的初始化與之前的外掛類似:
app/__init__.py
: Flask-Babel例項。
# ...
from flask_babel import Babel
app = Flask(__name__)
# ...
babel = Babel(app)
作為本章的一部分,我將向你展示如何將應用翻譯成西班牙語,因為我碰巧會這種語言。 我當然也可以與翻譯機制合作來支援其他語言。 為了跟蹤支援的語言列表,我將新增一個配置變數:
config.py:支援的語言列表。
class Config(object):
# ...
LANGUAGES = ['en' , 'es']
我為本應用使用雙字母程式碼來表示語言種類,但如果你需要更具體,還可以新增國家程式碼。 例如,你可以使用en-US
,en-GB
和en-CA
來支援美國、英國和加拿大的英語以示區分。
Babel
例項提供了一個localeselector
裝飾器。 為每個請求呼叫裝飾器函式以選擇用於該請求的語言:
app/__init__.py
:選擇最匹配的語言。
from flask import request
# ...
@babel.localeselector
def get_locale():
return request.accept_languages.best_match(app.config['LANGUAGES' ])
這裡我使用了Flask中request
物件的屬性accept_languages
。 request
物件提供了一個高階介面,用於處理客戶端傳送的帶Accept-Language頭部的請求。 該頭部指定了客戶端語言和區域設定首選項。 該頭部的內容可以在瀏覽器的首選項頁面中配置,預設情況下通常從計算機作業系統的語言設定中匯入。 大多數人甚至不知道存在這樣的設定,但是這是有用的,因為應用可以根據每個語言的權重,提供優選語言的列表。 為了滿足你的好奇心,下面是一個複雜的Accept-Languages
頭部的例子:
Accept-Language: da, en-gb;q=0.8, en;q=0.7
這表示丹麥語(da
)是首選語言(預設權重= 1.0),其次是英式英語(en-GB
),其權重為0.8,最後是通用英語(en
),權重為0.7。
要選擇最佳語言,你需要將客戶請求的語言列表與應用支援的語言進行比較,並使用客戶端提供的權重,查詢最佳語言。 這樣做的邏輯有點複雜,但它已經全部封裝在best_match()
方法中了,該方法將應用提供的語言列表作為引數並返回最佳選擇。
標記文字以在Python原始碼中執行翻譯
好吧,壞訊息來了。 支援多語言的常規流程是在原始碼中標記所有需要翻譯的文字。 文字標記後,Flask-Babel將掃描所有檔案,並使用gettext工具將這些文字提取到單獨的翻譯檔案中。 不幸的是,這是一個繁瑣的任務,並且是啟用翻譯的必要條件。
我將在這裡向你展示標記操作的幾個示例,你也可以從下載包獲取本章完整的更改集,當然,也可以直接檢視GitHub的頁面。
為翻譯而標記文字的方式是將它們封裝在一個函式呼叫中,該函式呼叫為_()
,僅僅是一個下劃線。最簡單的情況是原始碼中出現的字串。下面是一個flask()
語句的例子:
from flask_babel import _
# ...
flash(_('Your post is now live!'))
_()
函式用於原始語言文字(在這種情況下是英文)的封裝。 該函式將使用由localeselector
裝飾器裝飾的選擇函式,來為給定客戶端查詢正確的翻譯語言。 _()
函式隨後返回翻譯後的文字,在本處,翻譯後的文字將成為flash()
的引數。
但是不可能每個情況都這麼簡單,試想如下的另一個flash()
呼叫:
flash('User {} not found.'.format(username))
該文字具有一個安插在靜態文字中間的動態元件。 _()
函式的語法支援這種型別的文字,但它基於舊版本的字串替換語法:
flash(_('User %(username)s not found.', username=username))
還有更難處理的情況。 有些字串文字並非是在發生請求時分配的,比如在應用啟動時。因此在評估這些文字時,無法知道要使用哪種語言。 一個例子是與表單欄位相關的標籤,處理這些文字的唯一解決方案是找到一種方法來延遲對字串的評估,直到它被使用,比如有實際上的請求發生了。 Flask-Babel提供了一個稱為lazy_gettext()
的_()
函式的延遲評估的版本:
from flask_babel import lazy_gettext as _l
class LoginForm(FlaskForm):
username = StringField(_l('Username'), validators=[DataRequired()])
# ...
在這裡,我正在匯入的這個翻譯函式被重新命名為_l()
,以使其看起來與原始的_()
相似。 這個新函式將文字包裝在一個特殊的物件中,這個物件會在稍後的字串使用時觸發翻譯。
Flask-Login外掛只要將使用者重定向到登入頁面,就會閃現訊息。 此訊息為英文,來自外掛本身。 為了確保這個訊息也能被翻譯,我將重寫預設訊息,並用_l()
函式進行延遲處理:
login = LoginManager(app)
login.login_view = 'login'
login.login_message = _l('Please log in to access this page.')
標記文字以在模板中進行翻譯
在前面的章節中,你已經看到了如何在Python原始碼中標記可翻譯的文字,但這只是該過程的一部分,因為模板檔案也包含文字。 _()
函式也可以在模板中使用,所以過程非常相似。 例如,參考來自404.html的這段HTML程式碼:
<h1>File Not Found</h1>
啟用翻譯之後的版本是:
<h1>{{ _('File Not Found') }}</h1>
請注意,除了用_()
包裝文字外,還需要新增{{...}}
來強制_()
進行翻譯,而不是將其視為模板中的文字字面量。
對於具有動態元件的更復雜的短語,也可以使用引數:
<h1>{{ _('Hi, %(username)s!', username=current_user.username) }}</h1>
_post.html中的一個特別棘手的案例讓我花了一些時間才理順:
{% set user_link %}
<a href="{{ url_for('user', username=post.author.username) }}">
{{ post.author.username }}
</a>
{% endset %}
{{ _('%(username)s said %(when)s',
username=user_link, when=moment(post.timestamp).fromNow()) }}
這裡的問題是我希望username
是一個超連結,指向使用者的個人主頁,而不僅僅是名字,所以我必須使用set
和endset
模板指令建立一個名為user_link
的中間變數 ,然後將其作為引數傳遞給翻譯函式。
正如我上面提到的,你可以下載該版本的應用,其中的Python原始碼和模板中都已被標記成可翻譯文字。
提取文字進行翻譯
一旦應用所有_()
和_l()
都到位了,你可以使用pybabel
命令將它們提取到一個.pot檔案中,該檔案代表可移植物件模板。 這是一個文字檔案,其中包含所有標記為需要翻譯的文字。 這個檔案的目的是作為一個模板來為每種語言建立翻譯檔案。
提取過程需要一個小型配置檔案,告訴pybabel哪些檔案應該被掃描以獲得可翻譯的文字。 下面你可以看到我為這個應用建立的babel.cfg:
babel.cfg:PyBabel配置檔案。
[python: app/**.py]
[jinja2: app/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_
前兩行分別定義了Python和Jinja2模板檔案的檔名匹配模式。 第三行定義了Jinja2模板引擎提供的兩個擴充套件,以幫助Flask-Babel正確解析模板檔案。
可以使用以下命令來將所有文字提取到* .pot *檔案:
(venv) $ pybabel extract -F babel.cfg -k _l -o messages.pot .
pybabel extract
命令讀取-F
選項中給出的配置檔案,然後從命令給出的目錄(當前目錄或本處的.
)掃描與配置的源匹配的目錄中的所有程式碼和模板檔案。 預設情況下,pybabel
將查詢_()
以作為文字標記,但我也使用了重新命名為_l()
的延遲版本,所以我需要用-k _l
來告訴該工具也要查詢它 。 -o
選項提供輸出檔案的名稱。
我應該注意,messages.pot檔案不需要合併到專案中。 這是一個只要再次執行上面的命令,就可以在需要時輕鬆地重新生成的檔案。 因此,不需要將該檔案提交到原始碼管理。
生成語言目錄
該過程的下一步是在除了原始語言(在本例中為英語)之外,為每種語言建立一份翻譯。 我要從新增西班牙語(語言程式碼es
)開始,所以這樣做的命令是:
(venv) $ pybabel init -i messages.pot -d app/translations -l es
creating catalog app/translations/es/LC_MESSAGES/messages.po based on messages.pot
pybabel init
命令將messages.pot檔案作為輸入,並將語言目錄寫入-d
選項中指定的目錄中,-l
選項中指定的是翻譯語言。 我將在app/translations目錄中安裝所有翻譯,因為這是Flask-Babel預設提取翻譯檔案的地方。 該命令將在該目錄內為西班牙資料檔案建立一個es子目錄。 特別是,將會有一個名為app/translations/es/LC_MESSAGES/messages.po的新檔案,是需要翻譯的檔案路徑。
如果你想支援其他語言,只需要各自的語言程式碼重複上述命令,就能使得每種語言都有一個包含messages.po檔案的儲存庫。
在每個語言儲存庫中建立的messages.po
檔案使用的格式是語言翻譯的事實標準,使用的格式為gettext。 以下是西班牙語messages.po開頭的若干行:
# Spanish translations for PROJECT.
# Copyright (C) 2017 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <[email protected]>, 2017.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: [email protected]\n"
"POT-Creation-Date: 2017-09-29 23:23-0700\n"
"PO-Revision-Date: 2017-09-29 23:25-0700\n"
"Last-Translator: FULL NAME <[email protected]>\n"
"Language: es\n"
"Language-Team: es <[email protected]>\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.5.1\n"
#: app/email.py:21
msgid "[Microblog] Reset Your Password"
msgstr ""
#: app/forms.py:12 app/forms.py:19 app/forms.py:50
msgid "Username"
msgstr ""
#: app/forms.py:13 app/forms.py:21 app/forms.py:43
msgid "Password"
msgstr ""
如果你跳過首段,可以看到後面的是從_()
和_l()
呼叫中提取的字串列表。 對每個文字,都會展示其在應用中的引用位置。 然後,msgid
行包含原始語言的文字,後面的msgstr
行包含一個空字串。 這些空字串需要被編輯,以使目標語言中的文字內容被填充。
有很多翻譯應用程式與.po
檔案一起工作。 如果你擅長編輯文字檔案,量少的時候也就罷了,但如果你正在處理大型專案,可能會推薦使用專門的編輯器。 最流行的翻譯應用程式是開源的poedit,可用於所有主流作業系統。 如果你熟悉vim,那麼po.vim 外掛會提供一些鍵值對映,使得處理這些檔案更加輕鬆。
在新增翻譯後,你可以在下面看到一部分西班牙語messages.po:
#: app/email.py:21
msgid "[Microblog] Reset Your Password"
msgstr "[Microblog] Nueva Contraseña"
#: app/forms.py:12 app/forms.py:19 app/forms.py:50
msgid "Username"
msgstr "Nombre de usuario"
#: app/forms.py:13 app/forms.py:21 app/forms.py:43
msgid "Password"
msgstr "Contraseña"
本章的下載包中包含所有翻譯,此檔案當然也在其中,所以你不必擔心這部分的翻譯工作。
messages.po檔案是一種用於翻譯的原始檔。 當你想開始使用這些翻譯後的文字時,這個檔案需要被編譯成一種格式,這種格式在執行時可以被應用程式使用。 要編譯應用程式的所有翻譯,可以使用pybabel compile
命令,如下所示:
(venv) $ pybabel compile -d app/translations
compiling catalog app/translations/es/LC_MESSAGES/messages.po to
app/translations/es/LC_MESSAGES/messages.mo
此操作在每個語言儲存庫中的messages.po旁邊新增messages.mo檔案。 .mo檔案是Flask-Babel將用於為應用程式載入翻譯的檔案。
在為西班牙語或任何其他新增到專案中的語言建立messages.mo檔案之後,可以在應用中使用這些語言。 如果你想檢視應用程式以西班牙語顯示的方式,則可以在Web瀏覽器中編輯語言配置,以將西班牙語作為首選語言。 對Chrome,這是設定頁面的高階部分:
如果你不想更改瀏覽器設定,另一種方法是通過使localeselector
函式始終返回一種語言來強制實現。 對西班牙語,你可以這樣做:
app/__init__.py
:選擇最佳語言。
@babel.localeselector
def get_locale():
# return request.accept_languages.best_match(app.config['LANGUAGES'])
return 'es'
使用為西班牙語配置的瀏覽器執行該應用或返回es
的localeselector
函式,將使所有文字在使用該應用時顯示為西班牙文。
更新翻譯
處理翻譯時的一個常見情況是,即使翻譯檔案不完整,你也可能要開始使用翻譯檔案。 這是非常好的,你可以編譯一個不完整的messages.po檔案,任何可用的翻譯都將被使用,而任何缺失的部分將使用原始語言。 隨後,你可以繼續處理翻譯並再次編譯,以便在取得進展時更新messages.mo檔案。
如果在新增_()
包裝器時錯過了一些文字,則會出現另一種常見情況。 在這種情況下,你會發現你錯過的那些文字將保持為英文,因為Flask-Babel對他們一無所知。 當你檢測到這種情況時,會想要將其用_()
或_l()
包裝,然後執行更新過程,這包括兩個步驟:
(venv) $ pybabel extract -f babel.cfg -k _l -o messages.pot .
(venv) $ pybabel update -i messages.pot -d app/translations
extract
命令與我之前執行的命令相同,但現在它會生成messages.pot的新版本,其中包含所有以前的文字以及最近用_()
或_l()
包裝的文字。 update
呼叫採用新的messages.pot
檔案並將其合併到與專案相關的所有messages.po檔案中。 這將是一個智慧合併,其中任何現有的文字將被單獨保留,而只有在messages.pot中新增或刪除的條目才會受到影響。
messages.po檔案更新後,你就可以繼續新的測試了,再次編譯它,以便對應用生效。
翻譯日期和時間
現在,我已經為Python程式碼和模板中的所有文字提供了完整的西班牙語翻譯,但是如果你使用西班牙語執行應用並且是一個很好的觀察者,那麼會注意到還有一些內容以英文顯示。 我指的是由Flask-Moment和moment.js生成的時間戳,顯然這些時間戳並未包含在翻譯工作中,因為這些包生成的文字都不是應用程式原始碼或模板的一部分。
moment.js庫確實支援本地化和國際化,所以我需要做的就是配置適當的語言。 Flask-Babel通過get_locale()
函式返回給定請求的語言和語言環境,所以我要做的就是將語言環境新增到g
物件,以便我可以從基礎模板中訪問它:
app/routes.py:儲存選擇的語言到flask.g中。
# ...
from flask import g
from flask_babel import get_locale
# ...
@app.before_request
def before_request():
# ...
g.locale = str(get_locale())
Flask-Babel的get_locale()
函式返回一個本地語言物件,但我只想獲得語言程式碼,可以通過將該物件轉換為字串來獲取語言程式碼。 現在我有了g.locale
,可以從基礎模板中訪問它,並以正確的語言配置moment.js:
app/templates/base.html:為moment.js設定本地語言
...
{% block scripts %}
{{ super() }}
{{ moment.include_moment() }}
{{ moment.lang(g.locale) }}
{% endblock %}
現在所有的日期和時間都與文字使用相同的語言了。 你可以在下面看到西班牙語的外觀:
此時,除使用者在使用者動態或個人資料說明中提供的文字外,所有其他的文字均可翻譯成其他語言。
命令列增強
你可能會同意我的看法,pybabel命令有點長,難以記憶。 我將利用這個機會向你展示如何建立與flask
命令整合的自定義命令。 到目前為止,你已經看到我使用Flask-Migrate擴充套件提供的flask run
、flask shell
和幾個flask db
子命令。 將應用特定的命令新增到flask
實際上也很容易。 所以我現在要做的就是建立一些簡單的命令,並用這個應用特有的引數觸發pybabel
命令。 我要新增的命令是:
flask translate init LANG
用於新增新語言flask translate update
用於更新所有語言儲存庫flask translate compile
用於編譯所有語言儲存庫
babel export
步驟不會設定為一個命令,因為生成messages.pot檔案始終是執行init
或update
命令的先決條件,因此這些命令的執行將會生成翻譯模板檔案作為臨時檔案。
Flask依賴Click進行所有命令列操作。 像translate
這樣的命令是幾個子命令的根,它們是通過app.cli.group()
裝飾器建立的。 我將把這些命令放在一個名為app/cli.py的新模組中:
app/cli.py:翻譯命令組
from app import app
@app.cli.group()
def translate():
"""Translation and localization commands."""
pass
該命令的名稱來自被裝飾函式的名稱,並且幫助訊息來自文件字串。 由於這是一個父命令,它的存在只為子命令提供基礎,函式本身不需要執行任何操作。
update
和compile
很容易實現,因為它們沒有任何引數:
app/cli.py:更新子命令和編譯子命令:
import os
# ...
@translate.command()
def update():
"""Update all languages."""
if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'):
raise RuntimeError('extract command failed')
if os.system('pybabel update -i messages.pot -d app/translations'):
raise RuntimeError('update command failed')
os.remove('messages.pot')
@translate.command()
def compile():
"""Compile all languages."""
if os.system('pybabel compile -d app/translations'):
raise RuntimeError('compile command failed')
請注意,這些函式的裝飾器是如何從translate
父函式派生的。 這似乎令人困惑,因為translate()
是一個函式,但它是Click構建命令組的標準方式。 與translate()
函式相同,這些函式的文件字串在--help
輸出中用作幫助訊息。
你可以看到,對於所有命令,執行它們並確保返回值為零(這意味著命令沒有返回任何錯誤)。 如果命令錯誤,那麼我會引發一個RuntimeError
,這會導致指令碼停止。 update()
函式在同一個命令中結合了extract
和update
步驟,如果一切都成功的話,它會在更新完成後刪除messages.pot檔案,因為當再次需要這個檔案時,可以很容易地重新生成 。
init
命令將新的語言程式碼作為引數。 這是其執行流程:
app/cli.py:Init子命令。
import click
@translate.command()
@click.argument('lang')
def init(lang):
"""Initialize a new language."""
if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'):
raise RuntimeError('extract command failed')
if os.system(
'pybabel init -i messages.pot -d app/translations -l ' + lang):
raise RuntimeError('init command failed')
os.remove('messages.pot')
該命令使用@click.argument
裝飾器來定義語言程式碼。 Click將命令中提供的值作為引數傳遞給處理函式,然後將該引數併入到init
命令中。
啟用這些命令的最後一步是匯入它們,以便註冊命令。 我決定在頂級目錄的microblog.py檔案中執行此操作:
microblog.py:註冊命令。
from app import cli
這裡我唯一需要做的就是匯入新的cli.py模組,不需要做任何事情,因為匯入操作會導致命令裝飾器執行並註冊命令。
此時,執行flask --help
將列出translate
命令作為選項。 flask translate --help
將顯示我定義的三個子命令:
(venv) $ flask translate --help
Usage: flask translate [OPTIONS] COMMAND [ARGS]...
Translation and localization commands.
Options:
--help Show this message and exit.
Commands:
compile Compile all languages.
init Initialize a new language.
update Update all languages.
所以現在工作流程就簡便多了,而且不需要記住長而複雜的命令。 要新增新的語言,請使用:
(venv) $ flask translate init <language-code>
在更改_()
和_l()
語言標記後更新所有語言:
(venv) $ flask translate update
在更新翻譯檔案後編譯所有語言:
(venv) $ flask translate compile