Мега-Учебник Flask, Часть XVI: Полнотекстовый поиск

в 13:54, , рубрики: elasticsearch, flask, python, Разработка веб-сайтов

(издание 2018)

Miguel Grinberg


Мега-Учебник Flask, Часть XVI: Полнотекстовый поиск - 1 Туда Сюда Мега-Учебник Flask, Часть XVI: Полнотекстовый поиск - 2

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

Под спойлером приведен список всех статей этой серии 2018 года.

Оглавление

Примечание 1: Если вы ищете старые версии данного курса, это здесь.

Примечание 2: Если вдруг Вы захотели бы выступить в поддержку моей(Мигеля) работы, или просто не имеете терпения дожидаться статьи неделю, я (Мигель Гринберг)предлагаю полную версию данного руководства(на английском языке) в виде электронной книги или видео. Для получения более подробной информации посетите learn.miguelgrinberg.com.

Цель этой главы заключается в реализации функции поиска для Микроблога, так что пользователи могут найти интересные сообщения, используя естественный язык. Для многих типов веб-сайтов можно просто позволить Google, Бинг и т. д. индексируйте все содержимое и предоставляйте результаты поиска через API поиска. Это хорошо работает для сайтов, которые имеют в основном статические страницы, такие как форум. Но в моем приложении основная единица Контента-это пост пользователя, который представляет собой небольшую часть всей веб-страницы. Тип результатов поиска, которые я хочу, предназначены для этих отдельных сообщений в блоге, а не для целых страниц. Например, если я ищу слово "собака", я хочу видеть сообщения в блоге от любых пользователей, которые включают это слово. Очевидно, что страница, которая показывает все сообщения в блоге, в которых есть слово "собака" (или любой другой возможный поисковый термин), на самом деле не существует как страница, которую могут найти и проиндексировать большие поисковые системы, поэтому у меня нет другого выбора, кроме как свернуть мою собственную функцию поиска.

Цель этой главы — реализовать функцию поиска для Microblog, чтобы пользователи могли находить интересные сообщения с использованием привычного языка. Для многих типов веб-сайтов можно просто позволить Google, Bing и т. д. Индексировать весь контент и предоставлять результаты поиска через свои API поиска. Это работает для сайтов, которые имеют в основном статические страницы, например форум. Но в моем приложении основной единицей контента является пользовательский пост, который представляет собой небольшую часть всей веб-страницы. Поиск, который мне нужен, относится к этим отдельным сообщениям в блоге, а не целым страницам. Например, если я ищу слово «собака», то хочу видеть сообщения, которые включают это слово в блогах разных пользователей. Проблема в том, что страница, которая показывает все сообщения, в которых есть слово «собака» (или любой другой возможный термин поиска), не существует как страница в блоге, которую могут найти и индексировать большие поисковые системы, поэтому у меня нет другого выбора, кроме как сотворить свою собственную функцию поиска.

Ссылки GitHub для этой главы: Browse, Zip, Diff.

Введение в полнотекстовые поисковые системы

Поддержка полнотекстового поиска не стандартизирована, как реляционные базы данных. Существует несколько полнотекстовых движков с открытым исходным кодом: Elasticsearch, Apache Solr, Whoosh, Xapian, Sphinx и т.д. Как будто этого недостаточно, есть несколько баз данных, которые также предоставляют возможности поиска, сравнимые с выделенными поисковыми системами, такими как те, которые я перечислил выше. SQLite, MySQL и PostgreSQL предлагают некоторую поддержку для поиска текста, а также базы данных NoSQL, такие как MongoDB и CouchDB.

Если вам интересно, какие из них могут работать в приложении Flask, ответ — все они! Это одна из сильных сторон Flask, он делает свою работу и не упрямится. Так что это лучший выбор?

По моему мнению, из списка специализированных поисковых систем Elasticsearch — особенно выделяется. И как самый популярный, и значимый стоит первым в качестве символа «E» в стеке ELK для индексирования логов, совместно с Logstash и Kibana. Использование возможностей поиска в одной из реляционных баз данных может быть хорошим выбором, но с учетом того факта, что SQLAlchemy не поддерживает эту функциональность, мне пришлось бы обрабатывать поиск с помощью необработанных SQL-инструкций или найти пакет с доступом к текстовым запросам, а также возможность совместного использования с SQLAlchemy.

Основываясь на выше сказанном, я выбираю Elasticsearch, но собираюсь реализовать все функции индексирования текста и поиска таким образом, чтобы очень легко переключиться на другой движок. Это позволит заменить мою реализацию альтернативной версией на основе другого механизма, просто переписав несколько функций в одном модуле.

