本系列删改自Miguel Grinberg的Flask Mega-Tutorial系列。点此查看作者原文
服务端 vs. 客户端
到目前为止我们都遵循着传统的服务端模式,由客户端(用户控制的浏览器)向应用服务发出HTTP请求(request)。request可以获取HTML页面,比如当你点击“Profile”链接时,或者可以触发一个行为,比如当你修改了资料页面然后点击Submit按钮时。两种request中服务端都通过将新的web页面发送到客户端来完成请求,不管是直接创建新页面还是重定向。然后客户端会将当前页面替换为新页面。只要用户还停留在当前应用网站上,这个循环就会一直重复。在这个模式中服务端做了所有的工作,而客户端只是在显示web页面以及接收用户的输入。
另外还有一种模式,客户端扮演着更积极的角色。在这种模式中,客户端发送一个请求(request),服务端响应(response)一个web页面,但不像之前的例子,并不是所有的页面数据都是HTML,页面中包含通常是用JavaScript写的代码。一旦客户端接收到了页面,它会显示HTML的部分,并执行代码。从此你的客户端就可以独立完成工作而只需与服务器进行极少的通信甚至完全不需要。在一个严格的客户端应用中整个应用都在第一次页面请求中下载到了客户端,然后应用就完全在客户端运行了,只有在服务端取回或存储数据以及对第一且唯一的页面的外观进行动态更改时才会进行通信。这种类型的应用叫做单页应用或者SPA。
大多应用混合了这两种模式并兼备两种技术。我的微型博客主要是个服务端应用,但是今天我会增加一点客户端行为。要完成用户帖子的实时翻译,客户端浏览器会发送异步请求到服务端,服务端的响应不会造成页面的重载。客户端会动态的将翻译插入到当前页面。这种技术被称为Ajax,也就是Asynchronous JavaScript and XML(异步JavaScript和XML,虽然如今XML通常都被JSON所取代了)的缩写。
实时翻译工作流
多亏Flask-Babel,这个应用对外语有很好的支持,使支持更多语言成为可能(只要我能找到译者)。但是当然,还有个元素缺失了。用户会用他们自己的语言写博客帖子,所以用户很可能会遇到用未知语言编写的帖子。自动翻译的质量并不总是很好,但在大多数情况下如果你只是想对一些文本在其他语言中是什么意思有个基本的概念的话,那就够了。
这是作为Ajax服务实现的理想功能。想象一下index或者explore页面显示多个帖子,其中可能有外语的。如果我用传统的服务端技术实现翻译,对翻译的请求会造成原页面被新页面替换。事实是,对多个帖子中的一个页面进行翻译并不足以需要对整个页面进行更新,如果动态地将翻译文本插入到原始文本下方,而页面的其余部分保持不变,这个功能的效果会更好。
实现实时自动翻译需要几个步骤。第一,我需要一种方式来识别要翻译的文本的源语言。我也得知道每个用户的偏好语言,因为我想在用其他语言写的帖子上显示一个“translate”链接。当用户点击了翻译链接,我需要发送Ajax请求到服务端,服务端会与第三方翻译API通信。一旦服务端返回含有已翻译文本的响应,客户端的javascript代码将会动态的插入这个文本到页面上。你肯定注意到了,其中有些复杂的问题,我会一个个的解释。
语言识别
第一个问题是识别帖子是用什么语言写的。这并不是精密科学,因为并非总能准确的检测出一种语言,但是在大多数情况下,自动识别做的不错。在Python中,有个很好的语言识别库叫做guess_language。这个包的原始版本很老并且从没有被移植到Python 3中,因此我要安装一个支持Python 2和3的派生版本:
(venv) $ pip install guess-language_spirit
计划是将每个博客帖子反馈到这个包中,来试着确定语言。由于做这项分析有点花费时间,我不想再每次帖子被渲染到一个页面时都重复一次。我要做的是在帖子被提交时设置源语言。被检测到的语言将会被存储到posts表。
第一步是向 Post 模型添加一个 language 字段:
app/models.py
class Post(db.Model):
# ...
language = db.Column(db.String(5))
正如你记得的,每次数据库模型有更改,都得执行一次数据库迁移:
(venv) $ flask db migrate -m "add language to posts"
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added column 'post.language'
Generating migrations/versions/2b017edaa91f_add_language_to_posts.py ... done
然后向数据库应用迁移:
(venv) $ flask db upgrade
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Upgrade ae346256b650 -> 2b017edaa91f, add language to posts
现在当一个帖子提交后就可以检测并储存语言了:
app/routes.py
from guess_language import guess_language
@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
form = PostForm()
if form.validate_on_submit():
language = guess_language(form.post.data)
if language == 'UNKNOWN' or len(language) > 5:
language = ''
post = Post(body=form.post.data, author=current_user,
language=language)
# ...
有了这个修改,每次有帖子提交了,我都会通过 guess_language 函数来运行文本识别其语言。如果返回的是未知语言或者得到了长结果,那么我会保存空字符串到数据库。我将将所有language字段为空字符串的帖子假定为未知语言。
显示“Translate”链接
第二步很简单。我要做的向是语言对当前用户无效的帖子上添加一个“Translate”链接。
app/templates/_post.html
{% if post.language and post.language != g.locale %}
<br><br>
<a href="#">{{ _('Translate') }}</a>
{% endif %}
我在 _post.html 子模版添加这个链接,是此功能显示在所有帖子上。翻译链接只会在语言被识别出来并且这种语言与Flask-Babel的 localeselector 装饰器装饰的函数所选的语言不一样时才会显示。在13章重选择的地区存储在g.locale中。链接的文本需要以可以被Flask-Babel翻译的方式添加,所以我用了 _() 函数。
注意现在我还没有给这个链接添加行为。首先我要搞清楚如何进行实际的翻译。
5. 使用第三方翻译服务
两个主流的翻译服务是Google Cloud Translation API 和 Microsoft Translator Text API。两者都是付费服务,但微软为少量的翻译提供了入门级的免费选择。以前谷歌也提供免费翻译服务,但是现在即使是最低级的服务也是付费的。由于我想要不付费的翻译服务,所以我选择微软。
在你能使用Microsoft Translator API之前,你需要一个 Azure账号, 这是微软的云服务。你可以选择免费版本,注册时你会被要求提供信用卡卡号,使用这个级别的服务你的信用卡不会被收费。
一旦你有了Azure账户,转到Azure Portal按钮点击左上方的“New”按钮,然后输入或选择“Translator Text API”。当你点击“Create”按钮,将会出现一个表单,你可以定义了将添加到您的账户中的新的翻译资源。下面是我填的表单:
当你再次点击“Create”按钮, 翻译API资源将会添加到你的账户。如果你再等待几秒,上边栏会收到一条通知。点击通知中的“Go to resource”按钮,然后点击左侧边栏的“Keys”选项。现在你会看到两个key,“Key 1”和“Key 2”。复制任一个key然后在终端设置环境变量。
(venv) $ export MS_TRANSLATOR_KEY=<paste-your-key-here>
这个key是用于认证翻译服务的,所以需要添加到应用配置中:
config.py
class Config(object):
# ...
MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY')
和往常一样,我喜欢把将它设为环境变量然后导入到Flask应用配置中。对于用于访问第三方服务的敏感信息比如密钥密码来说这是非常重要的。你肯定不想把这些信息显式的写到代码中。
Microsoft Translator API 是一个接受HTTP请求的web服务。Python中有几个HTTP客户端,但最流行和最易于使用的是 requests 包。那么让我们将它安装到虚拟环境中:
(venv) $ pip install requests
下面你能看到我写的使用Microsoft Translator API翻译文本的函数。我将它放在了新的 app/translate.py 模块中:
app/translate.py
import json
import requests
from flask_babel import _
from app import app
def translate(text, source_language, dest_language):
if 'MS_TRANSLATOR_KEY' not in app.config or \
not app.config['MS_TRANSLATOR_KEY']:
return _('Error: the translation service is not configured.')
auth = {'Ocp-Apim-Subscription-Key': app.config['MS_TRANSLATOR_KEY']}
r = requests.get('https://api.microsofttranslator.com/v2/Ajax.svc'
'/Translate?text={}&from={}&to={}'.format(
text, source_language, dest_language),
headers=auth)
if r.status_code != 200:
return _('Error: the translation service failed.')
return json.loads(r.content.decode('utf-8-sig'))
这个函数接受要翻译的函数、源语言、目标语言的代码作为参数,以字符串的形式返回翻译完成的文本。保证万一出错了,用户能看到一条语义化的错误消息。
requests包的get()方法用GET方法发送一条HTTP请求到给定的URL(即第一个参数)。 我用的是 /v2/Ajax.svc/Translate URL,这是翻译服务中作为JSON payload返回的翻译内容的endpoint。文本,源语言,目标语言需要以查询字符串参数的形式在URL中给出,依次为 text,from,和to。为了进行身份验证,我需要传递我添加到配置中的key。这个key要在自定义HTTP header中的 Ocp-Apim-Subscription-Key 给定。我用这个header创建了auth字典,然后以headers参数的形式,将其传递给requests。
requests.get() 方法返回一个响应对象,包含了服务器提供的所有细节。首先我需要检查状态码是否为200,这个代码代表成功请求。如果我收到了其他状态码,那就代表有错误,所以在那种情况下会返回错误字符串。如果状态码是200,那么响应的body包含了带有JSON编码的翻译字符串,所以我需要用Python标准库的 json.loads() 函数来将JSON解码为Python字符串。响应对象的content属性包含了字节形式的原始响应体,转换为UTF-8字符串然后发送给json.loads()。
下面你能看到我在Python控制台中使用translate()函数:
>>> from app.translate import translate
>>> translate('Hi, how are you today?', 'en', 'es') # English to Spanish
'Hola, ¿cómo estás hoy?'
>>> translate('Hi, how are you today?', 'en', 'de') # English to German
'Are Hallo, how you heute?'
>>> translate('Hi, how are you today?', 'en', 'it') # English to Italian
'Ciao, come stai oggi?'
>>> translate('Hi, how are you today?', 'en', 'fr') # English to French
"Salut, comment allez-vous aujourd'hui ?"
服务端的Ajax
我将从实现服务端开始。当用户点击帖子下方的Translate链接,会向服务器发送一个异步HTTP请求。在下一节我会向你展示怎么实现它,现在我们先来专注实现服务器对此请求的处理。
异步(或Ajax)请求与应用中的路由和视图函数相似,唯一的不同是它返回的不是HTML或者重定向,而是数据,格式为XML或者更常见的JSON。下面你能看到翻译视图函数,调用了Microsoft Translator API然后以JSON的格式返回已翻译的文本:
app/routes.py
from flask import jsonify
from app.translate import translate
@app.route('/translate', methods=['POST'])
@login_required
def translate_text():
return jsonify({'text': translate(request.form['text'],
request.form['source_language'],
request.form['dest_language'])})
正如你看到的,这很简单。我将此路由作为POST请求实现。对于何时使用“GET”或“POST”(或其他你没见过的方法),没有绝对的规则。request.form属性是字典形式的,这是Flask中包含在提交中的所有数据。当我使用web表单时,我无需检查request.form因为Flask-WTF为我做了所有的工作,但在这个例子中,并没有web表单,所以我直接访问了数据。
因此,在这个函数中我所做的就是调用前文写的translate()函数,然后直接从请求中提交的数据中传递三个参数。结果合并为一个单键字典,键为text,这个字典作为参数传递给Flask的jsonify()函数,也就是将这个字典转换为一个JSON格式的数据。jsonify()函数的返回值是一个将要发送会客户端的HTTP响应。比如说,如果客户端想要将字符串 Hello, World! 翻译为西班牙语,这个请求的响应会是这样的:
{ "text": "Hola, Mundo!" }
客户端的Ajax
因此现在服务端可以通过/translate URL提供翻译了,我需要在用户点击“Translate”链接时调用这个URL,传递文本,源语言,目标语言并翻译。
当在浏览器中使用JavaScript时,当前显示的页面在内部以 Document Object Model(文档对象模型,即DOM)的形式表示。这是一个层级结构,引用页面内存在的所有元素。运行在上下文中的JavaScript代码可以改变DOM来触发对页面的更改。
首先来讨论我的JavaScript代码是如何获得我需要发送到服务端翻译函数的三个参数。要获得文本,我需要找到包含博客帖子的DOM节点并读取它的内容。为了更容易地识别这个包含博客帖子的DOM节点,我将给它们加上一个唯一ID。如果你看一下 _post.html 模板,渲染帖子内容的行读取 {{ post.body }}。我要做的是将它包围在一个<span>元素中。看起来没有什么变化,但是它能使我在其中防止标识符:
app/templates/_post.html
<span id="post{{ post.id }}">{{ post.body }}</span>
这将为每个博客帖子分配一个唯一标识符,像post1, post2这样的格式,其数字与帖子在数据库中的标识符相匹配。现在每个博客帖子都有了一个唯一标识符,给定一个ID值,我可以用JQuery来找到该帖子并取得其文本。比如,如果我想要获取ID为123的帖子的文本,我可以这么做:
$('#post123').text()
$标识是jQuery库提供的一个函数的名称。Bootstrap也用到了这个库,所以Flask-Bootstrap中已经包含它了。 #是jQuery选择器语法中的一部分,代表ID的意思。
一旦我收到了服务端返回的已翻译文本,我还需要找个地方放置这些文本。我要做的是将“Translate”链接替换为已翻译的文本,所以要给这个节点也加上一个唯一标识符:
app/templates/_post.html
<span id="translation{{ post.id }}">
<a href="#">{{ _('Translate') }}</a>
</span>
因此现在对于一个给定的帖子ID,我有一个代表博客帖子的 post<ID>节点,以及一个用于防止已翻译文本的translation<ID>节点。
下一步是写个可以完成整个翻译工作的函数,这个函数将会接收输入和输出DOM节点,以及源语言和目标语言,用所需的三个参数向服务器发起异步请求,最后,一旦服务端响应了,将翻译链接替换为已翻译的文本。听起来是很多工作,但实现起来相当简单:
app/templates/base.html
{% block scripts %}
...
<script>
function translate(sourceElem, destElem, sourceLang, destLang) {
$(destElem).html('<img src="{{ url_for('static', filename='loading.gif') }}">');
$.post('/translate', {
text: $(sourceElem).text(),
source_language: sourceLang,
dest_language: destLang
}).done(function(response) {
$(destElem).text(response['text'])
}).fail(function() {
$(destElem).text("{{ _('Error: Could not contact server.') }}");
});
}
</script>
{% endblock %}
前两个参数是帖子和翻译链接节点的唯一ID。后两个参数是源语言和目标语言的代码。
这个函数在最开始将翻译链接替换为加载gif,这样用户就知道翻译正在进行中。这通过jQuery实现,通过$(destElem).html()函数将翻译链接所在的原始HTML替换为<img>标签的新HTML。
有了这个gif,用户就知道要等待翻译显示了。下一步是向前面定义的 /translate URL发送POST请求。对于这个功能,我也会用jQuery,$.post() 函数提交数据到服务端,和浏览器提交web表单的形式差不多,这么做很方便,因为这使得Flask将数据组合为 request.form 字典,$.post()有两个参数,第一个是要发送请求的URL,然后是一个含有三个数据项的字典(这在JavaScript中被称为对象)。
你大概知道JavaScript在回调函数中作用很大,或者更高级的回调函数称为 promise。我要做的是明确一旦这个请求完成浏览器收到了响应我要做些什么。在JavaScript中并没有等待某事这样的东西,所有东西都是异步的。我要做的是在浏览器接收到响应后将会调用的回调函数。并且这也是使所有内容更健壮的一种方式,我指出了万一在有错误发生的情况下做些什么,因此这将是用于处理错误的第二个回调函数。指定这些回调的方法的好几种,在这个例子中,用promise可以使代码更清晰。其语法如下:
$.post(<url>, <data>).done(function(response) {
// success callback
}).fail(function() {
// error callback
})
promise的语法允许你将 $.post() 回调的返回值“串联”起来。在回调成功时,我要做的是调用$(destElem).text()以及字典中key为text的已翻译文本。在回调错误时,做法一样,但是显示的文本是个通用错误消息。
现在最后一件事是在用户点击翻译链接后,用正确的参数作为结果触发 translate() 函数。方法也有好几种,我要做的是将对函数的调用嵌入到链接的href属性中:
app/templates/_post.html
<span id="translation{{ post.id }}">
<a href="javascript:translate(
'#post{{ post.id }}',
'#translation{{ post.id }}',
'{{ post.language }}',
'{{ g.locale }}');">{{ _('Translate') }}</a>
</span>
链接的href元素可以接受任何以javascript:作为前缀的JavaScript代码,所以这是个方便的方式来调用翻译函数。因为当客户端请求这个页面时,这个链接在服务端将会在被渲染,我可以用{{}}表达式来生成该函数的四个参数。每个帖子都会有带有其自己唯一参数的翻译链接。你看到的#代表跟着的是 post<ID> 和 translation<ID> 这样的ID元素。
现在在线翻译功能完成了,如果你在环境中设置了有效的 Microsoft Translator API key,你应该就可以触发翻译了。
在本章中我介绍了一些需要翻译成应用程序所支持语言的新文本,因此有必要更新一下翻译目录:
(venv) $ flask translate update
编译:
(venv) $ flask translate compile