16 Flask mega-tutorial 第16章 全文搜索 Full-Text Search(Elasticsearch)

如需转载请注明出处。
win10 64位、Python 3.6.3、Notepad++、Chrome 67.0.3396.99(正式版本)(64 位)
注:作者编写时间2018-03-21,linux、python 3.5.2

以下内容均是加入自己的理解与增删,以记录学习过程。不限于翻译,部分不完全照搬作者Miguel Grinberg的博客,版权属于作者,感谢他提供免费学习的资料。

传送门
00 开篇01 Hello world02 模板03 Web表单
04 数据库05 用户登录06 个人资料和头像07 错误处理
08 关注09 分页10 支持QQ邮箱11 美化页面
12 时间和日期13 I18n和L10n 翻译成中文 zh-CN14 Ajax(百度翻译API15 更好的App结构(蓝图)
16 全文搜索17 部署到腾讯云Ubuntu18 部署到Heroku19 部署到Docker容器
20 JavaScript魔法21 用户通知22 后台工作(Redis)23 应用程序编程接口(API)
本章将为Microblog添加全文搜索功能。以便用户可以使用自然语言找到有趣的帖子。对于许多类型的网站,可让Google、Bing等对所有内容编制索引,并通过其搜索API提供搜索结果。适用于主要是静态页面的网站,例如论坛。但在这个应用程序中,内容的基本单元是用户帖子,它是整个网页的一小部分。我想要的搜索结果类型是针对这些单独的博客帖子而不是整个页面。例如,如果我搜索单词“dog”,将查看到包含该单词的任何用户的博客帖子。显然是一个页面,显示所有包含“dog”一词的博客。

全文搜索引擎简介

对全文搜索的支持不像关系数据库那样标准化。有几个开源全文引擎:[Elasticsearch](https://www.elastic.co/cn/products/elasticsearch)、[Apache Solr](http://lucene.apache.org/solr/)、[Whoosh](https://whoosh.readthedocs.io/en/latest/)、[Xapian](https://xapian.org/)、[Sphinx](http://sphinxsearch.com/)等。好像这还不够,有几个数据库也提供可与专用搜索引擎相媲美的搜索功能。 SQLiteMySQLPostgreSQL都为搜索文本提供了一些支持,而 MongoDBCouchDBNoSQL数据库也是如此。

如果想知道哪些可在Flask应用程序中运行,答案就是所有这些!!这是Flask的优势之一,它可以完成它的工作,而不是自以为是。那么什么是最好的选择?

从专用搜索引擎列表中,Elasticsearch对我们而言非常受欢迎,部分原因在于其作为索引日志的ELK堆栈中的“E”,以及LogstashKibana。使用其中一个关系数据库的搜索功能也是一个不错的选择,但鉴于SQLAlchemy不支持此功能,我们将不得不使用原始SQL语句处理搜索,或者找到一个提供高级语言的包。在能够与SQLAlchemy共存的同时对文本搜索进行级别访问。

基于上述分析,这将使用Elasticsearch,但将以一种非常容易切换到另一个引擎的方式实现所有文本索引和搜索功能。这将允许我们通过在单个模块中重写几个函数来替换基于不同引擎的替代实现。

安装Elasticsearch

有几种方法可安装 Elasticsearch,包括一键安装程序,包含自己需要安装的二进制文件的zip文件,甚至是Docker镜像。这个文档有一个[安装](https://www.elastic.co/guide/en/elasticsearch/reference/current/install-elasticsearch.html)页面,其中包含所有这些选项的详细信息。

我是在Elasticsearch官网下载的.zip文件后,将其解压在D盘。运行bin文件夹下的elasticsearch.bat文件。浏览器地址栏输入127.0.0.1:9200localhost:9200来验证它是否正在运行,出现如下内容,说明安装ES成功:即以JSON格式返回有关服务的一些基本信息。
这里写图片描述

由于我们将从Python管理Elasticsearch,还得使用Python客户端库:

(venv) D:\microblog>pip install elasticsearch
Collecting elasticsearch
  Downloading https://files.pythonhosted.org/packages/b1/f1/89735ebb863767516d55cee2cfdd5e2883ff1db903be3ba1fe15a1725adc/elasticsearch-6.3.1-py2.py3-none-any.whl (119kB)
    100% |████████████████████████████████| 122kB 154kB/s
Requirement already satisfied: urllib3>=1.21.1 in d:\microblog\venv\lib\site-packages (from elasticsearch)
Installing collected packages: elasticsearch
Successfully installed elasticsearch-6.3.1

更新requirements.txt文件:

(venv) D:\microblog>pip freeze > requirements.txt

Elasticsearch教程

首先展示从 Python shell使用 Elasticsearch的基础知识。帮助熟悉这个服务,以便可以了解将讨论的实现。

运行Elasticsearch安装目录下的bin文件夹下的elasticsearch.bat文件。

要创建与Elasticsearch的连接,得先创建 Elasticsearch类的实例,并传递一个连接URL作为一个参数:

(venv) D:\microblog>python
Python 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from elasticsearch import Elasticsearch
>>> es = Elasticsearch('http://localhost:9200')

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

>>> es.index(index='my_index', doc_type='my_index', id=1, body={'text':'this is a test'})
{'_index': 'my_index', '_type': 'my_index', '_id': '1', '_version': 2, 'result': 'updated', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 1, '_primary_term': 2}

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

对于存储的每个文档,Elasticsearch将获取唯一的id和带有数据的JSON对象。

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

>>> es.index(index='my_index', doc_type='my_index', id=2, body={'text':'a second test'})
{'_index': 'my_index', '_type': 'my_index', '_id': '2', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 0, '_primary_term': 2}

现在这个索引中有两个文档,可发出一个自由格式的搜索。下方例中将搜索 this test

>>> es.search(index='my_index', doc_type='my_index', body={'query':{'match':{'text':'this test'}}})
{
	'took': 107, 
	'_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.2876821, 
				'_source': {'text': 'a second test'}
			}
		]
	}
}

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