Существует несколько способов установки Elasticsearch, включая установку в один клик или zip-файл с бинарниками, которые необходимо установить самостоятельно, и даже образ Docker. В документации есть страница установки с подробной информацией обо всех этих опциях. Если вы используете Linux, у вас, вероятно, будет доступный пакет для вашего дистрибутива. Если Вы используете Mac и у вас установлен Homebrew, то вы можете просто запустить brew install elasticsearch.

После установки Elasticsearch на компьютер можно проверить, работает ли он, введя http://localhost:9200 в адресной строке браузера, которая должна возвращать некоторую основную информацию о сервисе в формате JSON.

Поскольку управляться Elasticsearch будет из Python, я буду использовать клиентскую библиотеку Python:

(venv) $ pip install elasticsearch

Теперь не помешает обновить файл requirements.txt:

(venv) $ pip freeze > requirements.txt

Elasticsearch Tutorial

Для начала я покажу вам основы работы с Elasticsearch из оболочки Python. Это поможет вам ознакомиться с этим сервисом и понять его реализацию, о которой я расскажу позже.

Чтобы создать подключение к Elasticsearch, создайте экземпляр класса Elasticsearch, передав URL-адрес подключения в качестве аргумента:

>>> from elasticsearch import Elasticsearch
>>> es = Elasticsearch('http://localhost:9200')

Данные в Elasticsearch при записи индексируются. В отличие от реляционной базы данных, это всего лишь объект JSON. Следующий пример записывает объект в поле типа text, под индексом my_index:

>>> es.index(index='my_index', doc_type='my_index', id=1, body={'text': 'this is a test'})

При желании index может хранить документы разных типов, и в этом случае аргумент doc_type может быть установлен в разные значения в соответствии с этими различными форматами. Я собираюсь хранить все документы в том же формате, поэтому я устанавливаю тип документа равным имени индекса.

Для каждого сохраненного документа Elasticsearch получает уникальный идентификатор и объект JSON с данными.

Давайте сохраним второй документ по этому же индексу:

>>> es.index(index='my_index', doc_type='my_index', id=2, body={'text': 'a second test'})

И теперь, когда в этом индексе есть два документа, я могу выполнить поиск в свободной форме. В этом примере я буду искать this test:

>>> es.search(index='my_index', doc_type='my_index',
... body={'query': {'match': {'text': 'this test'}}})

Ответ es.search() представляет собой словарь Python с результатами поиска:

{
    'took': 1,
    'timed_out': False,
    '_shards': {'total': 5, 'successful': 5, 'skipped': 0, 'failed': 0},
    'hits': {
        'total': 2, 
        'max_score': 0.5753642, 
        'hits': [
            {
                '_index': 'my_index',
                '_type': 'my_index',
                '_id': '1',
                '_score': 0.5753642,
                '_source': {'text': 'this is a test'}
            },
            {
                '_index': 'my_index',
                '_type': 'my_index',
                '_id': '2',
                '_score': 0.25316024,
                '_source': {'text': 'a second test'}
            }
        ]
    }
}

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

Теперь посмотрим результат для поиска слова second:

>>> es.search(index='my_index', doc_type='my_index',
... body={'query': {'match': {'text': 'second'}}})
{
    'took': 1,
    'timed_out': False,
    '_shards': {'total': 5, 'successful': 5, 'skipped': 0, 'failed': 0},
    'hits': {
        'total': 1,
        'max_score': 0.25316024,
        'hits': [
            {
                '_index': 'my_index',
                '_type': 'my_index',
                '_id': '2',
                '_score': 0.25316024,
                '_source': {'text': 'a second test'}
            }
        ]
    }
}

Полученная оценка довольно низкая, потому что мой поиск не соответствует тексту в этом документе, но поскольку только один из двух документов содержит слово "second", другой документ вообще не отображается.

Объект запроса Elasticsearch имеет много параметров и все они хорошо документированы. Среди них такие, как разбиение на страницы и сортировка, так же, как и в случае реляционных баз данных.

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

>>> es.indices.delete('my_index')

Конфигурация Elasticsearch

Интеграция Elasticsearch в приложение является отличным примером крутости Flask. Этот сервис и пакет Python, который не имеет ничего общего с Flask, но я все же постараюсь получить приличный уровень интеграции. Начну с конфигурации, которую буду писать в словаре app.config для Flask:

config.py: Elasticsearch Конфигурация.

class Config(object):
    # ...
    ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL')

