所在位置:

Flask大型教程的第十六章:全文搜索【翻译】

这是Flask大型教程系列的第十六部分,我将为微型博客添加一个全文搜索的功能。

本章目标是实现微型博客的搜索功能,以便用户可以使用自然语言来搜索有趣的文章。对于网站的很多类型,可以让Google,Bing等索引所有内容并通过他们的搜索 APIs来提供搜索结果。这适用于大部分静态页面的网站, 比如论坛。但是在我的应用程序中,基本的内容单元是用户发布的文章,它是整个页面的一小部分。我想为单独的博客文章作为搜索结果的类型而不是整个页面。例如,如果我搜索一个单词 "dog",我想查看包含这个单词的任何用户发表的的博客文章。很明显,展示所有包含这个单词 "dog" (或者其它可能的搜索单词)的博客文章并不是作为大型搜索引擎可以找到并索引真正存在的。因此我没有其它选择,只能自己来做搜索功能。

本章的 GitHub 地址是 Browse, Zip, Diff

全文搜索引擎介绍

对于全文搜索的支持不像关系型数据库那样是标准化的。这里有几个开源的全文搜索引擎: ElasticsearchApache Solr, Whoosh, Xapian, Sphinx等。如果这还不够,这里有几个数据库也像上面提到的全文搜索引擎提供了搜索的功能。SQLite, MySQLPostgreSQL 都为搜索文本提供了一些支持, 还有非关系型数据库像 MongoDBCouchDB

如果你想知道哪些应用程序能在 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 脚本中使用 Elasticsearch 基本知识。这将会帮助你熟悉这些服务,以便于你能理解我后面讨论的实现。

要创建一个 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'})

如果需要,索引能存储不同类型的文档,在这种情况下,这个 doc_type 参数能够根据不同的格式设置为不同的值。我将用相同的格式存储所有的文档,因此我将设置文档类型为索引名称。

对于每个存储的文档,Elasticsearch 使用唯一的 id 和 JSON 对象与数据。

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

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

现在在这个索引里有两个文档,我能使用 free-form 搜索。在这个例子,我将会搜索 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 没有任何关系,但是,我将从配置文件开始, 获得一个相当好的整成级别,我将会从 Flask 里开始写 app.config 的字典:

config.py: Elasticsearch 配置:

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

与许多其它配置条目一样,Elasticsearch 的 connection 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

    # ...

为应用程序实例添加一个新属性看起来挺奇怪的,但是Python对象在结构并不严格的,可以随时添加新属性。你可以考虑的另一种方法是创建一个 Flask 子类(也许叫 Microbolg),并在 __init__ 函数中定义 elasticsearch 属性。

请注意,如果在环境中没有定义 Elasticsearch 服务的 URL, 我如何使用 条件表达式 使得 Elasticsearch 的实例变为 None。

全文搜索抽象

就像我在本章介绍中所说的,我希望能够轻松地从 Elasticsearch 切换到其它的搜索引擎,并且我也不希望将这个功能专门用于博客文章搜索,如果我需要,我更愿意设计一个未来的解决方案,它可以很容易就扩展其它的模型。基于所有这些原因,我决定为搜索功能创建一个抽象。这个想法是以通用术语设计特征,所以我不会假设文章的模型仅仅需要被索引的,我也不会假设 Elasticsearch 是选择的索引引擎。但是如果我不能为任何事情作出任何假设,那我怎么样才能完成这项工作呢?

我需要的第一件事是,怎么样才能找出通用的方式来指示哪个模型和哪个或者哪些字段需要被索引。我要说任何需要索引的模型都需要定义一个 __searchable__ 类属性,这个属性列出需要被包含在索引中字段。对于文章模型,这里有一些改变:

app/models.py: 为文章模型添加一个 searchable 属性。

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

所以我在这里说的是这个模型的 body 字段需要被索引。但是为了确保这一点非常清楚,我添加的 searchable 的属性只是一个变量,它没有任何与它相关的行为。它只会帮助我以通用的方式编写索引函数

我将会编写在 app/search.py 模块中与 Elasticsearch 索引交互的所有的代码。这个想法是将所有的 Elasticsearch 代码保留这个模块中。应用程序的其余部分将在新的模块中会使用这个函数来访问索引,并且将不会直接访问 Elasticsearch。这一点很重要,因为如果某天我决定不再喜欢 Elasticsearch,想切换为不同的引擎,我需要做的就是重写这个模块的函数,和应用程序将会和以前一样继续工作。

对于这个应用程序,我决定需要提供三个相关的文本索引功能:我需要添加条目到全文索引,我需要从索引中删除条目(假设某天我将提供删除博客文章),以及我需要执行搜索查询。下面的 app/search.py 模块,我会从 Python 终端中给你展示使用 Elasticsearch 实现的三个函数:

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 服务器没有配置时,这个应用程序就会继续运行而没有搜索功能,也不会出现任何错误。这只是在开发或者运行单元测试期间的便利问题。

