Мега-Учебник Flask, Часть 15: Ajax

Это будет последняя статья на тему интернационализации и локализации(I18n и L10n), которой мы подведем итог нашим усилиям по увеличению доступности нашего приложения microblog для неанглоязычных пользователей.

В этой статье мы покинем «зону комфорта» серверной разработки, к которой мы успели привыкнуть, и начнем работать над функционалом, для которого в равной степени важны серверная и клиентская составляющие. Вы когда-нибудь видели кнопку «Перевести», которую некоторые сайты показывают рядом с пользовательским контентом? Эти ссылки выполняют автоматический перевод содержимого на родной язык пользователя в режиме реального времени. Переведенный контент вставляется, как правило, ниже оригинала. Google использует этот подход для результатов поиска на иностранном языке, Facebook — для постов. Сегодня мы добавим подобное поведение в наш microblog!

Server-side vs. Client-side


В традиционной модели клиент-серверного взаимодействия, которой мы до сих пор следовали, у нас есть клиент (веб-браузер клиента) посылающий запросы серверу. Запрос просто запрашивает веб страницу, как в случае, когда вы переходите по ссылку «Мой Профиль», или же он может выполнить какое-то действие на сервере, как в случае когда пользователь редактирует свой профиль и нажимает «Отправить». Независимо от типа, сервер отвечает на запрос отправкой новой страницы клиенту прямо или посредством редиректа. Затем браузер заменяет текущую страницу новой. Этот цикл повторяется до тех пор, пока пользователь остается на сайте. Мы называем эту модель «серверной» т. к. сервер выполняет всю работу по генерации страниц, клиент же просто отображает страницы по мере их получения. 

В модели же «клиентской», у нас по-прежнему есть браузер, отправляющий запросы серверу. Сервер отвечает страницей подобно тому, что у нас происходило при использовании «серверной» модели, но не все данные представлены HTML, также там присутствует код, преимущественно на javascript. Как только клиент получает страницу, он отображает её и затем исполняет код, пришедший вместо со страницей. Теперь у нас есть активный клиент, способный работать без постоянного взаимодействия с сервером. В идеальном случае, приложение скачивается на клиент при первоначальной загрузке, а затем запускается и работает на клиенте даже без обновления страницы, взаимодействуя с сервером только для получения и сохранения данных. Этот тип приложений называется Single Page Applications или SPA.

Большинство реальных приложений представляют собой комбинацию этих двух подходов. Наше приложение, на данный момент, работает целиком и полностью на сервере, но сегодня мы добавим немного логики на стороне клиента. Чтобы осуществлять перевод постов в режиме реального времени, браузер будет отправлять запросы на сервер, но сервер будет отвечать переведенным текстом, не вызывая обновления страницы на клиенте. Клиент затем динамически будет добавлять перевод на текущую страницу. Эта техника известна как Ajax, сокращение от Asynchronous Javascript and XML (даже несмотря на то, что сегодня XML часто заменяют на JSON).

Перевод пользовательского контента


На данный момент, наша поддержка иностранных языков достаточно хороша, благодаря Flask-Babel. Мы можем опубликовать наше приложение на огромном количестве языков, нужно только найти переводчиков готовых нам помочь.

Но мы упустили один момент. Мы сделали приложение доступным для пользователей, говорящих на разных языках, так что теперь, возможно, у нас начнут появляться и записи на различных языках. И теперь наши пользователи, просматривая записи, вполне вероятно, столкнутся с записями на языках, которые они не понимают. Было бы неплохо предложить функцию автоматического перевода, не так ли? Качество автоматического перевода остается не очень высоким, но в большинстве случаев его достаточно чтобы понять смысл написанного, так что все наши пользователи(включая нас) выиграют от наличия такой функции.