Как и во многих других записях конфигурации, URL-адрес подключения для Elasticsearch будет получен из переменной среды. Если переменная не определена, получим значение None, и это будет сигнал для отключения Elasticsearch. Сделано это в основном для удобства, что бы не вынуждать всегда иметь службу Elasticsearch при работе с приложением, и в частности при выполнении модульных тестов. Поэтому, чтобы убедиться, что сервис используется, мне нужно определить переменную среды ELASTICSEARCH_URL, либо непосредственно в терминале, либо добавив ее .env файл следующим образом:

ELASTICSEARCH_URL=http://localhost:9200

Elasticsearch создает проблему, потому что не декорирован расширением Flask. Я не могу создать экземпляр Elasticsearch в глобальной области, как в приведенных выше примерах, потому что для его инициализации мне нужен доступ к app.config, который становится доступным только после вызова функции create_app(). Поэтому я решил добавить атрибут elasticsearch в экземпляр app в функции фабрики приложений:

app/init.py: Elasticsearch instance.

# ...
from elasticsearch import Elasticsearch

# ...

def create_app(config_class=Config):
    app = Flask(__name__)
    app.config.from_object(config_class)

    # ...
    app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) 
        if app.config['ELASTICSEARCH_URL'] else None

    # ...

Добавление нового атрибута в экземпляр app может показаться немного странным, но объекты Python не слишком строги в своей структуре, новые атрибуты могут быть добавлены к ним в любое время. Альтернативой, которую вы также можете рассмотреть, является создание подкласса Flask (возможно, назовем его Microblog) с атрибутом elasticsearch, определенным в его функции __init__().

Обратите внимание, как я использую условное выражение, чтобы присвоить None экземпляру Elasticsearch, если URL-адрес службы Elasticsearch не был определен в среде.

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

Первое, что мне нужно, это как-то найти общий способ указать, какая модель и какое поле или поля в ней должны быть индексированы. Я хочу сказать, что любая модель, которая нуждается в индексации должна определить атрибут класса __searchable__, содержащий поля, которые должны быть включены в индекс. Для модели Post это будет выглядеть так:

app/models.py: Add a __searchable__ attribute to the Post model.

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

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

Я собираюсь написать весь код, который взаимодействует с индексом Elasticsearch в модуле app/search.py. Идея состоит в том, чтобы сохранить весь код Elasticsearch в этом модуле. Остальная часть приложения будет использовать функции в этом новом модуле для доступа к индексу и не будет иметь прямого доступа к Elasticsearch. Это важно, потому что если однажды я решу, что мне больше не нравится Elasticsearch и соберусь переключиться на другой движок, все, что мне нужно сделать, это перезаписать функции в этом одном модуле, и приложение будет продолжать работать по-прежнему.

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

  • добавить записи в полнотекстовый индекс,
  • удалить записи из индекса (при условии, что в один прекрасный день я поддержу удаление сообщений в блоге),
  • выполнить поисковый запрос.

Вот модуль app/search.py, который реализует эти три функции для Elasticsearch, используя функциональность, которую я показал вам выше с консоли Python:

app/search.py: Search functions.

from flask import current_app

def add_to_index(index, model):
    if not current_app.elasticsearch:
        return
    payload = {}
    for field in model.__searchable__:
        payload[field] = getattr(model, field)
    current_app.elasticsearch.index(index=index, doc_type=index, id=model.id,
                                    body=payload)

def remove_from_index(index, model):
    if not current_app.elasticsearch:
        return
    current_app.elasticsearch.delete(index=index, doc_type=index, id=model.id)

def query_index(index, query, page, per_page):
    if not current_app.elasticsearch:
        return [], 0
    search = current_app.elasticsearch.search(
        index=index, doc_type=index,
        body={'query': {'multi_match': {'query': query, 'fields': ['*']}},
              'from': (page - 1) * per_page, 'size': per_page})
    ids = [int(hit['_id']) for hit in search['hits']['hits']]
    return ids, search['hits']['total']

Все эти функции начинаются с проверки определен ли app.elasticsearch. В случае None выходим из функции без каких-либо действий. Это значит, что в случае когда сервер Elasticsearch не настроен, приложение продолжит работать без возможности поиска и без каких-либо ошибок. Насколько это удобно станет понятно во время разработки или при выполнении модульных тестов.

Функции принимают в качестве аргумента имя index. Во всех вызовах, которые я передаю Elasticsearch, я использую это имя в качестве имени индекса, а также в качестве типа документа, как я уже делал это в примерах консоли Python.