这些函数接受索引名称作为参数。在传递给 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() 函数 的用法。我这里发布的调用很相似,但是使用匹配查询类型来代替,我决定使用 multi_match,它可以通过多个字段来搜索。传递 * 字段的名字,我告诉 Elasticsearch 来查找所有的字段,因此基本上我会搜索整个条目。做通用的函数是有用的,因些不同的模型在索引中有不同的字段名。

除了查询自己以外,es.search() 里的 body 参数 包含分页参数。from 和 size 参数用来控制返回整个结果集的字集。Elasticsearch 没有像 Flask-SQLAlchemy那样提供一个友好的分页对象,因此我必须处理分页数学来计算 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 项时,我得到了全部七项,但接下来的三个例子会显示我如何用一种跟 Flask-SQLAlchemy 一样的方式来进行结果分页,除了结果将作为 ID 列表而不是 SQLAlchemy 的对象。

如果你想保持干净,在做完这个实验之后,请删除文章的索引:

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

搜索集成到 SQLAlchemy

我在前面的部分提到的解决方案是合适的,但它仍然会有一些问题。最明显的问题是结果以数字 ID 列表的形式出现。这是相当不方便的,我需要 SQLAlchemy 模型,以便我能把它们传递给模板渲染,并且我需要一种替换列表的方式,它可以从数据库中用列表数字来代表这些模型。这个解决方案需要应用程序在添加或删除帖子时显式地发出索引调用,这并不可怕,但并不理想,国为在 SQLAlchemy 侧进行更改时导致丢失索引调用的错误是不容易被检测到的,每次发生错误时,两个数据库会越来越不同步,并且你有一段时间不会注意到。更好的解决方案是在 SQLAlchemy 数据库进行更改时自动触发这些调用。

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

对于自动触发更改索引的问题,我决定从 SQLAlchemy 的事件驱动更新 Elasticsearch 的索引。SQLAlchemy 提供了可以通知应用程序的大量事件列表。例如,每次会话提交,我能得到在应用程序中被 SQLAlchemy 触发的函数,以及在这些函数中, 我能应用于 SQLAlchemy 会话的更新应用于 Elasticsearch 索引。

为了实现这两个问题的解决方案,我将会写一个 mixix 类。记得 mixin 类?在第五章,我将来自 Flask-login 的 UserMixin 类添加到用户模型,给它提供 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

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

在这个 mixin 类中有四个函数,所有类的方法。一个类方法是跟类关联的特殊方法而不是特定的实例。请注意,我如何把在常规实例中的 self 参数重命名cls,以明确这个方法接受类以及不是实例作为第一个参数。例如,一旦连接到文章模型,上面这个 search() 方法将会作为 Post.search() 进行调用,不需要文章类的实际实例。

这个 search() 类方法包含从 app/search.py 里的 query_index() 函数,这个函数用实际的对象来替换 ID 的列表对象。你可以看到这个函数的第一件事是用 cls.tablename 作为索引名传递给 query_index() 函数调用。这是相当方便的,所有的索引将用 Flask-SQLAlchemy 的名字来分配关系表。这个函数返回结果 ID 的列表,以及结果总数。这个 SQLAlchemy 通过其ID检索对象列表的SQLAlchemy查询基于来自SQL语言的CASE语句,该语句需要用于确保数据库中的结果与给定ID的顺序相同。这很重要,因为Elasticsearch查询返回的结果从更多到更不相关。如果你想了解更多关于这个查询的工作方式,你可以参考 StackOverflow 问题的接受答案。这个 search() 函数返回替换 ID 列表的查询,并将搜索结果的总数作为每二个返回值。

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

当调用after_commit()处理程序时,会话就已经成功提交了,因此这是在Elasticsearch 这一边进行更改的适当时间。会话对象有我在 before_commit() 添加的 _changes 变量,因此现在我可以迭代这些添加、修改和删除的对象,以及在 app/search.py 里的索引函数进行相应的调用。

这个 reindex() 类方法是一个简单的辅助方法,你可以使用它来刷新索引并使用关系端的所有数据。你看到我在上面的 Python 脚本会话执行类似的操作。它可以初始化读取所有数据到测试索引。用这个方法,我能使用 Post.reindex() 在数据库来添加所有的文章到搜索索引中。

为了将这个 SearchableMixin 类整合到文章模型中,我必须将它作为一个子类添加,并且我还需要连接之前和之后提交的事件。

app/models.py: 添加 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() 调用不是在这个类里面,而是在类之后的。这些设置了每个提交之前和之后的事件处理。现在这个文章模型自动维护文章的全文索引。我可以使用 reindex() 方法来初始化在当前数据库的所有文章的索引:

>>> Post.reindex()
And I can search posts working with SQLAlchemy models by running Post.search(). In the following example, I ask for the first page of five elements for my query:

