Flask教程第十四章:Ajax

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

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

這是Flask Mega-Tutorial系列的第十四部分,我將使用Microsoft翻譯服務和少許JavaScript來新增實時語言翻譯功能。

在本章中,我將從伺服器端開發的“安全區域”脫離,研究與伺服器端同樣重要的客戶端元件的功能。 你是否看到過某些網站在使用者生成的內容旁邊顯示的“翻譯”連結? 這些連結會觸發非使用者本地語言內容的實時自動翻譯。 翻譯的內容通常插入原始版本的下方。 Google將其顯示為外語搜尋結果。 Facebook在使用者動態上使用它。 Twitter在推文上使用它。 今天我將向你展示如何將相同的功能新增到Microblog!

本章的GitHub連結為:BrowseZipDiff.

伺服器端與客戶端

迄今為止,在我遵循的傳統伺服器端模型中,有一個客戶端(由使用者驅動的Web瀏覽器)嚮應用伺服器發出HTTP請求。 請求可以簡單地請求HTML頁面,例如當你單擊“個人主頁”連結時,或者它可以觸發一個操作,例如在編輯你的個人資訊之後單擊提交按鈕。 在這兩種型別的請求中,伺服器通過直接傳送新的網頁或通過傳送重定向來完成請求。 然後客戶端用新的頁面替換當前頁面。 只要使用者停留在應用的網站上,該週期就會重複。 在這種模式下,伺服器完成所有工作,而客戶端只顯示網頁並接受使用者輸入。

有一種不同的模式,客戶端扮演更積極的角色。 在這個模式中,客戶端向伺服器發出一個請求,伺服器響應一個網頁,但與前面的情況不同,並不是所有的頁面資料都是HTML,頁面中也有部分程式碼,通常用Javascript編寫。 一旦客戶端收到該頁面,它就會顯示HTML部分,並執行程式碼。 從那時起,你就擁有了一個可以獨立工作的活動客戶端,而無需與伺服器進行聯絡或只有很少聯絡。 在嚴格的客戶端應用中,整個應用通過初始頁面請求下載到客戶端,然後應用完全在客戶端上執行,只有在查詢或者變更資料時才與伺服器聯絡。 這種型別的應用稱為單頁應用(SPAs)。

大多數應用是這兩種模式的混合,並結合了兩者的技術特點。 我的Microblog應用主要是伺服器端應用,但今天我將新增一些客戶端操作。 為了實時翻譯使用者動態,客戶端瀏覽器將非同步請求傳送到伺服器,伺服器將響應該請求而不會導致頁面重新整理。然後客戶端將動態地將翻譯插入當前頁面。 這種技術被稱為Ajax,這是Asynchronous JavaScript和XML的簡稱(儘管現在XML常常被JSON取代)。

實時翻譯的工作流程

由於使用了Flask-Babel,本應用對外語有很好的支援,可以支援儘可能多的語言,只要我找到了對應的譯文。 但是遺漏了一個元素,使用者將會用他們自己的語言發表動態,所以使用者很可能會用應用未知的語言發表動態。 自動翻譯的質量大多數情況下不怎麼樣,但在,如果你只想對另一種語言的文字瞭解其基本含義,這已經足夠了。

這正是Ajax大展身手的好機會! 設想主頁或發現頁面可能會顯示若干使用者動態,其中一些可能是外語。 如果我使用傳統的伺服器端技術實現翻譯,則翻譯請求會導致原始頁面被替換為新頁面。 事實是,要求翻譯諸多使用者動態中的一條,並不是一個足夠大的動作來要求整個頁面的更新,如果翻譯文字可以被動態地插入到原始文字下方,而剩下的頁面保持原樣,則使用者體驗更加出色。

實施實時自動翻譯需要幾個步驟。 首先,我需要一種方法來識別要翻譯的文字的源語言。 我還需要知道每個使用者的首選語言,因為我想僅為使用其他語言發表的動態顯示“翻譯”連結。 當提供翻譯連結並且使用者點選它時,我需要將Ajax請求傳送到伺服器,伺服器將聯絡第三方翻譯API。 一旦伺服器傳送了帶有翻譯文字的響應,客戶端JavaScript程式碼將動態地將該文字插入到頁面中。 你一定注意到了,這裡有一些特殊的問題。 我將逐一審視這些問題。

語言識別

第一個問題是確定一條使用者動態的語言。這不是一門精確的科學,因為不能確保監測結果絕對正確,但是對於大多數情況,自動檢測的效果相當好。 在Python中,有一個稱為guess_language的語言檢測庫,還算好用。 這個軟體包的原始版本相當陳舊,從未被移植到Python 3,因此我將安裝支援Python 2和3的派生版本:

(venv) $ pip install guess-language_spirit

計劃是將每條使用者動態提供給這個包,以嘗試確定語言。 由於做這種分析有點費時,我不想每次把帖子呈現給頁面時重複這項工作。 我要做的是在提交時為帖子設定源語言。 檢測到的語言將被儲存在post表中。