Функции, добавляющие и удаляющие записи из индекса, принимают модель SQLAlchemy в качестве второго аргумента. Функция add_to_index() использует переменную класса __searchable__, добавленную в модель для построения документа, вставленного в индекс. Если вы помните, документы Elasticsearch также нуждались в уникальном идентификаторе. Для этого я использую поле id модели SQLAlchemy, которая также является уникальной. Использование одного и того же значения id для SQLAlchemy и Elasticsearch очень полезно при выполнении поиска, поскольку оно позволяет мне связывать записи в двух базах данных. Еще один прикол, заключается в том, что если вы попытаетесь добавить запись с существующим идентификатором, то Elasticsearch заменит старую запись новой, поэтому add_to_index() может использоваться как для новых объектов, так и для измененных.

Я не показывал вам функцию es.delete (), которую я раньше использовал в remove_from_index(). Эта функция удаляет документ, хранящийся под данным идентификатором. Вот хороший пример удобства использования одного и того же идентификатора для связывания записей в обеих базах данных.

Функция query_index() принимает имя индекса и текст для поиска, а также элементы управления разбиением на страницы, так что результаты поиска могут быть разбиты на страницы как результаты Flask-SQLAlchemy. Вы уже видели пример использования функции es.search() из консоли Python. Вызов, который я выдаю, довольно похож, но вместо использования типа запроса match, я я решил использовать multi_match, который может выполнять поиск по нескольким полям. Имя поля *, дает установку Elasticsearch просматривать все поля для поиска индекса. Это полезно, чтобы сделать эту функцию универсальной, так как разные модели могут иметь разные имена полей в индексе.

Аргумент body у es.search() дополняет сам запрос аргументом разбиения на страницы. Аргументы from и size определяют, какое подмножество всего набора результатов необходимо вернуть. Elasticsearch не предоставляет полноценный объект Pagination, подобный объекту Flask-SQLAlchemy, поэтому мне придется самому написать процедуру разбиения на страницы, чтобы вычислить значение from.

Оператор return в функции query_index() несколько посложнее. Он возвращает два значения: первое — список идентификационных элементов для результатов поиска, а второй — общее количество результатов. Оба они получены из словаря Python, возвращаемого функцией es.search(). Если вам не знакомо выражение, которое я использую для получения списка идентификаторов, то поясню: её зовут list comprehension (генератор списков), и это фантастическая функция языка Python, которая позволяет вам преобразовывать списки из одного формата в другой. В этом случае я использую генератор списка, чтобы извлечь значения id из гораздо большего списка результатов, предоставленных Elasticsearch.

Это слишком запутанно? Возможно, демонстрация этих функций в консоли Python может помочь вам понять их получше. В следующем сеансе я вручную добавляю все сообщения из базы данных в индекс Elasticsearch. В моей тестовой базе данных у меня было несколько сообщений, в которых были цифры "one", "two", "three", "four" и «пять», поэтому я использовал их в поисковых запросах. Возможно, вам придется адаптировать свой запрос для соответствия содержимому вашей базы данных:

>>> from app.search import add_to_index, remove_from_index, query_index
>>> for post in Post.query.all():
...     add_to_index('posts', post)
>>> query_index('posts', 'one two three four five', 1, 100)
([15, 13, 12, 4, 11, 8, 14], 7)
>>> query_index('posts', 'one two three four five', 1, 3)
([15, 13, 12], 7)
>>> query_index('posts', 'one two three four five', 2, 3)
([4, 11, 8], 7)
>>> query_index('posts', 'one two three four five', 3, 3)
([14], 7)

Запрос, который я отправил, возвращает семь результатов. Поначалу я запросил 1 страницу со 100 пунктами на страницу и получил все семь возможных. Затем следующие три примера показывают, как я могу разбивать страницы способом очень похожим, на то, как я это сделал для Flask-SQLAlchemy, за исключением того, что результаты приходят в виде списка идентификаторов вместо объектов SQLAlchemy.

Если вы хотите сохранить все в чистоте, удалите индекс posts после того, как наэкспериментируетесь с ним:

>>> app.elasticsearch.indices.delete('posts')

Интеграция поиска с SQLAlchemy