Эта функция идеально подходит, чтобы реализовать её при помощи Ajax. Предположим, что наша главная страница может содержать несколько постов на разных языках. Если бы мы реализовывали перевод постов традиционным способом, запрос перевода поста вызвал бы замещение текущей страницы новой страницей с переводом выбранного поста. После прочтения, пользователю пришлось бы нажимать кнопку «Назад» для возврата к списку всех постов. Дело в том, что запрос перевода поста не достаточно важная задача, чтобы вызывать полное обновление страницы. Было бы лучше, если бы переведенный текст просто вставлялся под оригинальным текстом поста, оставляя остальной контент нетронутым. Поэтому, сегодня мы реализуем наш первый сервис Ajax!

Реализация автоматического перевода потребует от нас несколько шагов. Для начала мы должны определить язык поста, который мы собираемся переводить. Зная язык поста, мы можем выяснить требуется ли его перевод для определенного пользователя, т. к. нам известен язык выбранный пользователем. Если перевод необходим и пользователь пожелает его увидеть, мы задействуем наш сервис перевода Ajax, работающий на сервере. В итоге, клиентский javascript добавит перевод поста на страницу.

Определение языка поста


Наша первая задача состоит в определении исходного языка поста. Не всегда возможно точно определить язык, поэтому мы просто попытаемся сделать всё, что возможно для этого. Для решения этой задачи мы используем модуль guess-language. Так давайте установим его. Пользователи Linux и Mac OS X:

flask/bin/pip install guess-language

Windows пользователи:

flask\Scripts\pip install guess-language

При помощи этого модуля мы просканируем текст каждого поста, пытаясь определить его язык. Т.к. мы не хотели бы сканировать одни и теже посты снова и снова, мы сделаем это по одному разу для каждого поста при отправке их пользователем. Затем мы сохраним информацию о языке поста вместе с самим постом в нашей базе данных.

Давайте добавим поле language в нашу таблицу Posts:

class Post(db.Model):
    __searchable__ = ['body']

    id = db.Column(db.Integer, primary_key = True)
    body = db.Column(db.String(140))
    timestamp = db.Column(db.DateTime)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
    language = db.Column(db.String(5))

 

При каждом изменении базы данных, мы не должны забывать о миграциях:

$ ./db_migrate.py
New migration saved as microblog/db_repository/versions/005_migration.py
Current database version: 5

 

Теперь, когда у нас есть где сохранить информацию о языке поста, давайте добавим определение языка в процесс добавления нового поста:

from guess_language import guessLanguage

@app.route('/', methods = ['GET', 'POST'])
@app.route('/index', methods = ['GET', 'POST'])
@app.route('/index/<int:page>', methods = ['GET', 'POST'])
@login_required
def index(page = 1):
    form = PostForm()
    if form.validate_on_submit():
        language = guessLanguage(form.post.data)
        if language == 'UNKNOWN' or len(language) > 5:
            language = ''
        post = Post(body = form.post.data, 
            timestamp = datetime.utcnow(), 
            author = g.user, 
            language = language)
        db.session.add(post)
        db.session.commit()
        flash(gettext('Your post is now live!'))
        return redirect(url_for('index'))
    posts = g.user.followed_posts().paginate(page, POSTS_PER_PAGE, False)
    return render_template('index.html',
        title = 'Home',
        form = form,
        posts = posts)

Если узнать язык поста не удалось или возвращается неожиданно длинный результат, мы сохраняем в поле language пустую строку. Это будет маркер того, что язык поста нам не известен.

Отображение ссылки «Перевести»


Следующим шагом станет отображение ссылки «Перевести» у каждого поста написанного на языке, отличном от языка используемого пользователем (файл app/templates/post.html):

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

Мы сделали это в шаблоне post.html, добавив тем самым функциональность перевода во все страницы отображающие посты. Ссылка «Перевести» будет отображаться в случае, если мы можем определить язык поста и определенный язык отличается от языка, возвращенного функцией localeselector из Flask-Babel, и доступного нам через g.locale.

Эта ссылка требует добавления нового текста, слова «Перевести», которое необходимо включить в наши файлы переводов, т.ч. мы сразу оборачиваем строку в функцию _(), чтобы пометить эту строку для Flask-Babel. Для перевода этого слова, нам необходимо обновить наш справочник языка (tr_update.py), перевести его при помощи poedit и скомпилировать (tr_compile.py), так, как мы это делали в предыдущей статье.

