Flask Mega-Tutorial V2.0 第16章:全文搜索

最近在Flask Web Development作者博客看到第二版Flask Mega-Tutorial已在2017年底更新,现翻译给大家参考,希望帮助大家学习flask。

这是Flask Mega-Tutorial系列的第十六章,其中我将为Microblog添加全文搜索功能。

供您参考,以下是本系列文章的列表。

注意1:如果您正在寻找本教程的旧版本,请在此处

注意2:如果您想在此博客上支持我的工作,或者只是没有耐心等待每周的文章,我将提供完整的本教程版本,打包成电子书或视频集。欲了解更多信息,请访问courses.miguelgrinberg.com

本章的目的是为Microblog实现搜索功能,以便用户可以使用自然语言找到有趣的帖子。对于许多类型的网站,可以让Google,Bing等搜索引擎将所有内容编入索引,并通过其搜索API提供搜索结果。这对于大多数页面都是静态的网站(例如论坛)非常有效。但是在我的应用中,基本内容单位是用户帖子,这是整个网页的一小部分。我想要的搜索结果类型是针对这些单个博客帖子,而不是整个页面。例如,如果我搜索单词“ dog”,那么我想查看来自包含该单词的所有用户的博客文章。显然,该页面显示了所有带有“dog”一词的博客帖子。

本章的GitHub链接是:BrowseZipDiff


全文搜索引擎简介

对全文搜索的支持没有像关系数据库那样标准化。有几个开源的全文本引擎:ElasticsearchApache SolrWhooshXapianSphinx等。似乎这还不够,有几个数据库还提供了与专用搜索引擎类似的搜索功能,例如我上面列举的那些。SQLiteMySQLPostgreSQL都提供了一些搜索文本的支持,而MongoDBCouchDB等NoSQL数据库也支持。

如果您想知道其中哪些可以在Flask应用中运行,答案就是所有这些!那是Flask的强项之一,,它在完成工作的同时不会自作主张。那么最好的选择是什么?

在专用搜索引擎列表中,Elasticsearch在我眼中相当受欢迎,部分原因是它在ELK栈中是用于索引日志的“E”,另两个是Logstash和Kibana。 使用某个关系数据库的搜索能力也是一个不错的选择,但是鉴于SQLAlchemy不支持此功能,我将不得不使用原始SQL语句来处理搜索,否则必须找到一个提供以下功能的包: 它提供一个文本搜索的高级接口,并与SQLAlchemy共存

基于以上分析,我将使用Elasticsearch,但我将以一种非常容易切换到另一个搜索引擎的方式来实现所有文本索引和搜索功能。 你可以用其他搜索引擎的替换我的实现,只需在单个模块中重写一些函数即可

安装Elasticsearch

有多种安装Elasticsearch的方法,包括一键式安装程序,包含您自己需要安装的二进制文件的zip文件,甚至是Docker镜像。该文档有一个“安装”页面,其中包含所有这些选项的详细信息。如果您使用的是Linux,则可能会有一个可用于您的发行版的软件包。如果您使用Mac并安装了Homebrew,则只需运行brew install elasticsearch即可。

在计算机上安装Elasticsearch之后,您可以在浏览器的地址栏通过输入http://localhost:9200来验证它是否正在运行,预期的返回结果是JSON格式的服务基本信息。

由于我将通过Python管理Elasticsearch,因此我还将使用Python客户端库:

(venv) $ pip install elasticsearch

您可能还需要更新您的requirements.txt文件:

(venv) $ pip freeze > requirements.txt

Elasticsearch教程

首先,我将从Python shell向您展示使用Elasticsearch的基础知识。这将帮助您熟悉此服务,以便您可以理解我稍后将讨论的实现部分。

要创建与Elasticsearch的连接,请创建class的实例Elasticsearch,并将连接URL作为参数传递:

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

Elasticsearch中的数据被写入索引。与关系数据库不同,数据只是一个JSON对象。以下示例将一个包含text字段的对象写入名为my_index的索引:

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

如果需要,索引可以存储不同类型的文档,在本处,可以根据不同的格式将doc_type参数设置为不同的值。 我要将所有文档存储为相同的格式,因此我将文档类型设置为索引名称。