Решение, которое я показал вам в предыдущем разделе, приемлемое, но у него все еще есть несколько нерешенных проблем. Первая и наиболее очевидная заключается в том, что результаты приходят в виде списка числовых идентификаторов. Это очень неудобно, поскольку мне нужны модели SQLAlchemy, чтобы я мог передавать их в шаблоны для визуализации, и мне нужен способ заменить этот список чисел соответствующими моделями из базы данных. Вторая проблема заключается в том, что это решение требует, чтобы приложение явной выдавало вызовы индексирования. Сообщения добавляются или удаляются, что не страшно, но не идеально, поскольку ошибка, которая вызывает пропущенные индексации вызова при внесении изменений на стороне SQLAlchemy не собираются быть легко обнаружены. Две базы данных будут рассинхронизироваться все больше и больше каждый раз при возникновении ошибки и вы, вероятно это не заметите какое то время. Лучшим решением было бы автоматическое включение этих вызовов при внесении изменений в базу данных SQLAlchemy.

Проблема замены идентификаторов объектами может быть решена путем создания запроса SQLAlchemy, который прочитает эти объекты из базы данных. Это звучит просто, но реализовать эффективное выполнение одного запроса на самом деле не так то просто.

Для решения второй проблемы (автоматического отслеживания изменений индексирования) я решил выполнять обновления индекса Elasticsearch из events (событий) SQLAlchemy. SQLAlchemy предоставляет большой список событий, о которых приложения могут получать уведомления. Например, для каждого commit-а (фиксации изменений) сеанса, я мог бы иметь функцию в приложении, вызываемом SQLAlchemy, в которой мог применять те же обновления, которые были сделаны в сеансе SQLAlchemy к индексу Elasticsearch.

Для реализации решений этих двух задач я собираюсь написать класс mixin. Помните занятия миксином? В главе 5 я добавил класс UserMixin из Flask-Login в пользовательскую модель, чтобы доверить ему некоторые функции, которые требовались Flask-Login. Для поддержки поиска я собираюсь определить свой собственный класс SearchableMixin, который при подключении к модели даст ему возможность автоматически управлять полнотекстовым индексом, связанным с моделью SQLAlchemy. Класс mixin будет выступать в качестве "связующего" слоя между мирами SQLAlchemy и Elasticsearch, предоставляя решения двух проблем, о которых я говорил выше.

Позвольте мне показать вам реализацию, а затем я рассмотрю некоторые интересные детали. Обратите внимание на использование нескольких передовых методов, так что вам нужно будет внимательно изучить этот код, чтобы полностью понять его.

app/models.py: SearchableMixin class.

from app.search import add_to_index, remove_from_index, query_index

class SearchableMixin(object):
    @classmethod
    def search(cls, expression, page, per_page):
        ids, total = query_index(cls.__tablename__, expression, page, per_page)
        if total == 0:
            return cls.query.filter_by(id=0), 0
        when = []
        for i in range(len(ids)):
            when.append((ids[i], i))
        return cls.query.filter(cls.id.in_(ids)).order_by(
            db.case(when, value=cls.id)), total

    @classmethod
    def before_commit(cls, session):
        session._changes = {
            'add': [obj for obj in session.new if isinstance(obj, cls)],
            'update': [obj for obj in session.dirty if isinstance(obj, cls)],
            'delete': [obj for obj in session.deleted if isinstance(obj, cls)]
        }

    @classmethod
    def after_commit(cls, session):
        for obj in session._changes['add']:
            add_to_index(cls.__tablename__, obj)
        for obj in session._changes['update']:
            add_to_index(cls.__tablename__, obj)
        for obj in session._changes['delete']:
            remove_from_index(cls.__tablename__, obj)
        session._changes = None

В этом классе mixin есть четыре функции — все методы класса (См. декоратор classmethod). Это специальный метод, который связан с классом, а не с конкретным экземпляром. Обратите внимание, как я переименовал аргумент self, используемый в обычных методах экземпляра, для cls, чтобы было ясно, что этот метод получает в качестве первого аргумента класс, а не экземпляр. Например, при подключении к модели Post, метод search() выше будет вызываться как Post.search(), не имея фактического экземпляра класса Post.

Метод класса search() обертывает функцию query_index() из app/search.py заменяя список идентификаторов объектов на фактические объекты. Очевидно, что первое, что делает эта функция, это вызывает query_index(), передавая cls.__ tablename__ как имя индекса. При таком соглашении, все индексы будут именоваться с именем Flask-SQLAlchemy, присвоенным реляционной таблице. Функция возвращает список ID результатов и их общее количество. Запрос SQLAlchemy, который получает список объектов по их ID, основан на операторе CASE из языка SQL, который должен использоваться в целях гарантии, что результаты из базы данных приходят в том же порядке, что и идентификаторы. Это важно, поскольку запрос Elasticsearch возвращает результаты, отсортированные от более к менее релевантным. Если вы хотите узнать больше о том, как работает этот запрос, вы можете обратиться к ответу на этот вопрос StackOverflow. Функция search() возвращает запрос, который заменяет список идентификаторов, а также передает общее количество результатов поиска в качестве второго возвращаемого значения.