在上述中,可看到搜索返回了两个文档,每个文档都有一个指定的分数。得分最高的文档包含搜索的两个单词,另一个文档只包含一个单词。可以看到即使是最好的结果也没有很好的分数,因为单词与文本不完全匹配。

如下是搜索单词 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.2876821, 
		'hits': [
			{
				'_index': 'my_index', 
				'_type': 'my_index', 
				'_id': '2', 
				'_source': {'text': 'a second test'}
			}
		]
	}
}

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

Elasticsearch查询对象有更多选项,都有详细记录,并包括分页、排序等选项,就像关系数据库一样。

随意添加更多条目到此索引 并尝试不同的搜索。完成实验后,可使用如下命令删除索引:

>>> es.indices.delete('my_index')
{'acknowledged': True}

Elasticsearch配置

Elasticsearch集成到应用程序中 是 Flask功能的一个很好例子。这是一个 服务和Python包,与 Flask没有任何关系,但是,将从配置开始获得相当高的集成度,将在 Flask的`app.config`字典中编写: microblog/config.py:Elasticsearch配置
#...
class Config:
	#...
	ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL')

	POSTS_PER_PAGE = 3

跟许多其他配置条目一样,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
from config import Config
#...
def create_app(config_class=Config):
	#...
	babel.init_app(app)

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

app实例中添加新属性 可能看起来有点奇怪,但Python对象的结构并不严格,可以随时向其添加新属性。可以考虑的另一种方法是创建一个Flask子类(可能称之为 Microblog),并在其__init__()函数中定义 elasticsearch属性。

注意,当环境中未定义Elasticsearch服务的URL时,如何使用条件表达式来生成Elasticsearch实例为None。

全文搜索抽象

正如在本章的介绍中所说,希望能够轻松地从 Elasticsearch切换到其他搜索引擎,而且也不想专门为搜索博客帖子编写此功能,更愿意设计一个未来的解决方案,如果需要,可以轻松扩展到其他模型。出于所有这些原因,我决定为 搜索功能 创建一个抽象。我们的想法是用通用术语来设计这个特性,所以我不会假设 `Post模型`是唯一需要索引的模型,也不会假设 Elasticsearch是首选的索引引擎。但如果不对任何事情做出任何假设,那怎样才能完成这项工作?

我们需要做的第一件事是 以某种方式找到一种通用的方法来指示哪个模型以及哪个字段 或哪个字段被索引。要说的是 任何需要索引的模型都需要定义一个 __searchable__类属性,这个属性列出了需要包含在索引中的字段。对于Post模型,如下是变化:

app/models.py
#...
class Post(db.Model):
	__searchable__ = ['body']
	#...

上述代码表示 这个模型需要将 body字段 编入索引。但只是为了确保这一点非常清楚,添加的属性__searchable__只是一个变量,它没有任何与之相关的行为。它只会帮助我们以通用的方式编写索引函数。

PS:这里不用迁移和更新数据库。