На данный момент мы не знаем до конца, что нам делать для запуска перевода, поэтому ссылка пока будет являться заглушкой.

 

Сервисы перевода

Прежде чем двигаться дальше, мы должны подобрать сервис перевода.

Существует много сервисов, предоставляющих услугу перевода, но к сожалению, большинство из них либо платные, либо имеют значительные ограничения.

Два лидирующих сервиса перевода это Google Translate и Microsoft Translator. Оба платные, но Microsoft предлагает бесплатный план с небольшим объемом переводов. Google предлагал бесплатный перевод ранее, но более этого не делает. И это упрощает нам выбор сервиса.

 

Использование сервиса Microsoft Translator


Для использования Microsoft Translator существует набор требований:

  • Разработчик приложения должен зарегистрироваться в Microsoft Translator appна Azure Marketplace. Здесь можно выбрать тарифный план (бесплатный находится в самом низу).
  • Затем разработчик должен зарегистрировать приложение. Зарегистрированному приложению будет выдан Client ID и Client Secret код, которые будут использоваться при отправке запроса сервису.


После завершения регистрации, процесс запроса перевода следующий:


Это на самом деле звучит сложнее, чем есть на самом деле, т.ч. не погружаясь в детали, вот функция которая выполняет всю работу и переводит текст на другой язык (файл app/translate.py):

import urllib, httplib
import json
from flask.ext.babel import gettext
from config import MS_TRANSLATOR_CLIENT_ID, MS_TRANSLATOR_CLIENT_SECRET

def microsoft_translate(text, sourceLang, destLang):
    if MS_TRANSLATOR_CLIENT_ID == "" or MS_TRANSLATOR_CLIENT_SECRET == "":
        return gettext('Error: translation service not configured.')
    try:
        # get access token
        params = urllib.urlencode({
            'client_id': MS_TRANSLATOR_CLIENT_ID,
            'client_secret': MS_TRANSLATOR_CLIENT_SECRET,
            'scope': 'http://api.microsofttranslator.com', 
            'grant_type': 'client_credentials'
        })
        conn = httplib.HTTPSConnection("datamarket.accesscontrol.windows.net")
        conn.request("POST", "/v2/OAuth2-13", params)
        response = json.loads (conn.getresponse().read())
        token = response[u'access_token']

        # translate
        conn = httplib.HTTPConnection('api.microsofttranslator.com')
        params = {
            'appId': 'Bearer ' + token,
            'from': sourceLang,
            'to': destLang,
            'text': text.encode("utf-8")
        }
        conn.request("GET", '/V2/Ajax.svc/Translate?' + urllib.urlencode(params))
        response = json.loads("{\"response\":" + conn.getresponse().read().decode('utf-8-sig') + "}")
        return response["response"]
    except:
        return gettext('Error: Unexpected error.')

 

Эта функция импортирует два новых элемента из нашего конфигурационного файла, id и secret коды выданные нам Microsoft (файл config.py):

# microsoft translation service
MS_TRANSLATOR_CLIENT_ID = '' # укажите здесь свой app id
MS_TRANSLATOR_CLIENT_SECRET = '' # а здесь secret

Для пользования сервисом, вам нужно зарегистрироваться самому и зарегистрировать приложение для получения данных для этих конфигурационных переменных. Даже если вы просто хотите потестировать наш microblog, вам нужно зарегистрироваться (это бесплатно).

Мы добавили немного нового текста — несколько сообщений об ошибках. Их требуется локализовать, поэтому мы снова запускаем tr_update.py, poedit и tr_compile.py для обновления наших файлов перевода.

Давайте же переведем что-нибудь!

Так как нам использовать сервис перевода? На самом деле, это просто. Вот пример:

$ flask/bin/python
Python 2.6.8 (unknown, Jun  9 2012, 11:30:32)
>>> from app import translate
>>> translate.microsoft_translate('Hi, how are you today?', 'en', 'es')
u'?Hola, c?mo est?s hoy?'

 

