点击上方蓝字关注我们
欢迎关注我的公众号,志学Python
01
全文搜索引擎简介
对于全文搜索的支持不像关系数据库那样是标准化的。有几种开源的全文搜索引擎:Elasticsearch,Apache Solr,Whoosh,Xapian,Sphinx等等,如果这还不够,常用的数据库也可以像我上面列举的那些专用搜索引擎一样提供搜索服务。 SQLite,MySQL和PostgreSQL都提供了对搜索文本的支持,以及MongoDB和CouchDB等NoSQL数据库当然也提供这样的功能。
如果你想知道哪些应用程序可以在Flask应用中运行,那么答案就是所有!这是Flask的强项之一,它在完成工作的同时不会自作主张。那么到底选择哪一个呢?
在专用搜索引擎列表中,Elasticsearch非常流行,部分原因是它在ELK栈中是用于索引日志的“E”,另两个是Logstash和Kibana。使用某个关系数据库的搜索能力也是一个不错的选择,但考虑到SQLAlchemy不支持这种功能,我将不得不使用原始SQL语句来处理搜索,否则就需要一个包, 它提供一个文本搜索的高级接口,并与SQLAlchemy共存。
基于上述分析,我将使用Elasticsearch,但我将以一种非常容易切换到另一个搜索引擎的方式来实现所有文本索引和搜索功能。你可以用其他搜索引擎的替代替换我的实现,只需在单个模块中重写一些函数即可。
02
安装 Elasticsearch
有几种方法可以安装Elasticsearch,包括一键安装程序,带有需要自行安装的二进制程序的zip包,甚至是Docker镜像。该文档有一个安装页面,其中包含所有这些安装选项的详细信息。如果你使用Linux,你可能会有一个可用于你的发行版的软件包。如果你使用的是Mac并安装了Homebrew,那么你可以简单地运行brew install elasticsearch
。
在计算机上安装Elasticsearch后,你可以在浏览器的地址栏中输入http://localhost:9200
来验证它是否正在运行,预期的返回结果是JSON格式的服务基本信息。
由于我使用Python来管理Elasticsearch,因此我会使用其对应的Python客户端库:
(venv) $ pip install elasticsearch
译者注:elasticsearch版本迭代频繁,建议此处使用下载代码中的requirements.txt中的elasticseach版本执行安装。
当然不要忘记更新requirements.txt文件:
(venv) $ pip freeze > requirements.txt
03
Elasticsearch 入门
我将在Python shell中为你展示使用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'})
现在,该索引中有两个文档,我可以发布自由格式的搜索。在本例中,我要搜索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')
04
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在环境变量中未定义,则赋值None
给app.elasticsearch
。
05
全文搜索抽象化
正如我在本章的介绍中所说的,我希望能够轻松地从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: 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
开始的,如果是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
参数还包含分页参数。 from
和size
参数控制整个结果集的哪些子集需要被返回。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')
06
集成 SQLAIchemy 到搜索
我在前面的章节中给出的解决方案是可行的,但它仍然存在一些问题。最明显的问题是结果是以数字ID列表的形式出现的。这非常不方便,我需要SQLAlchemy模型,以便我可以将它们传递给模板进行渲染,并且我需要用数据库中相应模型替换数字列表的方法。第二个问题是,这个解决方案需要应用在添加或删除用户动态时明确地发出对应的索引调用,这并非不可行,但并不理想,因为在SQLAlchemy侧进行更改时错过索引调用的情况是不容易被检测到的,每当发生这种情况时,两个数据库就会越来越不同步,并且你可能在一段时间内都不会注意到。更好的解决方案是在SQLAlchemy数据库进行更改时自动触发这些调用。
用对象替换ID的问题可以通过创建一个从数据库读取这些对象的SQLAlchemy查询来解决。这在实践中听起来很容易,但是使用单个查询来高效地实现它实际上有点棘手。
对于自动触发索引更改的问题,我决定用SQLAlchemy 事件驱动Elasticsearch索引的更新。SQLAlchemy提供了大量的事件,可以通知应用程序。例如,每次提交会话时,我都可以定义一个由SQLAlchemy调用的函数,并且在该函数中,我可以将SQLAlchemy会话中的更新应用于Elasticsearch索引。
为了实现这两个问题的解决方案,我将编写mixin类。记得mixin类吗?在第五章中,我将Flask-Login中的UserMixin类添加到了User
模型,为它提供Flask-Login所需的一些功能。对于搜索支持,我将定义我自己的SearchableMixin
类,当它被添加到模型时,可以自动管理与SQLAlchemy模型关联的全文索引。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': [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()
类方法封装来自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.new
,session.dirty
和session.deleted
。这些对象在会话提交后不再可用,所以我需要在提交之前保存它们。我使用session._changes
字典将这些对象写入会话提交后仍然存在的地方,因为一旦会话被提交,我将使用它们来更新Elasticsearch索引。
当调用after_commit()
处理程序时,会话已成功提交,因此这是在Elasticsearch端进行更新的适当时间。session对象具有before_commit()
中添加的_changes变量,所以现在我可以迭代需要被添加,修改和删除的对象,并对app/search.py中的索引函数进行相应的调用。
reindex()
类方法是一个简单的帮助方法,你可以使用它来刷新所有数据的索引。你看到我在上面做的将所有用户动态初始加载到测试索引中,这个操作与Python shell会话中的类似。有了这个方法,我可以调用Post.reindex()
将数据库中的所有用户动态添加到搜索索引中。
为了将SearchableMixin
类整合到Post
模型中,我必须将它作为Post
的基类,并且还需要监听提交之前和之后的事件:
app/models.py:添加SearchableMixin类到Post模型
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()
[, , , , ]
07
搜索表单
的确有些激进。我上面做的保持通用性的工作涉及到几个高级主题,因此可能需要一些时间才能完全理解。现在我有一套完整的系统来处理用户动态的自然语言搜索。所以现在需要做的是将所有这些功能与应用集成在一起。
基于网络搜索的一种相当标准的方法是在URL的查询字符串中将搜索词作为q
参数的值。例如,如果你想在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__
构造函数,它提供了formdata
和csrf_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
类的实例。唯一的要求是用户登录,因为对于匿名用户,我目前不会显示任何内容。与其在每个路由中创建表单对象,然后将表单传递给所有模板,我将向你展示一个非常有用的技巧,当你需要在整个应用中实现一个功能时,可以消除重复代码。回到第六章,我已经使用了before_request
处理程序, 来记录每个用户上次访问的时间。我要做的是在同样的功能中创建我的搜索表单,但有一点区别:
app/main/routes.py:在请求处理前的处理器中初始化搜索表单。
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
容器。这个g
变量是应用可以存储需要在整个请求期间持续存在的数据的地方。在这里,我将表单存储在g.search_form
中,所以当请求前置处理程序结束并且Flask调用处理请求的URL的视图函数时,g
对象将会是相同的,并且表单仍然存在。请注意,这个g
变量对每个请求和每个客户端都是特定的,因此即使你的Web服务器一次为不同的客户端处理多个请求,仍然可以依靠g
来专用存储各个请求的对应变量。
下一步是将表单渲染成页面。我在上面说过,我想在所有页面中展示这个表单,所以更有意义的是将其作为导航栏的一部分进行渲染。事实上,这很简单,因为模板也可以看到存储在g
变量中的数据,所以我不需要在所有render_template()
调用中将表单作为显式模板参数添加进去。以下是我如何在基础模板中渲染表单的代码:
app/templates/base.html:在导航栏中渲染搜索表单。
...