帶你認識 flask ajax 異步請求

  • 2019 年 11 月 24 日
  • 筆記

01

服務端與客戶端

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

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

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

02

實時翻譯工作流

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

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

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

03

語言識別

第一個問題是確定一條用戶動態的語言。這不是一門精確的科學,因為不能確保監測結果絕對正確,但是對於大多數情況,自動檢測的效果相當好。在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 upgradeINFO  [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_requireddef 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函數測試文本來嘗試確定語言。如果語言監測為未知,或者如果我得到意想不到的長字符串的結果,我會將一個空字符串保存到數據庫中以安全地使用它。我將採用約定,將任何將把語言設置為空字符串的帖子假定為未知語言

04

展示一個 『翻譯』鏈接

第二步很簡單。我現在要做的是在任何不是當前用戶的首選語言的用戶動態下,添加一個「翻譯」鏈接

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可以翻譯的方式添加,所以我在定義它時使用了_()函數

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

05

使用第三方『翻譯』服務

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

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

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

當你再次點擊「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 jsonimport requestsfrom 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 ?"

06

來自服務器的 Ajax

我將從實現服務器端部分開始。當用戶單擊動態下方顯示的翻譯鏈接時,將向服務器發出異步HTTP請求。我將在下一節中向你展示如何執行此操作,因此現在我將專註於實現服務器處理此請求的操作。

異步(Ajax)請求類似於我在應用中創建的路由和視圖函數,唯一的區別是它不返回HTML或重定向,而是返回數據,格式為XML或更常見的JSON。你可以在下面看到翻譯視圖函數,該函數調用Microsoft Translator API,然後返回JSON格式的翻譯文本:

app/routes.py:文本翻譯視圖函數

from flask import jsonifyfrom app.translate import translate  @app.route('/translate', methods=['POST'])@login_requireddef 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!" }

07

來自客戶端的 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,則現在應該能夠觸發翻譯。假設你的瀏覽器設置為偏好英語,則需要使用其他語言撰寫文章以查看「翻譯」鏈接。下面你可以看到一個例子:

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

(venv) $ flask translate update

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

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

(venv) $ flask translate compile