第一步,新增language欄位到Post模型:

app/models.py:新增監測到的語言到Post模型:

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函式測試文字來嘗試確定語言。 如果語言監測為未知,或者如果我得到意想不到的長字串的結果,我會將一個空字串儲存到資料庫中以安全地使用它。 我將採用約定,將任何將把語言設定為空字串的帖子假定為未知語言。

展示一個“翻譯”連結

第二步很簡單。 我現在要做的是在任何不是當前使用者的首選語言的使用者動態下,新增一個“翻譯”連結。

app/templates/_post.html:給使用者動態新增翻譯連結。

                {% if post.language and post.language != g.locale %}
                <br><br>
                <a href="#">{{ _(`Translate`) }}</a>
                {% endif %}

我在_post.html子模板中執行此操作,以便此功能出現在顯示使用者動態的任何頁面上。 翻譯連結只會出現在檢測到語言種類的動態下,並且必須滿足的條件是,這種語言與用Flask-Babel的localeselector裝飾器裝飾的函式選擇的語言不匹配。 回想一下第十三章所選語言環境儲存為g.locale。 連結文字需要以Flask-Babel可以翻譯的方式新增,所以我在定義它時使用了_()函式。

請注意,我還沒有關聯此連結的操作。 首先,我想弄清楚如何進行實際的翻譯。

使用第三方翻譯服務

兩種主要的翻譯服務是Google Cloud Translation APIMicrosoft Translator Text API。 兩者都是付費服務,但微軟為低頻少量的翻譯提供了免費的入門級選項。 谷歌過去提供免費翻譯服務,但現在,即使是最低層次的服務也需要付費。 因為我希望能夠在不產生費用的情況下嘗試翻譯,我將實施Microsoft的解決方案。

在使用Microsoft Translator API之前,你需要先獲得微軟雲服務Azure的帳戶。 你可以選擇免費套餐,但在註冊過程中系統會要求你提供信用卡號,但在你保持該級別的服務時,你的卡不會被收取費用。

獲得Azure帳戶後,轉到Azure門戶並單擊左上角的“New”按鈕,然後鍵入或選擇“Translator Text API”。 當你點選“Create”按鈕時,將看到一個表單,並可以在其中定義一個新的翻譯器資源,然後將其新增到你的帳戶中。 你可以在下面看到我是如何完成表單的:

Azure Translator

當你再次點選“Create”按鈕時,翻譯器API資源將被新增到你的帳戶中。幾秒鐘之後,你將在頂欄中收到通知,說明部署了翻譯器資源。 點選通知中的“Go to resource”按鈕,然後點選左側欄上的“Keys”選項。 你現在將看到兩個Key,分別標記為“Key 1”和“Key 2”。 將其中一個Key複製到剪貼簿,然後將其設定到終端的環境變數中(如果使用的是Microsoft Windows,請用set替換export):

(venv) $ export MS_TRANSLATOR_KEY=<paste-your-key-here>

該Key用於驗證翻譯服務,因此需要將其新增到應用配置中:

config.py: 新增Microsoft Translator API key到配置中。

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`))

該函式定義需要翻譯的文字、源語言和目標語言為引數,並返回翻譯後文字的字串。 它首先檢查配置中是否存在翻譯服務的Key,如果不存在,則會返回錯誤。 錯誤也是一個字串,所以從外部看,這將看起來像翻譯文字。 這可確保在出現錯誤時使用者將看到有意義的錯誤訊息。

requests包中的get()方法向作為第一個引數給定的URL傳送一個帶有GET方法的HTTP請求。 我使用/v2/Ajax.svc/Translate URL,它是翻譯服務中的一個端點,它將翻譯內容荷載為JSON返回。文字、源語言和目標語言都需要在URL中分別命名為textfromto作為查詢字串引數。 要使用該服務進行身份驗證,我需要將我新增到配置中的Key傳遞給該服務。 該Key需要在名為Ocp-Apim-Subscription-Key的自定義HTTP頭中給出。 我建立了auth字典,然後將它通過headers引數傳遞給requests

requests.get()方法返回一個響應物件,它包含了服務提供的所有細節。 我首先需要檢查和確認狀態碼是200,這是成功請求的程式碼。 如果我得到任何其他程式碼,我就知道發生了錯誤,所以在這種情況下,我返回一個錯誤字串。 如果狀態碼是200,那麼響應的主體就有一個帶有翻譯的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

我將從實現伺服器端部分開始。 當使用者單擊動態下方顯示的翻譯連結時,將向伺服器發出非同步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請求的形式實現了這條路由。 關於什麼時候使用GETPOST(或者還沒有見過的其他請求方法),真的沒有絕對的規則。 由於客戶端將傳送資料,因此我決定使用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提供翻譯,當使用者單擊我上面新增的“翻譯”連結時,我需要呼叫此URL,傳遞需要翻譯的文字、源語言和目標語言。 如果你不熟悉在瀏覽器中使用JavaScript,這將是一個很好的學習機會。

在瀏覽器中使用JavaScript時,當前顯示的頁面在內部被表示為文件物件模型(DOM)。 這是一個引用頁面中所有元素的層次結構。 在此上下文中執行的JavaScript程式碼可以更改DOM以觸發頁面中的更改。

我們首先需要討論的是,在瀏覽器中執行的JavaScript程式碼如何獲取需要傳送到伺服器中執行的翻譯函式的三個引數。 為了獲得文字,我需要找到包含使用者動態正文的DOM內的節點並獲取它的內容。 為了便於識別包含使用者動態的DOM節點,我將為它們附加一個唯一的ID。 如果你檢視_post.html模板,則呈現使用者動態正文的行只會讀取{{post.body}}。 我要做的是將這些內容包裝在一個<span>元素中。 這不會在視覺上改變任何東西,但它給了我一個可以插入識別符號的地方:

app/templates/_post.html:給每條使用者動態新增ID。

                <span id="post{{ post.id }}">{{ post.body }}</span>

這將為每條使用者動態分配一個唯一識別符號,格式為post1post2等,其中數字與每條使用者動態的資料庫識別符號相匹配。 現在每條使用者動態都有一個唯一的識別符號,給定一個ID值,我可以使用jQuery定位<span>元素並提取其中的文字。 例如,如果我想獲得ID為123的使用者動態的文字,我可以這樣做:

$(`#post123`).text()