我们将在 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, 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文档还需要一个唯一的标识符。为此,我们使用的是 SQLAchemy模型的字段 id,它也很方便。在运行搜索时,为SQLAlchemy和Elasticsearch使用相同的id值,因为它允许我链接两个数据库中的条目。上面没有提到的一点是,如果尝试添加一个带有现有条目 id,那么Elasticsearch会用新的条目替换旧条目,因此add_to_index()可以用于新对象以及修改后的对象。

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

query_index()函数使用索引名称和要搜索的文本 以及分页控件,以便搜索结果可以像Flask-SQLAlchemy结果一样进行分页。已经可在Python控制台中看到这个函数的示例用法。在这里发出的调用非常相似,但不是使用match查询类型,而是使用multi_match,可以搜索多个字段。通过传递一个名为 * 的字段,告诉Elasticsearch查看所有字段,所以基本上是正在搜索整个索引。这对于使此函数通用非常有用,因为不同的模型在索引中可以有不同的字段名称。

对于es.search()body参数包括查询本身分页参数。fromsize参数 控制需要的东西整个结果集的子集返回。Elasticsearch没有提供一个像Flask-SQLAlchemy那样很好的Pagination对象,所以我必须进行分页数学来计算from值。

在函数query_index()return语句有点复杂。它返回两个值:第一个是搜索结果的元素id列表;第二个是结果总数。两者都是从es.search()函数返回的Python字典中获得的。如果你不熟悉用来获取ID列表的表达式,这称为 列表推导,并且是Python语言的一个很棒的功能,它允许将列表从一种格式转换为另一种格式。在这种情况下,使用id列表推导 从Elasticsearch提供的更大的结果列表中提取值。

这令人困惑。不过从Python控制台演示这些函数可以帮助更多地理解它们。在下面的会话中,手动将数据库中的所有帖子添加到Elasticsearch索引。在测试数据库中,我有一些帖子中有数字 “one”、“two”、“three”、“four”、“five”,所以用它作为搜索查询。可能还需要调整查询以匹配数据库的内容:
PS:当我按如下方式运行时,报错

>>> for post in Post.query.all():
...     add_to_index('posts', post)
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'Post' is not defined

解决方案:使用flask shell命令启动shell,而不是python。即要求应用程序上下文处于活动状态

(venv) D:\microblog>flask shell
[2018-09-05 21:14:13,301] INFO in __init__: Microblog startup
Python 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)] on win32
App: app [production]
Instance: D:\microblog\instance
>>> 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)

发出的查询返回了7个结果。当向页面1 询问每页100个项目时,得到全部七个,但接下来的三个示例显示了如何能够以 与我为Flask-SQLAlchemy所做的非常类似的方式读结果进行分页,除了结果来自ID列表而不是SQLAlchemy对象。

如果想保持干净,请在进行实验后删除posts索引:

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

将搜索与SQLAlchemy集成

上一小节展示的解决方案很不错,但仍然存在一些问题。最明显的问题是 结果是一个数字ID列表。这非常不方便,我们需要 SQLAlchemy模型,以便可以将它们传递给模板进行渲染,需要一种方法用数据库中的相应模型 替换这个数字列表。第二个问题 是这个解决方案要求应用程序在添加或删除帖子时显示发出索引调用,这不是很糟糕,但不太理想,因为在 SQLAlchemy端进行更改时导致错过索引调用的错误是不容易被发现,每次发生错误时,这两个数据库会越来越不同步,可能暂时不会注意到。

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

对于自动触发索引更改的问题,决定从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 import db,login
from app.search import add_to_index, remove_from_index, query_index

class SearchableMixin:
	@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)

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()类方法 包装来自app/search.py的query_index()函数 与实际对象替换 对象ID的列表。可以看到这个函数做的第一件事是调用query_index()cls.__tablename__作为索引名称传递。这将是一个约定,所有索引都将使用Flask-SQLAlchemy分配给关系表的名称命名。这个函数返回结果ID列表和结果总数。通过ID检索对象列表的SQLAlchemy查询 基于SQL语言的 a CASE语句,需要使用它来确保数据库的结果 与给定ID的顺序相同。这很重要,因为Elasticsearch查询会返回从更多相关性排序的结果。如果想了解有关此查询的工作方式的更多信息,可参与此StackOverflow问题的已接受答案。search()函数返回替换ID列表的查询,并将搜索结果的总数作为第二个返回值传递。