对于存储的每个文档,Elasticsearch使用了一个唯一的ID来索引含有数据的JSON对象

让我们在该索引上存储第二个文档:

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

现在此索引中有两个文档,我可以发出自由格式的搜索。在此示例中,我将搜索this test

>>> es.search(index='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', 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魅力的绝佳范例这是一个与Flask没有任何关系的服务和Python包,然而,我将从配置开始将它们恰如其分地集成,我先在app.config 模块中实现这样的操作:

config.py:Elasticsearch配置。

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

与许多其他配置条目一样,Elasticsearch的连接URL将来自环境变量。如果未定义变量,则将设置设置为None,并将其用作禁用Elasticsearch的标志。这主要是为了方便起见,因此在处理应用时,尤其是在运行单元测试时,不必强制Elasticsearch服务启动和运行。因此,要确保服务的可用性,我需要直接在终端中定义环境变量ELASTICSEARCH_URL,或者将其添加到.env文件中,如下所示:

ELASTICSEARCH_URL=http://localhost:9200

使用Elasticsearch面临着非Flask插件如何使用的挑战我不能像在上面的例子中那样在全局范围内创建Elasticsearch实例,因为要初始化它,我需要访问app.config,该属性仅在调用create_app()函数后可用。因此,我决定在应用工厂函数中为app实例添加一个属性elasticsearch

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),然后在它的__init__()函数中定义elasticsearch属性。

请留意我设计的条件表达式,如果Elasticsearch服务的URL在环境变量中未定义,则赋值Noneapp.elasticsearch

全文搜索抽象化

就像我在本章的简介中所说的那样,我想轻松地从Elasticsearch切换到其他搜索引擎,并且我也不想为搜索博客文章专门编写此功能,我希望设计一种解决方案,以便将来如果需要使用,我可以轻松扩展到其他模型。由于所有这些原因,我决定将搜索功能抽象化我的想法是以通用条件来设计特性,所以不会假设Post模型是唯一需要编制索引的模型,也不会假设Elasticsearch是唯一选择的搜索引擎。 但是如果我不能对任何事情做出任何假设,我是不可能完成这项工作的!

我需要做的第一件事,是以某种方式找到一种通用的方式来指示要对哪个模型以及哪个字段或哪个字段进行索引。我要说的是,任何需要索引的模型都需要定义一个__searchable__类属性,该属性列出需要包含在索引中的字段。对于Post模型,这些是更改:

app / models.py:向Post模型添加一个__searchable__属性。

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

需要说明的是,这个模型需要有body字段才能被索引。 不过,为了清楚地确保这一点,我添加的这个__searchable__属性只是一个变量,它没有任何关联的行为。 它只会帮助我以通用的方式编写索引函数。

我将在app / search.py模块中编写所有与Elasticsearch索引进行交互的代码。 这么做是为了将所有Elasticsearch代码限制在这个模块中。本应用的其余部分将使用此新模块中的函数来访问索引,而不会直接访问Elasticsearch。这很重要,因为如果有一天我决定不再喜欢Elasticsearch并想要切换到其他引擎,那么我要做的就是重写此模块中的函数,该应用程将继续像以前一样工作。

对于本应用,我决定需要三个与文本索引相关的支持功能:我需要向全文索引添加条目,我需要从索引中删除条目(假设有一天我将支持删除博客文章),以及我需要执行搜索查询。下面是app / search.py模块,它使用我在Python控制台中向你展示的功能实现Elasticsearch的这三个函数:

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, body=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,
        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']['value']

这些函数都是通过检查app.elasticsearch是否为None开始的,如果是None,则不做任何事情就返回。 当Elasticsearch服务器未配置时,应用会在没有搜索功能的状态下继续运行,不会出现任何错误。 这都是为了方便开发或运行单元测试。

这些函数接受索引名称作为参数。在传递给Elasticsearch的所有调用中,都使用该名称作为索引名称以及文档类型,就像在Python控制台示例中所做的那样。