Методы before_commit() и after_commit() будут реагировать на два события из SQLAlchemy, которые запускаются до и после фиксации соответственно. Обработчик before полезен, потому что сеанс еще не был зафиксирован, поэтому я могу глянув на него выяснить, какие объекты будут добавлены, изменены и удалены, доступны как session.new session.dirty и session.deleted соответственно. Эти объекты больше не будут доступны после фиксации сеанса, поэтому мне нужно сохранить их до фиксации. Я использую session._changes словарь для записи этих объектов в месте, которое переживет все фиксации сеанса, потому что, как только сеанс пофиксится я буду использовать их для обновления индекса Elasticsearch.

Вызов обработчика after_commit() означает, что сеанс успешно завершен, поэтому сейчас самое время внести изменения на стороне Elasticsearch. Объект сеанса имеет переменную _changes, которую я добавил в before_commit(), поэтому теперь я могу перебирать добавленные, измененные и удаленные объекты и выполнять соответствующие вызовы для функций индексирования в app/search.py.

Метод класса reindex() — это простой вспомогательный метод, который можно использовать для обновления индекса со всеми данными из реляционной стороны. Вы видели, как я делал что-то подобное из сеанса оболочки Python выше, чтобы выполнить начальную загрузку всех сообщений в тестовый индекс. Используя этот метод, я могу опубликовать Post.reindex(), чтобы добавить все записи в базу данных в индекс поиска (search index).

Чтобы включить класс SearchableMixin в модель Post, я должен добавить его в качестве подкласса, и мне также необходимо подключить befor и after события фиксации:

app/models.py: Добавление SearchableMixin class в Post model.

class Post(SearchableMixin, db.Model):
    # ...

db.event.listen(db.session, 'before_commit', Post.before_commit)
db.event.listen(db.session, 'after_commit', Post.after_commit)

Обратите внимание, что вызовы db.event.listen() не входят в класс, а следуют после него. Они устанавливают обработчики событий, которые вызывают before и after для каждой фиксации. Теперь модель Post автоматически поддерживает индекс полнотекстового поиска для сообщений. Я могу использовать метод reindex() для инициализации индекса из всех сообщений, находящихся в настоящее время в базе данных:

>>> Post.reindex()

И я могу искать сообщения, работающие с моделями SQLAlchemy, запустив Post.search(). В следующем примере я запрашиваю первую страницу из пяти элементов для моего запроса:

>>> query, total = Post.search('one two three four five', 1, 5)
>>> total
7
>>> query.all()
[<Post five>, <Post two>, <Post one>, <Post one more>, <Post one>]

Форма поиска

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

Достаточно стандартный подход для веб-поиска заключается в том, чтобы строка поиска была передана, как аргумент q в строке запроса URL-адреса. Например, если вы хотите найти Python в Google, и вы хотите сэкономить пару секунд, Вы можете просто ввести следующий URL-адрес в адресной строке браузера, чтобы перейти непосредственно к результатам:

https://www.google.com/search?q=python

Разрешить поиску быть полностью инкапсулированным в URL-адрес удобно, потому что результаты могут быть разделены с другими людьми, которые просто нажав на ссылку получат доступ к результатам поиска.

Это вносит изменения в способ, который я демонстрировал вам при обрабатке веб-формы в прошлом. Я использовал запросы POST для отправки данных формы для всех форм, которые приложение имело до сих пор, но для реализации поиска, как указано выше, отправка формы должна будет идти как запрос GET, который является методом запроса, который используется при вводе URL-адреса в браузере или нажатии ссылки. Еще одно интересное отличие заключается в том, что поисковая форма будет находиться в навигационной панели, поэтому она должна присутствовать на всех страницах приложения.

Вот класс формы поиска, только с текстовым полем q:

app/main/forms.py: Search form.

from flask import request

class SearchForm(FlaskForm):
    q = StringField(_l('Search'), validators=[DataRequired()])

    def __init__(self, *args, **kwargs):
        if 'formdata' not in kwargs:
            kwargs['formdata'] = request.args
        if 'csrf_enabled' not in kwargs:
            kwargs['csrf_enabled'] = False
        super(SearchForm, self).__init__(*args, **kwargs)

