Flask教程第十三章:國際化和本地化

天降攻城獅發表於2019-02-22

本文轉載自:https://www.jianshu.com/p/e2923f4042d6

這是Flask Mega-Tutorial系列的第十三部分,我將告訴你如何擴充套件Microblog應用以支援多種語言。 作為其中的一部分,你還將學習如何為flask命令建立自己的CLI擴充套件。

本章的主題是國際化和本地化,通常縮寫為I18n和L10n。 為了使我的應用對不會英語的人更加友好,我將在語言翻譯機制的幫助下,實施翻譯工作流程,來使用多種語言向使用者提供服務。

本章的GitHub連結為:BrowseZipDiff.

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-USen-GBen-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是一個超連結,指向使用者的個人主頁,而不僅僅是名字,所以我必須使用setendset模板指令建立一個名為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@ADDRESS>, 2017.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION
"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS
"
"POT-Creation-Date: 2017-09-29 23:23-0700
"
"PO-Revision-Date: 2017-09-29 23:25-0700
"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>
"
"Language: es
"
"Language-Team: es <LL@li.org>
"
"Plural-Forms: nplurals=2; plural=(n != 1)
"
"MIME-Version: 1.0
"
"Content-Type: text/plain; charset=utf-8
"
"Content-Transfer-Encoding: 8bit
"
"Generated-By: Babel 2.5.1
"

#: 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,這是設定頁面的高階部分:

Chrome語言選項

如果你不想更改瀏覽器設定,另一種方法是通過使localeselector函式始終返回一種語言來強制實現。 對西班牙語,你可以這樣做:

app/__init__.py:選擇最佳語言。

@babel.localeselector
def get_locale():
    # return request.accept_languages.best_match(app.config[`LANGUAGES`])
    return `es`

使用為西班牙語配置的瀏覽器執行該應用或返回eslocaleselector函式,將使所有文字在使用該應用時顯示為西班牙文。

更新翻譯

處理翻譯時的一個常見情況是,即使翻譯檔案不完整,你也可能要開始使用翻譯檔案。 這是非常好的,你可以編譯一個不完整的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 %}

現在所有的日期和時間都與文字使用相同的語言了。 你可以在下面看到西班牙語的外觀:

西班牙語的Microblog

此時,除使用者在使用者動態或個人資料說明中提供的文字外,所有其他的文字均可翻譯成其他語言。

命令列增強

你可能會同意我的看法,pybabel命令有點長,難以記憶。 我將利用這個機會向你展示如何建立與flask命令整合的自定義命令。 到目前為止,你已經看到我使用Flask-Migrate擴充套件提供的flask runflask shell和幾個flask db子命令。 將應用特定的命令新增到flask實際上也很容易。 所以我現在要做的就是建立一些簡單的命令,並用這個應用特有的引數觸發pybabel命令。 我要新增的命令是:

  • flask translate init LANG用於新增新語言
  • flask translate update用於更新所有語言儲存庫
  • flask translate compile用於編譯所有語言儲存庫

babel export步驟不會設定為一個命令,因為生成messages.pot檔案始終是執行initupdate命令的先決條件,因此這些命令的執行將會生成翻譯模板檔案作為臨時檔案。

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

該命令的名稱來自被裝飾函式的名稱,並且幫助訊息來自文件字串。 由於這是一個父命令,它的存在只為子命令提供基礎,函式本身不需要執行任何操作。

updatecompile很容易實現,因為它們沒有任何引數:

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()函式在同一個命令中結合了extractupdate步驟,如果一切都成功的話,它會在更新完成後刪除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


相關文章