如需转载请注明出处。
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 world | 02 模板 | 03 Web表单 |
04 数据库 | 05 用户登录 | 06 个人资料和头像 | 07 错误处理 |
08 关注 | 09 分页 | 10 支持QQ邮箱 | 11 美化页面 |
12 时间和日期 | 13 I18n和L10n 翻译成中文 zh-CN | 14 Ajax(百度翻译API | 15 更好的App结构(蓝图) |
16 全文搜索 | 17 部署到腾讯云Ubuntu | 18 部署到Heroku | 19 部署到Docker容器 |
20 JavaScript魔法 | 21 用户通知 | 22 后台工作(Redis) | 23 应用程序编程接口(API) |
如果想知道哪些可在Flask应用程序中运行,答案就是所有这些!!这是Flask的优势之一,它可以完成它的工作,而不是自以为是。那么什么是最好的选择?
从专用搜索引擎列表中,Elasticsearch对我们而言非常受欢迎,部分原因在于其作为索引日志的ELK堆栈中的“E”,以及Logstash和Kibana。使用其中一个关系数据库的搜索功能也是一个不错的选择,但鉴于SQLAlchemy不支持此功能,我们将不得不使用原始SQL语句处理搜索,或者找到一个提供高级语言的包。在能够与SQLAlchemy共存的同时对文本搜索进行级别访问。
基于上述分析,这将使用Elasticsearch,但将以一种非常容易切换到另一个引擎的方式实现所有文本索引和搜索功能。这将允许我们通过在单个模块中重写几个函数来替换基于不同引擎的替代实现。
我是在Elasticsearch官网下载的.zip
文件后,将其解压在D盘。运行bin文件夹下的elasticsearch.bat
文件。浏览器地址栏输入127.0.0.1:9200
或localhost: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安装目录下的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}
#...
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。
我们需要做的第一件事是 以某种方式找到一种通用的方法来指示哪个模型以及哪个字段 或哪个字段被索引。要说的是 任何需要索引的模型都需要定义一个 __searchable__
类属性,这个属性列出了需要包含在索引中的字段。对于Post模型
,如下是变化:
#...
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
参数包括查询本身分页参数。from
和size
参数 控制需要的东西整个结果集的子集返回。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 "", line 1, in
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事件驱动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.new
, session.dirty
和 session.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()
[, , , , ]
基于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__
构造函数,假如调用者不提供的话,它会给formdata
和csrf_enabled
参数提供值。formdata
参数确定Flask-WTF从哪里获取表单提交。默认是使用request.form
,这是Flask放置通过POST请求提交的表单值的位置。通过GET
请求提交的表单获取查询字符串中的字段值,因此需要在request.args
指向Flask-WTF,这是Flask写入查询字符串参数的地方。应该还记得,表单默认添加了CSRF保护,包含通过模板中form.hidden_tag()
构造添加到表单的CSRF令牌。要使可点击的搜索链接起作用,需要禁用CSRF,因此我设置csrf_enabled
为False
使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:在导航栏中渲染搜索表单
...