Поле q не требует никаких объяснений, поскольку оно аналогично другим текстовым полям, которые я использовал в прошлом. Для этой формы я решил не использовать кнопку отправки. Для формы, которая имеет текстовое поле, браузер отправит форму, когда вы нажмете Enter с фокусом на поле, поэтому кнопка не нужна. Я также добавил функцию конструктора __init__, которая предоставляет значения для аргументов formdata и csrf_enabled, если они не предоставляются вызывающим. Аргумент formdata определяет, откуда Flask-WTF получает формы. По умолчанию используется request.form, где Flask помещает значения форм, которые передаются через запрос POST. Формы, представленные через запрос GET, получают значения полей в строке запроса, поэтому мне нужно указать Flask-WTF на request.args, где Flask записывает аргументы строки запроса. И, как вы помните, формы добавили CSRF-защиту по умолчанию, с добавлением токена CSRF, который добавляется в форму через конструкцию form.hidden_tag() в шаблонах. Для работы с интерактивными поисковыми ссылками CSRF-защиту необходимо отключить, поэтому я устанавливаю csrf_enabled в False, так что Flask-WTF знает, что ему необходимо обходить проверку CSRF для этой формы.

Поскольку мне нужно, чтобы эта форма была видна на всех страницах, мне нужно создать экземпляр класса SearchForm независимо от страницы, которую просматривает пользователь. Единственное требование заключается в том, что пользователь вошел в систему, потому что для анонимных пользователей я пока не показываю никакого контента. Вместо того, чтобы создавать объект формы в каждом маршруте (route), а затем передавать форму всем шаблонам (templates), я покажу вам очень полезный трюк, который устраняет дублирование кода, когда вам нужно реализовать функцию во всем приложении. Я уже использовал обработчик before_request раньше, еще в главе 6, чтобы записать время последнего посещения для каждого пользователя. То, что я собираюсь сделать, это создать свою форму поиска в той же функции, но с изюминкой:

app/main/routes.py: Создание формы поиска в before_request handler.

from flask import g
from app.main.forms import SearchForm

@bp.before_app_request
def before_request():
    if current_user.is_authenticated:
        current_user.last_seen = datetime.utcnow()
        db.session.commit()
        g.search_form = SearchForm()
    g.locale = str(get_locale())

В приведенном коде я создаю экземпляр класса формы поиска, когда у меня есть аутентифицированный пользователь. И, конечно, мне необходимо, чтобы этот объект формы сохранялся до тех пор, пока он не будет отображен в конце запроса, поэтому мне нужно где-то его сохранить. Это где-то будет контейнером g, предоставленным Flask. Переменная g, предоставленная Flask, является местом, где приложение может хранить данные, которые должны сохраняться в течение всего срока службы запроса. Я храню форму в g.search_form, поэтому, когда обработчик запроса before заканчивается и Flask вызывает функцию представления, которая обрабатывает запрошенный URL, объект g будет таким же, и все равно будет иметь форму, прикрепленную к нему. Важно отметить, что эта переменная g специфична для каждого запроса и каждого клиента, поэтому, даже если ваш веб-сервер обрабатывает несколько запросов одновременно для разных клиентов, вы все равно можете полагаться на g для работы в качестве частного хранилища для каждого запроса, независимо от того, что происходит в других запросах, которые обрабатываются одновременно.

Следующий шаг — отобразить форму на странице. Я сказал выше, что мне нужна эта форма на всех страницах, поэтому имеет смысл сделать ее частью навигационной панели. На самом деле это просто, потому что шаблоны также имеют доступ к данным, хранящимся в переменной g, поэтому мне не нужно беспокоиться о добавлении формы в качестве явного аргумента шаблона во всех вызовах render_template() в приложении. Вот как я могу отобразить форму в базовом шаблоне:

app/templates/base.html: Отображение формы поиска в панели навигации.

        ...
        <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
            <ul class="nav navbar-nav">
                ... home and explore links ...
            </ul>
            {% if g.search_form %}
            <form class="navbar-form navbar-left" method="get"
                    action="{{ url_for('main.search') }}">
                <div class="form-group">
                    {{ g.search_form.q(size=20, class='form-control',
                        placeholder=g.search_form.q.label.text) }}
                </div>
            </form>
            {% endif %}
            ...

