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

Оглавление

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

Ссылки на GitHub для этой главы: BrowseZipDiff.

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

Поддержка полнотекстового поиска не стандартизирована, в отличие от реляционных баз данных. Существует несколько полнотекстовых движков с открытым исходным кодом: ElasticsearchApache SolrWhooshXapianSphinx и др. Как будто этого выбора недостаточно, есть несколько баз данных, которые также предоставляют возможности поиска, сопоставимые со специализированными поисковыми системами, подобными тем, которые я перечислил выше. SQLiteMySQL и PostgreSQL предлагают некоторую поддержку поиска текста, а также базы данных NoSQL, такие как MongoDB и CouchDB.

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

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

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

Установка Elasticsearch

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

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

  • memory="2GB" запрашивает достаточно памяти для небольшого индексирования.

  • discovery.type=single-node чтобы указать, что это одиночный узел, а не кластер.

  • xpack.security.enabled=false чтобы отключить использование SSL-сертификата и учетных данных пользователя, которые не нужны во время разработки.

При запуске Elasticsearch с помощью Docker команда для его запуска выглядит так:

$ docker run --name elasticsearch -d --rm -p 9200:9200 \
    --memory="2GB" \
    -e discovery.type=single-node -e xpack.security.enabled=false \
    -t docker.elastic.co/elasticsearch/elasticsearch:8.11.1

Возможно, вам потребуется изменить версию в приведенной выше команде.

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

(venv) $ pip install elasticsearch

Возможно, вы также захотите обновить файл requirements.txt:

(venv) $ pip freeze > requirements.txt

Туториал по Elasticsearch

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

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

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

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

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

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

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

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

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

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

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

ObjectApiResponse({
    'took': 6,
    'timed_out': False,
    '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0},
    'hits': {
        'total': {'value': 2, 'relation': 'eq'},
        'max_score': 0.82713,
        'hits': [
            {
                '_index': 'my_index',
                '_id': '1',
                '_score': 0.82713,
                '_source': {'text': 'this is a test'}
            },
            {
                '_index': 'my_index',
                '_id': '2',
                '_score': 0.19363807,
                '_source': {'text': 'a second test'}
            }
        ]
    }
})

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

Результат, если я ищу слово second:

>>> es.search(index='my_index', query={'match': {'text': 'second'}})
ObjectApiResponse({
    'took': 2,
    'timed_out': False,
    '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0},
    'hits': {
        'total': {'value': 1, 'relation': 'eq'},
        'max_score': 0.7361701,
        'hits': [
            {
                '_index': 'my_index',
                '_id': '2',
                '_score': 0.7361701,
                '_source': {'text': 'a second test'}
            }
        ]
    }
})

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

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

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

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

Настройка Elasticsearch

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

config.py: Настройка Elasticsearch.

class Config:
    # ...
    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.

# ...
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__() .

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

Абстракция полнотекстового поиска

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

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

app/models.py: Добавляем атрибут для возможности поиска к модели публикаций.

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

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

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

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

app/search.py: Функции поиска.

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, id=model.id, document=payload)