从索引添加和删除条目的函数将SQLAlchemy模型作为第二个参数。add_to_index()函数使用我添加到模型中的类变量__searchable__来构建插入到索引中的文档。回想一下,Elasticsearch文档还需要一个唯一的标识符。为此,我使用了SQLAlchemy模型的字段id该字段正好是唯一的。在运行搜索时,对SQLAlchemy和Elasticsearch使用相同的id值非常有用,因为它允许我链接两个数据库中的条目。我上面没有提到的是,试添加一个带有现有id的条目,那么Elasticsearch会用新的条目替换旧条目,所以add_to_index()可以用于新建和修改对象。

remove_from_index()中的es.delete()函数,我之前没有展示过。 这个函数删除存储在给定id下的文档。 下面是使用相同id链接两个数据库中条目的便利性的一个很好的例子。个数据库中的条目。

query_index()函数使用索引名称和文本进行搜索,通过分页控件,还可以像Flask-SQLAlchemy结果那样对搜索结果进行分页。您已经从Python控制台中看到了es.search()函数的用法示例。 我在这里发布的调用非常相似,但不是使用match查询类型,而是使用multi_match,它可以跨多个字段进行搜索。 通过传递*的字段名称,我告诉Elasticsearch查看所有字段,所以基本上我就是搜索了整个索引。 这对于使该函数具有通用性很有用,因为不同的模型在索引中可以具有不同的字段名称。

es.search()查询的body参数还包含分页参数。 fromsize参数控制整个结果集的哪些子集需要被返回。 Elasticsearch没有像Flask-SQLAlchemy那样提供一个很好的Pagination对象,所以我必须使用分页数学逻辑来计算from值。

query_index()函数中的return语句有点复杂。 它返回两个值:第一个是搜索结果的id元素列表,第二个是结果总数。 两者都从es.search()函数返回的Python字典中获得。 用于获取ID列表的表达式,被称为列表推导式,是Python语言的一个奇妙功能,它允许你将列表从一种格式转换为另一种格式。 在本例,我使用列表推导式从Elasticsearch提供的更大的结果列表中提取id值。

这太令人困惑了吗?也许从Python控制台进行这些功能的演示可以帮助您更多地了解它们。在以下会话中,我将数据库中的所有帖子手动添加到Elasticsearch索引中。在我的测试数据库中,我有一些帖子,其中包含数字“one”,“two”, “three”, “four” 和“five”,,因此我将其用作搜索查询。您可能需要调整查询以匹配数据库的内容:

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

我发出的查询返回了七个结果。 当我以每页100项查询第1页时,我得到了全部的七项,但接下来的三个例子显示了我如何以与Flask-SQLAlchemy类似的方式对结果进行分页,当然,结果是ID列表而不是SQLAlchemy对象。

如果您想保持数据整洁,请尝试进行以下操作以删除posts索引:

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

集成SQLAlchemy到搜索

我在上一节中向您展示的解决方案很不错,但是仍然存在一些问题。最明显的问题是结果以数字ID列表的形式出现。这非常不方便,我需要SQLAlchemy模型,以便可以将它们传递给模板进行渲染,并且需要一种用数据库中的相应模型替换该数字列表的方法。第二个问题是此解决方案要求应用在添加或删除帖子时显式发出索引调用,这并非不可行,但并不理想因为在SQLAlchemy侧进行更改时错过索引调用的情况是不容易被检测到的,每当发生这种情况时,两个数据库就会越来越不同步,并且你可能在一段时间内都不会注意到。 更好的解决方案是在SQLAlchemy数据库进行更改时自动触发这些调用。

用对象替换ID的问题可以通过创建一个从数据库读取这些对象的SQLAlchemy查询来解决。 这在实践中听起来很容易,但是使用单个查询来高效地实现它实际上有点棘手。

对于自动触发索引更改的问题,我决定从SQLAlchemy事件驱动对Elasticsearch索引的更新。SQLAlchemy提供了可以通知应用的大量事件。例如,每次提交会话时,我都可以定义一个由SQLAlchemy调用的函数,并且在该函数中,我可以将SQLAlchemy会话中的更新应用于Elasticsearch索引。