這裡的$符號是jQuery庫提供的函式的名稱。 這個庫被Bootstrap使用,所以它已經被Flask-Bootstrap包含。 是jQuery使用的“選擇器”語法的一部分,這意味著接下來是元素的ID。

我也希望有一個地方可以在我從伺服器收到翻譯文字後插入翻譯文字。 我要做的是將“翻譯”連結替換為翻譯文字,因此我還需要為該節點提供唯一識別符號:

app/templates/_post.html:為翻譯連結新增ID。

                <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,後兩個引數是源語言和目標語言程式碼。

該函式從一個很好的接觸開始:它新增一個載入器替換翻譯連結,以便使用者知道翻譯正在進行中。 這是通過使用$(destElem).html()函式完成的,它用基於<img>元素的新HTML內容替換定義為翻譯連結的原始HTML。 對於載入器,我將使用一個小的動畫GIF,它已新增到Flask為靜態檔案保留的app/static目錄中。 為了生成引用這個影像的URL,我使用url_for()函式,傳遞特殊的路由名稱static並給出影像的檔名作為引數。 你可以在本章的下載包中找到loading.gif影像。

現在我用一個優雅的載入器代替了翻譯連結,以便使用者知道要等待翻譯出現。 下一步是將POST請求傳送到我在前一節中定義的/translate URL。 為此,我也將使用jQuery,本處使用$ .post()函式。 這個函式以一種類似於瀏覽器提交Web表單的格式向伺服器提交資料,這很方便,因為它允許Flask將這些資料合併到request.form字典中。 $ .post()的引數是兩個,第一個是傳送請求的URL,第二個是包含伺服器期望的三個資料項的字典(或者稱之為物件,因為這些是在JavaScript中呼叫的)。

你可能知道JavaScript對回撥函式(或者稱為promises的更高階的回撥形式)友好。 現在要做的就是說明一旦這個請求完成並且瀏覽器接收到響應,我想完成的事情。 在JavaScript中沒有需要等待的事情,一切都是非同步。 我需要做的是提供一個回撥函式,瀏覽器在接收到響應時呼叫它。 而且,為了使所有內容儘可能健壯,我想指出在出現錯誤的情況下該怎麼做,以作為處理錯誤的第二個回撥函式。 有幾種方法可以指定這些回撥,但在這種情況下,使用promises可以使程式碼更加清晰。 語法如下:

$.post(<url>, <data>).done(function(response) {
    // success callback
}).fail(function() {
    // error callback
})

promise語法允許將$ .post()呼叫的返回值“傳入”回撥函式作為引數。 在成功回撥中,我所需要做的就是使用翻譯後的文字呼叫$(destElem).text(),該文字在字典中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,則現在應該能夠觸發翻譯。 假設你的瀏覽器設定為偏好英語,則需要使用其他語言撰寫文章以檢視“翻譯”連結。 下面你可以看到一個例子:

Translation

在本章中,我介紹了一些需要翻譯成應用支援的所有語言的新文字,因此有必要更新翻譯目錄:

(venv) $ flask translate update

對於你自己的專案,需要編輯每個語言儲存庫中的messages.po檔案以包含這些新測試的翻譯,不過我已經在本章的下載包或GitHub儲存庫中建立了西班牙語翻譯。

要完成新的翻譯,還需要執行編譯:

(venv) $ flask translate compile


相關文章