def remove_from_index(index, model):
    if not current_app.elasticsearch:
        return
    current_app.elasticsearch.delete(index=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,
        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']['value']

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

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

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

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

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

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

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

>>> from app.search import add_to_index, remove_from_index, query_index
>>> for post in db.session.scalars(sa.select(Post)):
...     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(index='posts')

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

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

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

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

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

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

app/models.py: Класс микширования с возможностью поиска.

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 [], 0
        when = []
        for i in range(len(ids)):
            when.append((ids[i], i))
        query = sa.select(cls).where(cls.id.in_(ids)).order_by(
            db.case(*when, value=cls.id))
        return db.session.scalars(query), total

    @classmethod
    def before_commit(cls, session):
        session._changes = {
            'add': list(session.new),
            'update': list(session.dirty),
            'delete': list(session.deleted)
        }

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

    @classmethod
    def reindex(cls):
        for obj in db.session.scalars(sa.select(cls)):
            add_to_index(cls.__tablename__, obj)

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

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

Метод класса search() переносит функцию query_index() из app/search.py для замены списка идентификаторов объектов реальными объектами из SQLAlchemy. Вы можете видеть, что первое, что делает эта функция, это вызывает query_index(), передавая cls.__tablename__ в качестве имени индекса. Это будет соглашение, что все индексы будут именоваться именем Flask-SQLAlchemy, присвоенным реляционной таблице. Функция возвращает список идентификаторов результатов и общее количество результатов. Запрос SQLAlchemy, который извлекает список объектов по их идентификаторам, основан на инструкции 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 для объектов, у которых есть класс SearchableMixin.

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

После определения класса я выполнил два вызова функции SQLAlchemy db.event.listen(). Обратите внимание, что эти вызовы выполняются не внутри класса, а после него. Цель этих двух инструкций - настроить обработчики событий, которые заставят SQLAlchemy вызывать методы before_commit() и after_commit() до и после каждой фиксации соответственно.

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

app/models.py: Добавление класса SearchableMixin в модель Post.

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

Теперь модель 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: Форма поиска.

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 'meta' not in kwargs:
            kwargs['meta'] = {'csrf': False}
        super(SearchForm, self).__init__(*args, **kwargs)

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

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

app/main/routes.py: Создание экземпляра формы поиска в обработчике before_request.

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.now(timezone.utc)
        db.session.commit()
        g.search_form = SearchForm()
    g.locale = str(get_locale())

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

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

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

            ...
            <div class="collapse navbar-collapse" id="navbarSupportedContent">
                <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                    ... 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 был пустым, потому что они были отправлены на ту же страницу, на которой отображалась форма. Эта форма особенная, потому что она отображается на всех страницах, поэтому мне нужно явно указать в ней, куда ее нужно отправить, что представляет собой новый маршрут, специально предназначенный для обработки запросов.

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

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

app/main/routes.py: Функция просмотра поиска.

@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. Разбивка на страницы выполняется очень похожим образом, что и для страниц index и explore , но генерировать следующую и предыдущую ссылки немного сложнее без помощи объекта Pagination из Flask-SQLAlchemy. Здесь полезно общее количество результатов, переданных в качестве второго возвращаемого значения из Post.search().

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

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

{% extends "base.html" %}

{% block content %}
    <h1>{{ _('Search Results') }}</h1>
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    <nav aria-label="Post navigation">
        <ul class="pagination">
            <li class="page-item{% if not prev_url %} disabled{% endif %}">
                <a class="page-link" href="{{ prev_url }}">
                    <span aria-hidden="true">&larr;</span> {{ _('Newer posts') }}
                </a>
            </li>
            <li class="page-item{% if not next_url %} disabled{% endif %}">
                <a class="page-link" href="{{ next_url }}">
                    {{ _('Older posts') }} <span aria-hidden="true">&rarr;</span>
                </a>
            </li>
        </ul>
    </nav>
{% endblock %}

Логика отображения ссылок на предыдущую и следующую страниц аналогична той, что я использовал в шаблонах index.html и user.html.

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

Комментарии (2)


  1. IvanZaycev0717
    01.07.2024 08:51
    +1

    При запуске Elasticsearch с помощью Docker команда для его запуска выглядит так:

    $ docker run --name elasticsearch -d --rm -p 9200:9200 \    --memory="2GB" \    -e discovery.type=single-node -e xpack.security.enabled=false \    -t docker.elastic.co/elasticsearch/elasticsearch:8.11.1

    Эта инструкция работать не будет работать для IP-адресов из России, так как docker.elastic.co заблокировало доступ для "дорогих россиян".

    Но выйти из положения можно - здесь я использовал рамдомную версию Elasticsearch

    Подтягиваем образ с DockerHub

    docker pull elasticsearch:7.17.22

    Далее создаем контейнер

    docker run --name elasticsearch -d --rm -p 9200:9200 --memory="2GB" -e discovery.type=single-node -e xpack.security.enabled=false -t elasticsearch:7.17.22

    В Docker Compose в YAML-файле это будет выглядеть так:

    elasticsearch:
        image: elasticsearch:7.17.22
        container_name: elasticsearch
        environment:
          - discovery.type=single-node
          - xpack.security.enabled=false
        mem_limit: 2GB

    Вообще очень хорошие статьи - всегда хочется немного освежить основы. Причем оригинальный автор Miguel Grinberg - довольно известный ирландский программист. Если встретите его книжку по SQLAlchemy 2.0 - советую ознакомиться, очень хорошо она написана


    1. Alex_Mer5er Автор
      01.07.2024 08:51

      Спасибо большое за подробный гайд, уверен многие воспользуются им. И отдельное спасибо за рекомендацию по литературе.