为了实现这两个问题的解决方案,我将编写一个mixin类。还记得mixin类吗?在第5章中,我将Flask-Login中的UserMixin类添加到User模型中,以提供Flask-Login所需的一些功能。为了获得搜索支持,我将定义自己的SearchableMixin类,将其附加到模型后,它便可以自动管理关联的全文本索引。mixin类将充当SQLAlchemy和Elasticsearch世界之间的“胶水”层,为我上面提到的两个问题提供解决方案。

让我先告诉您实现,然后我将介绍一些有趣的细节。请注意,这利用了几种高级技术,因此您需要仔细研究此代码以完全理解它。

app / models.py:SearchableMixin类。

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': 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 cls.query:
            add_to_index(cls.__tablename__, obj)

这个mixin类中有四个函数,都是类方法。复习一下,类方法是与类相关联的特殊方法,而不是实例的。请注意,我如何将常规实例方法中使用的self参数重命名为cls,以明确表明此方法将接收类而不是实例作为其第一个参数。例如,一旦附加到Post模型,上面的search()方法将被调用为Post.search(),而不必将Post类实例化。

search()类方法封装来自app/search.py​​query_index()函数以将对象ID列表替换成实例对象。你可以看到这个函数做的第一件事就是调用query_index(),并传递cls .__tablename__作为索引名称。这将是一个约定,所有索引都将用Flask-SQLAlchemy模型关联的表名。该函数返回结果ID列表和结果总数。通过它们的ID检索对象列表的SQLAlchemy查询基于SQL语言的CASE语句,该语句需要用于确保数据库中的结果与给定ID的顺序相同。这很重要,因为Elasticsearch查询返回的结果按相关性从高到低排序。如果您想了解有关此查询工作方式的更多信息,可以查阅此StackOverflow问题的公认答案。该search()函数返回替换ID列表的查询,以及搜索结果的总数

before_commit()after_commit()方法分别对应来自SQLAlchemy的两个事件,这两个事件分别在提交发生之前和之后触发事前处理功能很有用,因为会话还没有提交,所以我可以查看并找出将要添加,修改和删除的对象,如session.newsession.dirtysession.deleted。在提交会话之后,这些对象将不再可用,因此我需要在提交之前保存它们。我正在使用session._changes字典将这些对象写入会话提交后仍然存在的地方,因为一旦提交会话,我将使用它们来更新Elasticsearch索引。

当调用after_commit()处理程序,该会话已经成功提交,因此这是在Elasticsearch端进行更新的适当时间session对象具有before_commit()中添加的_changes变量,所以现在我可以迭代需要被添加,修改和删除的对象,并对app/search.py中的索引函数进行相应的调用SearchableMixin

reindex()类方法是一种简单的辅助方法,您可以使用它来刷新所有数据的索引。您看到我从上面的Python shell会话中执行了类似的操作,以将所有帖子初始加载到测试索引中。使用此方法后,我可以调用Post.reindex()将数据库中的所有帖子添加到搜索索引。

在类定义之后,我对SQLALchemy的function进行了两次调用db.event.listen()。请注意,这些调用不在类内部,而是在类内部。这两个语句的目的是设置事件处理程序,使SQLAlchemy分别在每次提交之前和之后调用before_commit()after_commit()方法。

为了将SearchableMixin类整合到Post模型中,我必须将它作为Post的基类,并且还需要监听commit之前和之后的事件:

app / models.py:将SearchableMixin类添加到Post模型中。

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

现在,该Post模型将自动维护帖子的全文搜索索引。我可以使用该reindex()方法来初始化数据库中当前所有帖子的索引:

>>> Post.reindex()

而且我可以通过运行Post.search()来搜索使用SQLAlchemy模型的帖子。在以下示例中,我要求查询第一页的五个元素:

>>> 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>]

搜索表单

这非常激烈。我 我上面做的保持通用性的工作涉及到几个高级主题,因此可能需要一些时间才能完全理解。 现在我有一套完整的系统来处理用户动态的自然语言搜索。 所以现在需要做的是将所有这些功能与应用集成在一起。

基于Web搜索的一种相当标准的方法是将搜索词q作为URL的查询字符串中的参数。例如,如果您想在Google上搜索Python,并且想节省几秒钟,则只需在浏览器的地址栏中键入以下URL,即可直接转到结果:

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

