15.全文搜索

2021-08-15  本文已影响0人  SingleDiego

本章的目标是为 Microblog 实现搜索功能,以便用户可以使用自然语言查找有趣的用户动态内容。许多不同类型的网站,都可以使用 Google,Bing 等搜索引擎来索引所有内容,并通过其搜索 API 提供搜索结果。这种方法适用于静态页面较多的的大部分网站,比如论坛。

但在我的应用中,基本的内容单元是一条用户动态,它是整个网页的很小一部分。我想要的搜索结果的类型是针对这些单独的用户动态而不是整个页面。例如,如果我搜索单词 “dog”,我想查看任何用户发表的包含该单词的动态。很明显,显示所有包含 “dog”(或任何其他可能的搜索字词)的用户动态的页面并不存在,大型搜索引擎也就无法索引到它。所以,我别无选择,只能自己实现搜索功能。






全文搜索引擎简介

对于全文搜索的支持不像关系数据库那样是标准化的。常见的几种开源全文搜索引擎包括:ElasticsearchApache SolrWhooshXapianSphinx 等等。而常用的数据库也可以像我上面列举的那些专用搜索引擎一样提供搜索服务。SQLiteMySQLPostgreSQL 都提供了对搜索文本的支持,以及 MongoDBCouchDB 等 NoSQL 数据库当然也提供这样的功能。

如果你想知道哪些应用程序可以在 Flask 应用中运行,那么答案是所有都可以! 这是 Flask 的强项之一,它在完成工作的同时不会自作主张。 那么到底选择哪一个呢?

在专用搜索引擎列表中,Elasticsearch 非常流行,部分原因是它在 ELK 栈中是用于索引日志的 “E”,另两个是 LogstashKibana。使用某个关系数据库的搜索能力也是一个不错的选择,但考虑到 SQLAlchemy 不支持这种功能,我将不得不使用原始 SQL 语句来处理搜索,否则就需要一个包,它提供一个文本搜索的高级接口,并与 SQLAlchemy 共存。

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






安装 Elasticsearch

我们先去 Elasticsearch 官网下载页面 下载适合自己系统的安装包。我在 Windows 系统下使用,下载好压缩包后解压,在命令行运行:

$ bin\elasticsearch

elasticsearch 现在已经启动,它默认运行在本地的 9200 端口,我们现在通过浏览器打开:http://localhost:9200/,就能看到它返回的 JSON 格式的服务基本信息 :

{
"name": "DESKTOP-PE81USO",
"cluster_name": "elasticsearch",
"cluster_uuid": "quumilmYQOSBXu9R79xTzg",
"version": {
"number": "7.14.0",
"build_flavor": "default",
"build_type": "zip",
"build_hash": "dd5a0a2acaa2045ff9624f3729fc8a6f40835aa1",
"build_date": "2021-07-29T20:49:32.864135063Z",
"build_snapshot": false,
"lucene_version": "8.9.0",
"minimum_wire_compatibility_version": "6.8.0",
"minimum_index_compatibility_version": "6.0.0-beta1"
},
"tagline": "You Know, for Search"
}

由于我使用 Python 来管理 Elasticsearch,因此我会使用其对应的 Python 客户端库:

(venv) $ pip install elasticsearch






Elasticsearch 入门

我将编写小的 demo.py 来演示 elasticsearch 的基本使用。

from elasticsearch import Elasticsearch

es = Elasticsearch('http://localhost:9200')

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

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

第二行表示建立与 Elasticsearch 的连接,需要创建一个 Elasticsearch 类的实例,并将连接 URL 作为参数传递。

下面部分表示,Elasticsearch 中的数据需要被写入索引中。与关系数据库不同,数据只是一个 JSON 对象。 它演示了将一个包含 text 字段的对象写入名为 my_index 的索引。

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

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

现在,该索引中有两个文档,我可以发布自由格式的搜索。 在本例中,我要搜索 this test

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

来自 es.search() 调用的响应是一个包含搜索结果的 Python 字典:

{
    'took': 3, 
    'timed_out': False, 
    '_shards': {'total': 1, 'successful': 1, 'skipped': 0, 'failed': 0}, 
    'hits': {
        'total': {'value': 2, 'relation': 'eq'}, 
        'max_score': 0.7544184, 
        'hits': [
            {
                '_index': 'my_index', 
                '_type': 'my_index', 
                '_id': '1', 
                '_score': 0.7544184, 
                '_source': {'text': 'this is a test'}
            }, 
            {
                '_index': 'my_index', 
                '_type': 'my_index', 
                '_id': '2', 
                '_score': 0.11190013, 
                '_source': {'text': 'a second test'}
            }
        ]
    }
}

在结果中你可以看到搜索返回了两个文档,每个文档都有一个分配的分数 _score。分数最高的文档包含我搜索的两个单词,而另一个文档只包含一个单词,分数越高文档内容就和搜索关键字越接近,即使是最好的结果的分数也不是很高,因为这些单词与文本不是完全一致的。

