十三、flask博客项目实战-之国际化和本地化

oakcdrom0条评论 324 次浏览

概述

本章将学习如何扩展Microblog以支持多种语言。作为该工作的一部分,将学习向flask命令 建立自己CLI扩展。

l18n,是Internationalization的缩写,即首末字符i和n,18为中间的字符数,译作 软件国际化,是一个代码修改的过程,以实现代码完全独立于特定文化信息。

类似,L10n,是localization的缩写,即即在 l 和 n 之间有 10 个字母,译作 软件本地化。

为了使得我们的Microblog应用程序对不会说英语的人友好,将实现一个翻译工作流程,在语言翻译的帮助下,允许以多种语言为用户提供应用程序。

Flask-Babel简介

有一个Flask扩展可以让翻译变得很容易。该扩展名为 Flask-Babel,安装:

(venv) [root@localhost flaskblog]# pip3 install flask_babel

Flask-Babel像大多数其他Flask扩展一样需要进行初始化:

app/__init__.py:Flask-Babel实例

# ...
from flask_babel import Babel

app = Flask(__name__)
# ...
babel = Babel(app)
#...

作为本章的一部分,在此将展示如何将应用程序(英文)翻译成中文简体,原博客作者是翻译成西班牙语,我是中国人,当然翻译成中文了,哈哈!当然也可以翻译成其他语言。为了跟踪支持的语言列表,将添加一个配置变量:
microblog/config.py:支持的语言列表

class Config(object):
    # ...
    LANGUAGES = ['en', 'zh']#注意:不要填写zh_CN。有坑!

现在为应用程序使用双语言代码,但如果需要更具体,还可以添加国家/地区代码。例,可使用en-US、en-GB、en-CA以支持美国、英国、加拿大作为不同的语言。再如中文(语言代码 zh,不要后面的含区域的语言代码,有坑!), zh-HK 香港,zh-MO 澳门,zh-TW 台湾,zh-SG 新加坡。

这个Babel实例提供了一个localeselector装饰器。对每个请求调用装饰函数,以选择一个用于该请求的语言翻译:

app/__init__.py

#...
from flask import request

# ...

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

from app import routes,models,errors

上述代码中使用了一个Flask的request对象的属性accept_languages。这个对象提供了一个高级接口,用于处理随客户端随请求发送的Accept-Languages header。这个header 将客户端 语言和区域 设置首选项 指定为加权列表。可在浏览器的首选项页面中配置这个标题这个header的内容,默认情况下,通常从计算机操作系统中的语言设置中导入。大多数人甚至不知道存在这样的设置,但这很有用,因为用户提供首选语言列表,每个语言都有权重。如果你充满好奇心,如下正是一个复杂的Accept-Languages header示例:

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工具将这些文本提取到单独的翻译文件中。不幸的是,这是一项繁琐的工作,需要进行翻译才能实现。

在此,将展示这个标记的一些示例。文本被标记为翻译 的方式是将它们包装在一个函数调用中,这个函数调用约定为 _(),只是一个下划线。最简单的情况是文字字符串出现在源代码中的情况。下方是一个flash()声明的示例:
app/routes.py:

#...
from werkzeug.urls import url_parse
from flask_babel import _
# ...
def index():
    form = PostForm()
    if form.validate_on_submit():
        #...
        db.session.commit()
        flash(_('Your post is now live!'))
        #...
    #...
#...

上述代码表示 ()函数将文本包装在基本语言中(本例是英语)。这个函数将使用由localeselector函数装饰选择的最佳语言 去为一个给定客户端查找正确的翻译。()接着返回已翻译的文本,在这种情况下,将变成参数给flash()。

不幸的是,并非所有案例都那么简单。考虑到来自应用程序的另一个flash()调用:
app/routes.py:

#...
def follow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash('User {} not found.'.format(username))
        #...
     #...

这个文本有一个动态组件,这个组件插入到静态文本的中间。_()函数有支持这类文本的语法,但它是基于较旧的字符串替换语法:修改版如下

    flash(_('User %(username)s not found.', username=username))

还有一个更难处理的案例。某些字符串文字是在请求之外分配的,通常是在应用程序启动时,因此评估这些文本时,无法知道要使用的语言。一个例子是与表单字段相关联的标签。处理这些文本的唯一解决方案是找到一种方法来延迟对字符串的评估,直到它被使用为止,这将在实际请求之下。Flask-Babel提供了一个_()的懒惰评估版本,称为 lazy_gettext():
app/forms.py:

#...
from flask_babel import lazy_gettext as _l
from app.models import User

class LoginForm(FlaskForm):
    username = StringField(_l('Username'), validators=[DataRequired()])
    #...
#...