Ajax на сервере


Теперь мы можем переводить тексты с одного языка на другой, т.ч. мы готовы к интеграции этой функциональности в наше приложение.

Когда пользователь кликает на ссылке «Перевести» поста, на сервер посылается Ajax запрос. Мы увидим, как отправить этот запрос немного позже, а сейчас давайте сосредоточимся на реализации обработки запроса на стороне сервера. 

Ajax сервис на сервере будет представлять собой обыкновенную функцию представления с одним отличием — вместо HTML страницы или перенаправления, она будет возвращать данные, обычно в формате XML или JSON. Поскольку JSON несколько ближе Javascript, мы будем использовать этот формат (файл app/views.py):

from flask import jsonify
from translate import microsoft_translate

@app.route('/translate', methods = ['POST'])
@login_required
def translate():
    return jsonify({ 
        'text': microsoft_translate(
            request.form['text'], 
            request.form['sourceLang'], 
            request.form['destLang']) })

Тут не так много нового для нас. Это представление будет обрабатывать POST запрос, который должен содержать текст для перевода и коды языка оригинала и языка на который нужно перевести текст. Поскольку это POST запрос, мы получаем доступ к этим данным так, как будто они были переданы из HTML формы, используя словарь request.form. Мы вызываем одну из наших функций перевода с этими данными и, после получения перевода, конвертируем ответ в JSON используя функцию Flask-а jsonify(). Данные которые увидит клиент в качестве ответа на свой запрос, будет имет вот такой формат:

{ "text": "<здесь будет переведенный текст>" }

 

Ajax на клиенте


Теперь мы должны вызвать Ajax функцию представления из нашего браузера, поэтому мы возвращаемся к шаблону post.html чтобы закончить начатое ранее.

Начнем мы с того, что заключим текст поста в элемент span с уникальным id, чтобы позднее легко найти его в DOM (файл app/templates/post.html):

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

Заметьте, как мы создали уникальный id используя id поста. Если пост имеет id = 3, то id элемента будет post3.

Также мы поместим ссылку «Перевести» в span с уникальным id, чтобы иметь возможность скрыть эту ссылку после отображения перевода:

<div><span id="translation{{post.id}}"><a href="#">{{ _('Translate') }}</a></span></div>

По аналогии с примером выше, ссылка перевода будет находиться в элементе с id равным translation3.
И для повышения привлекательности и удобства пользователя, мы добавим анимацию, информирующую пользователя, что процесс перевода на сервере запущен. Она будет скрыта после загрузки страницы и будет отображаться только во время перевода, и также будет иметь уникальный id:

<img id="loading{{post.id}}" style="display: none" src="/static/img/loading.gif">

Итак, теперь мы имеем:

  • элемент с id равным post<id>, содержащий текст для перевода,
  • элемент с id равным translation<id>, содержащий ссылку «Перевести» и который позднее будет заменен переведенным текстом
  • и изображение с id равным loading<id>, которое будет отображаться пока перевод в процессе обработки.



Суффикс <id> делает эти элементы уникальными. У нас может быть сколь угодно много постов на странице и все они будут иметь свой собственный набор этих трех значений.

Теперь, по клику по ссылке «Перевести», мы должны послать Ajax запрос. Для этого мы создадим Javascript функцию, которая сделает всю работу. Давайте начнем с добавления вызова функции по клику на ссылке «Перевести»:

<a href="javascript:translate('{{post.language}}', '{{g.locale}}', '#post{{post.id}}', '#translation{{post.id}}', '#loading{{post.id}}');">{{ _('Translate') }}</a>

Переменные шаблона немного запутывают код, но сам вызов функции довольно прост. Рассмотрим пост с id = 23, написанный на испанском и просматриваемый пользователем, использующим английскую версию сайта. В этом случае функция будет вызвана так:

translate('es', 'en', '#post23', '#translation23', '#loading23')

Т.е. функция получит языки оригинала и назначения, а также селекторы трех связанных с постом элементов.