现在,如果我搜索单词 second,结果如下:

r = es.search(
    index='my_index', 
    doc_type='my_index', 
    body={'query': {'match': {'text': 'second'}}}
)
print(r)

// 搜索结果:
{
    'took': 1, 
    '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', 
                '_type': 'my_index', 
                '_id': '2', 
                '_score': 0.7361701, 
                '_source': {'text': 'a second test'}
            }
        ]
    }
}

搜索结果仍然得到相当低的分数,因为我的搜索与文档中的文本不匹配,但由于这两个文档中只有一个包含 “second” 这个词,所以不匹配的文档根本不显示。

演示完毕后我们把索引删除:

es.indices.delete('my_index')






Elasticsearch 配置

Elasticsearch 集成到本应用是展现 Flask 魅力的绝佳范例。这是一个与 Flask 没有任何关系的服务和 Python 包,但我们仍可以将从配置开始将它们一步一步地集成,我先在 app.config 模块中添加相应的配置项:

# app.config

from elasticsearch import Elasticsearch

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

与许多其他配置条目一样,Elasticsearch 的连接 URL 将来自环境变量。如果变量未定义,该配置项读取到的值为 None,应用将其用作禁用 Elasticsearch 的信号。 这主要是为了方便起见,所以当你运行应用时,尤其是在运行单元测试时,不必强制 Elasticsearch 服务启动和运行。

当我们需要使用 Elasticsearch 时候,需要设定 ELASTICSEARCH_URL 环境变量:

$ set ELASTICSEARCH_URL=http://localhost:9200

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

# app.config

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

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

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

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

对于本应用,我需要三个与文本索引相关的支持功能:

下面是 app/search.py 模块:

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

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

这些函数接受索引名称作为参数。在传递给 Elasticsearch 的所有调用中,我不仅将这个名称用作索引名称(index),还将其用作文档类型 (doc_type),一如我在上节控制台示例中所做的那样。

添加和删除索引条目的函数将 SQLAlchemy 模型的实例作为第二个参数。add_to_index() 函数使用 __searchable__ 变量来构建插入到索引中的文档。

回顾一下,Elasticsearch文档还需要一个唯一的标识符。为此,我使用 SQLAlchemy 模型的 id 字段,该字段正好是唯一的。 在 SQLAlchemyElasticsearch 使用相同的 id 值在运行搜索时非常有用,因为它允许我链接两个数据库中的条目。

如果尝试添加一个带有现有 id 的条目,那么 Elasticsearch 会用新的条目替换旧条目,所以 add_to_index() 可以用于新建和修改对象。

remove_from_index() 中的 es.delete() 函数作用是删除存储在给定 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 字典中获得。

下面我们在 Flask Shell 中演示上面三个函数的使用:

>>> from app.search import add_to_index, remove_from_index, query_index

>>> for post in Post.query.all():
...    add_to_index('post_index', post)

>>> query_index('post_index', 'test', 1, 10)
([5], 1)

>>> query_index('post_index', 'user', 1, 10)
([1, 2, 3, 4], 4)

>>> query_index('post_index', 'user', 1, 2)
([1, 2], 4)

这里我使用一个循环语句把 Post 表中的所以动态通过 add_to_index() 函数全部添加到 elasticsearch 的索引中,其索引名和文档类型都设定为 'post_index'

索引建立完成后就可以通过 query_index() 函数来进行搜索,它返回两个量,前面是符合搜索条件且经过分页后的 postid 列表,第二个则是全部符合搜索条件的总条目数量。

如果你想保持数据的清洁,可以在做实验之后删除 post_index 索引:

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






集成 SQLAlchemy 到搜索

现在我们的搜索方法仍然存在一些问题。最明显的问题是搜索结果是以数字 ID 列表的形式出现的,这非常不方便。我需要返回 SQLAlchemy 模型对象列表,以便我可以将它们传递给模板进行渲染。

第二个问题是,这个解决方案需要应用在添加或删除用户动态时同时触发对相应索引调用,这可以在视图函数层面来实现,但效果并不理想,因为在 SQLAlchemy 底层进行更改时索引的调用是不会被检测到的,每当发生这种情况时,两个数据库就会越来越不同步,并且你可能在一段时间内都不会注意到。 更好的解决方案是在 SQLAlchemy 数据库进行更改时自动触发这些调用。

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

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

为了实现这两个问题的解决方案,我将编写 mixin 类。记得 mixin 类吗? 在第五章中,我将 Flask-Login 中的 UserMixin 类添加到了 User 模型,为它提供 Flask-Login 所需的一些功能。对于搜索支持,我将定义我自己的 SearchableMixin 类,当它被添加到模型时,可以自动管理与 SQLAlchemy 模型关联的全文索引。mixin 类将充当 SQLAlchemyElasticsearch 世界之间的“粘合”层,为我上面提到的两个问题提供解决方案。