Форма отображается только в том случае, если определен g.search_form. Эта проверка необходима, потому что некоторые страницы, такие как страницы ошибок, могут не определить ее. Эта форма немного отличается от той, что я делал ранее. Я устанавливаю свой атрибут method равный get, потому что я хочу, чтобы данные формы были отправлены в строку запроса с запросом GET. Кроме того, другие созданные мной формы имели атрибут action empty, потому что они были отправлены на ту же страницу, которая отображала форму. Эта форма является специальной, потому что она появляется на всех страницах, поэтому я должен четко указать, место её отправки, что является новым маршрутом, специально предназначенным для обработки запросов.

Функция поиска в режиме просмотра

Последний бит функциональности в завершение функции поиска-это функция представления, которая получает представление формы поиска. Эта view — функция будет присоединена к маршруту /search, чтобы вы могли отправить запрос на поиск с помощью http://localhost:5000/search?q=search-words, как и Google.

app/main/routes.py: Search view function.

@bp.route('/search')
@login_required
def search():
    if not g.search_form.validate():
        return redirect(url_for('main.explore'))
    page = request.args.get('page', 1, type=int)
    posts, total = Post.search(g.search_form.q.data, page,
                               current_app.config['POSTS_PER_PAGE'])
    next_url = url_for('main.search', q=g.search_form.q.data, page=page + 1) 
        if total > page * current_app.config['POSTS_PER_PAGE'] else None
    prev_url = url_for('main.search', q=g.search_form.q.data, page=page - 1) 
        if page > 1 else None
    return render_template('search.html', title=_('Search'), posts=posts,
                           next_url=next_url, prev_url=prev_url)

Вы видели, что в других формах я использовал метод form.validate_on_submit(), чтобы проверить, является ли представление формы действительным. К сожалению, этот метод работает только для форм, представленных через запрос POST, поэтому для этой формы мне нужно использовать form.validate(), который просто проверяет значения полей, не проверяя, как данные были отправлены. Если проверка не удалась, это связано с тем, что пользователь отправил пустую форму поиска, поэтому в этом случае я просто перенаправляюсь на страницу исследования, в которой отображаются все сообщения в блоге.

Метод Post.search() из моего класса SearchableMixin используется для получения списка результатов поиска. Разбиение на страницы обрабатывается очень похожим образом на индексирование и просмотр страниц, но создание следующей и предыдущей ссылок немного сложнее без помощи объекта Pagination из Flask-SQLAlchemy. Здесь полезно использовать общее количество результатов, переданных как второе возвращаемое значение из Post.search().

После того, как страница результатов поиска и ссылки на страницы будут вычислены, все, что осталось-это отрисовать шаблон со всеми этими данными. Я мог бы найти способ повторно использовать index.html шаблон для отображения результатов поиска, но учитывая, что есть несколько отличий, я решил создать специальный search.html шаблон, который предназначен для отображения результатов поиска, воспользовавшись _post.html sub-шаблон для отображения результатов поиска:

app/templates/search.html: Шаблон результатов поиска.

{% extends "base.html" %}

{% block app_content %}
    <h1>{{ _('Search Results') }}</h1>
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    <nav aria-label="...">
        <ul class="pager">
            <li class="previous{% if not prev_url %} disabled{% endif %}">
                <a href="{{ prev_url or '#' }}">
                    <span aria-hidden="true">&larr;</span>
                    {{ _('Previous results') }}
                </a>
            </li>
            <li class="next{% if not next_url %} disabled{% endif %}">
                <a href="{{ next_url or '#' }}">
                    {{ _('Next results') }}
                    <span aria-hidden="true">&rarr;</span>
                </a>
            </li>
        </ul>
    </nav>
{% endblock %}

Логика отрисовки предыдущей и следующей ссылок выглядит слегка запутанной. Возможно помоможет разобраться документация Bootstrap для компонента разбиения на страницы.

Мега-Учебник Flask, Часть XVI: Полнотекстовый поиск - 3

Как вы считаете? Это была сложная и напряженная глава, где я представил довольно продвинутые методы. Некоторые из концепций в этой главе могут занять время для погружения. Самый важный вывод из этой главы заключается в том, что если вы хотите использовать поисковую систему отличающуюся от Elasticsearch, все, что вам нужно сделать, это повторно реализовать три функции в app/search.py. Следующее важное преимущество этих усилий состоит в том, что в будущем, если вам нужно добавить поддержку поиска для другой модели базы данных, вы можете просто сделать это, добавив к ней класс SearchableMixin, атрибут __searchable__ со списком полей для индекса и соединения обработчика событий SQLAlchemy. Я думаю, что это стоило потраченных усилий, потому что отныне будет легко иметь дело с полнотекстовыми индексами.

Автор: AlekSandrDr

Источник

Поделиться

* - обязательные к заполнению поля