before_commit()after_commit()方法将去响应来自SQLAlchemy的两个事件,这是之前触发响应,并提交后分别发生。before处理是有用的,因为会话尚未提交,所以我们可以看看它,弄清对象打算什么要添加、修改和删除,可分别作为session.newsession.dirtysession.deleted。提交会话后,这些对象将不再可用,因此我们需要在提交之前保存它们。我正在使用session._changes字典将这些对象写入一个将在会话提交中存活的地方,因为一旦提交了会话,将使用它们来更新Elasticsearch索引。

after_commit()调用处理程序时,会话已经成功提交,所以这是适当时做出的Elasticsearch方的变化。会话对象具有我在before_commit()中添加的_changes变量,因此现在可以迭代添加、修改、删除对象,并对app/search.py中的索引函数进行相应的调用,以获取具有SearchableMixin类的对象。

reindex()类方法 是一个简单辅助方法,可以用它来刷新指数从关系方面的所有数据。上面Python shell会话中做了类似的事情,将所有帖子初始加载到测试索引中。有了这个方法,可以发布Post.reindex()将数据库中的所有帖子添加到搜索索引中。

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

要将SearchableMixin类合并到Post模型中,必须将其添加为子类,并且还需挂接提交前后事件:
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搜索 的一种相当标准的方法是 在URL的查询字符串中将搜索项作为一个q参数。例如,假如你想在Google上搜索Python,并且想要保存几秒钟,则只需在浏览器的地址栏中输入以下网址即可直接转到结果:

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

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

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

这是搜索表单类,只包含 q文本字段:
app/main/forms.py:搜索表单

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请求提交的表单获取查询字符串中的字段值,因此需要在request.args指向Flask-WTF,这是Flask写入查询字符串参数的地方。应该还记得,表单默认添加了CSRF保护,包含通过模板中form.hidden_tag()构造添加到表单的CSRF令牌。要使可点击的搜索链接起作用,需要禁用CSRF,因此我设置csrf_enabledFalse使Flask-WTF知道它需要绕过此表单的CSRF验证。

由于需要在所有页面中显示此表单,因此无论用户正在查看哪个页面,都需要创建SearchForm类的实例。唯一的要求是用户已登录,因为对于匿名用户,目前没有显示任何内容。我不打算在每个路径中创建一个表单对象,然后将表单传递给所有模板,将展示一个非常有用的技巧,当需要在整个应用程序中实现一个功能时,它可以消除重复的代码。在第6章中,已经使用了一个before_request处理程序来记录每个用户上次访问的时间。我要做的是在同一个函数中创建我的搜索表单,但有一个转折点:
app/main/routes.py:在before/_request处理程序中实例化搜索表单

#...
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容器,由Flask提供。由Flask提供的 g变量是 一个应用程序可以存储需要在请求的生命周期中持续存储数据的地方。这里我将在g.search_form中存储表单,因此
before request处理程序结束和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">
					<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 %}
...

仅在g.search_form定义时才呈现表单。此检查是必要的,因为某些页面(例如错误页面)可能没有定义它。这种形式与之前的形式略有不同。将method属性设置为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()仅验证字段值,而不检查数据的提交方式。如果验证失败,那是因为提交了一个空的搜索表单,因此在这种情况下只需要重定向到/explore页面,该页面显示所有博客帖子。

SearchableMixin类的Post.search()方法 是用于获取搜索结果列表。分页的处理方式与/index/explore页面的处理方式非常相似,但是如果没有Flask-SQLAlchemy的Pagination对象帮助,生成下一个和上一个链接会有点棘手。这是作为Post.search()的第二个返回值传递的结果总数 很有用的地方。

一旦计算搜索结果和分页链接的页面,剩下的就是渲染包含所有这些数据的模板。可以找到一种方法来重复使用index.html模板来显示搜索结果,但鉴于存在一些差异,我决定创建专用的search.html模板,专门用于显示搜索结果,利用_post.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事件处理程序连接。

PS:新增翻译部分(更新翻译目录)查看第14章

运行程序即可查看效果了。

目前为止,项目结构:

microblog/
	app/
		auth/
		errors/
		main/
		static/
		templates/
			auth/
			email/
			_post.html
			base.html
			edit_profile.html
			index.html
			search.html
			user.html
		__init__.py
		cli.py
		email.py
		models.py
		search.py
		translate.py
	logs/
	migrations/
	env/
	app.db
	babel.cfg
	config.py
	microblgo.env
	microblog.py
	requirements.txt
	tests.py

参考:
作者博客
源代码

如需转载请注明出处。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值