>>> 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搜索的相当标准的方法是在 URL 的查询字符串里将搜索项作为 q 参数。例如,如果你想在 Google 上搜索 Python,并且想保存几秒钟,你可以在你的浏览器地址栏中输入下面的 URL 即可直接转到结果:

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

允许将搜索完全封装在 URL 是很好的,因为这些可以跟其它人分享,只要点击链接就可以访问搜索结果。

这引起了我向您展示过去处理Web表单的方式的变化。目前我使用 POST 请求为所有应用程序的表单提交表单数据,但是为了实现上述的搜索,这个表单提交必须用 GET 请求,这是请求方法,当你在输入浏览器的网址或者点击链接。另一个有趣的区别是搜索表单在导航栏中,因此它需要存于应用程序的所有页面。

这里是一个搜索表单的类,只带了一个 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 字段不需要任何解释,就好像我在过去使用的其它文本字段一样。对于这个表单,我决定不用提交按钮。对于具有文本字段的表单,当我按下回车的时候,浏览器会提交这个表单,焦点位于该字段上,因此不需要按钮。我也添加了一个 __init__ 构建函数,它为 fromdata 以及 csrf_enabled 参数提供值,如果它们不是由调用者提供的话。 formdata参数决定Flask-WTF从哪里获取表单提交。默认是使用 request.form,这是Flask放置通过POST请求提交的表单值的地方。通过 GET 请求的表单提交在查询字符串里有一个字段值,因此我需要指向 Flask-WTF 的 request.args,这里是 Flask 写查询字符串的参数。如果你还记得,表单默认添加了 CSRF 保,带有一个通过在模板的 from.hidden_tag() 构建的 CSRF 标记 。对于点击搜索的连接,需要禁止 CSRF,因此我设置 csrf_enabled 为 假,这样可以让 Flask-WTF 知道需要忽略这个表单 CSRF验证。

为了在所有的页面都显示这个表单,因此无论用户正在看哪个页面,我需要创建一个 SearchForm 类的实例。 唯一的要求是登录的用户,因为对于匿名用户,我目前没有显示任何内容。与其在每一个路由中创建一个表单对象,以及把这个表单传递给所有模板,我会给你展示一个非常有用的技巧,当你需要通过整个应用程序实现一个功能的时候,可以消除重复的代码。我已经在第六章之前使用过 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_from,所以当之前请求处理程序结束以及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 时才会呈现表单。这个检查是必需的,因此有些页面,例如错误页面,就可能没有被定义。这种形式跟我之前做过的有点不同。我设置它的方法属性为 get,因为我想通过 GET 请求把表单数据提交到查询字符串中。另外,我创建有一个 action 属性为空的表单,因为他们被提交到渲染表单的到同一页面。这种形式是特殊的,因为它呈现在所有页面,因此我需要精确地告诉它,哪里需要被提交,这是专门用于处理搜索的新路线。

搜索视图函数

完成这个搜索特性的最后一点功能是接收搜索表单提交的视图函数。这个视图函数会附加到 /search 路由,因此你能发送一个类似于 http://localhost:5000/search?q=search-words 这样的搜索请求,就好像 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() , 它只验证字段值,而不检查数据是如何被提交的。如果验证失败,是因为用户提交了一个空的搜索表单,因此在这种情况下,我只要重定向到探索页面,它能展示所有的博客文章。

我的 SearchableMixin 类中的 Post.search() 方法用来获取搜索结果列表。分页处理跟索引和探索页面一样都是非常相似的,但是在没有 Flask-SQLAlchemy 的分页对象的帮助,生成下一条和上一条的连接有点棘手。这是从 Post.search() 作为第二个返回值传递的结果总数有用的地方。

一旦计算了搜索结果和分页链接的页面,剩下的就是用所有的这些数据渲染这个模板。我能找出一个方式为了重复使用 index.html 模板来显示搜索结果,但考虑到一些差异,我决定创建一个专用的 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 %}

如果你对于上一页和下一页的链接渲染逻辑有点混乱 ,复习 Boostrap分页组件 文档可能可以帮助你。

搜索结果

你怎么看?这是一个很激烈的章节,我介绍了一些相当高级的技术。在这一章的一些概念可能需要一些时间来消化。这一章节最重要的一点,如果你想使用与Elasticsearch不同的搜索引擎 ,它只需要你在 app/search.py 重写这三个函数。另一个重要好处是,如果你需要为不同的数据库模型添加搜索支持,我能简单地添加 SearchableMixin 类到它来实现, 这个 __searchable__ 属性包含索引的列表以及 SQLAlchemy 事件处理的连接。我想这是值得的努力,因为从现在起,处理全文搜索将会变得很容易。

原文

【上一篇】用 ansible的playbook模式来部署Flask

【下一篇】centos下通过nginx+redis+supervisor+mysql+gunicorn配置Flask网站