Мы добавили вызов функции прямо в шаблон post.html, т. к. он используется для отображения каждого поста. Саму же функцию мы создадим в нашем базовом шаблоне, чтобы иметь доступ к ней на всех страницах приложения (файл app/templates/base.html):

<script>
function translate(sourceLang, destLang, sourceId, destId, loadingId) {
    $(destId).hide();
    $(loadingId).show();
    $.post('/translate', {
        text: $(sourceId).text(),
        sourceLang: sourceLang,
        destLang: destLang
    }).done(function(translated) {
        $(destId).text(translated['text'])
        $(loadingId).hide();
        $(destId).show();
    }).fail(function() {
        $(destId).text("{{ _('Error: Could not contact server.') }}");
        $(loadingId).hide();
        $(destId).show();
    });
}
</script>

Для реализации требуемой функциональности мы используем jQuery. Напомню, что мы подключили jQuery ранее, когда подключали Bootstrap.

Мы начинаем со скрытия ссылки «Перевести» и отображения индикатора загрузки.

Затем мы используем функцию $.post() для отправки Ajax запроса нашему серверу. Функция $.post отправляет POST запрос, который для сервера будет неотличим от запроса отправляемого браузером при отправке формы. Отличие состоит в том, что на клиенте этот запрос отправляется в фоне, без необходимости перезагрузки всей страницы. Когда получен ответ от сервера, будет выполнена функция являющаяся аргументом функции done(), которая попытается вставить полученные данные на страницу. Эта функция получает ответ в качестве аргумента, т.ч. мы просто заменяем ссылку «Перевести» в DOM переведенным текстом, затем скрываем индикатор загрузки, и, наконец, отображаем переведенный текст пользователю. Готово!

Если произошло что-то, помешавшее клиенту получить ответ от сервера, тогда выполнится функция являющаяся аргументом функции fail(). В этом случае, мы просто выводим сообщение об ошибке, которое также следует перевести на все поддерживаемые языки и обновить нашу базу перевода.

Unit тестирование


Помните наш фреймворк для тестирования? Всегда после добавления нового кода в приложение, стоит оценить имеет ли смысл писать тесты для этого кода. Обращение к сервису перевода может быть сломано нами в процессе последующей работы над кодом, или же в следствии обновлений в работе самого сервиса. Давайте напишем быстрый тест, позволяющий убедиться, что мы имеем доступ к сервису и можем получить перевод:

from app.translate import microsoft_translate

class TestCase(unittest.TestCase):
    #...
    def test_translation(self):
        assert microsoft_translate(u'English', 'en', 'es') == u'Ingl?s'
        assert microsoft_translate(u'Espa?ol', 'es', 'en') == u'Spanish'

На данный момент у нас нет фреймворка для тестирования кода клиентской стороны, поэтому мы не будем тестировать полный цикл запроса Ajax.
Запуск тестов должен пройти без ошибок:

$ ./tests.py
.....
----------------------------------------------------------------------
Ran 5 tests in 5.932s

OK

Но если вы запустите тесты до указания данных доступа к Microsoft Translator, вы получите следующее:

$ ./tests.py
....F
======================================================================
FAIL: test_translation (__main__.TestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "./tests.py", line 120, in test_translation
    assert microsoft_translate(u'English', 'en', 'es') == u'Ingl?s'
AssertionError

----------------------------------------------------------------------
Ran 5 tests in 3.877s

FAILED (failures=1)

 

Заключение


На этом мы и закончим данную статью. Надеюсь, вам чтение статьи доставило не меньше удовольствия, чем мне её написание!

Недавно меня информировали о некоторых проблемах с базой данных, при использовании Flask-WhooshAlchemy для полнотекстового поиска. В следующей статье я использую эту проблему как предлог для экскурса в некоторые применяемые мной техники отладки при работе с приложениями на Flask. Ожидайте части XVI!

Вот ссылка на последнюю версию microblog:

Скачать microblog-0.15.zip.

Или, если Вам так больше нравится, вы можете найти исходный код на GitHub.

Miguel

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте как обрабатываются ваши данные комментариев.