在上述代码中,将导入这个替换翻译函数,并重命名为l(),让其看起来跟原始的()类似。这个新函数将文本包装在一个特殊对象中,该对象在使用字符串时触发稍后执行的转换。

Flask-Login扩展在将用户重定向到/login页面时会闪烁一条消息。这条消息是英文的,来自扩展本身。为了确保这个消息也被翻译,将覆盖默认消息并提供我自己的包装器,其中包含 _()用于延迟处理的函数:

app/__init__.py

#... from flask_babel import Babel,lazy_gettext as _l #...

login = LoginManager(app)
login.login_view = ‘login’
login.login_message = _l(‘Please log in to access this page.’)
#…

上述仅是提供在Python源代码中标记要翻译的文本的每个情况的示例之一。

剩下工作得完成所有Python源代码中有关要标记翻译的文本。
app/email.py

def send_password_reset_email(user):
token = user.get_reset_password_token()
send_email(_(’[Microblog] Reset Your Password’),#…

还有forms.py、routes.py源代码文件中。

在模板中翻译标记文本

在上一小节中,已经了解了如何在Python源代码中标记要翻译的文本,但这只是此过程的一部分而已,因为模板文件中也有文本。这个_()函数也可在模板中使用,因此该过程非常相似。例如,404.html中代码段:

# File Not Found

启用翻译的版本为:

{{ _('File Not Found') }}

不过要注意,除了用()包装文本之外,还需要添加{{ ... }},以强制()进行评估,而不是将其视为模板中的文字。

对于具有 动态组件 这样更复杂的短语,也可以使用参数:

{{ _('Hi, %(username)s!', username=current_user.username) }}

在_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 是一个指向用户的个人资料页面的链接,而不仅仅是 名称,因此我必须建立一个名为 user_link的中间变量 用到 set和endset模板指令中,然后将其作为参数传递给翻译函数。

剩下的工作是将所有模板中需要翻译的内容进行处理。
app/templates目录下
404.html
500.html
_post.html
base.html
edit_profile.html
index.html
login.html
register.html
reset_password.html
reset_password_request.html
user.html
都得对要翻译的内容进行更新。

提取要翻译的文本

一旦应用程序中该有的地方有了 _()、_l(),就可以使用pybabel命令将它们提取到.pot文件,它代表 便携式对象模板。这是一个文本文件,其中包含 标记为需要翻译的所有文本。这个文件的目的是 作为模板 为每种语言创建翻译文件。

提取过程需要一个小的配置文件,它告诉pybabel应该扫描哪些文件用于可翻译文本。下方将看到为这个应用程序创建的 babel.cfg:
microblog/babel.cfg:

[python: app/**.py]
[jinja2: app/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_

前两行分别定义了Python和Jinja2模板文件的文件名模式。
第三行定义了Jinja2模板引擎提供的两个扩展,帮助Flask-Babel正确解析模板文件。

要将所有文本提取到.pof文件,可使用如下命令:

(venv) [root@python blog]# pybabel extract -F babel.cfg -k _l -o messages.pot .
 (extensions="jinja2.ext.autoescape,jinja2.ext.with_")
writing PO template file to messages.pot

pybabel extract命令读取-F选项中给出的配置文件;然后,从命令中给出的目录(当前目录 或 . 本例中)开始扫描与配置的源匹配的目录中的所有代码和模板文件。默认情况下,pybabel将查找_()这样的文本标记,而且上述还使用了导入的惰性版本_l(),所以需要告诉工具查找那些 -k _l。-o 选项提供输出文件的名称。

要注意到,messages.pot文件(生成在/microblog目录下)不是需要合并到项目中的文件。这是一个可在需要时轻松重新生成的文件,只需再次运行上面的命令即可。因此,无需将此文件提交给源代码控制。

生成语言目录

下一步是 为除基本语言之外 将支持的每种语言创建翻译,在本例中为英语。首先添加 中文(语言代码 zh,下方执行这个操作的命令:

(venv) [root@python blog]# pybabel init -i messages.pot -d app/translations -l zh
creating catalog app/translations/zh/LC_MESSAGES/messages.po based on messages.pot

pybabel init 命令将messages.pot文件作为输入,并将新语言目录写入给定的对用-l选项指定语言的-d 选项中指定的目录中。将在app/translations目录中安装所有翻译,因为这是Flask-Babel默认情况下预期翻译文件的地方。这个命令将在此目录中为 中文数据文件创建一个 zh_cn 子目录。特别是将会有一个名为 app/translations/zh_cn/LC_MESSAGES/messages.po的新文件,这是需要进行翻译的地方。

如果还想支持其他语言,只需使用想要的每种语言代码重复上述命令,以便每种语言都使用messages.po文件获得自己的存储库。

在每个语言仓库中建立的这个messages.po文件 使用的格式是 语言翻译的事实标准,即使用gettext实用程序使用的格式。以下是 中文messages.po开头的几行:

# Chinese translations for PROJECT.
# Copyright (C) 2018 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2018.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2018-08-24 11:52+0800\n"
"PO-Revision-Date: 2018-08-24 11:52+0800\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: zh\n"
"Language-Team: zh <LL@li.org>\n"
"Plural-Forms: nplurals=1; plural=0\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"

#: app/__init__.py:25
msgid "Please log in to access this page."
msgstr ""

#: app/email.py:19
msgid "[Microblog] Reset Your Password"
msgstr ""

如果跳过这个头部,将看到以下内容是从_()、_l()调用中提取的字符串列表。对于每个文本,都会获得应用程序中文本位置的引用。然后,msgid行 包含基本语言的文本,后面的 msgstr行包含一个空字符串。需要编辑这些空字符串以使得目标语言中的文本版本。

有许多翻译应用程序 可以处理 .po文件。如果觉得编辑文本文件很舒服,那就足够了,但如果正在使用大型项目,建议使用专门的编辑器。最流行的翻译应用程序是开源的 poedit(暂未研究是否支持中文),可用于所有主要操作系统。如果熟悉 vim,那么po.vim插件会提供一些关键映射,以便更轻松地使用这些文件。【泪崩!我现在就是手动编辑的!】

messages.po文件是一个可用于翻译的源文件。当想要开始使用这些翻译文本时,得将此文件 编译为在运行时 由应用程序有效使用的格式。要编译应用程序的所有翻译,可使用以下 pybabel compile 命令:

(venv) [root@python blog]# pybabel compile -d app/translations
compiling catalog app/translations\zh\LC_MESSAGES\messages.po to app/translations\zh\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 'zh_cn'

使用为 中文配置的浏览器运行应用程序,或 localeselector()函数返回 zh_cn 将让我们在使用应用程序时以中文显示所有文本。

flask run命令运行程序,效果:图略

更新翻译

使用翻译时有一个常见情况是,即便 文档不完整,但也希望可以使用翻译文件。这也是好的,可以编译一个不完整的 messages.po文件,并将使用任何可用的翻译,而任何缺少的翻译将使用基本语言。然后,可继续处理翻译,并再次编译,以便在我们取得进展时更新messages.mo文件。

如果在添加()包装器时 错过了某些文本,则会出现另一种常见情况。在这种情况下,将看到错过的那些文本将保留为英文,因为Flask-Babel对它们一无所知。在这种情形下,需要 在检测到没有它们的文本时 添加() 或_l()包装器,然后再执行更新过程,其中包括两个步骤:

(venv) [root@python blog]#pybabel extract -F babel.cfg -k _l -o messages.pot .

(venv) [root@python blog]#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()函数返回一个locale对象(语言环境对象),但我只是想拥有语言代码,这可通过将对象转换为字符串来获得。现在有了 g.locale,我们可从基础模板中访问它,用正确的语言配置moment.js:
app/templates/base.html:设置moment.js的语言环境

...
{% block scripts %}
    {{ super() }}
    {{ moment.include_moment() }}
    {{ moment.lang(g.locale) }}
{% endblock %}

现在,所有日期和时间都应该与文本使用相同的语言了。运行程序,效果:没效果,待研究。

此时,除了用户在博客文章或个人资料描述中提供的文本之外的所有文本都应该可翻译成其他语言。

PS:解决方案
app/routes.py文件before_request()方法内的g.locale = 'zh_CN' (不是'zh')写"死":

@app.before_request
def before_request():
    if current_user.is_authenticated:
        current_user.last_seen = datetime.utcnow()
        db.session.commit()
    #g.locale = str(get_locale())
    g.locale = 'zh_CN'

或者写为如下这种,它可识别诸如zh-Hans-CN、zh-HK(中文香港)、zh-MO(中文澳门)、zh-TW(中文台湾)、zh-SG(中文新加坡)。

g.locale = 'zh_CN' if str(get_locale()).startswith('zh') else str(get_locale())

就解决了!

命令行增强功能

从上述过程可注意到,这些pybabel命令有点长,难以记住。因此,接下来将展示创建与flask命令集成的自定义命令。目前为止,已使用过 flask run、flask shell,以及Flask-Migrate扩展提供的几个子命令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():
    #翻译和本地化命令
    pass

命令的名称 来自装饰器函数的命令,并且 帮助消息 来自docstring(文档字符串)。由于这是仅存在为子命令提供一个基础的父命令,因此这个函数本身不需要执行任何操作。

update 和 compile很容易实现,因为它们不带任何参数:
app/cli.py:更新和编译子命令

import os
from app import app

@app.cli.group()
def translate():
    #翻译和本地化命令
    pass

@translate.command()
def update():
    #更新所有语言
    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():
    #编译所有语言
    if os.system('pybabel compile -d app/translations'):
        raise RuntimeError('compile command failed')

注意,这些函数的装饰器是如何从translate父函数派生的。这可能看起来令人困惑,由于translate()是一个函数,但它是Click构建命令组的标准方法。与translate()函数相同,这些函数的文档字符串在--help输出中用作帮助消息。

可以看到,对于所有命令,我运行它们,并确保其返回值为0,这意味着这个命令没有返回任何错误。如果命令错误,那么会引发RuntimeError,这将导致脚本停止。update()函数组合了同一命令中的extract 和 update 步骤,并且如果一切成功,它会在更新完成后删除 messages.pot文件,因为这个文件可以在需要时再次轻松重新生成。

init 命令 将新语言代码作为参数,如下是实现:

import os
import click
from app import app

@app.cli.group()
def translate():
    #翻译和本地化命令
    pass

@translate.command()
@click.argument('lang')
def init(lang):
    #初始化一个新语言
    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/microblog.py:注册命令行命令

from app import cli

在此,唯一需要做的是导入新的 cli.py模块,不需要对它做任何事情,因为导入促使命令装饰器运行,并注册命令。

此时,运行 flask --help 将列出 translate命令作为选项。并且,flask translate --help 将显示我们定义的那 3个子命令:

运行上述两个命令,

(venv) [root@python microblog]# flask --help
[2018-08-24 16:15:33,934] INFO in __init__: Microblog startup
Usage: flask [OPTIONS] COMMAND [ARGS]...

  A general utility script for Flask applications.

  Provides commands from Flask, extensions, and the application. Loads the
  application defined in the FLASK_APP environment variable, or from a
  wsgi.py file. Setting the FLASK_ENV environment variable to 'development'
  will enable debug mode.

    > set FLASK_APP=hello.py
    > set FLASK_ENV=development
    > flask run

Options:
  --version  Show the flask version
  --help     Show this message and exit.

Commands:
  db      Perform database migrations.
  routes  Show the routes for the app.
  run     Runs a development server.
  shell   Runs a shell in the app context.

(venv) [root@python  microblog]#flask translate --help
[2018-08-24 16:04:47,204] INFO in __init__: Microblog startup
Usage: flask [OPTIONS] COMMAND [ARGS]...

Error: No such command "translate".

(venv) [root@python  microblog]#flask translate --help
[2018-08-24 16:15:23,730] INFO in __init__: Microblog startup
Usage: flask [OPTIONS] COMMAND [ARGS]...

Error: No such command "translate".

出Error了。待研究!!

【后续】PS:解决方案
得重新设置 FLASK_APP这个环境变量:

(venv) [root@python  microblog]#set FLASK_APP=microblog.py

(venv) [root@python  microblog]# flask --help
[2018-08-24 18:31:38,172] INFO in __init__: Microblog startup
Usage: flask [OPTIONS] COMMAND [ARGS]...

  A general utility script for Flask applications.

  Provides commands from Flask, extensions, and the application. Loads the
  application defined in the FLASK_APP environment variable, or from a
  wsgi.py file. Setting the FLASK_ENV environment variable to 'development'
  will enable debug mode.

    > set FLASK_APP=hello.py
    > set FLASK_ENV=development
    > flask run

Options:
  --version  Show the flask version
  --help     Show this message and exit.

Commands:
  db         Perform database migrations.
  routes     Show the routes for the app.
  run        Runs a development server.
  shell      Runs a shell in the app context.
  translate

(venv) [root@python  microblog]#flask translate --help
[2018-08-24 18:32:22,637] INFO in __init__: Microblog startup
Usage: flask translate [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  compile
  init
  update

顺利解决!!

所以现在,工作流程更简单了,不需要记住冗长复杂的命令。如果要添加新语言,可使用:

flask translate init <language-code>

在用_()、_l()语言标记做了更改后,去更新所有语言:

flask translate update

并在更新翻译文件后,编译所有语言:

flask translate compile

目前为止,项目结构

    microblog/
    app/
        templates/
            email/
                reset_password.html
                reset_password.txt
            _post.html
            404.html
            500.html
            base.html
            edit_profile.html
            index.html
            login.html
            register.html
            reset_password.html
            reset_password_request.html
            user.html
        translations/
            zh/
                LC_MESSAGES/
                    messages.mo
                    messages.po
        __init__.py
        cli.py
        email.py
        errors.py
        forms.py
        models.py
        routes.py
    logs/
        microblog.log
    migrations/
    venv/
    app.db
    babel.cfg
    config.py
    messagee.pot
    microblog.py
    tests.py

发表评论

? razz sad evil ! smile oops grin eek shock ??? cool lol mad twisted roll wink idea arrow neutral cry mrgreen