允许将搜索完全封装在URL中是一件很不错的事,因为可以与其他人共享这些内容,他们只需单击链接即可访问搜索结果。

请允许我向你介绍一种区别于以前的Web表单的处理方式。我已经使用POST请求为应用提交表单数据,但是要实现上述搜索,表单提交将必须以GET请求发送,这是一种请求方法。当您在浏览器中输入网址或点击链接时,就是GET请求。另一个有趣的区别是搜索表单将出现在导航栏中,因此它需要出现在应用的所有页面中。

这是搜索表单类,仅包含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 'csrf_enabled' not in kwargs:
            kwargs['csrf_enabled'] = False
        super(SearchForm, self).__init__(*args, **kwargs)

q字段无须解释,因为它与我过去使用的其他文本字段相似。对于此表单,我不需要“提交”按钮。对于具有文本字段的表单,当您按Enter键并将焦点放在该字段上时,浏览器将提交该表单,因此不需要按钮。我还添加了一个__init__构造函数,它提供了formdatacsrf_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类的实例。唯一的要求是用户已登录,因为对于匿名用户,我目前不显示任何内容。我将向您展示一个非常有用的技巧,与其在每个路由中创建表单对象,然后将表单传递给所有模板,当你需要在整个应用中实现一个功能时,可以消除重复代码。在第6章中,我已经使用过before_request处理程序,来记录每个用户的上次访问时间。我要做的就是在相同的函数中创建搜索表单,但是有一点区别:

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

在这里,当经过身份验证的用户时,我会创建搜索表单类的实例。当然,我需要将此表单对象持久化,直到可以在请求结束时将其渲染为止,因此我需要将其存储在某个位置。那个地方就是Flask提供的g容器。Flask提供的g变量是应用可以存储需要在整个请求期间持续存在的数据的地方。在这里,我将表单存储在g.search_form中,因此,当请求前置处理程序结束并且Flask调用用于处理请求的URL的视图函数时,该g对象将是相同的,并且仍将附加表单。重要的是,要注意g变量对于每个请求和每个客户端都是特定的,因此,即使您的Web服务器一次为不同的客户端处理多个请求,您仍然可以依赖于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属性为空,因为它们被提交到渲染表单的同一页面。 而这个表单很特殊,因为它出现在所有页面中,所以我需要明确告诉它需要提交的地方,这是专门用于处理搜索的新路由。

搜索视图功能

完成搜索功能的最后一项功能是接收搜索表单的视图函数。该视图函数将附加到/search路由,以便您可以像Google一样发送带有 http://localhost:5000/search?q=search-words的搜索请求。

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(),仅验证字段值而不检查数据如何提交。如果验证失败,那是因为用户提交了一个空的搜索表单,因此在这种情况下,我只重定向到显示所有博客文章的浏览页面。

SearchableMixin类中的Post.search()方法用于获取搜索结果列表。分页的处理方式与索引和浏览页的处理方式非常相似,但是如果没有Flask-SQLAlchemy的Pagination对象的帮助,则生成下一个和上一个链接会有些棘手。这是从Post.search()返回的结果总数的用途所在。

计算完搜索结果和分页链接页面后,剩下的就是用所有这些数据渲染模板。我本来可以找到一种方法,来重用index.html模板以显示搜索结果,但是由于存在一些差异,我决定利用_post来创建专用于显示搜索结果的专用search.html模板, 以 _post.html 子模板的优势来渲染搜索结果

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文档会有所帮助。

搜索结果

你怎么认为?这章很紧张,我介绍了一些相当先进的技术。本章中的某些概念可能需要花一些时间才能理解。最重要的一点是,如果您要使用与Elasticsearch不同的搜索引擎,则您需要做的就是重新实现app / search.py其中的三个功能。通过此工作的另一个重要好处是,,如果我需要为另外的数据库模型添加搜索支持,我可以简单地通过向它添加SearchableMixin类,为__searchable__属性填写要索引的字段列表和SQLAlchemy事件处理程序的监听即可。 我认为这些努力是值得的,因为从现在起,处理全文索引将会变得十分容易。


原文链接:https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-xvi-full-text-search

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值