现在我们先实现 SearchableMixin 类。

# app\search.py

from app import db

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

    @classmethod
    def reindex(cls):
        for obj in cls.query:
            add_to_index(cls.__tablename__, obj)

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

search() 类方法封装了 query_index() 函数以将 ID 列表替换成实例对象列表。你可以看到这个函数做的第一件事就是调用 query_index(),并传递 cls .__tablename__ 作为索引名称,这将是一个约定,所有索引都将用 Flask-SQLAlchemy 模型关联的表名。

该函数返回结果 ID 列表和结果总数。我们通过对返回的 ID 列表进行 SQLAlchemyin_ 查询:cls.query.filter(cls.id.in_(ids)) 来获得数据库模型对象列表。

获得列表后,我们需要对其进行排序,query_index() 函数给我们返回的 ID 列表是已经根据搜索相关性粉丝从高到低进行了排序,我们的数据库模型对象列表也应该根据这个顺序来排序。我们实现这个排序会基于 SQL 语言的 CASE 语句,我们先用列表 when 构建排序的依据,把它作为 CASE 子句的条件,想具体了解借助 CASE 子句的自定义排序如何实现,我建议可以看看 StackOverFlow 的这个回答

before_commit()after_commit() 方法分别对应来自 SQLAlchemy 的两个事件,这两个事件分别在提交发生之前和之后触发。前置处理功能很有用,我们可以查看并找出将要添加,修改和删除的对象,session.newsession.dirtysession.deleted 就和这三者一一对应。

因为这些对象在会话提交后不再可用,所以我需要在提交之前(即:before_commit())保存它们。我使用 session._changes 字典将这些对象写入会话,以保证提交后仍然能够读取到它们。

一旦会话被提交,我们就使用它们来更新 Elasticsearch 索引。索引的跟新安排在 after_commit() 部分,session._changes 变量保存了会话所添加,修改和删除的对象,所以我们现在可以引用 app/search.py 中处理索引相关的函数进行相应的调用。

reindex() 类方法是一个简单的帮助方法,你可以使用它来刷新所有数据的索引。有了这个方法,调用 Post.reindex() 就可将数据库中的所有用户动态添加到搜索索引中。

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

# app\models.py

from app.search import SearchableMixin

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() 调用不在类内部,而是在其后面。这两行代码设置了每次提交之前和之后调用的事件处理程序。现在当 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>]






搜索表单

接下来我们讨论如果构建搜索的 URL,基于网络搜索的一种相当标准的方法是在 URL 的查询字符串中将搜索词作为 q 参数的值。例如,如果你想在 Google 上搜索 Python, 它的 URL 将会是:

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

为了实现上述搜索,我们将以 GET 请求表发送单。另外我打算把搜索表单放在于导航栏中,因此它将会出现应用的所有页面中。

首先,我们编写表单类:

# 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 类的实例。唯一的要求是用户登录,因为对于匿名用户,我目前不会显示任何内容。与其在每个路由中创建表单对象,然后将表单传递给所有模板,更好的做法是在 Flask 的全局变量 g 中放置表单类。

# app\main\routes.py

from app.main.forms import SearchForm

@main_routes.before_app_request
def before_app_request():
    # ...
    if current_user.is_authenticated:
        g.search_form = SearchForm()

下一步是将表单渲染成页面。我们将其作为导航栏的一部分进行渲染。因为模板也可以调用存储在 g 变量中的数据,所以我不需要在所有 render_template() 调用中将 SearchForm 表单类作为显式模板参数添加进去。

# app\templates\base.html

<ul class="nav navbar-nav">
  <li><a href="{{ url_for('main.index') }}">{{ _('Home') }}</a></li>
  <li><a href="{{ url_for('main.explore') }}">{{ _('Explore') }}</a></li>
</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 %}

这个表单与我之前做过的略有不同。 我将 method 属性设置为 get,因为我希望表单数据作为查询字符串,通过 GET 请求提交。另外,我创建的其他表单 action 属性为空,因为它们被提交到渲染表单的同一页面。而这个表单很特殊,因为它出现在所有页面中,所以我需要明确告诉它需要提交的地方,这是专门用于处理搜索的新路由。






搜索视图函数

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

# app\main\routes.py

@main_routes.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(),它只验证字段值,而不检查数据是如何提交的。 如果验证失败,这是因为用户提交了一个空的搜索表单,所以在这种情况下,我只能重定向到 explore 页面。

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

一旦计算出搜索结果和分页链接的页面,剩下的就是渲染一个包含所有这些数据的模板。我决定创建一个专用于显示搜索结果的 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 %}

现在我们应用的搜索功能已经构建完毕:






本文源码:https://github.com/SingleDiego/Flask-Tutorial-Source-Code/tree/SingleDiego-patch-15

上一篇下一篇

猜你喜欢

热点阅读