精通-Flask-全-

精通 Flask(全)

原文:zh.annas-archive.org/md5/3704FA7246A3AC34DE99A41EE212E530

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Flask 是 Python 的一个 Web 框架,专门设计为提供创建 Web 应用程序所需的最少功能。与其他 Web 框架不同,特别是其他语言中的框架,Flask 没有整个与其捆绑的库生态系统,用于诸如数据库查询或表单处理之类的功能。相反,Flask 更喜欢是一个实现不可知的框架。

这种设置的主要特点是它允许程序员以任何他们想要的方式设计他们的应用程序和工具。不提供常见抽象的自己版本也意味着标准库可以比其他框架更常用,这保证了它们的稳定性和其他 Python 程序员的可读性。由于 Flask 社区相当庞大,也有许多不同的社区提供的添加常见功能的方式。本书的主要重点之一是介绍这些扩展,并找出它们如何帮助避免重复造轮子。这些扩展的最大优点是,如果您不需要它们的额外功能,您不需要包含它们,您的应用程序将保持较小。

这种设置的主要缺点是,绝大多数新的 Flask 用户不知道如何正确地构建大型应用程序,最终创建了难以理解和难以维护的代码混乱。这就是本书的另一个主要重点,即如何在 Flask 应用程序中创建模型视图控制器(MVC)架构。

最初是为设计桌面用户界面而发明的 MVC 设置允许数据处理(模型)、用户交互(控制器)和用户界面(视图)分离为三个不同的组件。

前言

将这三个不同的组件分开允许程序员重用代码,而不是为每个网页重新实现相同的功能。例如,如果数据处理代码没有分割成自己独立的函数,我们将不得不在渲染网页的每个函数中编写相同的数据库连接代码和 SQL 查询。

大量的研究和大量的痛苦的第一手经验使本书成为最全面的 Flask 资源,因此我真诚地希望您会喜欢阅读它。

本书涵盖内容

第一章,“入门”,帮助读者使用 Python 项目的最佳实践设置 Flask 开发环境。读者将获得一个非常基本的 Flask 应用程序框架,该框架将贯穿整本书。

第二章,“使用 SQLAlchemy 创建模型”,展示了如何使用 Python 数据库库 SQLAlchemy 与 Flask 一起创建面向对象的数据库 API。

第三章,“使用模板创建视图”,展示了如何使用 Flask 的模板系统 Jinja,通过利用 SQLAlchemy 模型动态创建 HTML。

第四章,“使用蓝图创建控制器”,介绍了如何使用 Flask 的蓝图功能来组织您的视图代码,同时避免重复。

第五章,“高级应用程序结构”,利用前四章所学的知识,解释了如何重新组织代码文件,以创建更易维护和可测试的应用程序结构。

第六章,“保护您的应用程序”,解释了如何使用各种 Flask 扩展来添加具有基于权限的访问权限的登录系统。

第七章,“在 Flask 中使用 NoSQL”,展示了 NoSQL 数据库是什么,以及如何在允许更强大功能时将其集成到您的应用程序中。

第八章,“构建 RESTful API”,展示了如何以安全且易于使用的方式向第三方提供应用程序数据库中存储的数据。

第九章,“使用 Celery 创建异步任务”,解释了如何将昂贵或耗时的程序移到后台,以便应用程序不会变慢。

第十章,“有用的 Flask 扩展”,解释了如何利用流行的 Flask 扩展,以使您的应用程序更快,添加更多功能,并使调试更容易。

第十一章,“构建您自己的扩展”,教您 Flask 扩展的工作原理以及如何创建您自己的扩展。

第十二章,“测试 Flask 应用”,解释了如何为您的应用程序添加单元测试和用户界面测试,以确保质量并减少错误代码的数量。

第十三章,“部署 Flask 应用”,解释了如何将您完成的应用程序从开发转移到托管在实时服务器上。

您需要为本书做好准备

要开始阅读本书,您只需要选择一个文本编辑器,一个网络浏览器,并在您的计算机上安装 Python。

Windows,Mac OS X 和 Linux 用户都应该能够轻松地跟上本书的内容。

这本书是为谁写的

这本书是为已经对 Flask 有一定了解并希望将他们的 Flask 理解从入门到精通的 Web 开发人员编写的。

约定

在本书中,您将找到一些区分不同类型信息的文本样式。以下是这些样式的一些示例及其含义的解释。

文本中的代码单词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄显示如下:“first()all()方法返回一个值,因此结束链。”

代码块设置如下:

class User(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    username = db.Column(db.String(255))
    password = db.Column(db.String(255))
    posts = db.relationship(
        'Post',
        backref='user',
        lazy='dynamic'
    )

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

from flask.ext.sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config.from_object(DevConfig)
db = SQLAlchemy(app)

任何命令行输入或输出都以以下方式编写:

$ python manage.py db init

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这样的方式出现在文本中:“点击另一个按钮,上面写着下载 Bootstrap,然后您将开始下载一个 Zip 文件。”

注意

警告或重要说明会出现在这样的框中。

提示

提示和技巧会以这种方式出现。

第一章:入门

Python是一种灵活的语言,给程序员自由构建他们的编程环境。然而,这种自由的危险后果是从一开始就不设置一个新的 Python 项目,以避免未来出现问题。

例如,你可能已经进行了一半的项目,意识到你五天前删除了一个你现在需要使用的文件或代码。再举一个例子,你希望使用的两个包需要同一个基础包的不同版本。除了本章介绍的工具之外,修复已经有解决方案的问题将需要大量额外的工作。在开始时多做一点额外的工作可以节省未来数天的工作。

为此,我们需要安装三个程序:Gitpipvirtualenv

使用 Git 进行版本控制

为了防止人为错误,我们将使用一个名为 Git 的版本控制系统。版本控制是一种记录文件随时间变化的工具。这使得程序员可以看到代码如何从以前的修订版变化,并甚至将代码恢复到以前的状态。版本控制系统还使得合作比以往更容易,因为更改可以在许多不同的程序员之间共享,并自动合并到项目的当前版本中,而无需复制和粘贴数百行代码。

简而言之,版本控制就像是你的代码的备份,只是更强大。

安装 Git

安装 Git 非常简单。只需转到www.git-scm.com/downloads,然后点击正在运行的操作系统OS)。一个程序将开始下载,它将引导您完成基本的安装过程。

Windows 上的 Git

Git 最初仅为 Unix 操作系统(例如 Linux、Mac OS X)开发。因此,在 Windows 上使用 Git 并不是无缝的。在安装过程中,安装程序会询问您是否要在普通的 Windows 命令提示符旁边安装 Git。不要选择此选项。选择默认选项,将在系统上安装一个名为Bash的新类型的命令行,这是 Unix 系统使用的相同命令行。Bash 比默认的 Windows 命令行更强大,本书中的所有示例都将使用它。

注意

初学者的 Bash 入门教程位于linuxcommand.org/learning_the_shell.php#contents

Git 基础知识

Git 是一个非常复杂的工具;这里只会涵盖本书所需的基础知识。

注意

要了解更多,请参阅 Git 文档www.git-scm.com/doc

Git 不会自动跟踪你的更改。为了让 Git 正常运行,我们必须提供以下信息:

  • 要跟踪哪些文件夹

  • 何时保存代码的状态

  • 要跟踪什么,不要跟踪什么

在我们做任何事情之前,我们告诉 Git 在我们的目录中创建一个git实例。在你的项目目录中,在终端中运行以下命令:

$ git init

Git 现在将开始跟踪我们项目中的更改。当git跟踪我们的文件时,我们可以通过输入以下命令来查看我们跟踪文件的状态,以及任何未跟踪的文件:

$ git status

现在我们可以保存我们的第一个提交,这是在运行commit命令时代码的快照。

# In Bash, comments are marked with a #, just like Python
# Add any files that have changes and you wish to save in this commit
$ git add main.py
# Commit the changes, add in your commit message with -m
$ git commit -m"Our first commit"

在将来的任何时候,我们都可以返回到项目的这一点。将要提交的文件称为 Git 中的暂存文件。记住只有在准备好提交它们时才添加暂存文件。一旦文件被暂存,任何进一步的更改也不会被暂存。对于更高级的 Git 使用示例,请向你的main.py文件添加任何文本,然后运行以下命令:

# To see the changes from the last commit
$ git diff
# To see the history of your changes
$ git log
# As an example, we will stage main.py
# and then remove any added files from the stage
$ git add main.py
$ git status
$ git reset HEAD main.py
# After any complicated changes, be sure to run status
# to make sure everything went well
$ git status
# lets delete the changes to main.py, reverting to its state at the last commit
# This can only be run on files that aren't staged
$ git checkout -- main.py

你的终端应该看起来像这样:

Git 基础知识

Git 系统的 checkout 命令对于这个简单的介绍来说相当高级,但它用于改变 Git 系统的 HEAD 指针的当前状态,也就是我们代码在项目历史中的当前位置。这将在下一个示例中展示。

现在,要查看以前提交的代码,请先运行此命令:

$ git log
Fri Jan 23 19:16:43 2015 -0500 f01d1e2 Our first commit  [Jack Stouffer]

紧挨着我们提交消息的字符串 f01d1e2,被称为我们提交的 哈希。它是该提交的唯一标识符,我们可以使用它返回到保存的状态。现在,要将项目恢复到该状态,请运行此命令:

$ git checkout f01d1e2

您的 Git 项目现在处于一种特殊状态,任何更改或提交都不会被保存,也不会影响您检出后进行的任何提交。这种状态只用于查看旧代码。要返回到 Git 的正常模式,请运行此命令:

$ git checkout master

使用 pip 进行 Python 包管理

在 Python 中,程序员可以从其他程序员那里下载库,以扩展标准 Python 库的功能。就像您从 Flask 中了解到的那样,Python 的很多功能来自于其大量的社区创建的库。

然而,安装第三方库可能会非常麻烦。假设有一个名为 X 的包需要安装。很简单,下载 Zip 文件并运行 setup.py,对吗?并不完全是这样。包 X 依赖于包 Y,而包 Y 又依赖于 Z 和 Q。这些信息都没有在包 X 的网站上列出,但它们需要被安装才能让 X 正常工作。然后,您必须逐个找到所有的包并安装它们,希望您安装的包不需要额外的包。

为了自动化这个过程,我们使用 pip,即 Python 包管理器。

在 Windows 上安装 pip Python 包管理器

如果您使用的是 Windows,并且已安装了当前版本的 Python,那么您已经有了 pip!如果您的 Python 安装不是最新的,最简单的方法就是重新安装它。在 www.python.org/downloads/ 下载 Python Windows 安装程序。

在 Windows 上,控制从命令行访问哪些程序的变量是 path。要修改您的路径以包括 Python 和 pip,我们必须添加 C:\Python27C:\Python27\Tools。通过打开 Windows 菜单,右键单击 计算机,然后单击 属性 来编辑 Windows 路径。在 高级系统设置 下,单击 环境变量...。向下滚动直到找到 Path,双击它,并在末尾添加 ;C:\Python27;C:\Python27\Tools

确保您已正确修改了路径,请关闭并重新打开终端,并在命令行中输入以下内容:

pip --help

提示

下载示例代码

您可以从 www.packtpub.com 的帐户中下载您购买的所有 Packt Publishing 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便直接通过电子邮件接收文件。

pip 应该已经打印出其使用消息,如下面的屏幕截图所示:

在 Windows 上安装 pip Python 包管理器

在 Mac OS X 和 Linux 上安装 pip Python 包管理器

一些 Linux 上的 Python 安装不带有 pip,Mac OS X 上的安装默认也不带有 pip。要安装它,请从 raw.githubusercontent.com/pypa/pip/master/contrib/get-pip.py 下载 get-pip.py 文件。

下载后,使用以下命令以提升的权限运行它:

$ sudo python get-pip.py

然后 pip 将被自动安装。

pip 基础知识

要使用 pip 安装一个包,请按照以下简单步骤进行:

$ pip install [package-name]

在 Mac 和 Linux 上,因为你在用户拥有的文件夹之外安装程序,你可能需要在安装命令前加上sudo。要安装 Flask,只需运行这个命令:

$ pip install flask

然后,Flask 的所有要求将被安装。

如果你想要移除一个不再使用的包,运行这个命令:

$ pip uninstall [package-name]

如果你想探索或找到一个包,但不知道它的确切名称,你可以使用搜索命令:

$ pip search [search-term]

现在我们安装了一些包,在 Python 社区中,通常习惯创建一个运行项目所需的包的列表,这样其他人可以快速安装所有所需的东西。这也有一个额外的好处,即你项目的任何新成员都能够快速运行你的代码。

这个列表可以通过 pip 运行这个命令来创建:

$ pip freeze > requirements.txt

这个命令到底做了什么?pip freeze单独运行会打印出安装的包及其版本的列表,如下所示:

Flask==0.10.1
itsdangerous==0.24
Jinja2==2.7.3
MarkupSafe==0.23
Werkzeug==0.10.4
wheel==0.24.0

>操作符告诉 Bash 获取上一个命令打印的所有内容并将其写入这个文件。如果你查看你的项目目录,你会看到一个名为requirements.txt的新文件,其中包含了pip freeze的输出。

要安装这个文件中的所有包,新的项目维护者将不得不运行这个命令:

$ pip install -r requirements.txt

这告诉pip读取requirements.txt中列出的所有包并安装它们。

使用 virtualenv 进行依赖隔离

所以你已经安装了你的新项目所需的所有包。太好了!但是,当我们在以后开发第二个项目时,会使用这些包的更新版本会发生什么?当你希望使用的库依赖于你为第一个项目安装的库的旧版本时会发生什么?当更新的包包含破坏性更改时,升级它们将需要在旧项目上进行额外的开发工作,这可能是你无法承受的。

幸运的是,有一个名为 virtualenv 的工具,它可以为你的 Python 项目提供隔离。virtualenv 的秘密在于欺骗你的计算机,让它在项目目录中查找并安装包,而不是在主 Python 目录中,这样你可以完全隔离它们。

现在我们有了 pip,要安装 virtualenv 只需运行这个命令:

$ pip install virtualenv

virtualenv 基础

让我们按照以下方式为我们的项目初始化 virtualenv:

$ virtualenv env

额外的env告诉virtualenv将所有的包存储到一个名为env的文件夹中。virtualenv 要求你在对项目进行隔离之前启动它:

$ source env/bin/activate
# Your prompt should now look like
(env) $

source命令告诉 Bash 在当前目录的上下文中运行脚本env/bin/activate。让我们在我们的新隔离环境中重新安装 Flask:

# you won't need sudo anymore
(env) $ pip install flask
# To return to the global Python
(env) $ deactivate

然而,跟踪你不拥有的东西违反了 Git 的最佳实践,所以我们应该避免跟踪第三方包的更改。要忽略项目中的特定文件,需要gitignore文件。

$ touch .gitignore

touch是 Bash 创建文件的命令,文件名开头的点告诉 Bash 不要列出它的存在,除非特别告诉它显示隐藏文件。我们现在将创建一个简单的gitignore文件:

env/
*.pyc

这告诉 Git 忽略整个env目录和所有以.pyc结尾的文件(一个编译的 Python 文件)。在这种用法中,*字符被称为通配符

我们项目的开始

最后,我们可以开始我们的第一个 Flask 项目了。为了在本书结束时拥有一个复杂的项目,我们需要一个简单的 Flask 项目来开始。

在名为config.py的文件中,添加以下内容:

class Config(object):
    pass

class ProdConfig(Config):
    pass

class DevConfig(Config):
    DEBUG = True

现在,在另一个名为main.py的文件中,添加以下内容:

from flask import Flask
from config import DevConfig

app = Flask(__name__)
app.config.from_object(DevConfig)

@app.route('/')
def home():
    return '<h1>Hello World!</h1>'

if __name__ == '__main__':
    app.run()

对于熟悉基本 Flask API 的人来说,这个程序非常基础。如果我们导航到http://127.0.0.1:5000/,它只会在浏览器上显示Hello World!。对于 Flask 用户可能不熟悉的一点是config.from_object,而不是app.config['DEBUG']。我们使用from_object是因为将来会使用多个配置,并且在需要在配置之间切换时手动更改每个变量是很繁琐的。

记得在 Git 中提交这些更改:

# The --all flag will tell git to stage all changes you have made
# including deletions and new files
$ git add --all
$ git commit -m "created the base application"

注意

不再提醒何时将更改提交到 Git。读者需要养成在达到一个停顿点时提交的习惯。还假定您将在虚拟环境中操作,因此所有命令行提示都不会以(env)为前缀。

使用 Flask Script

为了使读者更容易理解接下来的章节,我们将使用第一个Flask 扩展(扩展 Flask 功能的软件包)之一,名为Flask Script。Flask Script 允许程序员创建在 Flask 的应用上下文中操作的命令,即 Flask 中允许修改Flask对象的状态。Flask Script 带有一些默认命令来在应用上下文中运行服务器和 Python shell。要使用pip安装 Flask Script,请运行以下命令:

$ pip install flask-script

我们将在第十章中涵盖 Flask Script 的更高级用法;现在,让我们从一个名为manage.py的简单脚本开始。首先按照以下方式导入 Flask Script 的对象和你的应用程序:

from flask.ext.script import Manager, Server
from main import app

然后,将您的应用程序传递给Manager对象,它将初始化 Flask Script:

manager = Manager(app)

现在我们添加我们的命令。服务器与通过main.py运行的普通开发服务器相同。make_shell_context函数将创建一个可以在应用上下文中运行的 Python shell。返回的字典将告诉 Flask Script 默认要导入什么:

manager.add_command("server", Server())

@manager.shell
def make_shell_context():
    return dict(app=app)

注意

通过manage.py运行 shell 将在稍后变得必要,因为当 Flask 扩展只有在创建 Flask 应用程序时才会初始化时。运行默认的 Python shell 会导致这些扩展返回错误。

然后,以 Python 标准的方式结束文件,只有当用户运行了这个文件时才会运行:

if __name__ == "__main__":
    manager.run()

现在您可以使用以下命令运行开发服务器:

$ python manage.py server

使用以下命令运行 shell:

$ python manage.py shell
# Lets check if our app imported correctly
>>> app
<Flask 'main'>

摘要

现在我们已经设置好了开发环境,我们可以继续在 Flask 中实现高级应用程序功能。在我们可以做任何可视化之前,我们需要有东西来显示。在下一章中,您将被介绍并掌握在 Flask 中使用数据库。

第二章:使用 SQLAlchemy 创建模型

如前所述,模型是一种抽象和给数据提供一个通用接口的方式。在大多数 Web 应用程序中,数据存储和检索是通过关系数据库管理系统RDBMS)进行的,这是一个以行和列的表格格式存储数据并能够在表格之间比较数据的数据库。一些例子包括 MySQL,Postgres,Oracle 和 MSSQL。

为了在我们的数据库上创建模型,我们将使用一个名为SQLAlchemy的 Python 包。SQLAlchemy 在其最低级别是一个数据库 API,并在其最高级别执行对象关系映射ORM)。ORM 是一种在不同类型的系统和数据结构之间传递和转换数据的技术。在这种情况下,它将数据库中大量类型的数据转换为 Python 中类型和对象的混合。此外,像 Python 这样的编程语言允许您拥有不同的对象,这些对象相互引用,并获取和设置它们的属性。ORM,如 SQLAlchemy,有助于将其转换为传统数据库。

为了将 SQLAlchemy 与我们的应用程序上下文联系起来,我们将使用 Flask SQLAlchemy。Flask SQLAlchemy 是 SQLAlchemy 的一个便利层,提供了有用的默认值和特定于 Flask 的函数。如果您已经熟悉 SQLAlchemy,那么您可以在没有 Flask SQLAlchemy 的情况下自由使用它。

在本章结束时,我们将拥有一个完整的博客应用程序的数据库架构,以及与该架构交互的模型。

设置 SQLAlchemy

为了在本章中跟进,如果您还没有运行的数据库,您将需要一个。如果您从未安装过数据库,或者您没有偏好,SQLite 是初学者的最佳选择。

SQLite是一种快速的 SQL,无需服务器即可工作,并且完全包含在一个文件中。此外,SQLite 在 Python 中有原生支持。如果您选择使用 SQLite,将在我们的第一个模型部分为您创建一个 SQLite 数据库。

Python 包

要使用pip安装 Flask SQLAlchemy,请运行以下命令:

$ pip install flask-sqlalchemy

我们还需要安装特定的数据库包,用于作为 SQLAlchemy 的连接器。SQLite 用户可以跳过此步骤:

# MySQL
$ pip install PyMySQL
# Postgres
$ pip install psycopg2
# MSSQL
$ pip install pyodbc
# Oracle
$ pip install cx_Oracle

Flask SQLAlchemy

在我们可以抽象化我们的数据之前,我们需要设置 Flask SQLAlchemy。SQLAlchemy 通过特殊的数据库 URI 创建其数据库连接。这是一个看起来像 URL 的字符串,包含 SQLAlchemy 连接所需的所有信息。它的一般形式如下:

databasetype+driver://user:password@ip:port/db_name

对于您之前安装的每个驱动程序,URI 将是:

# SQLite
sqlite:///database.db
# MySQL
mysql+pymysql://user:password@ip:port/db_name
# Postgres
postgresql+psycopg2://user:password@ip:port/db_name
# MSSQL
mssql+pyodbc://user:password@dsn_name
# Oracle
oracle+cx_oracle://user:password@ip:port/db_name

在我们的config.py文件中,使用以下方式将 URI 添加到DevConfig文件中:

class DevConfig(Config):
    debug = True
    SQLALCHEMY_DATABASE_URI = "YOUR URI"

我们的第一个模型

您可能已经注意到,我们实际上没有在我们的数据库中创建任何表来进行抽象。这是因为 SQLAlchemy 允许我们从表中创建模型,也可以从我们的模型中创建表。这将在我们创建第一个模型后进行介绍。

在我们的main.py文件中,必须首先使用以下方式初始化 SQLAlchemy:

from flask.ext.sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config.from_object(DevConfig)
db = SQLAlchemy(app)

SQLAlchemy 将读取我们应用程序的配置,并自动连接到我们的数据库。让我们在main.py文件中创建一个User模型,以与用户表进行交互:

class User(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    username = db.Column(db.String(255))
    password = db.Column(db.String(255))

    def __init__(self, username):
        self.username = username

    def __repr__(self):
        return "<User '{}'>".format(self.username)

我们取得了什么成就?我们现在有一个基于用户表的模型,有三列。当我们从db.Model继承时,与数据库的整个连接和通信将已经为我们处理。

每个db.Column实例的类变量代表数据库中的一列。db.Column实例中有一个可选的第一个参数,允许我们指定数据库中列的名称。如果没有,SQLAlchemy 会假定变量的名称与列的名称相同。使用这个可选变量会看起来像这样:

username = db.Column('user_name', db.String(255))

db.Column的第二个参数告诉 SQLAlchemy 应将该列视为什么类型。本书中我们将使用的主要类型是:

  • db.String

  • db.Text

  • db.Integer

  • db.Float

  • db.Boolean

  • db.Date

  • db.DateTime

  • db.Time

每种类型代表的含义都相当简单。StringText类型接受 Python 字符串并将它们分别转换为varchartext类型的列。IntegerFloat类型接受任何 Python 数字并在将它们插入数据库之前将它们转换为正确的类型。布尔类型接受 Python 的TrueFalse语句,并且如果数据库有boolean类型,则将布尔值插入数据库。如果数据库中没有boolean类型,SQLAlchemy 会自动在 Python 布尔值和数据库中的 0 或 1 之间进行转换。DateDateTimeTime类型使用datetime本地库中同名的 Python 类型,并将它们转换为数据库中的类型。StringIntegerFloat类型接受一个额外的参数,告诉 SQLAlchemy 我们列的长度限制。

注意

如果您希望真正了解 SQLAlchemy 如何将您的代码转换为 SQL 查询,请将以下内容添加到DevConfig文件中:

SQLALCHMEY_ECHO = True

这将在终端上打印出创建的查询。随着您在本书中的进展,您可能希望关闭此功能,因为每次加载页面时可能会打印出数十个查询。

参数primary_key告诉 SQLAlchemy 该列具有主键索引。每个 SQLAlchemy 模型都需要一个主键才能正常工作。

SQLAlchemy 将假定您的表名是模型类名的小写版本。但是,如果我们希望我们的表被称为除了users之外的其他名称呢?要告诉 SQLAlchemy 使用什么名称,请添加__tablename__类变量。这也是连接到已经存在于数据库中的表的方法。只需将表的名称放在字符串中。

class User(db.Model):
    __tablename__ = 'user_table_name'

    id = db.Column(db.Integer(), primary_key=True)
    username = db.Column(db.String(255))
    password = db.Column(db.String(255))

我们不必包含__init____repr__函数。如果不包含,那么 SQLAlchemy 将自动创建一个接受列的名称和值作为关键字参数的__init__函数。

创建用户表

使用 SQLAlchemy 来完成繁重的工作,我们现在将在数据库中创建用户表。更新manage.py为:

from main import app, db, User
...
@manager.shell
def make_shell_context():
    return dict(app=app, db=db, User=User)

Style - "db","User" in first line as Code Highlight

提示

从现在开始,每当我们创建一个新模型时,导入它并将其添加到返回的dict中。

这将允许我们在 shell 中使用我们的模型。现在运行 shell 并使用db.create_all()来创建所有表:

$ python manage.py shell
>>> db.create_all()

现在您应该在数据库中看到一个名为users的表以及指定的列。此外,如果您使用 SQLite,您现在应该在文件结构中看到一个名为database.db的文件。

CRUD

对于数据的每种存储机制,都有四种基本类型的函数:创建、读取、更新和删除CRUD)。这些允许我们使用的所有基本方式来操作和查看我们的 Web 应用程序所需的数据。要使用这些函数,我们将在数据库上使用一个名为session的对象。会话将在本章后面进行解释,但现在,将其视为我们对数据库的所有更改的存储位置。

创建模型

要使用我们的模型在数据库中创建新行,请将模型添加到sessioncommit对象中。将对象添加到会话中标记其更改以进行保存,并且提交是将会话保存到数据库中的时候:

>>> user = User(username='fake_name')
>>> db.session.add(user)
>>> db.session.commit()

向我们的表中添加新行非常简单。

读取模型

在向数据库添加数据后,可以使用Model.query来查询数据。对于使用 SQLAlchemy 的人来说,这是db.session.query(Model)的简写。

对于我们的第一个示例,使用all()来获取数据库中的所有行作为列表。

>>> users = User.query.all()
>>> users
[<User 'fake_name'>]

当数据库中的项目数量增加时,此查询过程变得更慢。在 SQLAlchmey 中,与 SQL 一样,我们有限制功能来指定我们希望处理的总行数。

>>> users = User.query.limit(10).all()

默认情况下,SQLAlchemy 返回按其主键排序的记录。要控制这一点,我们有 order_by 函数,它的用法是:

# asending
>>> users = User.query.order_by(User.username).all()
# desending
>>> users = User.query.order_by(User.username.desc()).all()

要返回一个模型,我们使用 first() 而不是 all()

>>> user = User.query.first()
>>> user.username
fake_name

要通过其主键返回一个模型,使用 query.get()

>>> user = User.query.get(1)
>>> user.username
fake_name

所有这些函数都是可链式调用的,这意味着它们可以附加到彼此以修改返回结果。精通 JavaScript 的人会发现这种语法很熟悉。

>>> users = User.query.order_by(
 User.username.desc()
 ).limit(10).first()

first()all() 方法返回一个值,因此结束了链式调用。

还有一个特定于 Flask SQLAlchemy 的方法叫做 pagination,可以用来代替 first()all()。这是一个方便的方法,旨在启用大多数网站在显示长列表项目时使用的分页功能。第一个参数定义了查询应该返回到哪一页,第二个参数是每页的项目数。因此,如果我们传递 1 和 10 作为参数,将返回前 10 个对象。如果我们传递 2 和 10,将返回对象 11-20,依此类推。

分页方法与 first()all() 方法不同,因为它返回一个分页对象而不是模型列表。例如,如果我们想要获取博客中虚构的 Post 对象的第一页的前 10 个项目:

>>> Post.query.paginate(1, 10)
<flask_sqlalchemy.Pagination at 0x105118f50>

这个对象有几个有用的属性:

>>> page = User.query.paginate(1, 10)
# return the models in the page
>>> page.items
[<User 'fake_name'>]
# what page does this object represent
>>> page.page
1
# How many pages are there
>>> page.pages
1
# are there enough models to make the next or previous page
>>> page.has_prev, page.has_next
(False, False)
# return the next or previous page pagination object
# if one does not exist returns the current page
>>> page.prev(), page.next()
(<flask_sqlalchemy.Pagination at 0x10812da50>,
 <flask_sqlalchemy.Pagination at 0x1081985d0>)

过滤查询

现在我们来到了 SQL 的真正威力,即通过一组规则过滤结果。要获取满足一组相等条件的模型列表,我们使用 query.filter_by 过滤器。query.filter_by 过滤器接受命名参数,这些参数代表我们在数据库中每一列中寻找的值。要获取所有用户名为 fake_name 的用户列表:

>>> users = User.query.filter_by(username='fake_name').all()

这个例子是在一个值上进行过滤,但多个值可以传递给 filter_by 过滤器。就像我们之前的函数一样,filter_by 是可链式调用的:

>>> users = User.query.order_by(User.username.desc())
 .filter_by(username='fake_name')
 .limit(2)
 .all()

query.filter_by 只有在你知道你要查找的确切值时才有效。这可以通过将 Python 比较语句传递给 query.filter 来避免:

>>> user = User.query.filter(
 User.id > 1
 ).all()

这是一个简单的例子,但 query.filter 接受任何 Python 比较。对于常见的 Python 类型,比如 整数字符串日期,可以使用 == 运算符进行相等比较。如果有一个 整数浮点数日期 列,也可以使用 ><<=>= 运算符传递不等式语句。

我们还可以使用 SQLAlchemy 函数来转换复杂的 SQL 查询。例如,使用 INORNOT SQL 比较:

>>> from sqlalchemy.sql.expression import not_, or_
>>> user = User.query.filter(
 User.username.in_(['fake_name']),
 User.password == None
 ).first()
# find all of the users with a password
>>> user = User.query.filter(
 not_(User.password == None)
 ).first()
# all of these methods are able to be combined
>>> user = User.query.filter(
 or_(not_(User.password == None), User.id >= 1)
 ).first()

在 SQLAlchemy 中,与 None 的比较会被转换为与 NULL 的比较。

更新模型

要更新已经存在的模型的值,将 update 方法应用到查询对象上,也就是说,在你使用 first()all() 等方法返回模型之前:

>>> User.query.filter_by(username='fake_name').update({
 'password': 'test'
 })
# The updated models have already been added to the session
>>> db.session.commit()

删除模型

如果我们希望从数据库中删除一个模型:

>>> user = User.query.filter_by(username='fake_name').first()
>>> db.session.delete(user)
>>> db.session.commit()

模型之间的关系

SQLAlchemy 中模型之间的关系是两个或多个模型之间的链接,允许模型自动引用彼此。这允许自然相关的数据,比如 评论到帖子,可以轻松地从数据库中检索其相关数据。这就是关系型数据库管理系统中的 R,它赋予了这种类型的数据库大量的能力。

让我们创建我们的第一个关系。我们的博客网站将需要一些博客文章。每篇博客文章将由一个用户撰写,因此将博客文章链接回撰写它们的用户是很有意义的,可以轻松地获取某个用户的所有博客文章。这是一个 一对多 关系的例子。

一对多

让我们添加一个模型来代表我们网站上的博客文章:

class Post(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    title = db.Column(db.String(255))
    text = db.Column(db.Text())
    publish_date = db.Column(db.DateTime())
    user_id = db.Column(db.Integer(), db.ForeignKey('user.id'))

    def __init__(self, title):
        self.title = title

    def __repr__(self):
        return "<Post '{}'>".format(self.title)

请注意user_id列。熟悉 RDBMS 的人会知道这代表外键约束。外键约束是数据库中的一条规则,强制user_id的值存在于用户表中的id列中。这是数据库中的一个检查,以确保Post始终引用现有用户。db.ForeignKey的参数是user_id字段的字符串表示。如果决定用__table_name__来命名用户表,必须更改此字符串。在初始化 SQLAlchemy 时,使用此字符串而不是直接引用User.id,因为User对象可能尚不存在。

user_id列本身不足以告诉 SQLAlchemy 我们有一个关系。我们必须修改我们的User模型如下:

class User(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    username = db.Column(db.String(255))
    password = db.Column(db.String(255))
    posts = db.relationship(
        'Post',
        backref='user',
        lazy='dynamic'
    )

db.relationship函数在 SQLAlchemy 中创建一个虚拟列,与我们的Post模型中的db.ForeignKey相连接。第一个参数是我们引用的类的名称。我们很快就会介绍backref的作用,但lazy参数是什么?lazy参数控制 SQLAlchemy 如何加载我们的相关对象。subquery会在加载我们的Post对象时立即加载我们的关系。这减少了查询的数量,但当返回的项目数量增加时,速度会变慢。相比之下,使用dynamic选项,相关对象将在访问时加载,并且可以在返回之前进行筛选。如果返回的对象数量很大或将变得很大,这是最好的选择。

我们现在可以访问User.posts变量,它将返回所有user_id字段等于我们的User.id的帖子的列表。让我们在 shell 中尝试一下:

>>> user = User.query.get(1)
>>> new_post = Post('Post Title')
>>> new_post.user_id = user.id
>>> user.posts
[]
>>> db.session.add(new_post)
>>> db.session.commit()
>>> user.posts
[<Post 'Post Title'>]

请注意,如果没有将更改提交到数据库,我们将无法访问我们的关系中的帖子。

backref参数使我们能够通过Post.user访问和设置我们的User类。这是由以下给出的:

>>> second_post = Post('Second Title')
>>> second_post.user = user
>>> db.session.add(second_post)
>>> db.session.commit()
>>> user.posts
[<Post 'Post Title'>, <Post 'Second Title'>]

因为user.posts是一个列表,我们也可以将我们的Post模型添加到列表中以自动保存它:

>>> second_post = Post('Second Title')
>>> user.posts.append(second_post)
>>> db.session.add(user)
>>> db.session.commit()
>>> user.posts
[<Post 'Post Title'>, <Post 'Second Title'>]

使用backref选项作为 dynamic,我们可以将我们的关系列视为查询以及列表:

>>> user.posts
[<Post 'Post Title'>, <Post 'Second Title'>]
>>> user.posts.order_by(Post.publish_date.desc()).all()
[<Post 'Second Title'>, <Post 'Post Title'>]

在我们继续下一个关系类型之前,让我们为用户评论添加另一个模型,它具有一对多的关系,稍后将在书中使用:

class Post(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    title = db.Column(db.String(255))
    text = db.Column(db.Text())
    publish_date = db.Column(db.DateTime())
    comments = db.relationship(
        'Comment',
        backref='post',
        lazy='dynamic'
    )
    user_id = db.Column(db.Integer(), db.ForeignKey('user.id'))

    def __init__(self, title):
        self.title = title

    def __repr__(self):
        return "<Post '{}'>".format(self.title)

class Comment(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    name = db.Column(db.String(255))
    text = db.Column(db.Text())
    date = db.Column(db.DateTime())
    post_id = db.Column(db.Integer(), db.ForeignKey('post.id'))

    def __repr__(self):
        return "<Comment '{}'>".format(self.text[:15])

多对多

如果我们有两个可以相互引用的模型,但每个模型都需要引用每种类型的多个模型,该怎么办?例如,我们的博客帖子将需要标签,以便我们的用户可以轻松地将相似的帖子分组。每个标签可以指向多个帖子,但每个帖子可以有多个标签。这种类型的关系称为多对多关系。考虑以下示例:

tags = db.Table('post_tags',
    db.Column('post_id', db.Integer, db.ForeignKey('post.id')),
    db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'))
)

class Post(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    title = db.Column(db.String(255))
    text = db.Column(db.Text())
    publish_date = db.Column(db.DateTime())
    comments = db.relationship(
        'Comment',
        backref='post',
        lazy='dynamic'
    )
    user_id = db.Column(db.Integer(), db.ForeignKey('user.id'))
    tags = db.relationship(
        'Tag',
        secondary=tags,
        backref=db.backref('posts', lazy='dynamic')
    )

    def __init__(self, title):
        self.title = title

    def __repr__(self):
        return "<Post '{}'>".format(self.title)

class Tag(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    title = db.Column(db.String(255))

    def __init__(self, title):
        self.title = title

    def __repr__(self):
        return "<Tag '{}'>".format(self.title)

db.Table对象是对数据库的低级访问,比db.Model的抽象更低。db.Model对象建立在db.Table之上,并提供了表中特定行的表示。使用db.Table对象是因为不需要访问表的单个行。

tags变量用于表示post_tags表,其中包含两行:一行表示帖子的 id,另一行表示标签的 id。为了说明这是如何工作的,如果表中有以下数据:

post_id   tag_id
1         1
1         3
2         3
2         4
2         5
3         1
3         2

SQLAlchemy 会将其转换为:

  • id 为1的帖子具有 id 为13的标签

  • id 为2的帖子具有 id 为345的标签

  • id 为3的帖子具有 id 为12的标签

您可以将这些数据描述为与帖子相关的标签。

db.relationship函数设置我们的关系之前,但这次它有 secondary 参数。secondary 参数告诉 SQLAlchemy 这个关系存储在 tags 表中。让我们看看下面的代码:

>>> post_one = Post.query.filter_by(title='Post Title').first()
>>> post_two = Post.query.filter_by(title='Second Title').first()
>>> tag_one = Tag('Python')
>>> tag_two = Tag('SQLAlchemy')
>>> tag_three = Tag('Flask')
>>> post_one.tags = [tag_two]
>>> post_two.tags = [tag_one, tag_two, tag_three]
>>> tag_two.posts
[<Post 'Post Title'>, <Post 'Second Title'>]
>>> db.session.add(post_one)
>>> db.session.add(post_two)
>>> db.session.commit()

在一对多关系中,主关系列只是一个列表。主要区别在于backref选项现在也是一个列表。因为它是一个列表,我们可以从tag对象中向标签添加帖子,如下所示:

>>> tag_one.posts.append(post_one)
[<Post 'Post Title'>, <Post 'Second Title'>]
>>> post_one.tags
[<Tag 'SQLAlchemy'>, <Tag 'Python'>]
>>> db.session.add(tag_one)
>>> db.session.commit()

SQLAlchemy 会话的便利性

现在您了解了 SQLAlchemy 的强大之处,也可以理解 SQLAlchemy 会话对象是什么,以及为什么 Web 应用程序不应该没有它们。正如之前所述,会话可以简单地描述为一个跟踪我们模型更改并在我们告诉它时将它们提交到数据库的对象。但是,它比这更复杂一些。

首先,会话是事务的处理程序。事务是在提交时刷新到数据库的一组更改。事务提供了许多隐藏的功能。例如,当对象具有关系时,事务会自动确定哪些对象将首先保存。您可能已经注意到了,在上一节中保存标签时。当我们将标签添加到帖子中时,会话自动知道首先保存标签,尽管我们没有将其添加到提交。如果我们使用原始 SQL 查询和数据库连接,我们将不得不跟踪哪些行与其他行相关,以避免保存对不存在的对象的外键引用。

事务还会在将对象的更改保存到数据库时自动将数据标记为陈旧。当我们下次访问对象时,将向数据库发出查询以更新数据,但所有这些都是在后台进行的。如果我们不使用 SQLAlchemy,我们还需要手动跟踪需要更新的行。如果我们想要资源高效,我们只需要查询和更新那些行。

其次,会话使得不可能存在对数据库中同一行的两个不同引用。这是通过所有查询都经过会话来实现的(Model.query实际上是db.session.query(Model)),如果在此事务中已经查询了该行,则将返回指向该对象的指针,而不是一个新对象。如果没有这个检查,表示同一行的两个对象可能会以不同的更改保存到数据库中。这会产生微妙的错误,可能不会立即被发现。

请记住,Flask SQLAlchemy 为每个请求创建一个新会话,并在请求结束时丢弃未提交的任何更改,因此请记住保存您的工作。

注意

要深入了解会话,SQLAlchemy 的创建者 Mike Bayer 在 2012 年加拿大 PyCon 上发表了一次演讲。请参阅SQLAlchemy 会话-深入,链接在这里-www.youtube.com/watch?v=PKAdehPHOMo

使用 Alembic 进行数据库迁移

Web 应用程序的功能性不断变化,随着新功能的增加,我们需要改变数据库的结构。无论是添加或删除新列,还是创建新表,我们的模型都会在应用程序的生命周期中发生变化。然而,当数据库经常发生变化时,问题很快就会出现。在将我们的更改从开发环境移动到生产环境时,如何确保您在没有手动比较每个模型及其相应表的情况下携带了每个更改?假设您希望回到 Git 历史记录中查看您的应用程序的早期版本是否存在与您现在在生产环境中遇到的相同错误。在没有大量额外工作的情况下,您将如何将数据库更改回正确的模式?

作为程序员,我们讨厌额外的工作。幸运的是,有一个名为Alembic的工具,它可以根据我们的 SQLAlchemy 模型的更改自动创建和跟踪数据库迁移。数据库迁移是我们模式的所有更改的记录。Alembic 允许我们将数据库升级或降级到特定的保存版本。通过几个版本的升级或降级将执行两个选定版本之间的所有文件。Alembic 最好的部分是它的历史文件只是 Python 文件。当我们创建我们的第一个迁移时,我们可以看到 Alembic 语法是多么简单。

注意

Alembic 并不捕获每一个可能的变化。例如,它不记录 SQL 索引的更改。在每次迁移之后,建议读者查看迁移文件并进行任何必要的更正。

我们不会直接使用 Alembic;相反,我们将使用Flask-Migrate,这是专门为 SQLAlchemy 创建的扩展,并与 Flask Script 一起使用。要使用pip安装它:

$ pip install Flask-Migrate

要开始,我们需要将命令添加到我们的manage.py文件中,如下所示:

from flask.ext.script import Manager, Server
from flask.ext.migrate import Migrate, MigrateCommand

from main import app, db, User, Post, Tag

migrate = Migrate(app, db)

manager = Manager(app)
manager.add_command("server", Server())
manager.add_command('db', MigrateCommand)

@manager.shell
def make_shell_context():
    return dict(app=app, db=db, User=User, Post=Post, Tag=Tag)

if __name__ == "__main__":
    manager.run()

我们使用我们的应用程序和我们的 SQLAlchemy 实例初始化了Migrate对象,并且通过manage.py db使迁移命令可调用。要查看可能的命令列表,请运行此命令:

$ python manage.py db

要开始跟踪我们的更改,我们使用init命令如下:

$ python manage.py db init

这将在我们的目录中创建一个名为migrations的新文件夹,其中将保存我们的所有历史记录。现在我们开始进行我们的第一个迁移:

$ python manage.py  db migrate -m"initial migration"

这个命令将导致 Alembic 扫描我们的 SQLAlchemy 对象,并找到所有在此提交之前不存在的表和列。由于这是我们的第一个提交,迁移文件会相当长。一定要使用-m指定迁移消息,因为这是识别每个迁移在做什么的最简单方法。每个迁移文件都存储在migrations/versions/文件夹中。

要将迁移应用到您的数据库并更改模式,请运行以下命令:

$ python manage.py db upgrade

要返回到以前的版本,使用history命令找到版本号,并将其传递给downgrade命令:

$ python manage.py db history
<base> -> 7ded34bc4fb (head), initial migration
$ python manage.py db downgrade 7ded34bc4fb

就像 Git 一样,每个迁移都有一个哈希标记。这是 Alembic 的主要功能,但这只是表面层次。尝试将您的迁移与 Git 提交对齐,以便在还原提交时更容易降级或升级。

总结

现在我们已经掌握了数据控制,我们现在可以继续在我们的应用程序中显示我们的数据。下一章,第三章 使用模板创建视图,将动态地涵盖根据我们的模型创建基于 HTML 的视图,并从我们的 Web 界面添加模型。

第三章:使用模板创建视图

现在我们的数据以一种方便访问的格式呈现,将信息显示在网页上变得更加容易。在本章中,我们将使用 Flask Jinja 的包含模板语言,从我们的 SQLAlchemy 模型动态创建 HTML。我们还将研究 Jinja 的方法,自动创建 HTML 并修改数据以在模板内进行呈现。然后,本章将以使用 Jinja 自动创建和验证 HTML 表单结束。

Jinja 的语法

Jinja是用 Python 编写的模板语言。模板语言是一种旨在帮助自动创建文档的简单格式。在任何模板语言中,传递给模板的变量将替换模板中预定义的位置。在 Jinja 中,变量替换由{{}}定义。{{}}语法称为变量块。还有由{% %}定义的控制块,它声明语言函数,如循环if语句。例如,当从上一章传递给它的Post模型时,我们有以下 Jinja 代码:

<h1>{{ post.title }}</h1>

这将产生以下结果:

<h1>First Post</h1>

在 Jinja 模板中显示的变量可以是任何 Python 类型或对象,只要它们可以通过 Python 函数str()转换为字符串。例如,传递给模板的字典或列表可以通过其属性显示:

{{ your_dict['key'] }}
{{ your_list[0] }}

许多程序员更喜欢使用 JavaScript 来模板化和动态创建他们的 HTML 文档,以减轻服务器的 HTML 渲染负载。本章不会涵盖这个话题,因为这是一个高级的 JavaScript 话题。然而,许多 JavaScript 模板引擎也使用{{}}语法。如果您选择将 Jinja 和在 HTML 文件中定义的 JavaScript 模板结合在一起,则将 JavaScript 模板包装在raw控制块中,以告诉 Jinja 忽略它们:

{% raw %}
<script id="template" type="text/x-handlebars-template">
    <h1>{{title}}</h1>
    <div class="body">
        {{body}}
    </div>
</script>
{% endraw %}

过滤器

认为 Jinja 和 Python 的语法是相同的是一个常见的错误,因为它们相似。然而,它们之间有很多不同之处。正如您将在本节中看到的,普通的 Python 函数实际上并不存在。相反,在 Jinja 中,变量可以传递给修改变量以供显示目的的内置函数。这些函数,称为过滤器,使用管道字符|在变量块中调用:

{{ variable | filter_name(*args) }}

否则,如果没有向过滤器传递参数,则可以省略括号,如下所示:

{{ variable | filter_name }}

过滤器也可以被称为控制块,以将它们应用于文本块:

{% filter filter_name %}
    A bunch of text
{% endfilter %}

Jinja 中有许多过滤器;本书将仅涵盖最有用的过滤器。为了简洁起见,在每个示例中,每个过滤器的输出将直接列在过滤器本身下面。

注意

有关 Jinja 中所有默认过滤器的完整列表,请访问jinja.pocoo.org/docs/dev/templates/#list-of-builtin-filters

默认

如果传递的变量是None,则将其替换为默认值,如下所示:

{{ post.date | default('2015-01-01') }}
2015-01-01

如果您希望用默认值替换变量,并且如果变量求值为False,则将可选的第二个参数传递给True

{{ '' | default('An empty string', True) }}
An empty string

逃脱

如果传递的变量是 HTML 字符串,则将打印&<>'"字符作为 HTML 转义序列:

{{ "<h1>Title</h1>" | escape }}
&#60;h1&#62;Title&#60;/h1&#62;

float

这将使用 Python 的float()函数将传递的值转换为浮点数,如下所示:

{{ 75 | float }}
75.0

整数

这将使用 Python 的int()函数将传递的值转换为整数,如下所示:

{{ 75.7 | int }}
75

连接

这是一个使用字符串和字符串列表的元素连接的过滤器,与相同名称的list方法完全相同。它被给定为:

{{ ['Python', 'SQLAlchemy'] | join(',') }}
Python, SQLAlchemy

长度

这是一个填充与 Python len()函数相同作用的过滤器。它被给定为:

Tag Count: {{ post.tags | length }}
Tag Count: 2

这将四舍五入浮点数到指定的精度:

{{ 3.141592653589793238462 | round(1) }}
3.1

您还可以指定要将数字舍入到的方式:

{{ 4.7 | round(1, "common") }}
5
{{ 4.2 | round(1, "common") }}
4
{{ 4.7 | round(1, "floor") }}
4
{{ 4.2 | round(1, "ceil") }}
5

common选项像人一样四舍五入:大于或等于 0.5 的四舍五入,小于 0.5 的舍去。floor选项总是向下舍入数字,ceil选项总是向上舍入,不考虑小数。

safe

如果你尝试从变量插入 HTML 到你的页面中,例如,当你希望显示一个博客文章时,Jinja 将自动尝试向输出添加 HTML 转义序列。看下面的例子:

{{ "<h1>Post Title</h1>" }}
&lt;h1&gt;Post Title&lt;/h1&gt;

这是一个必要的安全功能。当应用程序具有允许用户提交任意文本的输入时,它允许恶意用户输入 HTML 代码。例如,如果用户提交一个脚本标签作为评论,而 Jinja 没有这个功能,该脚本将在访问页面的所有浏览器上执行。

然而,我们仍然需要一种方法来显示我们知道是安全的 HTML,比如我们博客文章的 HTML。我们可以使用safe过滤器来实现这一点,如下所示:

{{ "<h1>Post Title</h1>" | safe }}
<h1>Post Title</h1>

title

我们使用标题格式来大写字符串,如下所示:

{{ "post title" | title }}
Post Title

tojson

我们可以将变量传递给 Python 的json.dumps函数。请记住,你传递的对象必须是json模块可序列化的。

{{ {'key': False, 'key2': None, 'key3': 45} | tojson }}
{key: false, key2: null, key3: 45}

这个功能最常用于在页面加载时将 SQLAlchemy 模型传递给 JavaScript MVC 框架,而不是等待 AJAX 请求。如果你以这种方式使用tojson,请记住也将结果传递给safe过滤器,以确保你的 JavaScript 中不会出现 HTML 转义序列。以下是一个使用Backbone.js的示例,这是一个流行的 JavaScript MVC 框架,包含了一系列模型:

var collection = new PostCollection({{ posts | tojson | safe }});

truncate

这将获取一个长字符串,并返回指定长度的字符串,并附加省略号:

{{ "A Longer Post Body Than We Want" | truncate(10) }}
A Longer...

默认情况下,任何在中间被截断的单词都会被丢弃。要禁用这一点,作为额外参数传递True

{{ "A Longer Post Body Than We Want" | truncate(10, True) }}
A Longer P...

自定义过滤器

将自己的过滤器添加到 Jinja 中就像编写 Python 函数一样简单。为了理解自定义过滤器,我们将看一个例子。我们的简单过滤器将计算字符串中子字符串的出现次数并返回它。看下面的调用:

{{ variable | filter_name("string") }}

这将被更改为:

filter_name(variable, "string")

我们可以定义我们的过滤器如下:

def count_substring(string, sub):
    return string.count(sub)

要将此功能添加到可用过滤器列表中,我们必须手动将其添加到main.py文件中jinja_env对象的filters字典中:

app.jinja_env.filters['count_substring'] = count_substring

注释

模板中的注释由{# #}定义,将被 Jinja 忽略,并不会出现在返回的 HTML 代码中:

{# Note to the maintainers of this code #}

if 语句

Jinja 中的if语句类似于 Python 的if语句。任何返回或是布尔值的东西决定了代码的流程:

{%if user.is_logged_in() %} 
    <a href='/logout'>Logout</a>
{% else %}
    <a href='/login'>Login</a>
{% endif %}

过滤器也可以用在if语句中:

{% if comments | length > 0 %} 
    There are {{ comments | length }} comments
{% else %}
    There are no comments
{% endif %}

循环

我们可以在 Jinja 中使用循环来迭代任何列表或生成器函数:

{% for post in posts %}
    <div>
        <h1>{{ post.title }}</h1>
        <p>{{ post.text | safe }}</p>
    </div>
{% endfor %}

循环和if语句可以结合使用,以模仿 Python 循环中的break功能。在这个例子中,只有当post.text不是None时,循环才会使用post

{% for post in posts if post.text %}
    <div>
        <h1>{{ post.title }}</h1>
        <p>{{ post.text | safe }}</p>
    </div>
{% endfor %}

在循环内,你可以访问一个名为loop的特殊变量,它可以让你访问有关for循环的信息。例如,如果我们想知道当前循环的当前索引以模拟 Python 中的enumerate函数,我们可以使用循环变量的索引变量,如下所示:

{% for post in posts %}
    {{ loop.index }}. {{ post.title }}
{% endfor %}

这将产生以下输出:

1\. Post Title
2\. Second Post

loop对象公开的所有变量和函数在下表中列出:

变量 描述
loop.index 循环的当前迭代(从 1 开始索引)
loop.index0 循环的当前迭代(从 0 开始索引)
loop.revindex 距离循环末尾的迭代次数(从 1 开始索引)
loop.revindex0 距离循环末尾的迭代次数(从 0 开始索引)
loop.first 如果当前项目是迭代器中的第一个,则为 True
loop.last 如果当前项目是迭代器中的最后一个,则为 True
loop.length 迭代器中的项目数
loop.cycle 用于在迭代器中循环的辅助函数,稍后会解释
loop.depth 表示递归循环中当前循环的深度(从级别 1 开始)
loop.depth0 表示递归循环中当前循环的深度(从级别 0 开始)

cycle函数是一个在每次循环时逐个遍历迭代器的函数。我们可以使用前面的示例来演示:

{% for post in posts %}
    {{ loop.cycle('odd', 'even') }} {{ post.title }} 
{% endfor %}

这将输出:

odd Post Title
even Second Post

最好理解为 Jinja 中返回模板或 HTML 字符串的函数。这用于避免重复的代码,并将其减少到一个函数调用。例如,以下是一个用于在模板中添加 Bootstrap CSS 输入和标签的宏:

{% macro input(name, label, value='', type='text') %}
    <div class="form-group">
        <label for"{{ name }}">{{ label }}</label>
        <input type="{{ type }}" name="{{ name }}"
            value="{{ value | escape }}" class="form-control">
    </div>
{% endmacro %}

现在,要在任何模板中快速添加输入到表单,使用以下方式调用您的宏:

{{ input('name', 'Name') }}

这将输出:

<div class="form-group">
    <label for"name">Name</label>
    <input type="text" name="name" value="" class="form-control">
</div>

Flask 特定的变量和函数

Flask 在模板中默认提供了几个函数和对象。

config

Flask 在模板中提供了当前的config对象:

{{ config.SQLALCHEMY_DATABASE_URI }}
sqlite:///database.db

request

这是 Flask 的request对象,用于当前请求。

{{ request.url }}
http://127.0.0.1/

session

Flask 的session对象是:

{{ session.new }}
True

url_for()

url_for函数通过将路由函数名称作为参数返回路由的 URL。这允许更改 URL 而不必担心链接会断开。

{{ url_for('home') }}
/

如果我们有一个在 URL 中有位置参数的路由,我们将它们作为kwargs传递。它们将在生成的 URL 中为我们填充:

{{ url_for('post', post_id=1) }}
/post/1

get_flashed_messages()

这将返回通过 Flask 中的flash()函数传递的所有消息的列表。flash函数是一个简单的函数,用于排队消息,这些消息只是 Python 字符串,供get_flashed_messages函数消耗。

{% for message in get_flashed_messages() %}
    {{ message }}
{% endfor %}

创建我们的视图

要开始,我们需要在项目目录中创建一个名为templates的新文件夹。该文件夹将存储所有的 Jinja 文件,这些文件只是带有 Jinja 语法的 HTML 文件。我们的第一个模板将是我们的主页,它将是前 10 篇帖子的摘要列表。还将有一个用于显示帖子内容、页面上的评论、作者用户页面的链接和标签页面的链接的帖子视图。还将有用户和标签页面,显示用户的所有帖子和具有特定标签的所有帖子。每个页面还将有一个侧边栏,显示最近的五篇帖子和使用最多的五个标签。

视图函数

因为每个页面都会有相同的侧边栏信息,我们可以将其拆分为一个单独的函数,以简化我们的代码。在main.py文件中,添加以下代码:

from sqlalchemy import func
...
def sidebar_data():
    recent = Post.query.order_by(
        Post.publish_date.desc()
    ).limit(5).all()
    top_tags = db.session.query(
        Tag, func.count(tags.c.post_id).label('total')
    ).join(
        tags
    ).group_by(Tag).order_by('total DESC').limit(5).all()

    return recent, top_tags

最近的帖子查询很直接,但最受欢迎的标签查询看起来有些熟悉,但有点奇怪。这有点超出了本书的范围,但使用 SQLAlchemy 的func库返回计数,我们可以按最常用的标签对标签进行排序。func函数在docs.sqlalchemy.org/en/rel_1_0/core/sqlelement.html#sqlalchemy.sql.expression.func中有详细说明。

main.py中的主页函数将需要一个分页对象中的所有帖子和侧边栏信息:

from flask import Flask, render_template 
...
@app.route('/')
@app.route('/<int:page>')
def home(page=1):
    posts = Post.query.order_by(
        Post.publish_date.desc()
    ).paginate(page, 10)
    recent, top_tags = sidebar_data()

    return render_template(
        'home.html',
        posts=posts,
        recent=recent,
        top_tags=top_tags
    )

在这里,我们终于看到了 Flask 和 Jinja 是如何联系在一起的。Flask 函数render_template接受模板文件夹中的文件名,并将所有kwargs作为变量传递给模板。另外,我们的home函数现在有多个路由来处理分页,并且如果斜杠后面没有内容,将默认显示第一页。

现在您已经掌握了编写视图函数所需的所有知识,我挑战您尝试根据前面的描述编写其余的视图函数。尝试后,将您的结果与以下内容进行比较:

@app.route('/post/<int:post_id>')
def post(post_id):
    post = Post.query.get_or_404(post_id)
    tags = post.tags
    comments = post.comments.order_by(Comment.date.desc()).all()
    recent, top_tags = sidebar_data()

    return render_template(
        'post.html',
        post=post,
        tags=tags,
        comments=comments,
        recent=recent,
        top_tags=top_tags
    )

@app.route('/tag/<string:tag_name>')
def tag(tag_name):
    tag = Tag.query.filter_by(title=tag_name).first_or_404()
    posts = tag.posts.order_by(Post.publish_date.desc()).all()
    recent, top_tags = sidebar_data()

    return render_template(
        'tag.html',
        tag=tag,
        posts=posts,
        recent=recent,
        top_tags=top_tags
    )

@app.route('/user/<string:username>')
def user(username):
    user = User.query.filter_by(username=username).first_or_404()
    posts = user.posts.order_by(Post.publish_date.desc()).all()
    recent, top_tags = sidebar_data()
    return render_template(
        'user.html',
        user=user,
        posts=posts,
        recent=recent,
        top_tags=top_tags
    )

在编写所有视图之后,唯一剩下的事情就是编写模板。

编写模板和继承

因为本书不专注于界面设计,我们将使用 CSS 库 Bootstrap,并避免编写自定义 CSS。如果你以前没有使用过,Bootstrap是一组默认的 CSS 规则,可以使你的网站在所有浏览器上运行良好,并具有工具,可以轻松控制网站的布局。要下载 Bootstrap,转到getbootstrap.com/,点击下载 Bootstrap按钮。再点击另一个按钮下载 Bootstrap,你将开始下载一个 Zip 文件。将此文件解压缩到你的项目目录,并将文件夹重命名为staticstatic文件夹必须与main.py文件在同一目录级别,Flask 才能自动找到这些文件。从现在开始,我们将在这里保存我们的 CSS、字体、图像和 JavaScript 文件。

因为每个路由都将有一个分配给它的模板,每个模板都需要具有我们的元信息、样式表、常用 JavaScript 库等的必需 HTML 样板代码。为了保持我们的模板DRY不要重复自己),我们将使用 Jinja 最强大的功能之一,模板继承。模板继承是指子模板可以导入基础模板作为起点,并只替换基础模板中标记的部分。要开始我们的基础模板,我们需要一个基本的 HTML 骨架如下:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial- scale=1">
  <title>{% block title %}Blog{% endblock %}</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
</head>
<body>
  <div class="container">
    <div class="jumbotron">
      <h1><a href="{{ url_for('home') }}">My Blog</a></h1>
        <p>Welcome to the blog!</p>
    </div>
    {% block body %}
    {% endblock %}
  </div>

  <script src="img/jquery.min.js') }}">></script>
  <script src="img/bootstrap.min.js') }}">></script>
</body>
</html>

将其保存为base.html在你的templates目录中。block控制块在继承中用于标记可以由子模板替换的部分。因为我们将在几个不同的页面中使用分页,让我们创建一个宏来渲染一个分页小部件:

{% macro render_pagination(pagination, endpoint) %}
  <nav>
    <ul class="pagination">
      <li>
        <a href="{{ url_for('home', page=pagination.prev().page) }}" aria-label="Previous">
          <span aria-hidden="true">&laquo;</span>
        </a>
      </li>
      {% for page in pagination.iter_pages() %}
        {% if page %}
          {% if page != pagination.page %}
            <li>
              <a href="{{ url_for(endpoint, page=page) }}">
                {{ page }}
              </a>
            </li>
          {% else %}
            <li><a href="">{{ page }}</a></li>
          {% endif %}
        {% else %}
          <li><a>…</a><li>
        {% endif %}
      {% endfor %}
      <li>
        <a href="{{ url_for('home', page=pagination.next().page) }}" aria-label="Next">
          <span aria-hidden="true">&raquo;</span>
        </a>
      </li>
    </ul>
  </nav>
{% endmacro %}

这个宏接受一个 Flask SQLAlchemy 分页对象和一个视图函数名称,并构建一个 Bootstrap 页面链接列表。将其添加到base.html的顶部,以便所有从中继承的页面都可以访问它。

主页模板

要继承一个模板,使用extends控制块:

{% extends "base.html" %}
{% block title %}Home{% endblock %}

这个模板将使用所有 HTML base.html,但替换title块中的数据。如果我们不声明一个title块,base.html中的内容将保持不变。将此模板保存为index.html。现在我们可以看到它的效果。在浏览器中打开http://127.0.0.1:5000/,你应该会看到以下内容:

主页模板

在这一点上,如果你有代表性的假数据,开发和模拟 UI 会更容易。因为我们只有两篇文章,手动从命令行添加大量模型是繁琐的(我们将在第十章中解决这个问题,有用的 Flask 扩展),让我们使用以下脚本添加 100 个示例文章:

import random
import datetime

user = User.query.get(1)

tag_one = Tag('Python')
tag_two = Tag('Flask')
tag_three = Tag('SQLAlechemy')
tag_four = Tag('Jinja')
tag_list = [tag_one, tag_two, tag_three, tag_four]

s = "Example text"

for i in xrange(100):
    new_post = Post("Post " + str(i))
    new_post.user = user
    new_post.publish_date = datetime.datetime.now()
    new_post.text = s
    new_post.tags = random.sample(tag_list, random.randint(1, 3))
    db.session.add(new_post)

db.session.commit()

这个脚本是一个简单的循环,设置一个新文章的所有属性,并随机确定文章的标签。现在,为了认真地开发我们的模板,我们将从主页开始添加以下内容:博客文章的摘要和链接,最近的博客文章,以及最常用的标签。

现在,让我们将内容添加到home.html中:

{% block body %}
<div class="row">
  <div class="col-lg-9">
    {% for post in posts.items %}
    <div class="row">
      <div class="col-lg-12">
        <h1>{{ post.title }}</h1>
      </div>
    </div>
    <div class="row">
      <div class="col-lg-12">
        {{ post.text | truncate(255) | safe }}
        <a href="{{
          url_for('posts', post_id=post.id)
          }}">Read More</a>
      </div>
    </div>
    {% endfor %}
  </div>
  <div class="col-lg-3">
    <div class="row">
      <h5>Recent Posts</h5>
      <ul>
        {% for post in recent %}
        <li><a href="{{
          url_for('post', post_id=post.id)
          }}">{{ post.title }}</a></li>
        {% endfor %}
      </ul>
    </div>
    <div class="row">
      <h5>Popular Tags</h5>
      <ul>
        {% for tag in top_tags %}
        <li><a href="{{ url_for('tag', tag_name=tag[0].title) }}">{{ tag[0].title }}</a></li>
        {% endfor %}
      </ul>
    </div>
  </div>
</div>
{% endblock %}

所有其他页面将采用这种中间内容一般形式,侧边栏链接到热门内容。

编写其他模板

现在你已经了解了继承的各个方面,也知道了哪些数据将会放到每个模板中,我将提出与上一节相同的挑战。尝试编写剩余模板的内容部分。完成后,你应该能够自由地浏览你的博客,点击文章并查看用户页面。在本章中还有一个最后的功能要添加——读者添加评论的能力。

Flask WTForms

在应用程序中添加表单似乎是一项简单的任务,但当您开始编写服务器端代码时,随着表单变得更加复杂,验证用户输入的任务变得越来越大。安全性至关重要,因为数据来自不可信任的来源,并将被输入到数据库中。WTForms是一个库,通过检查输入与常见表单类型进行验证,来处理服务器端表单验证。Flask WTForms 是在 WTForms 之上的 Flask 扩展,它添加了功能,如 Jinja HTML 渲染,并保护您免受SQL 注入跨站请求伪造等攻击。要安装 Flask WTForms 和 WTForms,我们有:

$ pip install Flask-WTF

注意

保护自己免受 SQL 注入和跨站请求伪造是非常重要的,因为这些是您的网站将接收到的最常见的攻击形式。要了解更多关于这些攻击的信息,请访问en.wikipedia.org/wiki/SQL_injectionen.wikipedia.org/wiki/Cross-site_request_forgery分别了解 SQL 注入和跨站请求伪造。

为了使 Flask WTForms 的安全措施正常工作,我们需要一个秘钥。秘钥是一个随机的字符串,将用于对需要进行真实性测试的任何内容进行加密签名。这不能是任何字符串;它必须是随机的,以避免削弱安全保护的强度。要生成一个随机字符串,请在 Bash 中输入以下内容:

$ cat /dev/urandom | tr -cd 'a-f0-9' | head -c 32

如果您使用 Mac,请输入以下内容:

cat /dev/urandom | env LC_CTYPE=C tr -cd 'a-f0-9' | head -c 32

Config对象的config.py中添加输出:

class Config(object):
    SECRET_KEY = 'Your key here'

WTForms 基础

WTForms 有三个主要部分——表单字段验证器。字段是输入字段的表示,并进行基本的类型检查,验证器是附加到字段的函数,确保表单中提交的数据在我们的约束范围内。表单是一个包含字段和验证器的类,并在POST请求时对自身进行验证。让我们看看这个过程,以便更好地理解。在main.py文件中添加以下内容:

from flask_wtf import Form
from wtforms import StringField, TextAreaField
from wtforms.validators import DataRequired, Length
…
class CommentForm(Form):
    name = StringField(
        'Name',
        validators=[DataRequired(), Length(max=255)]
    )
    text = TextAreaField(u'Comment', validators=[DataRequired()])

这里我们有一个从 Flask WTForm 的Form对象继承的类,并使用等于 WTForm 字段的类变量定义输入。字段接受一个可选参数validators,这是一个将应用于我们数据的 WTForm 验证器列表。最常用的字段有:

  • fields.DateField

这代表了一个 Python Date对象,并接受一个可选参数格式,该格式采用stftime格式字符串来翻译数据。

  • fields.IntegerField

这尝试将传递的数据强制转换为整数,并在模板中呈现为数字输入。

  • fields.FloatField

这尝试将传递的数据强制转换为浮点数,并在模板中呈现为数字输入。

  • fields.RadioField

这代表了一组单选输入,并接受一个choices参数,即一个元组列表,作为显示值和返回值。

  • fields.SelectField

SelectMultipleField一起,它代表一组单选输入。接受一个choices参数,即一个元组列表,作为显示值和返回值。

  • fields.StringField

这代表了一个普通的文本输入,并将尝试将返回的数据强制转换为字符串。

注意

有关验证器和字段的完整列表,请访问 WTForms 文档wtforms.readthedocs.org

最常用的验证器如下:

  • validators.DataRequired()

  • validators.Email()

  • validators.Length(min=-1, max=-1)

  • validators.NumberRange(min=None, max=None)

  • validators.Optional()

  • validators.Regexp(regex)

  • validators.URL()

这些验证都遵循 Python 的命名方案。因此,它们对于它们的功能是相当直接的。所有验证器都接受一个名为message的可选参数,这是验证器失败时将返回的错误消息。如果未设置消息,则使用相同的默认值。

自定义验证器

编写自定义验证函数非常简单。所需的只是编写一个函数,该函数以form对象和field对象作为参数,并在数据未通过测试时引发 WTForm.ValidationError。以下是一个自定义电子邮件验证器的示例:

import re
import wtforms
def custom_email(form, field):
  if not re.match(r"[^@]+@[^@]+\.[^@]+", field.data):
    raise wtforms.ValidationError('Field must be a valid email address.')

要使用此函数,只需将其添加到字段的验证器列表中。

发布评论

现在我们有了评论表单,并且了解了如何构建它,我们需要将其添加到我们的帖子视图的开头:

@app.route('/post/<int:post_id>', methods=('GET', 'POST'))
def post(post_id):
form = CommentForm()
if form.validate_on_submit():
        new_comment = Comment()
    new_comment.name = form.name.data
    new_comment.text = form.text.data
    new_comment.post_id = post_id
    new_comment.date = datetime.datetime.now()

    db.session.add(new_comment)
    db.session.commit()
    post = Post.query.get_or_404(post_id)
    tags = post.tags
    comments = post.comments.order_by(Comment.date.desc()).all()
    recent, top_tags = sidebar_data()

    return render_template(
        'post.html',
        post=post,
        tags=tags,
        comments=comments,
        recent=recent,
        top_tags=top_tags,
        form=form
    )

首先,我们将POST方法添加到视图的允许方法列表中。然后,创建一个新的表单对象实例。然后,“validate_on_submit()”方法检查 Flask 请求是否为POST请求。如果是POST请求,则将请求表单数据发送到表单对象。如果数据经过验证,那么“validate_on_submit()”将返回True并将数据添加到form对象中。然后,我们从每个字段中获取数据,填充一个新的评论,并将其添加到数据库中。最后,我们将表单添加到要发送到模板的变量中,以便将表单添加到我们的post.html文件中:

<div class="col-lg-12">
  <h3>New Comment:</h3>
  <form method="POST" action="{{ url_for('post', post_id=post.id) }}">
    {{ form.hidden_tag() }}
    <div class="form-group">
      {{ form.name.label }}
      {% if form.name.errors %}
        {% for e in form.name.errors %}
          <p class="help-block">{{ e }}</p>
        {% endfor %}
      {% endif %}
      {{ form.name(class_='form-control') }}
    </div>
    <div class="form-group">
      {{ form.text.label }}
      {% if form.text.errors %}
        {% for e in form.text.errors %}
          <p class="help-block">{{ e }}</p>
        {% endfor %}
      {% endif %}
      {{ form.text(class_='form-control') }}
    </div>
    <input class="btn btn-primary" type="submit" value="Add Comment">
  </form>
</div>

这里发生了几件新事情。首先,“form.hidden_tag()”方法会自动添加一个反跨站请求伪造措施。其次,field.errors列表用于呈现我们的验证器在验证失败时发送的任何消息。第三,调用字段本身作为方法将呈现该字段的 HTML 代码。最后,调用field.label将自动为我们的输入创建一个 HTML 标签。现在,向字段添加信息并按下提交按钮应该会添加您的评论!

这将看起来像以下的屏幕截图:

发布评论

读者的最后一个挑战是制作一个宏,该宏接受一个form对象和一个要发送POST请求的端点,并自动生成整个表单标记的 HTML。如果遇到困难,请参考 WTForms 文档。这有点棘手,但不是太难。

摘要

现在,仅仅三章之后,您已经拥有了一个完全功能的博客。这是很多关于 Web 开发技术的书籍会结束的地方。然而,还有 10 章要去将您的实用博客转变为用户实际用于其网站的东西。在下一章中,我们将专注于构建 Flask 应用程序以适应长期开发和更大规模的项目。

第四章:使用蓝图创建控制器

模型视图控制器MVC)方程的最后一部分是控制器。我们已经在main.py文件中看到了视图函数的基本用法。现在,我们将介绍更复杂和强大的版本,并将我们零散的视图函数转化为统一的整体。我们还将讨论 Flask 如何处理 HTTP 请求的生命周期以及定义 Flask 视图的高级方法。

请求设置、拆卸和应用全局

在某些情况下,需要跨所有视图函数访问特定于请求的变量,并且还需要从模板中访问。为了实现这一点,我们可以使用 Flask 的装饰器函数@app.before_request和对象g。函数@app.before_request在每次发出新请求之前执行。Flask 对象g是每个特定请求需要保留的任何数据的线程安全存储。在请求结束时,对象被销毁,并在新请求开始时生成一个新对象。例如,以下代码检查 Flask session变量是否包含已登录用户的条目;如果存在,它将User对象添加到g中:

from flask import g, session, abort, render_template

@app.before_request
def before_request():
    if ‘user_id’ in session:
        g.user = User.query.get(session[‘user_id’])

@app.route(‘/restricted’)
def admin():
    if g.user is None:
        abort(403)
    return render_template(‘admin.html’)

多个函数可以使用@app.before_request进行装饰,并且它们都将在请求的视图函数执行之前执行。还存在一个名为@app.teardown_request的装饰器,它在每个请求结束后调用。请记住,这种处理用户登录的方法只是一个示例,不安全。推荐的方法在第六章 保护您的应用中有介绍。

错误页面

向最终用户显示浏览器的默认错误页面会让用户失去应用的所有上下文,他们必须点击返回按钮才能返回到您的站点。要在使用 Flask 的abort()函数返回错误时显示自己的模板,可以使用errorhandler装饰器函数:

@app.errorhandler(404)
def page_not_found(error):
    return render_template('page_not_found.html'), 404

errorhandler还可用于将内部服务器错误和 HTTP 500 代码转换为用户友好的错误页面。app.errorhandler()函数可以接受一个或多个 HTTP 状态码,以定义它将处理哪个代码。返回元组而不仅仅是 HTML 字符串允许您定义Response对象的 HTTP 状态代码。默认情况下,这被设置为200

基于类的视图

在大多数 Flask 应用中,视图由函数处理。但是,当许多视图共享公共功能或有代码片段可以拆分为单独的函数时,将视图实现为类以利用继承将非常有用。

例如,如果我们有渲染模板的视图,我们可以创建一个通用的视图类,以保持我们的代码DRY

from flask.views import View

class GenericView(View):
    def __init__(self, template):
        self.template = template
        super(GenericView, self).__init__()

    def dispatch_request(self):
        return render_template(self.template)

app.add_url_rule(
    '/', view_func=GenericView.as_view(
        'home', template='home.html'
    )
)

关于此代码的第一件事是我们视图类中的dispatch_request()函数。这是我们视图中充当普通视图函数并返回 HTML 字符串的函数。app.add_url_rule()函数模仿app.route()函数,因为它将路由与函数调用绑定在一起。第一个参数定义了函数的路由,view_func参数定义了处理路由的函数。View.as_view()方法传递给view_func参数,因为它将View类转换为视图函数。第一个参数定义了视图函数的名称,因此诸如url_for()之类的函数可以路由到它。其余参数传递给View类的__init__函数。

与普通的视图函数一样,除了GET之外的 HTTP 方法必须明确允许View类。要允许其他方法,必须添加一个包含命名方法列表的类变量:

class GenericView(View):
    methods = ['GET', 'POST']
    …
    def dispatch_request(self):
        if request.method == ‘GET’:
            return render_template(self.template)
        elif request.method == ‘POST’:
            …

方法类视图

通常,当函数处理多个 HTTP 方法时,由于大量代码嵌套在if语句中,代码可能变得难以阅读:

@app.route('/user', methods=['GET', 'POST', 'PUT', 'DELETE'])
def users():
    if request.method == 'GET':
        …
    elif request.method == 'POST':
        …
    elif request.method == 'PUT':
        …
    elif request.method == 'DELETE':
        …

这可以通过MethodView类来解决。MethodView允许每个方法由不同的类方法处理以分离关注点:

from flask.views import MethodView

class UserView(MethodView):
    def get(self):
        …
    def post(self):
        …
    def put(self):
        …
    def delete(self):
        …

app.add_url_rule(
    '/user',
    view_func=UserView.as_view('user')
)

蓝图

在 Flask 中,蓝图是扩展现有 Flask 应用程序的一种方法。蓝图提供了一种将具有共同功能的视图组合在一起的方式,并允许开发人员将其应用程序分解为不同的组件。在我们的架构中,蓝图将充当我们的控制器

视图被注册到蓝图中;可以为其定义一个单独的模板和静态文件夹,并且当它具有所有所需的内容时,可以在主 Flask 应用程序上注册蓝图内容。蓝图的行为很像 Flask 应用程序对象,但实际上并不是一个独立的应用程序。这就是 Flask 扩展提供视图函数的方式。为了了解蓝图是什么,这里有一个非常简单的例子:

from flask import Blueprint
example = Blueprint(
    'example',
    __name__,
    template_folder='templates/example',
    static_folder='static/example',
    url_prefix="/example"
)

@example.route('/')
def home():
    return render_template('home.html')

蓝图需要两个必需参数——蓝图的名称和包的名称——这些参数在 Flask 内部使用;将__name__传递给它就足够了。

其他参数是可选的,并定义蓝图将在哪里查找文件。因为指定了templates_folder,蓝图将不会在默认模板文件夹中查找,并且路由将呈现templates/example/home.html而不是templates/home.htmlurl_prefix选项会自动将提供的 URI 添加到蓝图中的每个路由的开头。因此,主页视图的 URL 实际上是/example/

url_for()函数现在必须告知所请求的路由位于哪个蓝图中:

{{ url_for('example.home') }}

此外,url_for()函数现在必须告知视图是否在同一个蓝图中呈现:

{{ url_for('.home') }}

url_for()函数还将在指定的静态文件夹中查找静态文件。

要将蓝图添加到我们的应用程序中:

app.register_blueprint(example)

让我们将我们当前的应用程序转换为使用蓝图的应用程序。我们首先需要在所有路由之前定义我们的蓝图:

blog_blueprint = Blueprint(
    'blog',
    __name__,
    template_folder='templates/blog',
    url_prefix="/blog"
)

现在,因为模板文件夹已经定义,我们需要将所有模板移到模板文件夹的子文件夹中,命名为 blog。接下来,我们所有的路由需要将@app.route改为@blog_blueprint.route,并且任何类视图分配现在需要注册到blog_blueprint。记住,模板中的url_for()函数调用也需要更改为在路由前加上一个句点以指示该路由在同一个蓝图中。

在文件末尾,在if __name__ == '__main__':语句之前,添加以下内容:

app.register_blueprint(blog_blueprint)

现在我们所有的内容都回到了应用程序中,该应用程序在蓝图下注册。因为我们的基本应用程序不再具有任何视图,让我们在基本 URL 上添加一个重定向:

@app.route('/')
def index():
    return redirect(url_for('blog.home'))

为什么是 blog 而不是blog_blueprint?因为 blog 是蓝图的名称,而名称是 Flask 在内部用于路由的。blog_blueprint是 Python 文件中的变量名称。

总结

我们现在的应用程序在一个蓝图中运行,但这给了我们什么?假设我们想要在我们的网站上添加一个照片分享功能;我们可以将所有视图函数分组到一个蓝图中,该蓝图具有自己的模板、静态文件夹和 URL 前缀,而不会担心破坏网站其余部分的功能。在下一章中,通过升级我们的文件和代码结构,蓝图将变得更加强大,通过将它们分离成不同的文件。

第五章:高级应用程序结构

我们的应用程序已经从一个非常简单的例子发展成一个可扩展的基础,可以很容易地构建强大的功能。然而,将整个应用程序代码都放在一个文件中会不必要地使我们的代码混乱。为了使应用程序代码更清晰、更易理解,我们将把整个代码转换为一个 Python 模块,并将代码分割成多个文件。

项目作为一个模块

目前,你的文件夹结构应该是这样的:

webapp/
  config.py
  database.db
  main.py
  manage.py
  env/
  migrations/
    versions/
  static/
    css/
    js/
  templates/
    blog/

为了将我们的代码转换为一个模块,我们的文件将被转换为这个文件夹结构:

webapp/
  manage.py
  database.db
  webapp/
    __init__.py
    config.py
    forms.py
    models.py
    controllers/
      __init__.py
      blog.py
    static/
      css/
      js/
    templates/
      blog/
  migrations/
    versions/

我们将逐步创建这个文件夹结构。要做的第一个改变是在你的应用程序中创建一个包含模块的文件夹。在这个例子中,它将被称为webapp,但可以被称为除了博客以外的任何东西,因为控制器被称为博客。如果有两个要从中导入的博客对象,Python 将无法正确地从父目录中导入blog.py文件中的对象。

接下来,将main.pyconfig.py——静态和模板文件夹,分别移动到你的项目文件夹中,并创建一个控制器文件夹。我们还需要在project文件夹中创建forms.pymodels.py文件,以及在控制器文件夹中创建一个blog.py文件。此外,main.py文件需要重命名为__init__.py

文件名__init__.py看起来很奇怪,但它有一个特定的功能。在 Python 中,通过在文件夹中放置一个名为__init__.py的文件,可以将文件夹标记为模块。这允许程序从文件夹中的 Python 文件中导入对象和变量。

注意

要了解更多关于在模块中组织 Python 代码的信息,请参考官方文档docs.python.org/2/tutorial/modules.html#packages

重构代码

让我们开始将我们的 SQLAlchemy 代码移动到models.py文件中。从__init__.py中剪切所有模型声明、标签表和数据库对象,并将它们与 SQLAlchemy 导入一起复制到models.py文件中。此外,我们的db对象将不再使用app对象作为参数进行初始化,因为models.py文件中没有app对象,导入它将导致循环导入。相反,我们将在初始化模型后将 app 对象添加到db对象中。这将在我们的__init__.py文件中实现。

你的models.py文件现在应该是这样的:

from flask.ext.sqlalchemy import SQLAlchemy

db = SQLAlchemy()

tags = db.Table(
    'post_tags',
    db.Column('post_id', db.Integer, db.ForeignKey('post.id')),
    db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'))
)

class User(db.Model):
    …

class Post(db.Model):
    …

class Comment(db.Model):
    …

class Tag(db.Model):
    …

接下来,CommentForm对象以及所有 WTForms 导入都应该移动到forms.py文件中。forms.py文件将保存所有 WTForms 对象在它们自己的文件中。

forms.py文件应该是这样的:

from flask_wtf import Form
from wtforms import StringField, TextAreaField
from wtforms.validators import DataRequired, Length

class CommentForm(Form):
    …

blog_blueprint数据函数、它的所有路由以及sidebar_data数据函数需要移动到控制器文件夹中的blog.py文件中。

blog.py文件现在应该是这样的:

import datetime
from os import path
from sqlalchemy import func
from flask import render_template, Blueprint

from webapp.models import db, Post, Tag, Comment, User, tags
from webapp.forms import CommentForm

blog_blueprint = Blueprint(
    'blog',
    __name__,
    template_folder=path.join(path.pardir, 'templates', 'blog')
    url_prefix="/blog"
)

def sidebar_data():
    …

现在,每当创建一个新的蓝图时,可以在控制器文件夹中为其创建一个新的文件,将应用程序代码分解为逻辑组。此外,我们需要在控制器文件夹中创建一个空的__init__.py文件,以便将其标记为模块。

最后,我们专注于我们的__init__.py文件。__init__.py文件中应该保留的内容只有app对象的创建、index路由和blog_blueprintapp对象上的注册。然而,还有一件事要添加——数据库初始化。通过db.init_app()函数,我们将在导入app对象后将app对象添加到db对象中:

from flask import Flask, redirect, url_for
from config import DevConfig

from models import db
from controllers.blog import blog_blueprint

app = Flask(__name__)
app.config.from_object(DevConfig)

db.init_app(app)

@app.route('/')
def index():
    return redirect(url_for('blog.home'))

app.register_blueprint(blog_blueprint)

if __name__ == '__main__':
    app.run()

在我们的新结构生效之前,有两件最后需要修复的事情,如果你使用的是 SQLite——config.py中的 SQLAlchemy 数据库 URL 需要更新,以及manage.py中的导入需要更新。因为 SQLite 数据库的 SQLAlchemy URL 是一个相对文件路径,所以它必须更改为:

from os import path

class DevConfig(object):
    SQLALCHEMY_DATABASE_URI = 'sqlite://' + path.join(
        path.pardir,
        'database.db'
    )

要修复manage.py的导入,用以下内容替换main.py中的导入:

from webapp import app
from webapp.models import db, User, Post, Tag, Comment

现在,如果你运行manage.py文件,你的应用将以新的结构运行。

应用工厂

现在我们以模块化的方式使用蓝图,然而,我们可以对我们的抽象进行另一个改进,即为我们的应用创建一个工厂。工厂的概念来自面向对象编程OOP)世界,它简单地意味着一个函数或对象创建另一个对象。我们的应用工厂将接受我们在书的开头创建的config对象之一,并返回一个 Flask 应用对象。

注意

对象工厂设计是由现在著名的《设计模式:可复用面向对象软件的元素》一书所推广的。要了解更多关于这些设计模式以及它们如何帮助简化项目代码的信息,请查看en.wikipedia.org/wiki/Structural_pattern

为我们的应用对象创建一个工厂函数有几个好处。首先,它允许环境的上下文改变应用的配置。当服务器创建应用对象进行服务时,它可以考虑服务器中任何必要的更改,并相应地改变提供给应用的配置对象。其次,它使测试变得更加容易,因为它允许快速测试不同配置的应用。第三,可以非常容易地创建使用相同配置的同一应用的多个实例。这对于需要在多个不同的服务器之间平衡网站流量的情况非常有用。

现在应用工厂的好处已经清楚,让我们修改我们的__init__.py文件来实现它:

from flask import Flask, redirect, url_for
from models import db
from controllers.blog import blog_blueprint

def create_app(object_name):
    app = Flask(__name__)
    app.config.from_object(object_name)

    db.init_app(app)

    @app.route('/')
    def index():
        return redirect(url_for('blog.home'))

    app.register_blueprint(blog_blueprint)

    return app

对文件的更改非常简单;我们将代码包含在一个函数中,该函数接受一个config对象并返回一个应用对象。我们需要修改我们的manage.py文件,以便与create_app函数一起工作,如下所示:

import os
from flask.ext.script import Manager, Server
from flask.ext.migrate import Migrate, MigrateCommand
from webapp import create_app
from webapp.models import db, User, Post, Tag, Comment

# default to dev config
env = os.environ.get('WEBAPP_ENV', 'dev')
app = create_app('webapp.config.%sConfig' % env.capitalize())
…
manager = Manager(app)
manager.add_command("server", Server())

当我们创建配置对象时,提到了应用运行的环境可能会改变应用的配置。这段代码有一个非常简单的例子,展示了环境变量的功能,其中加载了一个环境变量,并确定要给create_app函数的config对象。环境变量是 Bash 中的全局变量,可以被许多不同的程序访问。它们可以用以下语法在 Bash 中设置:

$ export WEBAPP_ENV="dev"

读取变量时:

$ echo $WEBAPP_ENV
dev

您也可以按以下方式轻松删除变量:

$ unset $WEBAPP_ENV
$ echo $WEBAPP_ENV

在生产服务器上,您将把WEBAPP_ENV设置为prod。一旦在第十三章 部署 Flask 应用中部署到生产环境,并且当我们到达第十二章 测试 Flask 应用时,即可清楚地看到这种设置的真正威力,该章节涵盖了对项目进行测试。

总结

我们已经将我们的应用转变为一个更加可管理和可扩展的结构,这将在我们继续阅读本书并添加更多高级功能时为我们节省许多麻烦。在下一章中,我们将为我们的应用添加登录和注册系统,以及其他功能,使我们的网站更加安全。

第六章:保护您的应用程序

我们有一个大部分功能正常的博客应用,但缺少一些关键功能,比如用户登录、注册以及从浏览器添加和编辑帖子。用户登录功能可以通过许多不同的方式创建,因此每个部分演示了创建登录的互斥方法。第一种方法是直接使用浏览器的 cookies,第二种方法是使用名为Flask Login的 Flask 扩展。

设置

在我们立即开始创建用户认证系统之前,需要进行大量的设置代码。为了运行任何类型的认证,我们的应用程序将需要以下所有常见的元素:

  • 首先,用户模型将需要适当的密码哈希

  • 其次,需要登录表单和注册表单来验证用户输入

  • 其次,需要登录视图和注册视图以及每个视图的模板

  • 其次,需要设置各种社交登录,以便在实施登录系统时将它们与登录系统绑定

更新模型

直到现在,我们的用户的密码以明文形式存储在数据库中。这是一个重大的安全漏洞。如果任何恶意用户能够访问数据库中的数据,他们可以登录到任何账户。这样的违规行为的后果将比我们的网站更大。互联网上有很多人在许多网站上使用相同的密码。

如果攻击者能够获得电子邮件和密码的组合,很可能可以使用这些信息登录到 Facebook 账户甚至银行账户。

为了保护我们用户的密码,它们将使用一种名为哈希算法的单向加密方法进行加密。单向加密意味着在信息加密后,无法从结果中恢复原始信息。然而,对于相同的数据,哈希算法将始终产生相同的结果。提供给哈希算法的数据可以是从文本文件到电影文件的任何内容。在这种情况下,数据只是一串字符。有了这个功能,我们的密码可以被存储为哈希值(已经被哈希过的数据)。然后,当用户在登录或注册页面输入他们的密码时,输入的文本密码将通过相同的哈希算法发送,然后验证存储的哈希和输入的哈希是否匹配。

有许多哈希算法,其中大多数都不安全,因为它们很容易被暴力破解。黑客不断尝试将数据通过哈希算法,直到有匹配的数据。为了最好地保护用户密码,bcrypt 将是我们选择的哈希算法。Bcrypt被特意设计成对计算机来说是低效和慢的(毫秒级对比微秒级),从而使其更难以被暴力破解。要将 bcrypt 添加到我们的项目中,需要安装Flask Bcrypt包,方法如下:

$ pip install Flask-Bcrypt

这是第二个将在app对象上初始化的 Flask 扩展,另一个是 SQLAlchemy 对象。db对象存储在models.py文件中,但没有明显的地方来初始化 Flask Bcrypt。为了保存所有未来的扩展,需要在与__init__.py文件相同的目录中添加名为extensions.py的文件。在其中,需要初始化 Flask Bcrypt:

from flask.ext.bcrypt import Bcrypt
bcrypt = Bcrypt()

然后将其添加到app对象中:

from webapp.extensions import bcrypt

def create_app(object_name):
    app = Flask(__name__)
    app.config.from_object(object_name)

    db.init_app(app)
    bcrypt.init_app(app)

Bcrypt 现在已经准备好使用。为了让我们的User对象使用 bcrypt,我们将添加两个方法来设置密码并检查字符串是否与存储的哈希匹配:

from webapp.extensions import bcrypt

class User(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    username = db.Column(db.String(255))
    password = db.Column(db.String(255))
    posts = db.relationship(
        'Post',
        backref='user',
        lazy='dynamic'
    )

    def __init__(self, username):
        self.username = username

    def __repr__(self):
        return '<User {}>'.format(self.username)

    def set_password(self, password):
        self.password = bcrypt.generate_password_hash(password)

    def check_password(self, password):
        return bcrypt.check_password_hash(self.password, password)

现在,我们的User模型可以安全地存储密码。接下来,我们的登录过程需要使用这些方法来创建新用户和检查密码。

创建表单

需要三种表单:登录表单、注册表单和发布创建页面的表单。登录表单将包含用户名和密码字段:

from wtforms import (
    StringField,
    TextAreaField,
    PasswordField,
    BooleanField
)
from wtforms.validators import DataRequired, Length, EqualTo, URL

class LoginForm(Form):
    username = StringField('Username', [
        DataRequired(), Length(max=255)
    ])
    password = PasswordField('Password', [DataRequired()])

   def validate(self):
        check_validate = super(LoginForm, self).validate()

        # if our validators do not pass
        if not check_validate:
            return False

        # Does our the exist
        user = User.query.filter_by(
           username=self.username.data
        ).first()
        if not user:
            self.username.errors.append(
                'Invalid username or password'
            )
            return False

        # Do the passwords match
        if not self.user.check_password(self.password.data):
            self.username.errors.append(
                'Invalid username or password'
            )
            return False

        return True

除了正常的验证外,我们的LoginForm方法还将检查传递的用户名是否存在,并使用check_password()方法来检查哈希值。

使用 reCAPTCHA 保护您的表单免受垃圾邮件攻击

注册表单将包含用户名字段、带有确认字段的密码字段和名为 reCAPTCHA 字段的特殊字段。CAPTCHA 是 Web 表单上的一个特殊字段,用于检查输入表单数据的人是否真的是一个人,还是一个正在向您的站点发送垃圾邮件的自动化程序。reCAPTCHA 只是 CAPTCHA 的一种实现。reCAPTCHA 已经集成到 WTForms 中,因为它是 Web 上最流行的实现。

要使用 reCAPTCHA,您需要从www.google.com/recaptcha/intro/index.html获取 reCAPTCHA 登录。由于 reCAPTCHA 是 Google 产品,您可以使用 Google 账户登录。

登录后,它将要求您添加一个站点。在这种情况下,任何名称都可以,但域字段必须包含localhost。一旦部署您的站点,您的域也必须添加到此列表中。

现在您已经添加了一个站点,下拉菜单中将显示有关服务器和客户端集成的说明。当我们创建登录和注册视图时,给定的script标签将需要添加到我们的模板中。WTForms 需要从此页面获取的是如下截图中显示的密钥:

使用 reCAPTCHA 保护您的表单免受垃圾邮件攻击

记住永远不要向公众展示这些密钥。由于这些密钥仅注册给localhost,因此可以在此处显示而不会受到影响。

将这些密钥添加到config.py文件中的config对象中,以便 WTForms 可以访问它们,如下所示:

class Config(object):
    SECRET_KEY = 'Key Here'
    RECAPTCHA_PUBLIC_KEY = 
"6LdKkQQTAAAAAEH0GFj7NLg5tGicaoOus7G9Q5Uw"
    RECAPTCHA_PRIVATE_KEY =
'6LdKkQQTAAAAAMYroksPTJ7pWhobYb88fTAcxcYn'

以下是我们的注册表单:

class RegisterForm(Form):
    username = StringField('Username', [
        DataRequired(),
        Length(max=255)
    ])
    password = PasswordField('Password', [
        DataRequired(),
        Length(min=8)
    ])
    confirm = PasswordField('Confirm Password', [
        DataRequired(),
        EqualTo('password')
    ])
    recaptcha = RecaptchaField()

    def validate(self):
        check_validate = super(RegisterForm, self).validate()

        # if our validators do not pass
        if not check_validate:
            return False

        user = User.query.filter_by(
            username=self.username.data
        ).first()

        # Is the username already being used
        if user:
            self.username.errors.append(
                "User with that name already exists"
            )
            return False

        return True

帖子创建表单将只包含标题的文本输入和帖子内容的文本区域输入:

class PostForm(Form):
    title = StringField('Title', [
        DataRequired(), 
        Length(max=255)
    ])
    text = TextAreaField('Content', [DataRequired()])

创建视图

在上一章中,包含重定向到博客主页的索引视图存储在create_app函数中。这对于一个视图来说是可以的。现在,本节将在站点的基本 URL 上添加许多视图。因此,我们需要在controllers/main.py中添加一个新的控制器:

main_blueprint = Blueprint(
    'main',
    __name__,
    template_folder='../templates/main'
)

@main_blueprint.route('/')
def index():
    return redirect(url_for('blog.home'))

登录和注册视图将创建我们的表单对象并将它们传递给模板。目前,如果传递的数据验证通过,登录表单将不执行任何操作。实际的登录功能将在下一节中添加。但是,如果数据通过验证,注册视图将创建一个新用户。除了登录和注册视图之外,还需要一个注销视图,目前也不会执行任何操作。

main.py控制器中,添加以下内容:

from webapp.forms import LoginForm, RegisterForm

@main_blueprint.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()

    if form.validate_on_submit():
        flash("You have been logged in.", category="success") 
        return redirect(url_for('blog.home'))

    return render_template('login.html', form=form)

@main_blueprint.route('/logout', methods=['GET', 'POST'])
def logout():
    flash("You have been logged out.", category="success")
    return redirect(url_for('.home'))

@main_blueprint.route('/register', methods=['GET', 'POST'])
def register():
    form = RegisterForm()

    if form.validate_on_submit():
        new_user = User()
        new_user.username = form.username.data
        new_user.set_password(form.username.data)

        db.session.add(new_user)
        db.session.commit()

        flash(
            "Your user has been created, please login.", 
            category="success"
        )

           return redirect(url_for('.login'))

    return render_template('register.html', form=form)

在前面的代码中使用的login.htmlregister.html模板(放置在templates/main文件夹中)可以使用第三章中创建的form宏来创建,但是 reCAPTCHA 的script标签尚不能添加到register.html中。

首先,我们的子模板需要一种方法来向base.html模板添加新的 JavaScript 文件。还需要一种方法让我们的视图使用 Flask 的flash函数向用户闪现消息。在base.html文件中还需要添加一个新的内容块以及对消息的循环:

<body>
  <div class="container">
    <div class="jumbotron">
      <h1><a href="{{ url_for('blog.home') }}">My Blog</a></h1>
      <p>Welcome to the blog!</p>
    </div>
    {% with messages = get_flashed_messages(with_categories=true) %}
      {% if messages %}
        {% for category, message in messages %}
           <div class="alert alert-{{ category }} alert-dismissible" 
             role="alert">
           <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>

           {{ message }}
          </div>
        {% endfor %}
      {% endif %}
    {% endwith %}
    {% block body %}
    {% endblock %}
  </div>
  <script 
    src="img/jquery.min.js"> 
    </script>
  <script 
    src="img/bootstrap.min.js"> 
    </script>
  {% block js %}
  {% endblock %}
</body>

您的登录页面现在应该类似于以下内容:

创建视图

您的注册页面应该如下所示:

创建视图

现在我们需要创建帖子创建和编辑页面,以便可以进行安全保护。这两个页面将需要将文本区域字段转换为所见即所得WYSIWYG)编辑器,以处理将帖子文本包装在 HTML 中。在blog.py控制器中,添加以下视图:

from webapp.forms import CommentForm, PostForm

@blog_blueprint.route('/new', methods=['GET', 'POST'])
def new_post():
    form = PostForm()

    if form.validate_on_submit():
        new_post = Post(form.title.data)
        new_post.text = form.text.data
        new_post.publish_date = datetime.datetime.now() 

        db.session.add(new_post)
        db.session.commit()

    return render_template('new.html', form=form)

@blog_blueprint.route('/edit/<int:id>', methods=['GET', 'POST'])
def edit_post(id):

    post = Post.query.get_or_404(id)
    form = PostForm()

    if form.validate_on_submit():
        post.title = form.title.data
        post.text = form.text.data
        post.publish_date = datetime.datetime.now()

        db.session.add(post)
        db.session.commit()

        return redirect(url_for('.post', post_id=post.id))

    form.text.data = post.text

    return render_template('edit.html', form=form, post=post)

这个功能与用于添加新评论的代码非常相似。文本字段的数据在视图中设置,因为没有简单的方法在模板中设置TextAreaField的内容。

new.html 模板将需要一个用于所见即所得编辑器的 JavaScript 文件。CKEditor 安装和使用非常简单。现在,我们的 new.html 文件可以按以下方式创建:

{% extends "base.html" %}
{% block title %}Post Creation{% endblock %}
{% block body %}
<div class="row">
  <h1 class="text-center">Create A New Post</h1>
  <form method="POST" action="{{ url_for('.new_post') }}">
    {{ form.hidden_tag() }}
    <div class="form-group">
      {{ form.title.label }}
      {% if form.title.errors %}
        {% for e in form.title.errors %}
          <p class="help-block">{{ e }}</p>
        {% endfor %}
      {% endif %}
      {{ form.title(class_='form-control') }}
    </div>
    <div class="form-group">
      {{ form.text.label }}
      {% if form.text.errors %}
        {% for e in form.text.errors %}
          <p class="help-block">{{ e }}</p>
        {% endfor %}
      {% endif %}
      {{ form.text(id="editor", class_='form-control') }}
    </div>
    <input class="btn btn-primary" type="submit" value="Submit">
  </form>
</div>
{% endblock %}

{% block js %}
<script src="img/ckeditor.js"></script>
<script>
    CKEDITOR.replace('editor');
</script>
{% endblock %}

这就是将用户输入存储为 HTML 在数据库中所需的全部内容。因为我们在帖子模板中传递了安全过滤器,所以 HTML 代码在我们的帖子页面上显示正确。edit.html 模板类似于 new.html 模板。唯一的区别是 form 开放标签和创建 title 字段:

<form method="POST" action="{{ url_for('.edit_post', id=post.id) }}">
…
{{ form.title(class_='form-control', value=post.title) }}
…
</form>

post.html 模板将需要一个按钮,以将作者链接到编辑页面:

<div class="row">
  <div class="col-lg-6">
    <p>Written By <a href="{{ url_for('.user', username=post.user.username) 
      }}">{{ post.user.username }}</a> on {{ post.publish_date }}</p>
  </div>
  …
  <div class="row">
    <div class="col-lg-2">
    <a href="{{ url_for('.edit_post', id=post.id) }}" class="btn btn- 
      primary">Edit</a>
  </div>
</div>

当我们能够检测到当前用户时,编辑按钮将只显示给创建帖子的用户。

社交登录

随着时间的推移,将替代登录和注册选项集成到您的网站变得越来越重要。每个月都会有另一个公告称密码已从热门网站中被盗。实现以下登录选项意味着我们网站的数据库永远不会为该用户存储密码。

验证由一个大型品牌公司处理,用户已经对其信任。通过使用社交登录,用户对其所使用的网站的信任程度要低得多。您的登录流程也变得更短,降低了用户使用您的应用的门槛。

社交认证用户表现为普通用户,与基于密码的登录方法不同,它们可以同时使用。

OpenID

OpenID 是一种开放协议,允许在一个站点上的用户由实现该协议的任何第三方站点进行身份验证,这些站点被称为 Relaying Parties (RPs)。OpenID 登录表示为来自其中一个 RP 的 URL,通常是网站的个人资料页面。

注意

要了解使用 OpenID 的所有网站列表以及如何使用每个网站,转到 openid.net/get-an-openid/

要将 OpenID 添加到 Flask,需要一个名为 Flask-OpenID 的 Flask 扩展:

$ pip install Flask-OpenID

我们的应用程序将需要一些东西来实现 OpenID:

  • 一个新的表单对象

  • 登录和注册页面的表单验证

  • 表单提交后的回调以登录用户或创建新用户

extensions.py 文件中,可以按以下方式初始化 OpenID 对象:

from flask.ext.bcrypt import Bcrypt
from flask.ext.openid import OpenID
bcrypt = Bcrypt()
oid = OpenID()

__init__.py 文件中,将 oid 对象注册到 app 对象:

from .models import db

def create_app(object_name):
    app = Flask(__name__)
    app.config.from_object(object_name)

    db.init_app(app)
    bcrypt.init_app(app)
    oid.init_app(app)

新的 form 对象只需要 RP 的 URL:

from wtforms.validators import DataRequired, Length, EqualTo, URL

class OpenIDForm(Form):
    openid = StringField('OpenID URL', [DataRequired(), URL()])

在登录和注册视图上,将初始化 OpenIDForm(),如果数据有效,将发送登录请求:

from webapp.extensions import oid
…

@main_blueprint.route('/login', methods=['GET', 'POST'])
@oid.loginhandler
def login():
    form = LoginForm()
    openid_form = OpenIDForm()

    if openid_form.validate_on_submit():
        return oid.try_login(
            openid_form.openid.data,
            ask_for=['nickname', 'email'],
            ask_for_optional=['fullname']
        )

    if form.validate_on_submit():
        flash("You have been logged in.", category="success")
        return redirect(url_for('blog.home'))

    openid_errors = oid.fetch_error()
    if openid_errors:
        flash(openid_errors, category="danger")

    return render_template(
       'login.html',
       form=form,
       openid_form=openid_form
    )

@main_blueprint.route('/register', methods=['GET', 'POST'])
@oid.loginhandler
def register():
    form = RegisterForm()
    openid_form = OpenIDForm()

    if openid_form.validate_on_submit():
        return oid.try_login(
            openid_form.openid.data,
            ask_for=['nickname', 'email'],
            ask_for_optional=['fullname']
        )

    if form.validate_on_submit():
        new_user = User(form.username.data)
        new_user.set_password(form.password.data)

        db.session.add(new_user)
        db.session.commit()

        flash(
            "Your user has been created, please login.", 
            category="success"
        )

        return redirect(url_for('.login'))

    openid_errors = oid.fetch_error()
    if openid_errors:
        flash(openid_errors, category="danger")

    return render_template(
        'register.html',
        form=form,
        openid_form=openid_form
    )

两个视图都有新的装饰器 @oid.loginhandler,告诉 Flask-OpenID 监听来自 RP 的身份验证信息。使用 OpenID,登录和注册是相同的。可以从登录表单创建用户,也可以从注册表单登录。两个页面上都出现相同的字段,以避免用户混淆。

要处理用户创建和登录,需要在 extensions.py 文件中创建一个新函数:

@oid.after_login
def create_or_login(resp):
    from models import db, User
    username = resp.fullname or resp.nickname or resp.email
    if not username:
        flash('Invalid login. Please try again.', 'danger')
        return redirect(url_for('main.login'))

    user = User.query.filter_by(username=username).first()
    if user is None:
        user = User(username)
        db.session.add(user)
        db.session.commit()

    # Log the user in here
    return redirect(url_for('blog.home'))

每次从 RP 收到成功响应后都会调用此函数。如果登录成功并且不存在与该身份对应的用户对象,则此函数将创建一个新的 User 对象。如果已经存在,则即将到来的身份验证方法将登录用户。OpenID 不需要返回所有可能的信息,因此可能只会返回电子邮件而不是全名。这就是为什么用户名可以是昵称、全名或电子邮件的原因。在函数内导入 dbUser 对象,以避免从导入 bcrypt 对象的 models.py 文件中导入循环导入。

Facebook

要使用 Facebook 登录,以及后来的 Twitter,使用名为 OAuth 的协议。我们的应用程序不会直接使用 OAuth,而是将使用另一个名为 Flask OAuth 的 Flask 扩展:

$ pip install Flask-OAuth

使用 Facebook 登录,我们的应用程序需要使用我们应用程序的密钥定义一个 Facebook OAuth 对象。定义一个视图,将用户重定向到 Facebook 服务器上的登录授权过程,并在 Facebook 方法上定义一个函数,从登录过程中加载auth令牌。

首先,需要在developers.facebook.com创建一个 Facebook 应用。创建新应用后,查找列出应用程序 ID 和密钥的面板。

Facebook

extensions.py中添加以下代码时使用这些值:

from flask_oauth import OAuth

bcrypt = Bcrypt()
oid = OpenID()
oauth = OAuth()

…

facebook = oauth.remote_app(
    'facebook',
    base_url='https://graph.facebook.com/',
    request_token_url=None,
    access_token_url='/oauth/access_token',
    authorize_url='https://www.facebook.com/dialog/oauth',
    consumer_key=' FACEBOOK_APP_ID',
    consumer_secret=' FACEBOOK_APP_SECRET',
    request_token_params={'scope': 'email'}
)
@facebook.tokengetter
def get_facebook_oauth_token():
    return session.get('facebook_oauth_token')

在 Facebook 开发者界面中,请确保添加新的授权网站为http://localhost:5000/,否则登录将无法工作。在main.py控制器中,添加以下代码:

from webapp.extensions import oid, facebook
…

@main_blueprint.route('/facebook')
def facebook_login():
    return facebook.authorize(
        callback=url_for(
            '.facebook_authorized',
            next=request.referrer or None,
            _external=True
        )
    )

@main_blueprint.route('/facebook/authorized')
@facebook.authorized_handler
def facebook_authorized(resp):
    if resp is None:
        return 'Access denied: reason=%s error=%s' % (
            request.args['error_reason'],
            request.args['error_description']
        )

    session['facebook_oauth_token'] = (resp['access_token'], '')

    me = facebook.get('/me')
    user = User.query.filter_by(
        username=me.data['first_name'] + " " + me.data['last_name']
    ).first()

    if not user:
        user = User(me.data['first_name'] + " " + me.data['last_name'])
        db.session.add(user)
        db.session.commit()

    # Login User here
    flash("You have been logged in.", category="success")

    return redirect(
        request.args.get('next') or url_for('blog.home')
    )

第一个路由facebook_login只是重定向到 Facebook 网站上的登录过程。facebook_authorized视图接收来自 Facebook 服务器的响应,并且与 OpenID 过程一样,要么创建一个新用户,要么登录用户。现在,要开始这个过程,向注册和登录模板添加以下链接:

<h2 class="text-center">Register With Facebook</h2>
<a href="{{ url_for('.facebook_login') }}">Login via Facebook</a>

Twitter

Twitter 登录过程非常相似。要创建 Twitter 应用并获取您的密钥,请转到apps.twitter.com/。在extensions.py中:

twitter = oauth.remote_app(
    'twitter',
    base_url='https://api.twitter.com/1.1/',
    request_token_url='https://api.twitter.com/oauth/request_token',
    access_token_url='https://api.twitter.com/oauth/access_token',
    authorize_url='https://api.twitter.com/oauth/authenticate',
    consumer_key='',
    consumer_secret=''
)

@twitter.tokengetter
def get_twitter_oauth_token():
    return session.get('twitter_oauth_token')

main.py控制器中,添加以下视图:

@main_blueprint.route('/twitter-login')
def twitter_login():
    return twitter.authorize(
        callback=url_for(
            '.twitter_authorized',
            next=request.referrer or None,
            _external=True
        )
    )

@main_blueprint.route('/twitter-login/authorized')
@twitter.authorized_handler
def twitter_authorized(resp):
    if resp is None:
        return 'Access denied: reason: {} error: {}'.format(
            request.args['error_reason'],
            request.args['error_description']
        )

    session['twitter_oauth_token'] = resp['oauth_token'] + \
        resp['oauth_token_secret']

    user = User.query.filter_by(
        username=resp['screen_name']
    ).first()

    if not user:
        user = User(resp['screen_name'], '')
        db.session.add(user)
        db.session.commit()

    # Login User here
    flash("You have been logged in.", category="success")

    return redirect(
        request.args.get('next') or url_for('blog.home')
    )

这些视图执行与它们的 Facebook 对应项相同的功能。最后,在注册和登录模板中,添加以下链接以开始登录过程:

<h2 class="text-center">Register With Twitter</h2>
<a href="{{ url_for('.twitter_login') }}">Login</a>

使用会话

在 Flask 中创建身份验证的一种方法是使用session对象。session对象是 Flask 中的一个对象,它为服务器提供了一种使用 cookie 在用户浏览器中存储信息的简单方式。存储的数据使用应用程序的密钥进行加密签名。如果用户尝试修改 cookie,则签名将不再有效,cookie 将无法读取。

会话对象具有与dict对象相同的 API。要向其中添加数据,只需使用此代码:

session['key'] = data

要检索数据,请使用此代码:

session['key']

要登录用户,将用户名键添加到会话中,并设置为当前用户的用户名。

@main_blueprint.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()

    if form.validate_on_submit():
        # Add the user's name to the cookie
        session['username'] = form.username.data

    return render_template('login.html', form=form)

要注销用户,可以从会话中弹出密钥:

@main_blueprint.route('/logout', methods=['GET', 'POST'])
def logout():
    # Remove the username from the cookie
    session.pop('username', None)
    return redirect(url_for('.login'))

要检查用户当前是否已登录,视图可以测试会话中是否存在用户名键。考虑以下新帖子视图:

@blog_blueprint.route('/new', methods=['GET', 'POST'])
def new_post ():
    if 'username' not in session:
        return redirect(url_for('main.login'))
    …

我们的一些模板将需要访问当前用户对象。在每个请求开始时,我们的blog蓝图可以检查会话中是否存在用户名。如果是,则将User对象添加到g对象中,通过模板可以访问。

@blog_blueprint.before_request
def check_user():
    if 'username' in session:
        g.current_user = User.query.filter_by(
            username=session['username']
        ).one()
    else:
        g.current_user = None

我们的登录检查可以更改为:

@blog_blueprint.route('/new', methods=['GET', 'POST'])
def new_post():
    if not g.current_user:
        return redirect(url_for('main.login'))
    …

此外,帖子页面上的编辑按钮只有在当前用户是作者时才会出现:

{% if g.current_user == post.user %}
<div class="row">
  <div class="col-lg-2">
    <a href="{{ url_for('.edit_post', id=post.id) }}" class="btn btn- 
      primary">Edit</a>
  </div>
</div>
{% endif %}

编辑页面本身还应执行以下检查:

@blog_blueprint.route('/edit/<int:id>', methods=['GET', 'POST'])
def edit_post(id):
    if not g.current_user:
        return redirect(url_for('main.login'))

    post = Post.query.get_or_404(id)

    if g.current_user != post.user:
        abort(403)
    …

现在,我们的应用程序具有一个功能齐全的登录系统,具有传统的用户名和密码组合以及许多社交登录。但是,此系统中还有一些功能未涵盖。例如,如果我们希望一些用户只能评论而给其他人创建帖子的权限呢?此外,我们的登录系统没有实现记住我功能。为了覆盖这些功能,我们将重构我们的应用程序,使用名为Flask 登录的 Flask 扩展,而不是直接使用会话。

Flask 登录

要开始使用 Flask 登录,首先需要下载它:

$ pip install flask-login

主要的 Flask 登录对象是LoginManager对象。像其他 Flask 扩展一样,在extensions.py中初始化LoginManager对象:

from flask.ext.login import LoginManager
…
login_manager = LoginManager()

有一些需要在对象上更改的配置选项:

login_manager.login_view = "main.login"
login_manager.session_protection = "strong"
login_manager.login_message = "Please login to access this page"
login_manager.login_message_category = "info"

@login_manager.user_loader
def load_user(userid):
    from models import User
    return User.query.get(userid)

上述配置值定义了哪个视图应该被视为登录页面,以及用户在登录时应该看到什么样的消息。将选项session_protection设置为strong可以更好地防止恶意用户篡改他们的 cookie。当检测到篡改的 cookie 时,该用户的会话对象将被删除,并强制用户重新登录。load_user函数接受一个 id 并返回User对象。这是为了让 Flask Login 检查 id 是否标识了正确的用户对象。

User模型需要更新,包括一些用于 Flask Login 的方法。首先是is_authenticated,用于检查User对象是否已登录。接下来是is_active,用于检查用户是否已经通过某种激活过程,比如电子邮件确认。否则,它允许网站管理员封禁用户而不删除他们的数据。然后,is_anonymous用于检查这个用户是否是匿名用户且未登录。最后,get_id函数返回该User对象的唯一unicode标识符。

这个应用程序将使用一个简单的实现方式:

from flask.ext.login import AnonymousUserMixin
…

class User(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    username = db.Column(db.String(255))
    password = db.Column(db.String(255))
    posts = db.relationship(
        'Post',
        backref='user',
        lazy='dynamic'
    )

    def __init__(self, username):
        self.username = username

    def __repr__(self):
        return '<User {}>'.format(self.username)

    def set_password(self, password):
        self.password = bcrypt.generate_password_hash(password)

    def check_password(self, password):
        return bcrypt.check_password_hash(self.password, password)

    def is_authenticated(self):
        if isinstance(self, AnonymousUserMixin):
            return False
        else:
            return True

    def is_active(self):
        return True

    def is_anonymous(self):
        if isinstance(self, AnonymousUserMixin):
            return True
        else:
            return False

    def get_id(self):
        return unicode(self.id)

在 Flask Login 中,站点上的每个用户都继承自某个用户对象。默认情况下,它们继承自AnonymousUserMixin对象。如果您的站点需要一些匿名用户的功能,可以创建一个从AnonymousUserMixin继承的类,并将其设置为默认用户类,如下所示:

login_manager.anonymous_user = CustomAnonymousUser

注意

要更好地理解混入的概念,请访问en.wikipedia.org/wiki/Mixin

要使用 Flask Login 登录用户,使用:

from flask.ext.login import login_user
login_user(user_object)

Flask Login 会处理所有的会话处理。要让用户被记住,添加remember=Truelogin_user调用中。可以在登录表单中添加复选框,让用户选择:

from wtforms import (
    StringField,
    TextAreaField,
    PasswordField,
    BooleanField
)

class LoginForm(Form):
    username = StringField('Username', [
        DataRequired(),
        Length(max=255)
    ])
    password = PasswordField('Password', [DataRequired()])
    remember = BooleanField("Remember Me")
    …

在登录视图中,添加这个:

if form.validate_on_submit():
    user = User.query.filter_by(
        username=form.username.data
    ).one()
    login_user(user, remember=form.remember.data)

要注销当前用户,使用以下命令:

from flask.ext.login import login_user, logout_user
logout_user()

要保护视图不被未经授权的用户访问并将他们发送到登录页面,需要添加login_required装饰器如下:

from flask.ext.login import login_required

@blog_blueprint.route('/new', methods=['GET', 'POST'])
@login_required
def new_post():
    form = PostForm()
    …

Flask Login 还提供了一个代理,用于表示已登录用户的current_user。这个代理在视图和模板中都可用。因此,在我们的博客控制器中,可以删除自定义的before_request处理程序,并且我们对g.current_user的调用应该替换为current_user

现在,使用 Flask Login,我们应用程序的登录系统更加符合 Python 的风格和安全。还有一个最后的功能要实现:用户角色和权限。

用户角色

要向我们的应用程序添加用户权限,我们的User模型将需要与Role对象的多对多关系,并且还需要另一个名为Flask Principal的 Flask 扩展。

使用我们从第二章中的代码,使用 SQLAlchemy 创建模型,向User对象添加一个多对多的关系很容易:

roles = db.Table(
    'role_users',
    db.Column('user_id', db.Integer, db.ForeignKey('user.id')),
    db.Column('role_id', db.Integer, db.ForeignKey('role.id'))
)

class User(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    username = db.Column(db.String(255), unique=True)
    password = db.Column(db.String(255))
    posts = db.relationship(
        'Post',
        backref='user',
        lazy='dynamic'
    )
    roles = db.relationship(
        'Role',
        secondary=roles,
        backref=db.backref('users', lazy='dynamic')
    )

    def __init__(self, username):
        self.username = username

        default = Role.query.filter_by(name="default").one()
        self.roles.append(default)
    …

class Role(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    name = db.Column(db.String(80), unique=True)
    description = db.Column(db.String(255))

    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return '<Role {}>'.format(self.name)

从命令行中,使用以下命令填充角色表格,包括三个角色:admin,poster 和 default。这些将作为 Flask Principal 的主要权限。

Flask Principal 围绕着身份的概念展开。应用程序中的某个东西,在我们的例子中是一个User对象,与之关联了一个身份。身份提供Need对象,它们本质上只是命名元组。Needs定义了身份可以做什么。权限是用Need初始化的,并且它们定义了资源需要访问的Need对象。

Flask Principal 提供了两个方便的Need对象:UserNeedRoleNeed,这正是我们应用程序所需要的。在extensions.py中,Flask Principal 将被初始化,并且我们的RoleNeed对象将被创建:

from flask.ext.principal import Principal, Permission, RoleNeed
principals = Principal()
admin_permission = Permission(RoleNeed('admin'))
poster_permission = Permission(RoleNeed('poster'))
default_permission = Permission(RoleNeed('default'))

Flask Principal 需要一个函数,在身份发生变化后向其中添加Need对象。因为这个函数需要访问app对象,所以这个函数将驻留在__init__.py文件中:

from flask.ext.principal import identity_loaded, UserNeed, RoleNeed
from extensions import bcrypt, oid, login_manager, principals
def create_app(object_name):
    app = Flask(__name__)
    app.config.from_object(object_name)

    db.init_app(app)
    bcrypt.init_app(app)
    oid.init_app(app)
    login_manager.init_app(app)
    principals.init_app(app)

    @identity_loaded.connect_via(app)
    def on_identity_loaded(sender, identity):
        # Set the identity user object
        identity.user = current_user

        # Add the UserNeed to the identity
        if hasattr(current_user, 'id'):
            identity.provides.add(UserNeed(current_user.id))

        # Add each role to the identity
        if hasattr(current_user, 'roles'):
            for role in current_user.roles:
                identity.provides.add(RoleNeed(role.name))
     …

现在,当身份发生变化时,它将添加一个UserNeed和所有的RoleNeed对象。当用户登录或注销时,身份发生变化:

from flask.ext.principal import (
    Identity,
    AnonymousIdentity,
    identity_changed
)    
@main_blueprint.route('/login', methods=['GET', 'POST'])
@oid.loginhandler
def login():
    …

    if form.validate_on_submit():
        user = User.query.filter_by(
            username=form.username.data
        ).one()
        login_user(user, remember=form.remember.data)

        identity_changed.send(
            current_app._get_current_object(),
            identity=Identity(user.id)
        )

        flash("You have been logged in.", category="success")
        return redirect(url_for('blog.home'))
@main_blueprint.route('/logout', methods=['GET', 'POST'])
def logout():
    logout_user()

    identity_changed.send(
        current_app._get_current_object(),
        identity=AnonymousIdentity()
    )

    flash("You have been logged out.", category="success")
    return redirect(url_for('.login'))

当用户登录时,他们的身份将触发on_identity_loaded方法,并设置他们的Need对象。现在,如果我们有一个页面,我们只想让发布者访问:

from webapp.extensions import poster_permission
@blog_blueprint.route('/edit/<int:id>', methods=['GET', 'POST'])
@login_required
@poster_permission.require(http_exception=403)
def edit_post(id):
    …

我们还可以在同一个视图中用UserNeed检查替换我们的用户检查,如下所示:

from webapp.extensions import poster_permission, admin_permission

@blog_blueprint.route('/edit/<int:id>', methods=['GET', 'POST'])
@login_required
@poster_permission.require(http_exception=403)
def edit_post(id):
    post = Post.query.get_or_404(id)
    permission = Permission(UserNeed(post.user.id))

    # We want admins to be able to edit any post
    if permission.can() or admin_permission.can():
        form = PostForm()

        if form.validate_on_submit():
            post.title = form.title.data
            post.text = form.text.data
            post.publish_date = datetime.datetime.now()

            db.session.add(post)
            db.session.commit()

            return redirect(url_for('.post', post_id=post.id))

        form.text.data = post.text
        return render_template('edit.html', form=form, post=post)

    abort(403)

注意

访问pythonhosted.org/Flask-Principal/上的 Flask Principal 文档,了解如何创建更复杂的Need对象。

摘要

我们的用户现在拥有安全登录、多重登录和注册选项,以及明确的访问权限。我们的应用程序具备成为一个完整的博客应用程序所需的一切。在下一章中,本书将停止跟随这个示例应用程序,以介绍一种名为NoSQL的技术。

第七章:使用 Flask 与 NoSQL

NoSQL(缩写为Not Only SQL)数据库是任何非关系型数据存储。它通常侧重于速度和可伸缩性。在过去的 7 年里,NoSQL 一直在网页开发领域掀起了风暴。像 Netflix 和 Google 这样的大公司宣布他们正在将许多服务迁移到 NoSQL 数据库,许多较小的公司也跟随着这样做。

这一章将偏离本书的其余部分,其中 Flask 不会是主要焦点。在一本关于 Flask 的书中,专注于数据库设计可能看起来有些奇怪,但选择正确的数据库对于设计技术栈来说可能是最重要的决定。在绝大多数网络应用中,数据库是瓶颈,因此你选择的数据库将决定应用的整体速度。亚马逊进行的一项研究表明,即使 100 毫秒的延迟也会导致 1%的销售额减少,因此速度应该始终是网络开发人员的主要关注点之一。此外,程序员社区中有大量关于选择流行的 NoSQL 数据库然后并不真正了解数据库在管理方面需要什么的恐怖故事。这导致大量数据丢失和崩溃,进而意味着失去客户。总的来说,毫不夸张地说,你选择应用的数据库可能是应用成功与否的关键。

为了说明 NoSQL 数据库的优势和劣势,将对每种 NoSQL 数据库进行检查,并阐明 NoSQL 与传统数据库之间的区别。

NoSQL 数据库的类型

NoSQL 是一个用来描述数据库中非传统数据存储方法的总称。更让人困惑的是,NoSQL 也可能指的是关系型但没有使用 SQL 作为查询语言的数据库,例如RethinkDB。绝大多数 NoSQL 数据库不是关系型的,不像 RDBMS,这意味着它们无法执行诸如JOIN之类的操作。缺少JOIN操作是一种权衡,因为它允许更快的读取和更容易的去中心化,通过将数据分布在多个服务器甚至是不同的数据中心。

现代 NoSQL 数据库包括键值存储、文档存储、列族存储和图数据库。

键值存储

键值 NoSQL 数据库的工作方式类似于 Python 中的字典。一个键关联一个值,并通过该键访问。此外,就像 Python 字典一样,大多数键值数据库的读取速度不受条目数量的影响。高级程序员会知道这是O(1)读取。在一些键值存储中,一次只能检索一个键,而不是传统 SQL 数据库中的多行。在大多数键值存储中,值的内容是不可查询的,但键是可以的。值只是二进制块;它们可以是从字符串到电影文件的任何东西。然而,一些键值存储提供默认类型,如字符串、列表、集合和字典,同时还提供添加二进制数据的选项。

由于其简单性,键值存储通常非常快。但是,它们的简单性使它们不适合大多数应用程序的主数据库。因此,大多数键值存储用例是存储需要在一定时间后过期的简单对象。这种模式的两个常见示例是存储用户会话数据和购物车数据。此外,键值存储通常用作应用程序或其他数据库的缓存。例如,经常运行或 CPU 密集型查询或函数的结果与查询或函数名称一起存储为键。应用程序在运行数据库上的查询之前将检查键值存储中的缓存,从而减少页面加载时间和对数据库的压力。此功能的示例将在第十章中展示,有用的 Flask 扩展

最流行的键值存储是RedisRiakAmazon DynamoDB

文档存储

文档存储是最流行的 NoSQL 数据库类型之一,通常用于替代 RDBMS。数据库将数据存储在称为文档的键值对集合中。这些文档是无模式的,意味着没有文档必须遵循另一个文档的结构。此外,可以在文档创建后附加额外的键。大多数文档存储将数据存储在JSONJavaScript 对象表示法)中,JSON 的超集,或 XML 中。例如,以下是存储在 JSON 中的两个不同的帖子对象:

{
    "title": "First Post",
    "text": "Lorem ipsum...",
    "date": "2015-01-20",
    "user_id": 45
}
{
    "title": "Second Post",
    "text": "Lorem ipsum...",
    "date": "2015-01-20",
    "user_id": 45,
    "comments": [
        {
            "name": "Anonymous",
            "text": "I love this post."
        }
    ]
}

请注意,第一个文档没有评论数组。如前所述,文档是无模式的,因此此格式是完全有效的。无模式还意味着在数据库级别没有类型检查。数据库上没有任何内容可以阻止将整数输入到帖子的标题字段中。无模式数据是文档存储的最强大功能,并吸引许多人采用它们的应用程序。但是,它也可能被认为非常危险,因为有一个检查可以阻止错误或格式错误的数据进入数据库。

一些文档存储将类似的对象收集到文档集合中,以便更容易地查询对象。但是,在一些文档存储中,所有对象都是一次查询的。文档存储存储每个对象的元数据,这允许查询并返回匹配的文档中的所有值。

最流行的文档存储是MongoDBCouchDBCouchbase

列族存储

列族存储,也称为宽列存储,与键值存储和文档存储有许多共同之处。列族存储是最快的 NoSQL 数据库类型,因为它们设计用于大型应用程序。它们的主要优势是能够处理大量数据,并且通过以智能方式将数据分布在多台服务器上,仍然具有非常快的读写速度。

列族存储也是最难理解的,部分原因是列族存储的行话,因为它们使用与 RDBMS 相同的术语,但含义大相径庭。为了清楚地理解列族存储是什么,让我们直接举个例子。让我们在典型的列族存储中创建一个简单的用户到帖子关联。

首先,我们需要一个用户表。在列族存储中,数据是通过唯一键存储和访问的,例如键值存储,但内容是无结构的列,例如文档存储。考虑以下用户表:

杰克 约翰
全名 生物
杰克·斯托弗 这是我的个人简介

请注意,每个键都包含列,这些列也是键值对。而且,并不要求每个键具有相同数量或类型的列。每个键可以存储数百个唯一的列,或者它们可以都有相同数量的列,以便更容易进行应用程序开发。这与键值存储形成对比,后者可以存储每个键的任何类型的数据。这也与文档存储略有不同,后者可以在每个文档中存储类型,比如数组和字典。现在让我们创建我们的帖子表:

帖子/1 帖子/2
标题 日期
你好,世界 2015-01-01

在我们继续之前,有几件事情需要了解关于列族存储。首先,在列族存储中,数据只能通过单个键或键范围进行选择;无法查询列的内容。为了解决这个问题,许多程序员使用外部搜索工具与他们的数据库一起使用,比如Elasticsearch,它将列的内容存储在可搜索的格式中,并返回匹配的键供数据库查询。这种限制性是为什么在列族存储中适当的模式设计是如此关键的,必须在存储任何数据之前仔细考虑。

其次,数据不能按列的内容排序。数据只能按键排序,这就是为什么帖子的键是整数的原因。这样可以按照输入顺序返回帖子。这不是用户表的要求,因为没有必要按顺序排序用户。

第三,没有JOIN运算符,我们无法查询包含用户键的列。根据我们当前的模式,没有办法将帖子与用户关联起来。要创建这个功能,我们需要一个第三个表来保存用户到帖子的关联:

杰克
帖子

这与我们迄今为止看到的其他表略有不同。帖子列被命名为超级列,它是一个包含其他列的列。在这个表中,超级列与我们的用户键相关联,它包含了一个帖子到一个帖子的位置的关联。聪明的读者可能会问,为什么我们不把这个关联存储在用户表中,就像在文档存储中解决问题的方式一样。这是因为常规列和超级列不能存储在同一张表中。您必须在创建每个表时选择一个。

要获取用户的所有帖子列表,我们首先必须查询帖子关联表,使用我们的用户键,使用返回的关联列表获取帖子表中的所有键,并使用这些键查询帖子表。

如果这个查询对你来说似乎是一个绕圈子的过程,那是因为它确实是这样,而且它是有意设计成这样的。列族存储的限制性质使得它能够如此快速地处理如此多的数据。删除诸如按值和列名搜索等功能,使列族存储能够处理数百 TB 的数据。毫不夸张地说,SQLite 对程序员来说比典型的列族存储更复杂。

因此,大多数 Flask 开发人员应该避免使用列族存储,因为它给应用程序增加了不必要的复杂性。除非您的应用程序要处理每秒数百万次的读写操作,否则使用列族存储就像用原子弹钉打钉子。

最受欢迎的列族存储包括BigTableCassandraHBase

图数据库

图数据库旨在描述然后查询关系,它们类似于文档存储,但具有创建和描述两个节点之间链接的机制。

图存储中的节点是单个数据,通常是一组键值对或 JSON 文档。节点可以被标记为属于某个类别,例如用户或组。在定义了节点之后,可以创建任意数量的节点之间的单向关系(称为链接),并带有自己的属性。例如,如果我们的数据有两个用户节点,每个用户都认识对方,我们可以在它们之间定义两个“认识”链接来描述这种关系。这将允许您查询所有认识一个用户的人,或者一个用户认识的所有人。

图形数据库

图存储还允许您按照链接的属性进行查询。这使您可以轻松地创建否则复杂的查询,例如在 2001 年 10 月被一个用户标记为已知的所有用户。图存储可以从节点到节点跟随链接,创建更复杂的查询。如果这个示例数据集有更多的群组,我们可以查询那些我们认识的人已经加入但我们还没有加入的群组。或者,我们可以查询与某个用户在同一群组的人,但该用户不认识他们。图存储中的查询还可以跟随大量的链接来回答复杂的问题,比如“纽约有哪些评分为三星或更高的餐厅,提供汉堡,我的朋友们喜欢吗?”

图数据库最常见的用例是构建推荐引擎。例如,假设我们有一个图存储,其中填充了来自社交网络网站的朋友数据。利用这些数据,我们可以通过查询用户来构建一个共同的朋友查找器,其中超过两个朋友标记他们为朋友。

图数据库很少被用作应用程序的主要数据存储。大多数图存储的用途是,每个节点都充当主数据库中数据片段的表示,通过存储其唯一标识符和少量其他标识信息。

最流行的图存储是 Neo4j 和 InfoGrid。

RDBMS 与 NoSQL

NoSQL 是一种工具,就像任何工具一样,它有特定的用例,它擅长的地方,以及其他工具更适合的用例。没有人会用螺丝刀来敲钉子。这是可能的,但使用锤子会让工作更容易。NoSQL 数据库的一个很大的问题是,人们在 RDBMS 可以同样好甚至更好地解决问题时采用了它们。

要了解何时使用哪种工具,我们必须了解两种系统的优势和劣势。

RDBMS 数据库的优势

关系型数据库管理系统(RDBMS)的最大优势之一是其成熟性。RDBMS 背后的技术已经存在了 40 多年,基于关系代数和关系演算的坚实理论。由于它们的成熟性,在许多不同行业中,它们都有着长期的、经过验证的数据处理记录。

数据安全

安全性也是 RDBMS 的最大卖点之一。RDBMS 有几种方法来确保输入到数据库中的数据不仅是正确的,而且数据丢失几乎是不存在的。这些方法结合在一起形成了所谓的ACID,即原子性、一致性、隔离性和持久性。ACID 是一组事务规则,保证事务的安全处理。

首先,原子性要求每个事务要么全部完成,要么全部失败。这很像 Python 之禅中的思维方式:“错误不应悄悄地过去。除非明确地被消除。”如果数据更改或输入存在问题,事务不应继续操作,因为后续操作很可能需要先前的操作成功。

其次,一致性要求事务修改或添加的任何数据都要遵循每个表的规则。这些规则包括类型检查、用户定义的约束,如“外键”、级联规则和触发器。如果任何规则被违反,那么根据原子性规则,事务将被取消。

第三,隔离要求如果数据库并发运行事务以加快写入速度,那么如果它们按顺序运行,事务的结果将是相同的。这主要是数据库程序员的规则,而不是 Web 开发人员需要担心的事情。

最后,持久性要求一旦接受了一个事务,数据就绝不能丢失,除非在事务被接受后发生硬盘故障。如果数据库崩溃或断电,持久性原则要求在问题发生之前写入的任何数据在服务器备份时仍然存在。这基本上意味着一旦事务被接受,所有事务必须被写入磁盘。

速度和规模

一个常见的误解是 ACID 原则使得关系型数据库无法扩展并且速度慢。这只是一半正确;关系型数据库完全可以扩展。例如,由专业数据库管理员配置的 Oracle 数据库可以处理每秒数万个复杂查询。像 Facebook、Twitter、Tumblr 和 Yahoo!这样的大公司正在有效地使用 MySQL,而由于其速度优势,PostgreSQL 正在成为许多程序员的首选。

然而,关系型数据库最大的弱点是无法通过将数据跨多个数据库进行分割来轻松扩展。这并非不可能,正如一些批评者所暗示的那样,只是比 NoSQL 数据库更困难。这是由于JOIN的性质,它需要扫描整个表中的所有数据,即使它分布在多个服务器上。存在一些工具来帮助创建分区设置,但这仍然主要是专业数据库管理员的工作。

工具

在评估编程语言时,对于或反对采用它的最有力的观点是其社区的规模和活跃程度。更大更活跃的社区意味着如果遇到困难会有更多的帮助,并且更多的开源工具可用于项目中。

数据库也不例外。例如 MySQL 或 PostgreSQL 等关系型数据库为商业环境中几乎每种语言都有官方库,而其他语言也有非官方库。诸如 Excel 之类的工具可以轻松地从这些数据库中下载最新数据,并允许用户像对待任何其他数据集一样处理它。每个数据库都有几个免费的桌面 GUI,并且一些是由数据库的公司赞助的官方支持的。

NoSQL 数据库的优势

许多人使用 NoSQL 数据库的主要原因是它在传统数据库上的速度优势。许多 NoSQL 数据库可以在开箱即用的情况下比关系型数据库表现出色。然而,速度是有代价的。许多 NoSQL 数据库,特别是文档存储,为了可用性而牺牲了一致性。这意味着它们可以处理许多并发读写,但这些写入可能彼此冲突。这些数据库承诺“最终一致性”,而不是在每次写入时进行一致性检查。简而言之,许多 NoSQL 数据库不提供 ACID 事务,或者默认情况下已关闭。一旦启用 ACID 检查,数据库的速度会接近传统数据库的性能。每个 NoSQL 数据库都以不同的方式处理数据安全,因此在选择一个数据库之前仔细阅读文档非常重要。

吸引人们使用 NoSQL 的第二个特性是其处理非格式化数据的能力。将数据存储为 XML 或 JSON 允许每个文档具有任意结构。存储用户设计的数据的应用程序从采用 NoSQL 中受益良多。例如,允许玩家将他们的自定义级别提交到某个中央存储库的视频游戏现在可以以可查询的格式存储数据,而不是以二进制大块存储。

吸引人们使用 NoSQL 的第三个特性是轻松创建一组协同工作的数据库集群。没有JOIN或者只通过键访问值使得在多台服务器之间分割数据相对来说是一个相当简单的任务,与关系型数据库相比。这是因为JOIN需要扫描整个表,即使它分布在许多不同的服务器上。当文档或键可以通过简单的算法分配到服务器时,JOIN变得更慢,例如,可以根据其唯一标识符的起始字符将其分配到服务器。例如,以字母 A-H 开头的所有内容发送到服务器一,I-P 发送到服务器二,Q-Z 发送到服务器三。这使得查找连接客户端的数据位置非常快。

在选择数据库时使用哪种

因此,每个数据库都有不同的用途。在本节的开头就提到了一个主要问题,即程序员在选择 NoSQL 数据库作为技术栈时的主要问题是,他们选择了一个关系型数据库同样适用的情况下。这源于一些常见的误解。首先,人们试图使用关系型思维和数据模型,并认为它们在 NoSQL 数据库中同样适用。人们通常会产生这种误解,因为 NoSQL 数据库网站上的营销是误导性的,并鼓励用户放弃他们当前的数据库,而不考虑非关系模型是否适用于他们的项目。

其次,人们认为必须只使用一个数据存储来进行应用程序。许多应用程序可以从使用多个数据存储中受益。以使用 Facebook 克隆为例,它可以使用 MySQL 来保存用户数据,redis 来存储会话数据,文档存储来保存人们共享的测验和调查数据,以及图形数据库来实现查找朋友的功能。

如果一个应用程序功能需要非常快的写入,并且写入安全性不是主要关注点,那么就使用文档存储数据库。如果需要存储和查询无模式数据,那么应该使用文档存储数据库。

如果一个应用程序功能需要存储一些在指定时间后自行删除的东西,或者数据不需要被搜索,那么就使用键值存储。

如果一个应用程序功能依赖于查找或描述两个或多个数据集之间的复杂关系,则使用图形存储。

如果一个应用程序功能需要保证写入安全性,每个条目可以固定到指定的模式,数据库中的不同数据集需要使用 JOIN 进行比较,或者需要对输入的数据进行约束,那么就使用关系型数据库。

Flask 中的 MongoDB

MongoDB 远远是最受欢迎的 NoSQL 数据库。MongoDB 也是 Flask 和 Python 中最受支持的 NoSQL 数据库。因此,我们的示例将重点放在 MongoDB 上。

MongoDB 是一个文档存储的 NoSQL 数据库。文档存储在集合中,允许对类似的文档进行分组,但在存储文档时不需要文档之间的相似性。文档在一个名为 BSON 的 JSON 超集中定义,BSON 代表二进制 JSON。BSON 允许以二进制格式存储 JSON,而不是字符串格式,节省了大量空间。BSON 还区分了存储数字的几种不同方式,例如 32 位整数和双精度浮点数。

为了理解 MongoDB 的基础知识,我们将使用 Flask-MongoEngine 来覆盖前几章中 Flask-SQLAlchemy 的相同功能。请记住,这些只是例子。重构我们当前的代码以使用 MongoDB 没有任何好处,因为 MongoDB 无法为我们的用例提供任何新功能。MongoDB 的新功能将在下一节中展示。

安装 MongoDB

要安装 MongoDB,请转到www.mongodb.org/downloads,并从标题“下载并运行 MongoDB 自己”下的选项卡中选择您的操作系统。每个支持版本的操作系统都有安装说明列在安装程序的下载按钮旁边。

要运行 MongoDB,请转到 bash 并运行:

$ mongod

这将在窗口打开的时间内运行服务器。

设置 MongoEngine

在开始之前,需要使用 pip 安装 MongoEngine:

$ pip install Flask-MongoEngine

models.py文件中,将创建一个代表我们数据库的 mongo 对象:

from flask.ext.mongoengine import MongoEngine
…
db = SQLAlchemy()
mongo = MongoEngine()

与 SQLAlchemy 对象一样,我们的 mongo 对象需要在__init__.py中的 app 对象上初始化。

from models import db, mongo
…
db.init_app(app)
mongo.init_app(app)

在我们的应用程序运行之前,我们的config.py中的DevConfig对象需要设置 mongo 连接的参数:

MONGODB_SETTINGS = {
    'db': 'local',
    'host': 'localhost',
    'port': 27017
}

这些是全新 MongoDB 安装的默认值。

定义文档

MongoEngine 是围绕 Python 对象系统构建的 ORM,专门用于 MongoDB。不幸的是,没有支持所有 NoSQL 驱动程序的 SQLAlchemy 风格的包装器。在关系型数据库管理系统中,SQL 的实现是如此相似,以至于创建一个通用接口是可能的。然而,每个文档存储的基本实现都有足够的不同,以至于创建类似接口的任务比它的价值更麻烦。

您的 mongo 数据库中的每个集合都由从 mongo.Document 继承的类表示:

class Post(mongo.Document):
    title = mongo.StringField(required=True)
    text = mongo.StringField()
    publish_date = mongo.DateTimeField(
        default=datetime.datetime.now()
    )

    def __repr__(self):
        return "<Post '{}'>".format(self.title)

每个类变量都是文档所属的键的表示,这在本例中代表了一个 Post 类。类变量名称用作文档中的键。

与 SQLAlchemy 不同,无需定义主键。唯一的 ID 将在 ID 属性下为您生成。前面的代码将生成一个类似于以下的 BSON 文档:

{
    "_id": "55366ede8b84eb00232da905",
    "title": "Post 0",
    "text": "<p>Lorem ipsum dolor...",
    "publish_date": {"$date": 1425255876037}
}

字段类型

有许多字段,每个字段代表 Mongo 中的一个不同数据类别。与底层数据库不同,每个字段在允许保存或更改文档之前提供类型检查。最常用的字段如下:

  • BooleanField

  • DateTimeField

  • DictField

  • DynamicField

  • EmbeddedDocumentField

  • FloatField

  • IntField

  • ListField

  • ObjectIdField

  • ReferenceField

  • StringField

注意

要获取字段的完整列表和详细文档,请访问 MongoEngine 网站docs.mongoengine.org

其中大多数都以它们接受的 Python 类型命名,并且与 SQLAlchemy 类型的工作方式相同。但是,还有一些新类型在 SQLAlchemy 中没有对应的。DynamicField是一个可以容纳任何类型值并且对值不执行类型检查的字段。DictField可以存储json.dumps()序列化的任何 Python 字典。ReferenceField只是存储文档的唯一 ID,并且在查询时,MongoEngine 将返回引用的文档。与ReferenceField相反,EmbeddedDocumentField将传递的文档存储在父文档中,因此不需要进行第二次查询。ListField类型表示特定类型的字段列表。

这通常用于存储对其他文档的引用列表或嵌入式文档的列表,以创建一对多的关系。如果需要一个未知类型的列表,可以使用DynamicField。每种字段类型都需要一些常见的参数,如下所示。

Field(
    primary_key=None
    db_field=None,
    required=False,
    default=None,
    unique=False,
    unique_with=None,
    choices=None
)

primary_key参数指定您不希望 MongoEngine 自动生成唯一键,而应使用字段的值作为 ID。现在,该字段的值将从id属性和字段的名称中访问。

db_field定义了每个文档中键的名称。如果未设置,它将默认为类变量的名称。

如果将required定义为True,则该键必须存在于文档中。否则,该类型的文档不必存在该键。当查询定义了一个类的不存在键时,它将返回 None。

default指定如果未定义值,则该字段将被赋予的值。

如果unique设置为True,MongoEngine 会检查确保集合中没有其他文档具有该字段的相同值。

当传递字段名称列表时,unique_with将确保在组合中取值时,所有字段的值对于每个文档都是唯一的。这很像 RDBMS 中的多列UNIQUE索引。

最后,当给定一个列表时,choices选项将限制该字段的可允许值为列表中的元素。

文档类型

MongoEngine 定义文档的方法可以根据集合的不同实现灵活性或严格性。从mongo.Document继承意味着只有在类中定义的键才能保存到数据库中。类中定义的键可以为空,但其他所有内容都将被忽略。另一方面,如果您的类继承mongo.DynamicDocument,任何设置的额外字段都将被视为DynamicFields并将与文档一起保存。

class Post(mongo.DynamicDocument):
    title = mongo.StringField(required=True, unique=True)
    text = mongo.StringField()
    …

为了展示不推荐的极端情况,以下类是完全有效的;它没有必填字段,并允许设置任何字段:

class Post(mongo.DynamicDocument):
    pass

最后一种文档类型是EmbeddedDocumentEmbeddedDocument只是一个传递给EmbeddedDocumentField并按原样存储在文档中的文档,如下所示:

class Comment(mongo.EmbeddedDocument):
    name = mongo.StringField(required=True)
    text = mongo.StringField(required=True)
    date = mongo.DateTimeField(
        default=datetime.datetime.now()
    )

为什么在它们似乎执行相同功能时使用EmbeddedDocumentField而不是DictField?使用每个的最终结果是相同的。然而,嵌入式文档为数据定义了一个结构,而DictField可以是任何东西。为了更好地理解,可以这样想:Document对应于DynamicDocument,而EmbeddedDocument对应于DictField

meta 属性

使用meta类变量,可以手动设置文档的许多属性。如果您正在处理现有数据集并希望将您的类连接到集合,请设置meta字典的 collection 键:

class Post(mongo.Document):
    …
    meta = {'collection': 'user_posts'}

您还可以手动设置集合中文档的最大数量以及每个文档的大小。在此示例中,只能有 10,000 个文档,每个文档的大小不能超过 2 MB:

 class Post(mongo.Document):
    …
    meta = {
        'collection': 'user_posts',
        'max_documents': 10000,
        'max_size': 2000000
    }

索引也可以通过 MongoEngine 设置。索引可以使用字符串设置单个字段,或使用元组设置多字段:

class Post(mongo.Document):
    …
    meta = {
        'collection': 'user_posts',
        'max_documents': 10000,
        'max_size': 2000000,
        'indexes': [
            'title',
            ('title', 'user')
        ]
    }

集合的默认排序可以通过meta变量和ordering key进行设置。当在字段前加上-时,它告诉 MongoEngine 按该字段的降序顺序排序结果。如果在字段前加上+,它告诉 MongoEngine 按该字段的升序顺序排序结果。如果在查询中指定了order_by函数,将覆盖此默认行为,这将在CRUD部分中显示。

class Post(mongo.Document):
    …
    meta = {
        'collection': 'user_posts',
        'max_documents': 10000,
        'max_size': 2000000,
        'indexes': [
            'title',
            ('title', 'user')
        ],
        'ordering': ['-publish_date']
    }

meta变量还可以启用从用户定义的文档继承,这默认情况下是禁用的。原始文档的子类将被视为父类的成员,并将存储在同一集合中,如下所示:

class Post(mongo.Document):
    …
    meta = {'allow_inheritance': True}

class Announcement(Post):
    …

CRUD

如第二章中所述,使用 SQLAlchemy 创建模型,任何数据存储必须实现四种主要形式的数据操作。它们是创建新数据,读取现有数据,更新现有数据和删除数据。

创建

要创建新文档,只需创建类的新实例并调用save方法。

>>> post = Post()
>>> post.title = "Post From The Console"
>>> post.text = "Lorem Ipsum…"
>>> post.save()

否则,可以将值作为关键字传递给对象创建:

>>> post = Post(title="Post From Console", text="Lorem Ipsum…")

与 SQLAlchemy 不同,MongoEngine 不会自动保存存储在ReferenceFields中的相关对象。要保存对当前文档的引用文档的任何更改,请将cascade传递为True

>>> post.save(cascade=True)

如果您希望插入文档并跳过其对类定义中定义的参数的检查,则将 validate 传递为False

>>> post.save(validate=False)

提示

记住这些检查是有原因的。只有在非常充分的理由下才关闭它

写入安全性

默认情况下,MongoDB 在确认写入发生之前不会等待数据写入磁盘。这意味着已确认的写入可能失败,无论是硬件故障还是写入时发生的某些错误。为了确保数据在 Mongo 确认写入之前写入磁盘,请使用write_concern关键字。写关注告诉 Mongo 何时应该返回写入的确认:

# will not wait for write and not notify client if there was an error
>>> post.save(write_concern={"w": 0})
# default behavior, will not wait for write
>>> post.save(write_concern={"w": 1})
# will wait for write
>>> post.save(write_concern={"w": 1, "j": True})

注意

如 RDBMS 与 NoSQL 部分所述,您了解您使用的 NoSQL 数据库如何处理写入非常重要。要了解有关 MongoDB 写入关注的更多信息,请访问docs.mongodb.org/manual/reference/write-concern/

阅读

要访问数据库中的文档,使用objects属性。要读取集合中的所有文档,请使用all方法:

>>> Post.objects.all()
[<Post: "Post From The Console">]

要限制返回的项目数量,请使用limit方法:

# only return five items
>>> Post.objects.limit(5).all()

limit命令与 SQL 版本略有不同。在 SQL 中,limit命令也可用于跳过第一个结果。要复制此功能,请使用skip方法如下:

# skip the first 5 items and return items 6-10
>>> Post.objects.skip(5).limit(5).all()

默认情况下,MongoDB 返回按其创建时间排序的结果。要控制此行为,有order_by函数:

# ascending
>>> Post.objects.order_by("+publish_date").all()
# descending
>>> Post.objects.order_by("-publish_date").all()

如果您只想要查询的第一个结果,请使用first方法。如果您的查询返回了空值,并且您期望它是这样的,请使用first_or_404来自动中止并返回 404 错误。这与其 Flask-SQLAlchemy 对应物完全相同,并由 Flask-MongoEngine 提供。

>>> Post.objects.first()
<Post: "Post From The Console">
>>> Post.objects.first_or_404()
<Post: "Post From The Console">

get方法也具有相同的行为,它期望查询只返回一个结果,否则将引发异常:

# The id value will be different your document
>>> Post.objects(id="5534451d8b84ebf422c2e4c8").get()
<Post: "Post From The Console">
>>> Post.objects(id="5534451d8b84ebf422c2e4c8").get_or_404()
<Post: "Post From The Console">

paginate方法也存在,并且与其 Flask-SQLAlchemy 对应物具有完全相同的 API:

>>> page = Post.objects.paginate(1, 10)
>>> page.items()
[<Post: "Post From The Console">]

此外,如果您的文档具有ListField方法,则可以使用文档对象上的paginate_field方法来分页显示列表项。

过滤

如果您知道要按字段过滤的确切值,请将其值作为关键字传递给objects方法:

>>> Post.objects(title="Post From The Console").first()
<Post: "Post From The Console">

与 SQLAlchemy 不同,我们不能通过真值测试来过滤结果。相反,使用特殊的关键字参数来测试值。例如,要查找 2015 年 1 月 1 日后发布的所有帖子:

>>> Post.objects(
 publish_date__gt=datetime.datetime(2015, 1, 1)
 ).all()
[<Post: "Post From The Console">]

关键字末尾的__gt称为操作符。MongoEngine 支持以下操作符:

  • ne:不等于

  • lt:小于

  • lte:小于或等于

  • gt:大于

  • gte:大于或等于

  • not:否定操作符,例如,publish_date__not__gt

  • in:值在列表中

  • nin:值不在列表中

  • modvalue % a == bab作为(a, b)传递

  • all:提供的值列表中的每个项目都在字段中

  • size:列表的大小

  • exists:字段存在值

MongoEngine 还提供了以下操作符来测试字符串值:

  • exact:字符串等于该值

  • iexact:字符串等于该值(不区分大小写)

  • contains:字符串包含该值

  • icontains:字符串包含该值(不区分大小写)

  • startswith:字符串以该值开头

  • istartswith:字符串以该值开头(不区分大小写)

  • endswith:字符串以该值结尾

  • iendswith:字符串以该值结尾(不区分大小写)更新

这些运算符可以组合在一起,创建与前几节中创建的相同强大的查询。例如,要查找所有在 2015 年 1 月 1 日之后创建的帖子,标题中不包含post一词,正文以Lorem一词开头,并按发布日期排序,最新的在前:

>>> Post.objects(
 title__not__icontains="post",
 text__istartswith="Lorem",
 publish_date__gt=datetime.datetime(2015, 1, 1),
).order_by("-publish_date").all()

但是,如果有一些无法用这些工具表示的复杂查询,那么也可以传递原始的 Mongo 查询:

>>> Post.objects(__raw__={"title": "Post From The Console"})

更新

要更新对象,需要在查询结果上调用update方法。

>>> Post.objects(
 id="5534451d8b84ebf422c2e4c8"
 ).update(text="Ipsum lorem")

如果查询只应返回一个值,则使用update_one仅修改第一个结果:

>>> Post.objects(
 id="5534451d8b84ebf422c2e4c8"
 ).update_one(text="Ipsum lorem")

与传统的 SQL 不同,在 MongoDB 中有许多不同的方法来更改值。使用运算符以不同的方式更改字段的值:

  • set:这设置一个值(与之前给定的相同)

  • unset:这会删除一个值并移除键

  • inc:这增加一个值

  • dec:这减少一个值

  • push:这将一个值附加到列表

  • push_all:这将多个值附加到列表

  • pop:这会移除列表的第一个或最后一个元素

  • pull:这从列表中移除一个值

  • pull_all:这会从列表中移除多个值

  • add_to_set:仅当列表中不存在时,将值添加到列表中

例如,如果需要将Python值添加到具有MongoEngine标签的所有Post文档的名为标签的ListField中:

>>> Post.objects(
 tags__in="MongoEngine",
 tags__not__in="Python"
 ).update(push__tags="Python")

相同的写关注参数对于更新存在。

>>> Post.objects(
 tags__in="MongoEngine"
 ).update(push__tags="Python", write_concern={"w": 1, "j": True})

删除

要删除文档实例,请调用其delete方法:

>>> post = Post.objects(
 id="5534451d8b84ebf422c2e4c8"
 ).first()
>>> post.delete()

NoSQL 中的关系

就像我们在 SQLAlchemy 中创建关系一样,我们可以在 MongoEngine 中创建对象之间的关系。只有使用 MongoEngine,我们将在没有JOIN运算符的情况下这样做。

一对多关系

在 MongoEngine 中创建一对多关系有两种方法。第一种方法是通过使用ReferenceField在两个文档之间创建关系,指向另一个对象的 ID。

class Post(mongo.Document):
    …
    user = mongo.ReferenceField(User)

访问ReferenceField的属性直接访问引用对象如下:

>>> user = User.objects.first()
>>> post = Post.objects.first()
>>> post.user = user
>>> post.save()
>>> post.user
<User Jack>

与 SQLAlchemy 不同,MongoEngine 没有办法访问具有与另一个对象的关系的对象。使用 SQLAlchemy,可以声明db.relationship变量,允许用户对象访问具有匹配user_id列的所有帖子。MongoEngine 中不存在这样的并行。

一个解决方案是获取要搜索的帖子的用户 ID,并使用用户字段进行过滤。这与 SQLAlchemy 在幕后执行的操作相同,但我们只是手动执行:

>>> user = User.objects.first()
>>> Post.objects(user__id=user.id)

创建一对多关系的第二种方法是使用带有EmbeddedDocumentEmbeddedDocumentField

class Post(mongo.Document):
    title = mongo.StringField(required=True)
    text = mongo.StringField()
    publish_date = mongo.DateTimeField(
        default=datetime.datetime.now()
    )
    user = mongo.ReferenceField(User)
    comments = mongo.ListField(
        mongo.EmbeddedDocumentField(Comment)
    )

访问comments属性会给出所有嵌入文档的列表。要向帖子添加新评论,将其视为列表并将comment文档附加到其中:

>>> comment = Comment()
>>> comment.name = "Jack"
>>> comment.text = "I really like this post!"
>>> post.comments.append(comment)
>>> post.save()
>>> post.comments
[<Comment 'I really like this post!'>]

请注意,评论变量上没有调用save方法。这是因为评论文档不是真正的文档,它只是DictField的抽象。还要记住,文档只能有 16MB 大,所以要小心每个文档上有多少EmbeddedDocumentFields以及每个文档上有多少EmbeddedDocuments

多对多关系

文档存储数据库中不存在多对多关系的概念。这是因为使用ListFields它们变得完全无关紧要。为了按照惯例为Post对象创建标签功能,添加一个字符串列表:

class Post(mongo.Document):
    title = mongo.StringField(required=True)
    text = mongo.StringField()
    publish_date = mongo.DateTimeField(
        default=datetime.datetime.now()
    )
    user = mongo.ReferenceField(User)
    comments = mongo.ListField(
        mongo.EmbeddedDocumentField(Comment)
    )
    tags = mongo.ListField(mongo.StringField())

现在,当我们希望查询具有特定标签或多个标签的所有Post对象时,这是一个简单的查询:

>>> Post.objects(tags__in="Python").all()
>>> Post.objects(tags__all=["Python", "MongoEngine"]).all()

对于每个用户对象上的角色列表,可以提供可选的 choices 参数来限制可能的角色:

available_roles = ('admin', 'poster', 'default')

class User(mongo.Document):
    username = mongo.StringField(required=True)
    password = mongo.StringField(required=True)
    roles = mongo.ListField(
        mongo.StringField(choices=available_roles)
    )

    def __repr__(self):
        return '<User {}>'.format(self.username)

利用 NoSQL 的强大功能

到目前为止,我们的 MongoEngine 代码应该如下所示:

available_roles = ('admin', 'poster', 'default')

class User(mongo.Document):
    username = mongo.StringField(required=True)
    password = mongo.StringField(required=True)
    roles = mongo.ListField(
        mongo.StringField(choices=available_roles)
    )

    def __repr__(self):
        return '<User {}>'.format(self.username)

class Comment(mongo.EmbeddedDocument):
    name = mongo.StringField(required=True)
    text = mongo.StringField(required=True)
    date = mongo.DateTimeField(
        default=datetime.datetime.now()
    )

    def __repr__(self):
        return "<Comment '{}'>".format(self.text[:15])

class Post(mongo.Document):
    title = mongo.StringField(required=True)
    text = mongo.StringField()
    publish_date = mongo.DateTimeField(
        default=datetime.datetime.now()
    )
    user = mongo.ReferenceField(User)
    comments = mongo.ListField(
        mongo.EmbeddedDocumentField(Comment)
    )
    tags = mongo.ListField(mongo.StringField())

    def __repr__(self):
        return "<Post '{}'>".format(self.title)

这段代码实现了与 SQLAlchemy 模型相同的功能。为了展示 NoSQL 的独特功能,让我们添加一个在 SQLAlchemy 中可能实现但更加困难的功能:不同的帖子类型,每种类型都有自己的自定义内容。这将类似于流行博客平台 Tumblr 的功能。

首先,允许您的帖子类型充当父类,并从Post类中删除文本字段,因为并非所有帖子都会有文本:

class Post(mongo.Document):
    title = mongo.StringField(required=True)
    publish_date = mongo.DateTimeField(
        default=datetime.datetime.now()
    )
    user = mongo.ReferenceField(Userm)
    comments = mongo.ListField(
        mongo.EmbeddedDocumentField(Commentm)
    )
    tags = mongo.ListField(mongo.StringField())

    meta = {
        'allow_inheritance': True
    }

每种帖子类型都将继承自Post类。这样做将使代码能够将任何Post子类视为Post。我们的博客应用将有四种类型的帖子:普通博客帖子、图片帖子、视频帖子和引用帖子。

class BlogPost(Post):
    text = db.StringField(required=True)

    @property
    def type(self):
        return "blog"

class VideoPost(Post):
    url = db.StringField(required=True)

    @property
    def type(self):
        return "video"

class ImagePost(Post):
    image_url = db.StringField(required=True)

    @property
    def type(self):
        return "image"

class QuotePost(Post):
    quote = db.StringField(required=True)
    author = db.StringField(required=True)

    @property
    def type(self):
        return "quote"

我们的帖子创建页面需要能够创建每种帖子类型。forms.py中的PostForm对象,用于处理帖子创建,将需要修改以首先处理新字段。我们将添加一个选择字段来确定帖子类型,一个用于引用类型的author字段,一个用于保存 URL 的image字段,以及一个用于保存嵌入式 HTML iframe 的video字段。引用和博客帖子内容都将共享text字段,如下所示:

class PostForm(Form):
    title = StringField('Title', [
        DataRequired(),
        Length(max=255)
    ])
    type = SelectField('Post Type', choices=[
        ('blog', 'Blog Post'),
        ('image', 'Image'),
        ('video', 'Video'),
        ('quote', 'Quote')
    ])
    text = TextAreaField('Content')
    image = StringField('Image URL', [URL(), Length(max=255)])
    video = StringField('Video Code', [Length(max=255)])
    author = StringField('Author', [Length(max=255)])

blog.py控制器中的new_post视图函数还需要更新以处理新的帖子类型:

@blog_blueprint.route('/new', methods=['GET', 'POST'])
@login_required
@poster_permission.require(http_exception=403)
def new_post():
    form = PostForm()

    if form.validate_on_submit():
        if form.type.data == "blog":
            new_post = BlogPost()
            new_post.text = form.text.data
        elif form.type.data == "image":
            new_post = ImagePost()
            new_post.image_url = form.image.data
        elif form.type.data == "video":
            new_post = VideoPost()
            new_post.video_object = form.video.data
        elif form.type.data == "quote":
            new_post = QuotePost()
            new_post.text = form.text.data
            new_post.author = form.author.data

        new_post.title = form.title.data
        new_post.user = User.objects(
            username=current_user.username
        ).one()

        new_post.save()

    return render_template('new.html', form=form)

渲染我们的表单对象的new.html文件将需要显示添加到表单的新字段:

<form method="POST" action="{{ url_for('.new_post') }}">
…
<div class="form-group">
    {{ form.type.label }}
    {% if form.type.errors %}
        {% for e in form.type.errors %}
            <p class="help-block">{{ e }}</p>
        {% endfor %}
    {% endif %}
    {{ form.type(class_='form-control') }}
</div>
…
<div id="image_group" class="form-group">
    {{ form.image.label }}
    {% if form.image.errors %}
         {% for e in form.image.errors %}
            <p class="help-block">{{ e }}</p>
         {% endfor %}
    {% endif %}
    {{ form.image(class_='form-control') }}
</div>
<div id="video_group" class="form-group">
    {{ form.video.label }}
    {% if form.video.errors %}
        {% for e in form.video.errors %}
            <p class="help-block">{{ e }}</p>
        {% endfor %}
    {% endif %}
    {{ form.video(class_='form-control') }}
</div>
<div id="author_group" class="form-group">
    {{ form.author.label }}
        {% if form.author.errors %}
            {% for e in form.author.errors %}
                <p class="help-block">{{ e }}</p>
            {% endfor %}
        {% endif %}
        {{ form.author(class_='form-control') }}
</div>
<input class="btn btn-primary" type="submit" value="Submit">
</form>

现在我们有了新的输入,我们可以添加一些 JavaScript 来根据帖子类型显示和隐藏字段:

{% block js %}
<script src="img/ckeditor.js"></script>
<script>
    CKEDITOR.replace('editor');

    $(function () {
        $("#image_group").hide();
        $("#video_group").hide();
        $("#author_group").hide();

        $("#type").on("change", function () {
            switch ($(this).val()) {
                case "blog":
                    $("#text_group").show();
                    $("#image_group").hide();
                    $("#video_group").hide();
                    $("#author_group").hide();
                    break;
                case "image":
                    $("#text_group").hide();
                    $("#image_group").show();
                    $("#video_group").hide();
                    $("#author_group").hide();
                    break;
                case "video":
                    $("#text_group").hide();
                    $("#image_group").hide();
                    $("#video_group").show();
                    $("#author_group").hide();
                    break;
                case "quote":
                    $("#text_group").show();
                    $("#image_group").hide();
                    $("#video_group").hide();
                    $("#author_group").show();
                    break;
            }
        });
    })
</script>
{% endblock %}

最后,post.html需要能够正确显示我们的帖子类型。我们有以下内容:

<div class="col-lg-12">
    {{ post.text | safe }}
</div>
All that is needed is to replace this with:
<div class="col-lg-12">
    {% if post.type == "blog" %}
        {{ post.text | safe }}
    {% elif post.type == "image" %}
        <img src="img/{{ post.image_url }}" alt="{{ post.title }}">
    {% elif post.type == "video" %}
        {{ post.video_object | safe }}
    {% elif post.type == "quote" %}
        <blockquote>
            {{ post.text | safe }}
        </blockquote>
        <p>{{ post.author }}</p>
    {% endif %}
</div>

摘要

在本章中,介绍了 NoSQL 和传统 SQL 系统之间的基本区别。我们探讨了 NoSQL 系统的主要类型,以及应用程序可能需要或不需要使用 NoSQL 数据库的原因。利用我们应用程序的模型作为基础,展示了 MongoDB 和 MongoEngine 的强大之处,以及设置复杂关系和继承的简单性。在下一章中,我们的博客应用将通过一个专为希望使用我们网站构建自己服务的其他程序员设计的功能进行扩展,即 RESTful 端点。

第八章:构建 RESTful API

表述状态转移,或者REST,是在客户端和服务器之间传输信息的一种方法。在 Web 上,REST 是建立在 HTTP 之上的,并允许浏览器和服务器通过利用基本的 HTTP 命令轻松通信。通过使用 HTTP 命令,REST 是平台和编程语言无关的,并且解耦了客户端和服务器,使开发更加容易。这通常用于需要在服务器上拉取或更新用户信息的 JavaScript 应用程序。REST 还用于为外部开发人员提供用户数据的通用接口。例如,Facebook 和 Twitter 在其应用程序编程接口(API)中使用 REST,允许开发人员获取信息而无需解析网站的 HTML。

REST 是什么

在深入了解 REST 的细节之前,让我们看一个例子。使用一个客户端,这里是一个 Web 浏览器,和一个服务器,客户端通过 HTTP 向服务器发送请求以获取一些模型,如下所示:

REST 是什么

然后服务器将回应包含所有模型的文档。

REST 是什么

然后客户端可以通过PUT HTTP 请求修改服务器上的数据:

REST 是什么

然后服务器将回应已经修改了数据。这只是一个非常简化的例子,但它将作为 REST 定义的背景。

REST 不是严格的标准,而是对通信的一组约束,以定义一种可以以多种方式实现的方法。这些约束是通过多年与其他通信协议(如远程过程调用RPC)或简单对象访问协议SOAP))的试验和错误产生的。这些协议由于其严格性、冗长性和使用它们创建 API 的困难而被淘汰。这些系统的问题被识别出来,REST 的约束被创建出来,以防止这些问题再次发生。

第一个约束要求客户端和服务器必须有关注点的分离。客户端不能处理永久数据存储,服务器不能处理任何与用户界面有关的事务。

第二个约束是服务器必须是无状态的。这意味着处理请求所需的任何信息都存储在请求本身或由客户端存储。服务器无状态的一个例子是 Flask 中的会话对象。会话对象不会将其信息存储在服务器上,而是将其存储在客户端的 cookie 中。每次请求都会发送 cookie 给服务器解析,并确定所请求资源的必要数据是否存储在其中,而不是服务器为每个用户存储会话信息。

第三个约束是提供的所有资源必须具有统一的接口。这个约束有许多不同的部分,如下所示:

  • 接口是围绕资源构建的,在我们的案例中是模型。

  • 服务器发送的数据不是服务器中的实际数据,而是一个表示。例如,实际数据库不会随每个请求发送,而是发送数据的 JSON 抽象。

  • 服务器发送的数据足以让客户端修改服务器上的数据。在前面的例子中,传递给客户端的 ID 起到了这个作用。

  • API 提供的每个资源必须以相同的方式表示和访问。例如,一个资源不能以 XML 表示,另一个以 JSON 表示,一个通过原始 TCP,一个通过 HTTP。

最后一个约束是系统必须允许层。负载均衡器、代理、缓存和其他服务器和服务可以在客户端和服务器之间起作用,只要最终结果与它们不在那里时相同。

当系统遵循所有这些约束时,被认为是一个 RESTful 系统。最常见的 RESTful 系统形式是由 HTTP 和 JSON 构建的。每个资源位于自己的 URL 路径上,并使用不同的 HTTP 请求类型进行修改。通常采用以下形式:

HTTP 方法 URL 操作
GET http://host/resource 获取所有资源表示
GET http://host/resource/1 获取 ID 为 1 的资源
POST http://host/resource POST中的表单数据创建新资源
PUT http://host/resource/1 修改 ID 为 1 的资源的现有数据
DELETE http://host/resource/1 删除 ID 为 1 的资源

例如,对第二个GET请求的响应将如下所示:

{
    "id": 100,
    "date": "2015-03-02T00:24:36+00:00",
    "title": "Resource #98"
}

在 REST API 中,返回正确的 HTTP 状态代码与响应数据同样非常重要,以便通知客户端服务器上实际发生了什么,而无需客户端解析返回的消息。以下是 REST API 中使用的主要 HTTP 代码及其含义的列表。

HTTP 代码 名称 含义
200 OK HTTP 的默认代码。请求成功,并返回了数据。
201 创建成功 请求成功,并在服务器上创建了一个新资源。
204 无内容 请求成功,但响应未返回任何内容。
400 错误请求 请求被拒绝,因为存在某种感知的客户端错误,要么是格式错误的请求,要么是缺少必需的数据。
401 未经授权 请求被拒绝,因为客户端未经身份验证,应在再次请求此资源之前进行身份验证。
403 禁止 请求被拒绝,因为客户端没有权限访问此资源。这与 401 代码相反,后者假定用户未经身份验证。403 代码表示无论身份验证如何,资源都是不可访问的。
404 未找到 请求的资源不存在。
405 方法不允许 请求被拒绝,因为 URL 不可用的 HTTP 方法。

设置 RESTful Flask API

在我们的应用程序中,我们将在数据库中创建一个博客文章数据的 RESTful 接口。数据的表示将以 JSON 格式发送。数据将使用前面表格中的一般形式进行检索和修改,但 URI 将是/api/posts

我们可以使用标准的 Flask 视图来创建 API,但 Flask 扩展Flask Restful使任务变得更加容易。

安装 Flask Restful:

$ pip install Flask-Restful

extensions.py文件中,初始化将处理所有路由的Api对象:

from flask.ext.restful import Api
…
rest_api = Api()

我们的 Post API 的控制逻辑和视图应存储在controllers文件夹中的新文件夹rest中。在此文件夹中,我们需要一个空的__init__.py和一个名为post.py的文件。在post.py中,让我们创建一个简单的Hello World示例:

from flask.ext.restful import Resource

class PostApi(Resource):
    def get(self):
        return {'hello': 'world'}

在 Flask Restful 中,每个 REST 资源都被定义为从Resource对象继承的类。就像第四章中显示的MethodView对象一样,从Resource对象继承的任何类都使用命名为 HTTP 方法的方法定义其逻辑。例如,当GET HTTP 方法命中PostApi类时,将执行get方法。

就像我们使用的其他 Flask 扩展一样,在__init__.py文件中的应用程序对象上需要初始化Api对象,该文件包含create_app函数。PostApi类还将使用Api对象的add_resource()方法定义其路由:

from .extensions import (
    bcrypt,
    oid,
    login_manager,
    principals,
    rest_api
)
from .controllers.rest.post import PostApi

def create_app(object_name):
    …
    rest_api.add_resource(PostApi, '/api/post')
    rest_api.init_app(app)

现在,如果您在浏览器中打开/api/post URI,将显示Hello World JSON。

GET 请求

对于我们的一些GETPUTDELETE请求,我们的 API 将需要修改帖子的 ID。add_resource方法可以接受多个路由,因此让我们添加捕获传递的 ID 的第二个路由:

   rest_api.add_resource(
        PostApi,
        '/api/post',
        '/api/post/<int:post_id>',
        endpoint='api'
    )

现在get方法将需要接受post_id作为关键字参数:

class PostApi(Resource):
    def get(self, post_id=None):
        if post_id:
            return {"id": post_id}

        return {"hello": "world"}

要发送到客户端的数据必须是 JSON 中的 Post 对象的表示,那么我们的 Post 对象将如何转换?Flask Restful 通过fields对象和marshal_with函数装饰器提供了将任何对象转换为 JSON 的方法。

输出格式

输出格式是通过创建代表基本类型的field对象的字典来定义的。字段的键定义了字段将尝试转换的属性。通过将字典传递给marshal_with装饰器,get方法尝试返回的任何对象都将首先使用字典进行转换。这也适用于对象列表:

from flask import abort 
from flask.ext.restful import Resource, fields, marshal_with
from webapp.models import Post

post_fields = {
    'title': fields.String(),
    'text': fields.String(),
    'publish_date': fields.DateTime(dt_format='iso8601')
}

class PostApi(Resource):
    @marshal_with(post_fields)
    def get(self, post_id=None):
        if post_id:
            post = Post.query.get(post_id)
            if not post:
                abort(404)

            return post
        else:
            posts = Post.query.all()
            return posts

在浏览器中重新加载 API 时,每个 Post 对象将以 JSON 格式显示。但是,问题在于 API 不应返回帖子创建表单中所见的 WYSIWYG 编辑器中的 HTML。如前所述,服务器不应关心 UI,而 HTML 纯粹是用于输出规范。为了解决这个问题,我们需要一个自定义字段对象,它可以从字符串中去除 HTML。在名为fields.pyrest文件夹中添加以下内容:

from HTMLParser import HTMLParser
from flask.ext.restful import fields

class HTMLStripper(HTMLParser):
    def __init__(self):
        self.reset()
        self.fed = []

    def handle_data(self, d):
        self.fed.append(d)

    def get_data(self):
        return ''.join(self.fed)

    def strip_tags(html):
        s = HTMLStripper()
        s.feed(html)

    return s.get_data()

class HTMLField(fields.Raw):
    def format(self, value):
        return strip_tags(str(value))

现在,我们的post_fields字典应该更新以适应新字段:

from .fields import HTMLField

post_fields = {
    'title': fields.String(),
    'text': HTMLField(),
    'publish_date': fields.DateTime(dt_format='iso8601')
}

使用标准库HTMLParser模块,我们现在有一个strip_tags函数,它将返回任何已清除 HTML 标记的字符串。通过从fields.Raw类继承并通过strip_tags函数发送值,定义了一个新的字段类型HTMLfield。如果页面再次重新加载,所有 HTML 都将消失,只剩下文本。

Flask Restful 提供了许多默认字段:

  • fields.String:这将使用str()转换值。

  • fields.FormattedString:这在 Python 中传递格式化的字符串,变量名在括号中。

  • fields.Url:这提供了与 Flask url_for函数相同的功能。

  • fields.DateTime:这将 Python datedatetime对象转换为字符串。格式关键字参数指定字符串应该是ISO8601日期还是RFC822日期。

  • fields.Float:这将将值转换为浮点数的字符串表示。

  • fields.Integer:这将将值转换为整数的字符串表示。

  • fields.Nested:这允许通过另一个字段对象的字典来表示嵌套对象。

  • fields.List:与 MongoEngine API 类似,此字段将另一个字段类型作为参数,并尝试将值列表转换为字段类型的 JSON 列表。

  • fields.Boolean:这将将值转换为布尔参数的字符串表示。

还有两个字段应该添加到返回的数据中:作者和标签。评论将被省略,因为它们应该包含在自己的资源下。

nested_tag_fields = {
    'id': fields.Integer(),
    'title': fields.String()
}

post_fields = {
    'author': fields.String(attribute=lambda x: x.user.username),
    'title': fields.String(),
    'text': HTMLField(),
    'tags': fields.List(fields.Nested(nested_tag_fields)),
    'publish_date': fields.DateTime(dt_format='iso8601')
}

author字段使用field类的属性关键字参数。这允许表示对象的任何属性,而不仅仅是基本级别的属性。因为标签的多对多关系返回对象列表,所以不能使用相同的解决方案。使用ListField中的NestedField类型和另一个字段字典,现在可以返回标签字典的列表。这对 API 的最终用户有额外的好处,因为它们可以轻松查询标签 ID,就像有一个标签 API 一样。

请求参数

在向资源的基础发送GET请求时,我们的 API 当前发送数据库中的所有 Post 对象。如果对象的数量较少或使用 API 的人数较少,则这是可以接受的。但是,如果任一方增加,API 将对数据库施加大量压力。与 Web 界面类似,API 也应该进行分页。

为了实现这一点,我们的 API 将需要接受一个GET查询字符串参数page,指定要加载的页面。Flask Restful 提供了一种方法来获取请求数据并解析它。如果必需的参数不存在,或者类型不匹配,Flask Restful 将自动创建一个 JSON 错误消息。在名为parsers.pyrest文件夹中的新文件中,添加以下代码:

from flask.ext.restful import reqparse

post_get_parser = reqparse.RequestParser()
post_get_parser.add_argument(
    'page',
    type=int,
    location=['args', 'headers'],
    required=False
)

现在,PostApi类将需要更新以与我们的解析器一起使用:

from .parsers import post_get_parser

class PostApi(Resource):
    @marshal_with(post_fields)
    def get(self, post_id=None):
        if post_id:
            post = Post.query.get(post_id)
            if not post:
                abort(404)

            return post
        else:
            args = post_get_parser.parse_args()
            page = args['page'] or 1
            posts = Post.query.order_by(
                Post.publish_date.desc()
            ).paginate(page, 30)

            return posts.items

在上面的示例中,RequestParser在查询字符串或请求标头中查找page变量,并从该页面返回 Post 对象的页面。

使用RequestParser创建解析器对象后,可以使用add_argument方法添加参数。add_argument的第一个参数是要解析的参数的键,但add_argument还接受许多关键字参数:

  • action:这是解析器在成功解析后对值执行的操作。两个可用选项是storeappendstore将解析的值添加到返回的字典中。append将解析的值添加到字典中列表的末尾。

  • case_sensitive:这是一个boolean参数,用于允许或不允许键区分大小写。

  • choices:这类似于 MongoEngine,是参数允许的值列表。

  • default:如果请求中缺少参数,则生成的值。

  • dest:这是将解析值添加到返回数据中的键。

  • help:这是一个消息,如果验证失败,将返回给用户。

  • ignore:这是一个boolean参数,允许或不允许类型转换失败。

  • location:这表示要查找数据的位置。可用的位置是:

  • args以查找GET查询字符串

  • headers以查找 HTTP 请求标头

  • form以查找 HTTP POST数据

  • cookies以查找 HTTP cookies

  • json以查找任何发送的 JSON

  • files以查找POST文件数据

  • required:这是一个boolean参数,用于确定参数是否是可选的。

  • store_missing:这是一个boolean参数,用于确定是否应存储默认值,如果参数不在请求中。

  • 类型:这是 Python 类型,用于转换传递的值。

使用 Flask Restful 解析器,很容易向 API 添加新参数。例如,让我们添加一个用户参数,允许我们搜索用户发布的所有帖子。首先,在parsers.py文件中,添加以下内容:

post_get_parser = reqparse.RequestParser()
post_get_parser.add_argument(
    'page',
    type=int,
    location=['json', 'args', 'headers']
)
post_get_parser.add_argument(
    'user',
    type=str,
    location=['json', 'args', 'headers']
)

然后,在post.py中添加以下内容:

class PostApi(Resource):
    @marshal_with(post_fields)
    def get(self, post_id=None):
        if post_id:
            post = Post.query.get(post_id)
            if not post:
                abort(404)

            return post
        else:
            args = post_get_parser.parse_args()
            page = args['page'] or 1

            if args['user']:
                user = User.query.filter_by(
                    username=args['user']
                ).first()
                if not user:
                    abort(404)

                posts = user.posts.order_by(
                    Post.publish_date.desc()
                ).paginate(page, 30)
            else:
                posts = Post.query.order_by(
                    Post.publish_date.desc()
                ).paginate(page, 30)

            return posts.items

当从Resource调用 Flask 的abort函数时,Flask Restful 将自动创建一个错误消息,以与状态代码一起返回。

POST 请求

使用我们对 Flask Restful 解析器的新知识,可以添加POST端点。首先,我们需要一个解析器,它将获取标题、正文文本和标签列表。在parser.py文件中,添加以下内容:

post_post_parser = reqparse.RequestParser()
post_post_parser.add_argument(
    'title',
    type=str,
    required=True,
    help="Title is required"
)
post_post_parser.add_argument(
    'text',
    type=str,
    required=True,
    help="Body text is required"
)
post_post_parser.add_argument(
    'tags',
    type=str,
    action='append'
)

接下来,PostApi类将需要一个post方法来处理传入的请求。post方法将使用给定的标题和正文文本。此外,如果存在标签键,则将标签添加到帖子中,如果传递的标签不存在,则创建新标签:

import datetime
from .parsers import (
    post_get_parser,
    post_post_parser
)
from webapp.models import db, User, Post, Tag

class PostApi(Resource):
    …
    def post(self, post_id=None):
        if post_id:
            abort(400)
        else:
            args = post_post_parser.parse_args(strict=True)
            new_post = Post(args['title']) 
            new_post.date = datetime.datetime.now()
            new_post.text = args['text']

            if args['tags']:
                for item in args['tags']:
                    tag = Tag.query.filter_by(title=item).first()

                    # Add the tag if it exists.
                    # If not, make a new tag
                    if tag:
                        new_post.tags.append(tag)
                    else:
                        new_tag = Tag(item) 
                        new_post.tags.append(new_tag)

            db.session.add(new_post)
            db.session.commit()
            return new_post.id, 201

return语句处,如果返回一个元组,则第二个参数将被视为状态代码。还有一个作为额外标头值的第三个值,通过传递一个字典。

为了测试这段代码,必须使用与 Web 浏览器不同的工具,因为在浏览器中很难创建自定义的 POST 请求而不使用浏览器插件。而是使用名为 curl 的工具。Curl是 Bash 中包含的命令行工具,允许创建和操作 HTTP 请求。要使用 curl 执行GET请求,只需传递 URL:

$ curl http://localhost:5000/api/post/1

要传递POST变量,使用d标志:

$ curl -d "title=From REST" \
-d "text=The body text from REST" \
-d "tag=Python" \
http://localhost:5000/api/post

新创建的帖子的 id 应该被返回。但是,如果你现在在浏览器中加载你创建的帖子,会出现错误。这是因为我们的Post对象没有与之关联的用户。为了让帖子对象分配给用户,并且只有网站的经过身份验证的用户才有权限POST帖子,我们需要创建一个身份验证系统。

身份验证

为了解决我们的身份验证问题,可以使用 Flask-Login,并检查登录的 cookie 数据。然而,这将要求希望使用我们的 API 的开发人员通过 Web 界面登录他们的程序。我们也可以让开发人员在每个请求中发送他们的登录数据,但是只在绝对必要时发送敏感信息是一个很好的设计实践。相反,我们的 API 将提供一个auth端点,允许他们发送登录凭据并获得一个访问令牌。

这个access令牌将由 Flask 使用的 Python 库it's dangerous创建,用于对 cookie 上的会话数据进行编码,因此它应该已经安装。令牌将是一个由应用程序的秘钥加密签名的 Python 字典,其中包含用户的 id。这个令牌中编码了一个过期日期,在过期后将不允许使用。这意味着即使令牌被恶意用户窃取,它在客户端必须重新进行身份验证之前只能在有限的时间内使用。首先,需要一个新的解析器来处理解析用户名和密码数据:

user_post_parser = reqparse.RequestParser()
user_post_parser.add_argument('username', type=str, required=True)
user_post_parser.add_argument('password', type=str, required=True)

rest文件夹内新建一个名为auth.py的文件,添加以下代码:

from flask import abort, current_app

from .parsers import user_post_parser
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer

class AuthApi(Resource):
    def post(self):
        args = user_post_parser.parse_args()
        user = User.query.filter_by(
            username=args['username']
        ).one()

        if user.check_password(args['password']):
            s = Serializer(
                current_app.config['SECRET_KEY'], 
                expires_in=600
            )
            return {"token": s.dumps({'id': user.id})}
        else:
            abort(401)

注意

不要允许用户通过不安全的连接发送他们的登录凭据!如果你希望保护用户的数据,需要使用 HTTPS。最好的解决方案是要求整个应用程序都使用 HTTPS,以避免可能性。

我们的 API 的用户必须将从这个资源接收到的令牌传递给任何需要用户凭据的方法。但是,首先我们需要一个验证令牌的函数。在models.py文件中,verify_auth_token将是User对象上的staticmethod

from itsdangerous import (
    TimedJSONWebSignatureSerializer as Serializer,
    BadSignature,
    SignatureExpired
)
from flask import current_app

class User(db.Model):
…
    @staticmethod
    def verify_auth_token(token):
        s = Serializer(current_app.config['SECRET_KEY'])

        try:
            data = s.loads(token)
        except SignatureExpired:
            return None
        except BadSignature:
            return None

        user = User.query.get(data['id'])
        return user

我们的POST解析器需要一个令牌参数来接受auth令牌:

post_post_parser = reqparse.RequestParser()
post_post_parser.add_argument(
    'token',
    type=str,
    required=True,
    help="Auth Token is required to create posts"
)

现在,我们的post方法可以正确地添加新的帖子,如下所示:

class PostApi(Resource):
    def get(self, post_id=None):
       …

    def post(self, post_id=None):
        if post_id:
            abort(405)
        else:
            args = post_post_parser.parse_args(strict=True)

            user = User.verify_auth_token(args['token'])
            if not user:
                abort(401)

            new_post = Post(args['title'])
            new_post.user = user
            …

使用 curl,我们现在可以测试我们的authpostAPI。为了简洁起见,这里省略了令牌,因为它非常长:

$ curl -d "username=user" \
-d "password=password" \
http://localhost:5000/api/auth

{token: <the token>}

$ curl -d "title=From REST" \
-d "text=this is from REST" \
-d "token=<the token>" \
-d "tags=Python" \
-d "tags=Flask" \
http://localhost:5000/api/post

PUT 请求

如本章开头的表格所列,PUT请求用于更改现有资源的值。与post方法一样,首先要做的是在parsers.py中创建一个新的解析器:

post_put_parser = reqparse.RequestParser()
post_put_parser.add_argument(
    'token',
    type=str,
    required=True,
    help="Auth Token is required to edit posts"
)
post_put_parser.add_argument(
    'title',
    type=str
)
post_put_parser.add_argument(
    'text',
    type=str
)
post_put_parser.add_argument(
    'tags',
    type=str,
    action='append'
)

put方法的逻辑与post方法非常相似。主要区别在于每个更改都是可选的,任何没有提供post_id的请求都将被拒绝:

from .parsers import (
    post_get_parser,
    post_post_parser,
    post_put_parser
)

class PostApi(Resource):
    @marshal_with(post_fields)
    def get(self, post_id=None):
        …

    def post(self, post_id=None):
        …

    def put(self, post_id=None):
        if not post_id:
            abort(400)

        post = Post.query.get(post_id)
        if not post:
            abort(404)

        args = post_put_parser.parse_args(strict=True)
        user = User.verify_auth_token(args['token'])
        if not user:
            abort(401)
        if user != post.user:
            abort(403)

        if args['title']:
            post.title = args['title']

        if args['text']:
            post.text = args['text']

        if args['tags']:
            for item in args['tags']:
                tag = Tag.query.filter_by(title=item).first()

                # Add the tag if it exists. If not, make a new tag
                if tag:
                    post.tags.append(tag)
                else:
                    new_tag = Tag(item)
                    post.tags.append(new_tag)

        db.session.add(post)
        db.session.commit()
        return post.id, 201

为了测试这个方法,curl 也可以使用-X标志创建PUT请求:

$ curl -X PUT \
-d "title=Modified From REST" \
-d "text=this is from REST" \
-d "token=<the token>" \
-d "tags=Python" -d "tags=Flask" -d "tags=REST" \
http://localhost:5000/api/post/101

DELETE 请求

最后,我们有DELETE请求,这是四种支持方法中最简单的。delete方法的主要区别在于它不返回任何内容,这是DELETE请求的接受标准:

class PostApi(Resource):
    @marshal_with(post_fields)
    def get(self, post_id=None):
        …

    def post(self, post_id=None):
        …

    def put(self, post_id=None):
        …

    def delete(self, post_id=None):
        if not post_id:
            abort(400)

        post = Post.query.get(post_id)
        if not post:
            abort(404)

        args = post_delete_parser.parse_args(strict=True)
        user = verify_auth_token(args['token'])
        if user != post.user:
            abort(403)

        db.session.delete(post)
        db.session.commit()
        return "", 204

同样,我们可以测试:

$ curl -X DELETE\
-d "token=<the token>"\
http://localhost:5000/api/post/102

如果一切顺利删除,你应该收到一个 204 状态码,什么都不应该显示出来。

在我们完全迁移出 REST 之前,读者还有一个最后的挑战,来测试你对 Flask Restful 的理解。尝试创建一个评论 API,不仅可以从http://localhost:5000/api/comments进行修改,还允许开发人员通过 URLhttp://localhost:5000/api/post/<int:post_id>/comments来修改特定帖子上的评论。

摘要

我们的 Post API 现在是一个完整的功能。如果开发者希望,他们可以使用这个 API 创建桌面或移动应用程序,而无需使用 HTML 抓取,这是一个非常繁琐和漫长的过程。给予希望将您的网站作为平台使用的开发者这样做的能力将增加您网站的受欢迎程度,因为他们实质上会通过他们的应用程序或网站为您提供免费广告。

在下一章中,我们将使用流行的程序 Celery 来异步运行程序和任务与我们的应用程序。

第九章:使用 Celery 创建异步任务

在创建 Web 应用程序时,保持请求处理时间在 50 毫秒左右以下是至关重要的。由于大部分响应时间都被等待用户连接所占据,额外的处理时间可能会挂起服务器。应该避免服务器上的任何额外处理。然而,在 Web 应用程序中,有几个操作可能需要花费超过几秒钟的时间,特别是涉及复杂的数据库操作或图像处理时。为了保护用户体验,将使用名为 Celery 的任务队列将这些操作移出 Flask 进程。

Celery 是什么?

Celery是用 Python 编写的异步任务队列。Celery 通过 Python 多进程库并发运行任务,这些任务是用户定义的函数。Celery 接收消息,告诉它从代理开始任务,通常称为消息队列,如下图所示:

Celery 是什么?

消息队列是一个专门设计用于在生产者进程和消费者进程之间发送数据的系统。生产者进程是创建要发送到队列中的消息的任何程序,消费者进程是从队列中取出消息的任何程序。从生产者发送的消息存储在先进先出FIFO)队列中,最旧的项目首先被检索。消息存储直到消费者接收消息,之后消息被删除。消息队列提供实时消息传递,而不依赖于轮询,即持续检查进程状态的过程。当消息从生产者发送时,消费者正在其连接到消息队列上监听新消息;消费者不会不断地联系队列。这种差异就像AJAXWebSockets之间的差异;AJAX 需要与服务器保持不断的联系,而 WebSockets 只是一个持续的流。

可以用传统数据库替换消息队列。Celery 甚至内置了对 SQLAlchemy 的支持以实现这一点。然而,强烈不建议使用数据库作为 Celery 的代理。使用数据库代替消息队列需要消费者不断地轮询数据库以获取更新。此外,由于 Celery 使用多进程进行并发处理,大量读取的连接数量会迅速增加。在中等负载下,使用数据库需要生产者同时向数据库进行大量写入,而消费者正在读取。数据库不能有太多的连接同时进行读取、写入和更新相同的数据。当这种情况发生时,表通常会被锁定,所有其他连接都在等待每次写入完成后才能读取数据,反之亦然。更糟糕的是,这可能导致竞争条件,即并发事件更改和读取相同的资源,并且每个并发操作都使用过时版本的数据。特定于 Celery,这可能导致相同的操作针对相同的消息多次运行。

也可以使用消息队列作为代理和数据库来存储任务的结果。在前面的图表中,消息队列用于发送任务请求和任务结果。

然而,使用数据库存储任务的最终结果允许最终产品无限期地存储,而消息队列将在生产者接收数据后立即丢弃数据,如下图所示:

Celery 是什么?

这个数据库通常是一个键值 NoSQL 存储,以帮助处理负载。如果您计划对先前运行的任务进行分析,这将非常有用;否则,最好只使用消息队列。

甚至有一个选项可以完全丢弃任务的结果,而不返回任务的结果。这样做的缺点是生产者无法知道任务是否成功,但在较小的项目中通常足够。

对于我们的堆栈,我们将使用RabbitMQ作为消息代理。RabbitMQ 在所有主要操作系统上运行,并且非常简单设置和运行。Celery 还支持 RabbitMQ,无需任何额外的库,并且是 Celery 文档中推荐的消息队列。

注意

在撰写本文时,尚无法在 Python 3 中使用 RabbitMQ 与 Celery。您可以使用 Redis 代替 RabbitMQ。唯一的区别将是连接字符串。有关更多信息,请参见docs.celeryproject.org/en/latest/getting-started/brokers/redis.html

设置 Celery 和 RabbitMQ

要使用pip安装 Celery,请运行以下命令:

$ pip install Celery

我们还需要一个 Flask 扩展来帮助处理初始化 Celery:

$ pip install Flask-Celery-Helper

Flask 文档指出,Flask 对 Celery 的扩展是不必要的。但是,在使用应用程序工厂组织应用程序时,使 Celery 服务器能够与 Flask 的应用程序上下文一起工作是很重要的。因此,我们将使用Flask-Celery-Helper来完成大部分工作。

接下来,需要安装 RabbitMQ。RabbitMQ 不是用 Python 编写的;因此,每个操作系统的安装说明都将不同。幸运的是,RabbitMQ 在www.rabbitmq.com/download.html上为每个操作系统维护了详细的说明列表。

安装 RabbitMQ 后,打开终端窗口并运行以下命令:

$ rabbitmq-server

这将启动一个带有用户名为 guest 和密码为 guest 的 RabbitMQ 服务器。默认情况下,RabbitMQ 只接受本地主机上的连接,因此这种设置对开发来说是可以的。

在 Celery 中创建任务

如前所述,Celery 任务只是执行一些操作的用户定义函数。但在编写任何任务之前,需要创建我们的 Celery 对象。这是 Celery 服务器将导入以处理运行和调度所有任务的对象。

至少,Celery 需要一个配置变量才能运行:与消息代理的连接。连接被定义为 URL,就像 SQLAlchemy 连接一样。后端,用于存储我们任务结果的地方,也被定义为 URL,如下面的代码所示:

class DevConfig(Config):
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = 'sqlite:///../database.db'
    CELERY_BROKER_URL = "amqp://guest:guest@localhost:5672//"
    CELERY_BACKEND = "amqp://guest:guest@localhost:5672//"
In the extensions.py file, the Celery class from Flask-Celery-Helper will be initialized:
from flask.ext.celery import Celery
celery = Celery()

因此,为了使我们的 Celery 进程能够与数据库和任何其他 Flask 扩展一起工作,它需要在我们的应用程序上下文中工作。为了做到这一点,Celery 需要为每个进程创建我们应用程序的新实例。与大多数 Celery 应用程序不同,我们需要一个 Celery 工厂来创建应用程序实例并在其上注册我们的 Celery 实例。在顶级目录中的一个新文件中,与manage.py位于同一位置,命名为celery_runner.py,添加以下内容:

import os
from webapp import create_app
from celery import Celery
from webapp.tasks import log

def make_celery(app):
    celery = Celery(
        app.import_name,
        broker=app.config['CELERY_BROKER_URL'],
        backend=app.config['CELERY_BACKEND_URL']
    )
    celery.conf.update(app.config)
    TaskBase = celery.Task

    class ContextTask(TaskBase):
        abstract = True

        def __call__(self, *args, **kwargs):
            with app.app_context():
                return TaskBase.__call__(self, *args, **kwargs)

    celery.Task = ContextTask

    return celery

env = os.environ.get('WEBAPP_ENV', 'dev')
flask_app = create_app(
    'webapp.config.%sConfig' % env.capitalize()
)
celery = make_celery(flask_app)

make_celery函数的作用是在 Python 的with块中包装对每个 Celery 任务的每次调用。这确保了对任何 Flask 扩展的每次调用都可以正常工作,因为它正在与我们的应用程序一起工作。还要确保不要将 Flask 应用程序实例命名为app,因为 Celery 会尝试导入任何名为appcelery的对象作为 Celery 应用程序实例。因此,将您的 Flask 对象命名为app将导致 Celery 尝试将其用作 Celery 对象。

现在,我们可以编写我们的第一个任务。这将是一个简单的任务,只是返回传递给它的任何字符串。在应用程序目录中的一个新文件中命名为tasks.py,添加以下内容:

from webapp.extensions import celeryfrom webapp.extensions import celery
@celery.task()
def log(msg):
    return msg

现在,谜题的最后一部分是在新的终端窗口中运行 Celery 进程,称为worker。再次强调,这是将监听我们的消息代理以启动新任务的进程:

$ celery worker -A celery_runner --loglevel=info

loglevel标志存在的原因是,您可以在终端窗口中看到任务已收到的确认以及其输出的可用性。

现在,我们可以向 Celery 工作进程发送命令。打开 manage.py shell 并导入 log 任务:

>>> from webapp.tasks import log
>>> log("Message")
Message
>>> result = log.delay("Message")

该函数可以像调用其他函数一样调用;这样做将在当前进程中执行该函数。但是,在任务上调用 delay 方法将向工作进程发送消息,以使用给定的参数执行该函数。

在运行 Celery 工作进程的终端窗口中,您应该看到类似以下内容:

Task tasks.log succeeded in 0.0005873600021s: 'Message'

对于任何异步任务,ready 方法可用于判断任务是否成功完成。如果为真,则可以使用 get 方法来检索任务的结果。

>>> result.ready()
True
>>> result.get()
"Message"

get 方法会导致当前进程等待,直到 ready 函数返回 True 以检索结果。因此,在调用任务后立即调用 get 实质上使任务同步。因此,任务实际上很少返回值给生产者。绝大多数任务执行某些操作然后退出。

当在 Celery 工作进程上运行任务时,可以通过 state 属性访问任务的状态。这允许更细粒度地了解任务在工作进程中当前正在执行的操作。可用的状态如下:

  • FAILURE:任务失败,所有重试也失败

  • PENDING:任务尚未被工作进程接收

  • RECEIVED:任务已被工作进程接收,但尚未处理

  • RETRY:任务失败,正在等待重试

  • REVOKED:任务已停止

  • STARTED:工作进程已开始处理任务

  • SUCCESS:任务成功完成

在 Celery 中,如果任务失败,则任务可以使用 retry 方法重新调用自身,如下所示:

@celery.task(bind=True)
def task(self, param):
    try:
        some_code
    except Exception, e:
        self.retry(exc=e)

装饰器函数中的 bind 参数告诉 Celery 将任务对象的引用作为函数的第一个参数传递。使用 self 参数,可以调用 retry 方法,该方法将使用相同的参数重新运行任务。可以将其他参数传递给函数装饰器,以更改任务的行为:

  • max_retries:这是任务在被声明为失败之前可以重试的最大次数。

  • default_retry_delay:这是在再次运行任务之前等待的时间(以秒为单位)。如果您预期导致任务失败的条件是短暂的,例如网络错误,那么最好将其保持在大约一分钟左右。

  • rate_limit:这指定在给定间隔内允许运行此任务的唯一调用总数。如果值是整数,则是每秒允许运行此任务的总数。该值也可以是形式为 x/m 的字符串,表示每分钟 x 个任务,或形式为 x/h 的字符串,表示每小时 x 个任务。例如,传入 5/m 将只允许每分钟调用此任务五次。

  • time_limit:如果指定,任务将在运行时间超过此秒数时被终止。

  • ignore_result:如果不使用任务的返回值,则不要将其发送回。

最好为每个任务指定所有这些内容,以避免任务不会运行的任何机会。

运行 Celery 任务

delay 方法是 apply_async 方法的简写版本,格式如下所示:

task.apply_async(
    args=[1, 2],
    kwargs={'kwarg1': '1', 'kwarg2': '2'}
)

但是,args 关键字可以是隐式的:

apply_async([1, 2], kwargs={'kwarg1': '1', 'kwarg2': '2'})

调用 apply_async 允许您在任务调用中定义一些额外的功能,这些功能在 delay 方法中无法指定。首先,countdown 选项指定工作进程在接收到任务后等待运行任务的时间(以秒为单位):

>>> from webapp.tasks import log
>>> log.apply_async(["Message"], countdown=600)

countdown 不能保证任务将在 600 秒后运行。countdown 只表示任务在 x 秒后准备处理。如果所有工作进程都忙于处理其他任务,则任务将不会立即运行。

apply_async 提供的另一个关键字参数是 eta 参数。eta 通过一个指定任务应该运行的确切时间的 Python datetime 对象传递。同样,eta 不可靠。

>>> import datetime
>>> from webapp.tasks import log
# Run the task one hour from now
>>> eta = datetime.datetime.now() + datetime.timedelta(hours=1)
>>> log.apply_async(["Message"], eta=eta)

Celery 工作流

Celery 提供了许多方法来将多个依赖任务分组在一起,或者并行执行多个任务。这些方法受到函数式编程语言中的语言特性的很大影响。然而,要理解这是如何工作的,我们首先需要了解签名。考虑以下任务:

@celery.task()
def multiply(x, y):
    return x * y

让我们看看一个签名的实际操作以理解它。打开 manage.py shell:

>>> from celery import signature
>>> from webapp.tasks import multiply
# Takes the same keyword args as apply_async
>>> signature('webapp.tasks.multiply', args=(4, 4) , countdown=10)
webapp.tasks.multiply(4, 4)
# same as above
>>> from webapp.tasks import multiply
>>> multiply.subtask((4, 4), countdown=10)
webapp.tasks.multiply(4, 4)
# shorthand for above, like delay in that it doesn't take
# apply_async's keyword args
>>> multiply.s(4, 4)
webapp.tasks.multiply(4, 4)
>>> multiply.s(4, 4)()
16
>>> multiply.s(4, 4).delay()

调用任务的签名,有时称为任务的子任务,会创建一个可以传递给其他函数以执行的函数。执行签名,就像示例中倒数第三行那样,会在当前进程中执行函数,而不是在工作进程中执行。

部分

任务签名的第一个应用是函数式编程风格的部分。部分是最初接受许多参数的函数;然而,对原始函数应用操作以返回一个新函数,因此前 n 个参数始终相同。一个例子是一个不是任务的 multiply 函数:

>>> new_multiply = multiply(2)
>>> new_multiply(5)
10
# The first function is unaffected
>>> multiply(2, 2)
4

这是一个虚构的 API,但这与 Celery 版本非常接近:

>>> partial = multiply.s(4)
>>> partial.delay(4)

工作窗口中的输出应该显示 16。基本上,我们创建了一个新函数,保存到部分中,它将始终将其输入乘以四。

回调

一旦任务完成,根据前一个任务的输出运行另一个任务是非常常见的。为了实现这一点,apply_async 函数有一个 link 方法:

>>> multiply.apply_async((4, 4), link=log.s())

工作器输出应该显示 multiply 任务和 log 任务都返回 16

如果您有一个不需要输入的函数,或者您的回调不需要原始方法的结果,则必须使用 si 方法将任务签名标记为不可变:

>>> multiply.apply_async((4, 4), link=log.si("Message"))

回调可以用来解决现实世界的问题。如果我们想要在每次任务创建新用户时发送欢迎电子邮件,那么我们可以通过以下调用产生该效果:

>>> create_user.apply_async(("John Doe", password), link=welcome.s())

部分和回调可以结合产生一些强大的效果:

>>> multiply.apply_async((4, 4), link=multiply.s(4))

重要的是要注意,如果保存了此调用并在其上调用了 get 方法,则结果将是 16 而不是 64。这是因为 get 方法不会返回回调方法的结果。这将在以后的方法中解决。

group 函数接受一个签名列表,并创建一个可调用函数来并行执行所有签名,然后返回所有结果的列表:

>>> from celery import group
>>> sig = group(multiply.s(i, i+5) for i in range(10))
>>> result = sig.delay()
>>> result.get()
[0, 6, 14, 24, 36, 50, 66, 84, 104, 126]

chain 函数接受任务签名,并将每个结果的值传递给链中的下一个值,返回一个结果如下:

>>> from celery import chain
>>> sig = chain(multiply.s(10, 10), multiply.s(4), multiply.s(20))
# same as above
>>> sig = (multiply.s(10, 10) | multiply.s(4) | multiply.s(20))
>>> result = sig.delay()
>>> result.get()
8000

链和部分可以进一步发展。链可以用于在使用部分时创建新函数,并且链可以嵌套如下:

# combining partials in chains
>>> func = (multiply.s(10) | multiply.s(2))
>>> result = func.delay(16)
>>> result.get()
200
# chains can be nested
>>> func = (
 multiply.s(10) | multiply.s(2) | (multiply.s(4) | multiply.s(5))
)
>>> result = func.delay(16)
>>> result.get()
800

和弦

chord 函数创建一个签名,将执行一组签名,并将最终结果传递给回调:

>>> from celery import chord
>>> sig = chord(
 group(multiply.s(i, i+5) for i in range(10)),
 log.s()
)
>>> result = sig.delay()
>>> result.get()
[0, 6, 14, 24, 36, 50, 66, 84, 104, 126]

就像链接参数一样,回调不会随着 get 方法返回。

使用 chain 语法与组和回调自动创建一个和弦签名:

# same as above
>>> sig = (group(multiply.s(i, i+5) for i in range(10)) | log.s())
>>> result = sig.delay()
>>> result.get()
[0, 6, 14, 24, 36, 50, 66, 84, 104, 126]

定期运行任务

Celery 还有能力定期调用任务。对于熟悉 *nix 操作系统的人来说,这个系统很像命令行实用程序 cron,但它的额外好处是在我们的源代码中定义,而不是在某个系统文件中。因此,当我们的代码准备发布到 第十三章 部署 Flask 应用 中时,更新将更容易。此外,所有任务都在应用上下文中运行,而由 cron 调用的 Python 脚本则不会。

要添加定期任务,请将以下内容添加到 DevConfig 配置对象中:

import datetime
…

CELERYBEAT_SCHEDULE = {
    'log-every-30-seconds': {
        'task': 'webapp.tasks.log',
        'schedule': datetime.timedelta(seconds=30),
        'args': ("Message",)
    },
}

configuration变量定义了log任务应该每 30 秒运行一次,并将args元组作为参数传递。任何timedelta对象都可以用来定义运行任务的间隔。

要运行周期性任务,需要另一个名为beat工作程序的专门工作程序。在另一个终端窗口中,运行以下命令:

$ celery -A celery_runner beat

如果您现在观看主要的Celery工作程序中的终端输出,您应该每 30 秒看到一个日志事件。

如果您的任务需要以更具体的间隔运行,例如,每周二在 6 月的凌晨 3 点和下午 5 点?对于非常具体的间隔,有Celery crontab对象。

为了说明crontab对象如何表示间隔,以下是一些示例:

>>> from celery.schedules import crontab
# Every midnight
>>> crontab(minute=0, hour=0)
# Once a 5AM, then 10AM, then 3PM, then 8PM
>>> crontab(minute=0, hour=[5, 10, 15, 20])
# Every half hour
>>> crontab(minute='*/30')
# Every Monday at even numbered hours and 1AM
>>> crontab(day_of_week=1, hour ='*/2, 1')

该对象具有以下参数:

  • 分钟

  • 小时

  • 星期几

  • 每月的日期

  • 月份

这些参数中的每一个都可以接受各种输入。使用纯整数时,它们的操作方式与timedelta对象类似,但它们也可以接受字符串和列表。当传递一个列表时,任务将在列表中的每个时刻执行。当传递一个形式为**/x*的字符串时,任务将在模运算返回零的每个时刻执行。此外,这两种形式可以组合成逗号分隔的整数和除法的字符串。

监控 Celery

当我们的代码被推送到服务器时,我们的Celery工作程序将不会在终端窗口中运行,它将作为后台任务运行。因此,Celery 提供了许多命令行参数来监视您的Celery工作程序和任务的状态。这些命令采用以下形式:

$ celery –A celery_runner <command>

查看工作程序状态的主要任务如下:

  • 状态:这会打印运行的工作程序以及它们是否正常运行

  • 结果:当传递一个任务 id 时,这显示任务的返回值和最终状态

  • 清除:使用此命令,代理中的所有消息将被删除

  • 检查活动:这将列出所有活动任务

  • 检查已安排:这将列出所有已使用eta参数安排的任务

  • 检查已注册:这将列出所有等待处理的任务

  • 检查统计:这将返回一个字典,其中包含有关当前运行的工作程序和代理的统计信息

使用 Flower 进行基于 Web 的监控

Flower是一个基于 Web 的实时管理工具,用于 Celery。在 Flower 中,可以监视所有活动的,排队的和已完成的任务。Flower 还提供了关于每个图表在队列中停留的时间以及执行所需的时间和每个任务的参数的图表和统计信息。

要安装 Flower,请使用以下pip

$ pip install flower

要运行它,只需将flower视为Celery命令,如下所示:

$ celery flower -A celery_runner --loglevel=info

现在,打开浏览器到http://localhost:5555。最好在任务运行时熟悉界面,因此转到命令行并输入以下内容:

>>> sig = chord(
 group(multiply.s(i, i+5) for i in xrange(10000)),
 log.s()
)
>>> sig.delay()

您的工作程序现在将开始处理 10,000 个任务。在任务运行时浏览不同的页面,看看 Flower 在工作程序真正忙碌时如何与其交互。

创建一个提醒应用

让我们来看一些 Celery 中的真实例子。假设我们网站上的另一页现在需要一个提醒功能。用户可以创建提醒,将在指定时间发送电子邮件到指定位置。我们需要一个模型,一个任务,以及一种在每次创建模型时自动调用我们的任务的方法。

让我们从以下基本的 SQLAlchemy 模型开始:

class Reminder(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    date = db.Column(db.DateTime())
    email = db.Column(db.String())
    text = db.Column(db.Text())

    def __repr__(self):
        return "<Reminder '{}'>".format(self.text[:20])

现在我们需要一个任务,将发送电子邮件到模型中的位置。在我们的tasks.py文件中,添加以下任务:

import smtplib
from email.mime.text import MIMEText

@celery.task(
    bind=True,
    ignore_result=True,
    default_retry_delay=300,
    max_retries=5
)
def remind(self, pk):
    reminder = Reminder.query.get(pk)
    msg = MIMEText(reminder.text)

    msg['Subject'] = "Your reminder"
    msg['From'] = your_email
    msg['To'] = reminder.email

    try:
        smtp_server = smtplib.SMTP('localhost')
        smtp_server.starttls()
        smtp_server.login(user, password)
        smtp_server.sendmail(
            your_email, 
            [reminder.email],
            msg.as_string()
        )
        smtp_server.close()

        return
    except Exception, e:
        self.retry(exc=e)

请注意,我们的任务接受的是主键而不是模型。这是对抗竞争条件的一种保护,因为传递的模型可能在工作程序最终处理它时已经过时。您还需要用自己的登录信息替换占位符电子邮件和登录。

当用户创建提醒模型时,我们如何调用我们的任务?我们将使用一个名为events的 SQLAlchemy 功能。SQLAlchemy 允许我们在我们的模型上注册回调,当对我们的模型进行特定更改时将被调用。我们的任务将使用after_insert事件,在新数据输入到数据库后被调用,无论模型是全新的还是正在更新。

我们需要在tasks.py中的回调:

def on_reminder_save(mapper, connect, self):
    remind.apply_async(args=(self.id,), eta=self.date)

现在,在__init__.py中,我们将在我们的模型上注册我们的回调:

from sqlalchemy import event
from .tasks import on_reminder_save

def create_app(object_name):
    app = Flask(__name__)
    app.config.from_object(object_name)

    db.init_app(app)
    event.listen(Reminder, 'after_insert', on_reminder_save)
    …

现在,每当模型被保存时,都会注册一个任务,该任务将向我们的用户发送一封电子邮件。

创建每周摘要

假设我们的博客有很多不使用 RSS 而更喜欢邮件列表的人,这是大量的用户。我们需要一种方法,在每周末结束时创建一个新帖子列表,以增加我们网站的流量。为了解决这个问题,我们将创建一个摘要任务,该任务将由一个 beat worker 在每个星期六的上午 10 点调用。

首先,在tasks.py中,让我们创建我们的任务如下:

@celery.task(
    bind=True,
    ignore_result=True,
    default_retry_delay=300,
    max_retries=5
)
def digest(self):
    # find the start and end of this week
    year, week = datetime.datetime.now().isocalendar()[0:2]
    date = datetime.date(year, 1, 1)
    if (date.weekday() > 3):
        date = date + datetime.timedelta(days=7 - date.weekday())
    else:
        date = date - datetime.timedelta(days=date.weekday())
    delta = datetime.timedelta(days=(week - 1) * 7)
    start, end = date + delta, date + delta + datetime.timedelta(days=6)

    posts = Post.query.filter(
        Post.publish_date >= start,
        Post.publish_date <= end
    ).all()

    if (len(posts) == 0):
        return

    msg = MIMEText(
        render_template("digest.html", posts=posts),
        'html'
    )

    msg['Subject'] = "Weekly Digest"
    msg['From'] = your_email

    try:
        smtp_server = smtplib.SMTP('localhost')
        smtp_server.starttls()
        smtp_server.login(user, password)
        smtp_server.sendmail(
            your_email,
            [recipients],
            msg.as_string()
        )
        smtp_server.close()

        return
    except Exception, e:
        self.retry(exc=e)

我们还需要在config.py的配置对象中添加一个周期性计划来管理我们的任务:

CELERYBEAT_SCHEDULE = {
    'weekly-digest': {
        'task': 'tasks.digest',
        'schedule': crontab(day_of_week=6, hour='10')
    },
}

最后,我们需要我们的电子邮件模板。不幸的是,电子邮件客户端中的 HTML 已经非常过时。每个电子邮件客户端都有不同的渲染错误和怪癖,找到它们的唯一方法就是在所有客户端中打开您的电子邮件。许多电子邮件客户端甚至不支持 CSS,而那些支持的也只支持很少的选择器和属性。为了弥补这一点,我们不得不使用 10 年前的网页开发方法,也就是使用带有内联样式的表进行设计。这是我们的digest.html

"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html >
    <head>
        <meta http-equiv="Content-Type"
              content="text/html; charset=UTF-8" />
        <meta name="viewport"
              content="width=device-width, initial-scale=1.0"/>
        <title>Weekly Digest</title>
    </head>
    <body>
        <table align="center"
               border="0"
               cellpadding="0"
               cellspacing="0"
               width="500px">
            <tr>
                <td style="font-size: 32px;
                           font-family: Helvetica, sans-serif;
                           color: #444;
                           text-align: center;
                           line-height: 1.65">
                    Weekly Digest
                </td>
            </tr>
            {% for post in posts %}
                <tr>
                    <td style="font-size: 24px;
                               font-family: sans-serif;
                               color: #444;
                               text-align: center;
                               line-height: 1.65">
                        {{ post.title }}
                    </td>
                </tr>
                <tr>
                    <td style="font-size: 14px;
                               font-family: serif;
                               color: #444;
                               line-height:1.65">
                        {{ post.text | truncate(500) | safe }}
                    </td>
                </tr>
                <tr>
                    <td style="font-size: 12px;
                               font-family: serif;
                               color: blue;
                               margin-bottom: 20px">
                        <a href="{{ url_for('.post', post_id=post.id) }}">Read More</a>
                    </td>
                </tr>
            {% endfor %}
        </table>
    </body>
</html>

现在,每周末,我们的摘要任务将被调用,并且会向我们邮件列表中的所有用户发送一封电子邮件。

总结

Celery 是一个非常强大的任务队列,允许程序员将较慢的任务的处理推迟到另一个进程中。现在您了解了如何将复杂的任务移出 Flask 进程,我们将看一下一系列简化 Flask 应用程序中一些常见任务的 Flask 扩展。

第十章:有用的 Flask 扩展

正如我们在整本书中所看到的,Flask 的设计是尽可能小,同时又给您提供了创建 Web 应用程序所需的灵活性和工具。然而,许多 Web 应用程序都具有许多共同的特性,这意味着许多应用程序将需要编写执行相同任务的代码。为了解决这个问题,人们已经为 Flask 创建了扩展,以避免重复造轮子,我们已经在整本书中看到了许多 Flask 扩展。本章将重点介绍一些更有用的 Flask 扩展,这些扩展内容不足以单独成章,但可以节省大量时间和烦恼。

Flask Script

在第一章中,入门,我们使用 Flask 扩展 Flask Script 创建了一个基本的管理脚本,以便轻松运行服务器并使用 shell 进行调试。在本章中,我们将介绍那些基本介绍中未涉及的功能。

在 Flask Script 中,您可以创建自定义命令以在应用程序上下文中运行。所需的只是创建一个命令,用 Flask Script 提供的装饰器函数装饰一个普通的 Python 函数。例如,如果我们想要一个任务,返回字符串"Hello, World!",我们将把以下内容添加到manage.py中:

@manager.command
def test():
    print "Hello, World!"

从命令行,现在可以使用以下命令运行test命令:

$ python manage.py test
Hello, World!

删除测试命令,让我们创建一个简单的命令,以帮助为我们的应用程序设置新开发人员的 SQLite 数据库并填充测试数据。这个命令部分地来自第四章中创建的脚本,创建蓝图控制器

@manager.command
def setup_db():
    db.create_all()

    admin_role = Role()
    admin_role.name = "admin"
    admin_role.description = "admin"
    db.session.add(admin_role)

    default_role = Role()
    default_role.name = "default"
    default_role.description = "default"
    db.session.add(default_role)

    admin = User()
    admin.username = "admin"
    admin.set_password("password")
    admin.roles.append(admin_role)
    admin.roles.append(default_role)
    db.session.add(admin)

    tag_one = Tag('Python')
    tag_two = Tag('Flask')
    tag_three = Tag('SQLAlechemy')
    tag_four = Tag('Jinja')
    tag_list = [tag_one, tag_two, tag_three, tag_four]

    s = "Body text"

    for i in xrange(100):
        new_post = Post("Post {}".format(i))
        new_post.user = admin
        new_post.publish_date = datetime.datetime.now()
        new_post.text = s
        new_post.tags = random.sample(
            tag_list,
            random.randint(1, 3)
        )
        db.session.add(new_post)

    db.session.commit()

现在,如果有新的开发人员被分配到项目中,他们可以从我们的服务器下载git repo,安装pip库,运行setup_db命令,然后就可以运行项目了。

Flask Script 还提供了两个实用函数,可以轻松添加到我们的项目中。

from flask.ext.script.commands import ShowUrls, Clean
…
manager = Manager(app)
manager.add_command("server", Server())
manager.add_command("show-urls", ShowUrls())
manager.add_command("clean", Clean())

show-urls命令列出了在app对象上注册的所有路由以及与该路由相关的 URL。这在调试 Flask 扩展时非常有用,因为可以轻松地查看其蓝图的注册是否有效。清理命令只是从工作目录中删除.pyc.pyo编译的 Python 文件。

Flask Debug Toolbar

Flask Debug Toolbar 是一个 Flask 扩展,通过将调试工具添加到应用程序的 Web 视图中,帮助开发。它会提供一些信息,比如视图渲染代码中的瓶颈,以及渲染视图所需的 SQLAlchemy 查询次数。

像往常一样,我们将使用pip来安装 Flask Debug Toolbar:

$ pip install flask-debugtoolbar

接下来,我们需要将 Flask Debug Toolbar 添加到extensions.py文件中。由于在本章中我们将经常修改这个文件,所以以下是文件的开头以及初始化 Flask Debug Toolbar 的代码:

from flask import flash, redirect, url_for, session
from flask.ext.bcrypt import Bcrypt
from flask.ext.openid import OpenID
from flask_oauth import OAuth
from flask.ext.login import LoginManager
from flask.ext.principal import Principal, Permission, RoleNeed
from flask.ext.restful import Api
from flask.ext.celery import Celery
from flask.ext.debugtoolbar import DebugToolbarExtension

bcrypt = Bcrypt()
oid = OpenID()
oauth = OAuth()
principals = Principal()
celery = Celery()
debug_toolbar = DebugToolbarExtension()

现在,需要在__init__.py中的create_app函数中调用初始化函数:

from .extensions import (
    bcrypt,
    oid,
    login_manager,
    principals,
    rest_api,
    celery,
    debug_toolbar,
)

def create_app(object_name):

    debug_toolbar.init_app(app)

这就是让 Flask Debug Toolbar 运行起来所需的全部内容。如果应用程序的config中的DEBUG变量设置为true,则工具栏将显示出来。如果DEBUG没有设置为true,则工具栏将不会被注入到页面中。

Flask Debug Toolbar

在屏幕的右侧,您将看到工具栏。每个部分都是一个链接,点击它将在页面上显示一个值表。要获取呈现视图所调用的所有函数的列表,请点击Profiler旁边的复选标记以启用它,重新加载页面,然后点击Profiler。这个视图可以让您快速诊断应用程序中哪些部分最慢或被调用最多。

默认情况下,Flask Debug Toolbar 拦截HTTP 302 重定向请求。要禁用此功能,请将以下内容添加到您的配置中:

class DevConfig(Config):
    DEBUG = True
    DEBUG_TB_INTERCEPT_REDIRECTS = False

另外,如果您使用 Flask-MongoEngine,可以通过覆盖渲染的面板并添加 MongoEngine 的自定义面板来查看渲染页面时所做的所有查询。

class DevConfig(Config):
    DEBUG = True
    DEBUG_TB_PANELS = [
        'flask_debugtoolbar.panels.versions.VersionDebugPanel',
        'flask_debugtoolbar.panels.timer.TimerDebugPanel',
        'flask_debugtoolbar.panels.headers.HeaderDebugPanel',
        'flask_debugtoolbar.panels.request_vars.RequestVarsDebugPanel',
        'flask_debugtoolbar.panels.config_vars.ConfigVarsDebugPanel ',
        'flask_debugtoolbar.panels.template.TemplateDebugPanel',
        'flask_debugtoolbar.panels.logger.LoggingPanel',
        'flask_debugtoolbar.panels.route_list.RouteListDebugPanel'
        'flask_debugtoolbar.panels.profiler.ProfilerDebugPanel',
        'flask.ext.mongoengine.panels.MongoDebugPanel'
    ]
    DEBUG_TB_INTERCEPT_REDIRECTS = False

这将在工具栏中添加一个与默认 SQLAlchemy 非常相似的面板。

Flask Cache

在第七章中,使用 Flask 进行 NoSQL,我们了解到页面加载时间是确定您的 Web 应用程序成功的最重要因素之一。尽管我们的页面并不经常更改,而且由于新帖子不会经常发布,但我们仍然在用户浏览器每次请求页面时渲染模板并查询数据库。

Flask Cache 通过允许我们存储视图函数的结果并返回存储的结果而不是再次渲染模板来解决了这个问题。首先,我们需要从pip安装 Flask Cache:

$ pip install Flask-Cache

接下来,在extensions.py中初始化它:

from flask.ext.cache import Cache

cache = Cache()

然后,在__init__.py中的create_app函数中注册Cache对象:

from .extensions import (
    bcrypt,
    oid,
    login_manager,
    principals,
    rest_api,
    celery,
    debug_toolbar,
    cache
)

def create_app(object_name):
    …
    cache.init_app(app)

在我们开始缓存视图之前,需要告诉 Flash Cache 我们希望如何存储新函数的结果。

class DevConfig(Config):
    …
    CACHE_TYPE = 'simple'

simple选项告诉 Flask Cache 将结果存储在 Python 字典中的内存中,对于绝大多数 Flask 应用程序来说是足够的。我们将在本节后面介绍更多类型的缓存后端。

缓存视图和函数

为了缓存视图函数的结果,只需在任何函数上添加装饰器:

@blog_blueprint.route('/')
@blog_blueprint.route('/<int:page>')
@cache.cached(timeout=60)
def home(page=1):
    posts = Post.query.order_by(
        Post.publish_date.desc()
    ).paginate(page, 10)
    recent, top_tags = sidebar_data()

    return render_template(
        'home.html',
        posts=posts,
        recent=recent,
        top_tags=top_tags
    )

timeout参数指定缓存结果在函数再次运行并再次存储之前应该持续多少秒。要确认视图实际上被缓存了,可以在调试工具栏上查看 SQLAlchemy 部分。此外,我们可以通过激活分析器并比较之前和之后的时间来看到缓存对页面加载时间的影响。在作者顶级的笔记本电脑上,主博客页面需要 34 毫秒来渲染,主要是因为对数据库进行了 8 次不同的查询。但在激活缓存后,这个时间减少到 0.08 毫秒。这是速度提高了 462.5%!

视图函数并不是唯一可以被缓存的东西。要缓存任何 Python 函数,只需在函数定义中添加类似的装饰器:

@cache.cached(timeout=7200, key_prefix='sidebar_data')
def sidebar_data():
    recent = Post.query.order_by(
        Post.publish_date.desc()
    ).limit(5).all()

    top_tags = db.session.query(
        Tag, func.count(tags.c.post_id).label('total')
    ).join(
        tags
    ).group_by(
        Tag
    ).order_by('total DESC').limit(5).all()

    return recent, top_tags

关键字参数key_prefix对于非视图函数是必要的,以便 Flask Cache 正确地存储函数的结果。这需要对每个被缓存的函数都是唯一的,否则函数的结果将互相覆盖。另外,请注意,此函数的超时设置为 2 小时,而不是前面示例中的 60 秒。这是因为这个函数的结果不太可能改变,如果数据过时,这不是一个大问题。

带参数的函数缓存

然而,普通的缓存装饰器不考虑函数参数。如果我们使用普通的缓存装饰器缓存了带有参数的函数,它将对每个参数集返回相同的结果。为了解决这个问题,我们使用memoize函数:

    class User(db.Model):
        …

        @staticmethod
        @cache.memoize(60)
        def verify_auth_token(token):
            s = Serializer(current_app.config['SECRET_KEY'])

            try:
                data = s.loads(token)
            except SignatureExpired:
                return None
            except BadSignature:
                return None

            user = User.query.get(data['id'])
            return user

Memoize存储传递给函数的参数以及结果。在前面的例子中,memoize被用来存储verify_auth_token方法的结果,该方法被多次调用并且每次都查询数据库。如果传递给它相同的令牌,这个方法可以安全地被记忆化,因为它每次都返回相同的结果。唯一的例外是如果用户对象在函数被存储的 60 秒内被删除,但这是非常不可能的。

小心不要对依赖于全局作用域变量或不断变化数据的函数进行memoize或缓存。这可能导致一些非常微妙的错误,甚至在最坏的情况下会导致数据竞争。最适合 memoization 的候选者是所谓的纯函数。纯函数是当传递相同的参数时将产生相同结果的函数。函数运行多少次都无所谓。纯函数也没有任何副作用,这意味着它们不会改变全局作用域变量。这也意味着纯函数不能执行任何 IO 操作。虽然verify_auth_token函数不是纯函数,因为它执行数据库 IO,但这没关系,因为正如之前所述,底层数据很少会改变。

在开发应用程序时,我们不希望缓存视图函数,因为结果会不断变化。为了解决这个问题,将CACHE_TYPE变量设置为 null,并在生产配置中将CACHE_TYPE变量设置为 simple,这样当应用程序部署时,一切都能按预期运行:

class ProdConfig(Config):
    …
    CACHE_TYPE = 'simple'

class DevConfig(Config):
    …
    CACHE_TYPE = 'null'

使用查询字符串缓存路由

一些路由,比如我们的主页和post路由,通过 URL 传递参数并返回特定于这些参数的内容。如果缓存这样的路由,就会遇到问题,因为无论 URL 参数如何,路由的第一次渲染都将返回所有请求。解决方案相当简单。缓存方法中的key_prefix关键字参数可以是一个字符串或一个函数,该函数将被执行以动态生成一个键。这意味着可以创建一个函数来生成一个与 URL 参数相关联的键,因此只有在之前调用过具有特定参数组合的请求时,每个请求才会返回一个缓存的页面。在blog.py文件中,添加以下内容:

def make_cache_key(*args, **kwargs):
    path = request.path
    args = str(hash(frozenset(request.args.items())))
    lang = get_locale()
    return (path + args + lang).encode('utf-8')

@blog_blueprint.route(
    '/post/<int:post_id>',
    methods=('GET', 'POST')
)
@cache.cached(timeout=600, key_prefix=make_cache_key)
def post(post_id):
    …

现在,每个单独的帖子页面将被缓存 10 分钟。

使用 Redis 作为缓存后端

如果视图函数的数量或传递给缓存函数的唯一参数的数量变得太大而超出内存限制,您可以使用不同的缓存后端。正如在第七章中提到的,在 Flask 中使用 NoSQL,Redis 可以用作缓存的后端。要实现该功能,只需将以下配置变量添加到ProdConfig类中,如下所示:

class ProdConfig(Config):
    …
    CACHE_TYPE = 'redis'
    CACHE_REDIS_HOST = 'localhost'
    CACHE_REDIS_PORT = '6379'
    CACHE_REDIS_PASSWORD = 'password'
    CACHE_REDIS_DB = '0'

如果用自己的数据替换变量的值,Flask Cache 将自动创建到您的redis数据库的连接,并使用它来存储函数的结果。所需的只是安装 Python redis库:

$ pip install redis

使用 memcached 作为缓存后端

redis后端一样,memcached后端提供了一种替代的存储结果的方式,如果内存选项太过限制。与redis相比,memcached旨在缓存对象以供以后使用,并减少对数据库的负载。redismemcached都可以达到相同的目的,选择其中一个取决于个人偏好。要使用memcached,我们需要安装其 Python 库:

$ pip install memcache

连接到您的memcached服务器在配置对象中处理,就像redis设置一样:

class ProdConfig(Config):
    …
    CACHE_TYPE = 'memcached'
    CACHE_KEY_PREFIX = 'flask_cache'
    CACHE_MEMCACHED_SERVERS = ['localhost:11211']

Flask Assets

Web 应用程序中的另一个瓶颈是下载页面的 CSS 和 JavaScript 库所需的 HTTP 请求数量。只有在加载和解析页面的 HTML 之后才能下载额外的文件。为了解决这个问题,许多现代浏览器会同时下载许多这些库,但是浏览器发出的同时请求数量是有限制的。

服务器上可以做一些事情来减少下载这些文件所花费的时间。开发人员使用的主要技术是将所有 JavaScript 库连接成一个文件,将所有 CSS 库连接成另一个文件,同时从结果文件中删除所有空格和换行符。这样可以减少多个 HTTP 请求的开销,删除不必要的空格和换行符可以将文件大小减少多达 30%。另一种技术是告诉浏览器使用专门的 HTTP 头在本地缓存文件,因此文件只有在更改后才会再次加载。这些手动操作可能很繁琐,因为它们需要在每次部署到服务器后进行。

幸运的是,Flask Assets 实现了上述所有技术。Flask Assets 通过给它一个文件列表和一种连接它们的方法来工作,然后在模板中添加一个特殊的控制块,代替正常的链接和脚本标签。然后,Flask Assets 将添加一个链接或脚本标签,链接到新生成的文件。要开始使用 Flask Assets,需要安装它。我们还需要安装cssminjsmin,这是处理文件修改的 Python 库:

$ pip install Flask-Assets cssmin jsmin

现在,需要创建要连接的文件集合,即命名捆绑包。在extensions.py中,添加以下内容:

from flask_assets import Environment, Bundle

assets_env = Environment()

main_css = Bundle(
    'css/bootstrap.css',
    filters='cssmin',
    output='css/common.css'
)

main_js = Bundle(
    'js/jquery.js',
    'js/bootstrap.js',
    filters='jsmin',
    output='js/common.js'
)

每个Bundle对象都需要无限数量的文件作为位置参数来定义要捆绑的文件,一个关键字参数filters来定义要通过的过滤器,以及一个output来定义static文件夹中要保存结果的文件名。

注意

filters关键字可以是单个值或列表。要获取可用过滤器的完整列表,包括自动 Less 和 CSS 编译器,请参阅webassets.readthedocs.org/en/latest/上的文档。

虽然我们的网站样式较轻,CSS 捆绑包中只有一个文件。但是将文件放入捆绑包仍然是一个好主意,原因有两个。

在开发过程中,我们可以使用未压缩版本的库,这样调试更容易。当应用程序部署到生产环境时,库会自动进行压缩。

这些库将被发送到浏览器,并带有缓存头,通常在 HTML 中链接它们不会。

在测试 Flask Assets 之前,需要进行三项更改。首先,在__init__.py格式中,需要注册扩展和捆绑包:

from .extensions import (
    bcrypt,
    oid,
    login_manager,
    principals,
    rest_api,
    celery,
    debug_toolbar,
    cache,
    assets_env,
    main_js,
    main_css
)

def create_app(object_name):
    …
    assets_env.init_app(app)

    assets_env.register("main_js", main_js)
    assets_env.register("main_css", main_css)

接下来,DevConfig类需要一个额外的变量,告诉 Flask Assets 在开发过程中不要编译库:

class DevConfig(Config):
    DEBUG = True
    DEBUG_TB_INTERCEPT_REDIRECTS = False

    ASSETS_DEBUG = True

最后,base.html文件中的链接和脚本标签都需要用 Flask Assets 的控制块替换。我们有以下内容:

<link rel="stylesheet" href=https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css>

用以下内容替换:

{% assets "main_css" %}
<link rel="stylesheet" type="text/css" href="{{ ASSET_URL }}" />
{% endassets %}

我们还有以下内容:

<script src="img/jquery.min.js"></script>
<script src="img/bootstrap.min.js"></script>

用以下内容替换:

{% assets "main_js" %}
<script src="img/{{ ASSET_URL }}"></script>
{% endassets %}

现在,如果重新加载页面,所有的 CSS 和 JavaScript 现在都将由 Flask Assets 处理。

Flask Admin

在第六章中,保护您的应用程序,我们创建了一个界面,允许用户创建和编辑博客文章,而无需使用命令行。这足以演示本章介绍的安全措施,但仍然没有办法使用界面删除帖子或为其分配标签。我们也没有办法删除或编辑我们不希望普通用户看到的评论。我们的应用程序需要的是一个功能齐全的管理员界面,与 WordPress 界面相同。这对于应用程序来说是一个常见的需求,因此创建了一个名为 Flask Admin 的 Flask 扩展,以便轻松创建管理员界面。要开始使用 Flask Admin,请使用pip安装 Flask Admin:

$ pip install Flask-Admin

像往常一样,在extensions.py中需要创建extension对象:

from flask.ext.admin import Admin

admin = Admin()

然后,需要在__init__.py中的app对象上注册该对象:

from .extensions import (
    bcrypt,
    oid,
    login_manager,
    principals,
    rest_api,
    celery,
    debug_toolbar,
    cache,
    assets_env,
    main_js,
    main_css,
    admin
)

def create_app(object_name):
    …
    admin.init_app(app)

如果您导航到localhost:5000/admin,您现在应该看到空的 Flask Admin 界面:

Flask Admin

Flask Admin 通过在定义一个或多个路由的admin对象上注册视图类来工作。Flask Admin 有三种主要类型的视图:ModelViewFileAdminBaseView视图。

创建基本管理页面

BaseView类允许将普通的 Flask 页面添加到您的admin界面中。这通常是 Flask Admin 设置中最少使用的视图类型,但如果您希望包括类似使用 JavaScript 图表库的自定义报告,您可以使用基本视图。在名为admin.py的控制器文件夹中添加以下内容:

from flask.ext.admin import BaseView, expose

class CustomView(BaseView):
    @expose('/')
    def index(self):
        return self.render('admin/custom.html')

    @expose('/second_page')
    def second_page(self):
        return self.render('admin/second_page.html')

BaseView的子类中,如果它们一起定义,可以一次注册多个视图。但是请记住,BaseView的每个子类都需要至少一个在路径/上公开的方法。此外,除了路径/中的方法之外,管理员界面的导航中将不会有其他方法,并且必须将它们链接到类中的其他页面。exposeself.render函数的工作方式与普通 Flask API 中的对应函数完全相同。

要使您的模板继承 Flask Admin 的默认样式,请在模板目录中创建一个名为admin的新文件夹,其中包含一个名为custom.html的文件,并添加以下 Jinja 代码:

{% extends 'admin/master.html' %}
{% block body %}
    This is the custom view!
    <a href="{{ url_for('.second_page') }}">Link</a>
{% endblock %}

要查看此模板,需要在admin对象上注册CustomView的实例。这将在create_app函数中完成,而不是在extensions.py文件中,因为我们的一些管理页面将需要数据库对象,如果注册在extensions.py中会导致循环导入。在__init__.py中,添加以下代码来注册该类:

from webapp.controllers.admin import CustomView
…
def create_app(object_name):
    …
    admin.add_view(CustomView(name='Custom'))

name关键字参数指定admin界面顶部导航栏上使用的标签应该读取Custom。在将CustomView注册到admin对象之后,您的admin界面现在应该有第二个链接在导航栏中,如下所示。

Creating basic admin pages

创建数据库管理页面

Flask Admin 的主要功能来自于您可以通过将您的 SQLAlchemy 或 MongoEngine 模型提供给 Flask Admin 来自动创建数据的管理员页面。创建这些页面非常容易;在admin.py中,只需添加以下代码:

from flask.ext.admin.contrib.sqla import ModelView
# or, if you use MongoEngine
from flask.ext.admin.contrib.mongoengine import ModelView

class CustomModelView(ModelView):
    pass

然后,在__init__.py中,按照以下方式注册要使用的模型和数据库session对象的类:

from controllers.admin import CustomView, CustomModelView
from .models import db, Reminder, User, Role, Post, Comment, Tag

def create_app(object_name):

    admin.add_view(CustomView(name='Custom'))
    models = [User, Role, Post, Comment, Tag, Reminder]

    for model in models:
       admin.add_view(
           CustomModelView(model, db.session, category='models')
       )

category关键字告诉 Flask Admin 将具有相同类别值的所有视图放入导航栏上的同一个下拉菜单中。

如果您现在转到浏览器,您将看到一个名为Models的新下拉菜单,其中包含指向数据库中所有表的管理页面的链接,如下所示:

Creating database admin pages

每个模型的生成界面提供了许多功能。可以创建新的帖子,并可以批量删除现有的帖子。可以从这个界面设置所有字段,包括关系字段,这些字段实现为可搜索的下拉菜单。datedatetime字段甚至具有带有日历下拉菜单的自定义 JavaScript 输入。总的来说,这是对第六章中手动创建的界面的巨大改进,保护您的应用程序

增强文章管理

虽然这个界面在质量上有了很大的提升,但还是有一些功能缺失。我们不再拥有原始界面中可用的所见即所得编辑器,这个页面可以通过启用一些更强大的 Flask Admin 功能来改进。

要将所见即所得编辑器添加回post创建页面,我们需要一个新的WTForms字段,因为 Flask Admin 使用 Flask WTF 构建其表单。我们还需要用这种新的字段类型覆盖post编辑和创建页面中的textarea字段。需要做的第一件事是在forms.py中使用textarea字段作为基础创建新的字段类型:

from wtforms import (
    widgets,
    StringField,
    TextAreaField,
    PasswordField,
    BooleanField
)

class CKTextAreaWidget(widgets.TextArea):
    def __call__(self, field, **kwargs):
        kwargs.setdefault('class_', 'ckeditor')
        return super(CKTextAreaWidget, self).__call__(field, **kwargs)

class CKTextAreaField(TextAreaField):
    widget = CKTextAreaWidget()

在这段代码中,我们创建了一个新的字段类型CKTextAreaField,它为textarea添加了一个小部件,而小部件所做的就是向 HTML 标签添加一个类。现在,要将此字段添加到Post管理员页面,Post将需要自己的ModelView

from webapp.forms import CKTextAreaField

class PostView(CustomModelView):
    form_overrides = dict(text=CKTextAreaField)
    column_searchable_list = ('text', 'title')
    column_filters = ('publish_date',)

    create_template = 'admin/post_edit.html'
    edit_template = 'admin/post_edit.html'

在这段代码中有几个新的东西。首先,form_overrides类变量告诉 Flask Admin 用这种新的字段类型覆盖名称文本的字段类型。column_searchable_list函数定义了哪些列可以通过文本进行搜索。添加这个将允许 Flask Admin 在概述页面上包括一个搜索字段,用于搜索已定义字段的值。接下来,column_filters类变量告诉 Flask Admin 在此模型的概述页面上创建一个filters界面。filters界面允许非文本列通过向显示的行添加条件进行过滤。使用上述代码的示例是创建一个过滤器,显示所有publish_date值大于 2015 年 1 月 1 日的行。最后,create_templateedit_template类变量允许您定义 Flask Admin 要使用的自定义模板。对于我们将要使用的自定义模板,我们需要在 admin 文件夹中创建一个新文件post_edit.html。在这个模板中,我们将包含与第六章中使用的相同的 JavaScript 库,保护您的应用

{% extends 'admin/model/edit.html' %}
{% block tail %}
    {{ super() }}
    <script
        src="img/ckeditor.js">
    </script>
{% endblock %}

继承模板的尾部块位于文件末尾。创建模板后,您的post编辑和创建页面应如下所示:

增强帖子的管理

创建文件系统管理员页面

大多数admin界面涵盖的另一个常见功能是能够从 Web 访问服务器的文件系统。幸运的是,Flask Admin 通过FileAdmin类包含了这个功能

class CustomFileAdmin(FileAdmin):
    pass
Now, just import the new class into your __init__.py file and pass in the path that you wish to be accessible from the web:
import os
from controllers.admin import (
    CustomView,
    CustomModelView,
    PostView,
    CustomFileAdmin
)

def create_app(object_name):

    admin.add_view(
        CustomFileAdmin(
            os.path.join(os.path.dirname(__file__), 'static'),
            '/static/',
            name='Static Files'
        )
    )

保护 Flask Admin

目前,整个admin界面对世界都是可访问的;让我们来修复一下。CustomView中的路由可以像任何其他路由一样进行保护:

class CustomView(BaseView):
    @expose('/')
    @login_required
    @admin_permission.require(http_exception=403)
    def index(self):
        return self.render('admin/custom.html')

    @expose('/second_page')
    @login_required
    @admin_permission.require(http_exception=403)
    def second_page(self):
        return self.render('admin/second_page.html')

要保护ModeViewFileAdmin子类,它们需要定义一个名为is_accessible的方法,该方法返回truefalse

class CustomModelView(ModelView):
    def is_accessible(self):
        return current_user.is_authenticated() and\
               admin_permission.can()

class CustomFileAdmin(FileAdmin):
    def is_accessible(self):
        return current_user.is_authenticated() and\
               admin_permission.can()

因为我们在第六章中正确设置了我们的身份验证,所以这个任务很简单。

Flask Mail

本章将介绍的最终 Flask 扩展是 Flask Mail,它允许您从 Flask 的配置中连接和配置您的 SMTP 客户端。Flask Mail 还将帮助简化第十二章中的应用测试,测试 Flask 应用。第一步是使用pip安装 Flask Mail:

$ pip install Flask-Mail

接下来,在extentions.py文件中需要初始化Mail对象:

from flask_mail import Mail

mail = Mail()

flask_mail将通过读取app对象中的配置变量连接到我们选择的 SMTP 服务器,因此我们需要将这些值添加到我们的config对象中:

class DevConfig(Config):

    MAIL_SERVER = 'localhost'
    MAIL_PORT = 25
    MAIL_USERNAME = 'username'
    MAIL_PASSWORD = 'password'

最后,在__init__.py中的app对象上初始化mail对象:

from .extensions import (
    bcrypt,
    oid,
    login_manager,
    principals,
    rest_api,
    celery,
    debug_toolbar,
    cache,
    assets_env,
    main_js,
    main_css,
    admin,
    mail
)

def create_app(object_name):

    mail.init_app(app)

要了解 Flask Mail 如何简化我们的邮件代码,这是在第九章中创建的提醒任务,使用 Celery 创建异步任务,但使用 Flask Mail 而不是标准库 SMTP 模块:

from flask_mail import Message
from webapp.extensions import celery, mail

@celery.task(
    bind=True,
    ignore_result=True,
    default_retry_delay=300,
    max_retries=5
)
def remind(self, pk):
    reminder = Reminder.query.get(pk)
    msg = MIMEText(reminder.text)
    msg = Message("Your reminder",
                  sender="from@example.com",
                  recipients=[reminder.email])

    msg.body = reminder.text
    mail.send(msg)

摘要

本章大大增加了我们应用的功能。我们现在拥有一个功能齐全的管理员界面,在浏览器中有一个有用的调试工具,两个大大加快页面加载速度的工具,以及一个使发送电子邮件变得不那么头疼的实用程序。

正如本章开头所述,Flask 是一个基本的框架,允许您挑选并选择您想要的功能。因此,重要的是要记住,在您的应用程序中并不需要包含所有这些扩展。如果您是应用程序的唯一内容创建者,也许命令行界面就是您所需要的,因为添加这些功能需要开发时间和维护时间,当它们不可避免地出现故障时。本章末尾提出了这个警告,因为许多 Flask 应用程序变得难以管理的主要原因之一是它们包含了太多的扩展,测试和维护所有这些扩展变成了一项非常庞大的任务。

在下一章中,您将学习扩展的内部工作原理以及如何创建自己的扩展。

第十一章:创建自己的扩展

从本书的第一章开始,我们一直在向我们的应用程序中添加 Flask 扩展,以添加新功能并节省我们花费大量时间重新发明轮子。到目前为止,这些 Flask 扩展是如何工作的还是未知的。在本章中,我们将创建两个简单的 Flask 扩展,以更好地理解 Flask 的内部工作,并允许您使用自己的功能扩展 Flask。

创建 YouTube Flask 扩展

首先,我们要创建的第一个扩展是一个简单的扩展,允许在 Jinja 模板中嵌入 YouTube 视频,标签如下:

{{ youtube(video_id) }}

video_id对象是任何 YouTube URL 中v后面的代码。例如,在 URL www.youtube.com/watch?v=_OBlgSz8sSM 中,video_id对象将是_OBlgSz8sSM

目前,这个扩展的代码将驻留在extensions.py中。但是,这只是为了开发和调试目的。当代码准备分享时,它将被移动到自己的项目目录中。

任何 Flask 扩展需要的第一件事是将在应用程序上初始化的对象。这个对象将处理将其Blueprint对象添加到应用程序并在 Jinja 上注册youtube函数:

from flask import Blueprint

class Youtube(object):
    def __init__(self, app=None, **kwargs):
        if app:
            self.init_app(app)

    def init_app(self, app):
        self.register_blueprint(app)

    def register_blueprint(self, app):
        module = Blueprint(
            "youtube",
            __name__,
            template_folder="templates"
        )
        app.register_blueprint(module)
        return module

到目前为止,这段代码唯一做的事情就是在app对象上初始化一个空的蓝图。下一段所需的代码是视频的表示。接下来将是一个处理 Jinja 函数参数并渲染 HTML 以在模板中显示的类:

from flask import (
    flash,
    redirect,
    url_for,
    session,
    render_template,
    Blueprint,
    Markup
)

class Video(object):
    def __init__(self, video_id, cls="youtube"):
        self.video_id = video_id
        self.cls = cls

    def render(self, *args, **kwargs):
        return render_template(*args, **kwargs)

    @property
    def html(self):
        return Markup(
            self.render('youtube/video.html', video=self)
        )

这个对象将从模板中的youtube函数创建,并且模板中传递的任何参数都将传递给这个对象以渲染 HTML。在这段代码中还有一个新对象,Markup,我们以前从未使用过。Markup类是 Flask 自动转义 HTML 或将其标记为安全包含在模板中的方式。如果我们只返回 HTML,Jinja 会自动转义它,因为它不知道它是否安全。这是 Flask 保护您的网站免受跨站脚本攻击的方式。

下一步是创建将在 Jinja 中注册的函数:

def youtube(*args, **kwargs):
    video = Video(*args, **kwargs)
    return video.html

YouTube类中,我们必须在init_app方法中向 Jinja 注册函数:

class Youtube(object):
    def __init__(self, app=None, **kwargs):
        if app:
            self.init_app(app)

    def init_app(self, app):
        self.register_blueprint(app)
        app.add_template_global(youtube)

最后,我们必须创建 HTML,将视频添加到页面中。在templates目录中的一个名为youtube的新文件夹中,创建一个名为video.html的新 HTML 文件,并将以下代码添加到其中:

<iframe
    class="{{ video.cls }}"
    width="560"
    height="315" 
    src="img/{{ video.video_id }}"
    frameborder="0"
    allowfullscreen>
</iframe>

这是在模板中嵌入 YouTube 视频所需的所有代码。现在让我们来测试一下。在extensions.py中,在Youtube类定义下方初始化Youtube类:

youtube_ext = Youtube()

__init__.py中,导入youtube_ext变量,并使用我们创建的init_app方法将其注册到应用程序上:

from .extensions import (
    bcrypt,
    oid,
    login_manager,
    principals,
    rest_api,
    celery,
    debug_toolbar,
    cache,
    assets_env,
    main_js,
    main_css,
    admin,
    mail,
    youtube_ext
)

def create_app(object_name):
    …
    youtube_ext.init_app(app)

现在,作为一个简单的例子,在博客主页的顶部添加youtube函数:

{{ youtube("_OBlgSz8sSM") }}

这将产生以下结果:

创建 YouTube Flask 扩展

创建 Python 包

为了使我们的新 Flask 扩展可供他人使用,我们必须从到目前为止编写的代码中创建一个可安装的 Python 包。首先,我们需要一个新的项目目录,位于当前应用程序目录之外。我们需要两样东西:一个setup.py文件,稍后我们将填写它,和一个名为flask_youtube的文件夹。在flask_youtube目录中,我们将有一个__init__.py文件,其中将包含我们为扩展编写的所有代码。

以下是包含在__init__.py文件中的该代码的最终版本:

from flask import render_template, Blueprint, Markup

class Video(object):
    def __init__(self, video_id, cls="youtube"):
        self.video_id = video_id
        self.cls = cls

    def render(self, *args, **kwargs):
        return render_template(*args, **kwargs)

    @property
    def html(self):
        return Markup(
            self.render('youtube/video.html', video=self)
        )

def youtube(*args, **kwargs):
    video = Video(*args, **kwargs)
    return video.html

class Youtube(object):
    def __init__(self, app=None, **kwargs):
        if app:
            self.init_app(app)

    def init_app(self, app):
        self.register_blueprint(app)
        app.add_template_global(youtube)

    def register_blueprint(self, app):
        module = Blueprint(
            "youtube",
            __name__,
            template_folder="templates"
        )
        app.register_blueprint(module)
        return module

还在flask_youtube目录中,我们将需要一个templates目录,其中将包含我们放在应用程序templates目录中的youtube目录。

为了将这段代码转换成 Python 包,我们将使用名为setuptools的库。setuptools是一个 Python 包,允许开发人员轻松创建可安装的包。setuptools将捆绑代码,以便pipeasy_install可以自动安装它们,并且甚至可以将你的包上传到Python Package IndexPyPI)。

注意

我们一直从 PyPI 安装的所有包都来自pip。要查看所有可用的包,请转到pypi.python.org/pypi

要获得这个功能,只需要填写setup.py文件即可。

from setuptools import setup, find_packages
setup(
    name='Flask-YouTube',
    version='0.1',
    license='MIT',
    description='Flask extension to allow easy embedding of YouTube videos',
    author='Jack Stouffer',
    author_email='example@gmail.com',
    platforms='any',
    install_requires=['Flask'],
    packages=find_packages()
)

这段代码使用setuptools中的setup函数来查找你的源代码,并确保安装你的代码的机器具有所需的包。大多数属性都相当容易理解,除了package属性,它使用setuptools中的find_packages函数。package属性的作用是找到我们源代码中要发布的部分。我们使用find_packages方法自动找到要包含的代码部分。这基于一些合理的默认值,比如查找带有__init__.py文件的目录并排除常见的文件扩展名。

虽然这不是强制性的,但这个设置也包含了关于作者和许可的元数据,如果我们要在 PyPI 页面上上传这个设置,这些信息也会被包含在其中。setup函数中还有更多的自定义选项,所以我鼓励你阅读pythonhosted.org/setuptools/上的文档。

现在,你可以通过运行以下命令在你的机器上安装这个包:

$ python setup.py build
$ python setup.py install

这将把你的代码安装到 Python 的packages目录中,或者如果你使用virtualenv,它将安装到本地的packages目录中。然后,你可以通过以下方式导入你的包:

from flask_youtube import Youtube

使用 Flask 扩展修改响应

因此,我们创建了一个扩展,为我们的模板添加了新的功能。但是,我们如何创建一个修改应用程序在请求级别行为的扩展呢?为了演示这一点,让我们创建一个扩展,它通过压缩响应的内容来修改 Flask 的所有响应。这是 Web 开发中的常见做法,以加快页面加载时间,因为使用像gzip这样的方法压缩对象非常快速,而且在 CPU 方面相对便宜。通常,这将在服务器级别处理。因此,除非你希望仅使用 Python 代码托管你的应用程序,这在现实世界中并没有太多用处。

为了实现这一点,我们将使用 Python 标准库中的gzip模块来在每个请求处理后压缩内容。我们还需要在响应中添加特殊的 HTTP 头,以便浏览器知道内容已经被压缩。我们还需要在 HTTP 请求头中检查浏览器是否能接受 gzip 压缩的内容。

就像以前一样,我们的内容最初将驻留在extensions.py文件中:

from flask import request 
from gzip import GzipFile
from io import BytesIO
…

class GZip(object):
    def __init__(self, app=None):
        self.app = app
        if app is not None:
            self.init_app(app)

    def init_app(self, app):
        app.after_request(self.after_request)

    def after_request(self, response):
        encoding = request.headers.get('Accept-Encoding', '')

        if 'gzip' not in encoding or \
           not response.status_code in (200, 201):
            return response

        response.direct_passthrough = False

        contents = BytesIO()
        with GzipFile(
            mode='wb',
            compresslevel=5,
            fileobj=contents) as gzip_file:
            gzip_file.write(response.get_data())

        response.set_data(bytes(contents.getvalue()))

        response.headers['Content-Encoding'] = 'gzip'
        response.headers['Content-Length'] = response.content_length

        return response

flask_gzip = GZip()

就像以前的扩展一样,我们的压缩对象的初始化器适应了普通的 Flask 设置和应用工厂设置。在after_request方法中,我们注册一个新的函数来在请求后事件上注册一个新函数,以便我们的扩展可以压缩结果。

after_request方法是扩展的真正逻辑所在。首先,它通过查看请求头中的Accept-Encoding值来检查浏览器是否接受 gzip 编码。如果浏览器不接受 gzip,或者没有返回成功的响应,函数将只返回内容并不对其进行任何修改。但是,如果浏览器接受我们的内容并且响应成功,那么我们将压缩内容。我们使用另一个名为BytesIO的标准库类,它允许文件流被写入和存储在内存中,而不是在中间文件中。这是必要的,因为GzipFile对象期望写入文件对象。

数据压缩后,我们将响应对象的数据设置为压缩的结果,并在响应中设置必要的 HTTP 头值。最后,gzip 内容被返回到浏览器,然后浏览器解压内容,大大加快了页面加载时间。

为了测试浏览器中的功能,您必须禁用Flask Debug Toolbar,因为在撰写本文时,其代码中存在一个 bug,它期望所有响应都以 UTF-8 编码。

如果重新加载页面,什么都不应该看起来不同。但是,如果您使用所选浏览器的开发人员工具并检查响应,您将看到它们已经被压缩。

摘要

现在我们已经通过了两个不同类型的 Flask 扩展的示例,您应该非常清楚我们使用的大多数 Flask 扩展是如何工作的。利用您现在拥有的知识,您应该能够为您的特定应用程序添加任何额外的 Flask 功能。

在下一章中,我们将看看如何向我们的应用程序添加测试,以消除我们对代码更改是否破坏了应用程序功能的猜测。

第十二章:测试 Flask 应用程序

在本书中,每当我们对应用程序的代码进行修改时,我们都必须手动将受影响的网页加载到浏览器中,以测试代码是否正确工作。随着应用程序的增长,这个过程变得越来越繁琐,特别是如果您更改了低级别且在各处都使用的东西,比如 SQLAlchemy 模型代码。

为了自动验证我们的代码是否按预期工作,我们将使用 Python 的内置功能,通常称为单元测试,对我们应用程序的代码进行检查。

什么是单元测试?

测试程序非常简单。它只涉及运行程序的特定部分,并说明您期望的结果,并将其与程序片段实际的结果进行比较。如果结果相同,则测试通过。如果结果不同,则测试失败。通常,在将代码提交到 Git 存储库之前以及在将代码部署到实时服务器之前运行这些测试,以确保破损的代码不会进入这两个系统。

在程序测试中,有三种主要类型的测试。单元测试是验证单个代码片段(如函数)正确性的测试。第二种是集成测试,它测试程序中各个单元一起工作的正确性。最后一种测试类型是系统测试,它测试整个系统的正确性,而不是单独的部分。

在本章中,我们将使用单元测试和系统测试来验证我们的代码是否按计划工作。在本章中,我们不会进行集成测试,因为代码中各部分的协同工作方式不是由我们编写的代码处理的。例如,SQLAlchemy 与 Flask 的工作方式不是由我们的代码处理的,而是由 Flask SQLAlchemy 处理的。

这带我们来到代码测试的第一个规则之一。为自己的代码编写测试。这样做的第一个原因是很可能已经为此编写了测试。第二个原因是,您使用的库中的任何错误都将在您想要使用该库的功能时在您的测试中显现出来。

测试是如何工作的?

让我们从一个非常简单的 Python 函数开始进行测试。

def square(x):
    return x * x

为了验证此代码的正确性,我们传递一个值,并测试函数的结果是否符合我们的期望。例如,我们会给它一个输入为 5,并期望结果为 25。

为了说明这个概念,我们可以在命令行中使用assert语句手动测试这个函数。Python 中的assert语句简单地表示,如果assert关键字后的条件语句返回False,则抛出异常如下:

$ python
>>> def square(x): 
...     return x * x
>>> assert square(5) == 25
>>> assert square(7) == 49
>>> assert square(10) == 100
>>> assert square(10) == 0
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
AssertionError

使用这些assert语句,我们验证了平方函数是否按预期工作。

单元测试应用程序

Python 中的单元测试通过将assert语句组合到它们自己的函数中的类中来工作。这个类中的测试函数集合被称为测试用例。测试用例中的每个函数应该只测试一件事,这是单元测试的主要思想。在单元测试中只测试一件事会迫使您逐个验证每个代码片段,而不会忽略代码的任何功能。如果编写单元测试正确,您最终会得到大量的单元测试。虽然这可能看起来过于冗长,但它将为您节省后续的麻烦。

在构建测试用例之前,我们需要另一个配置对象,专门用于设置应用程序进行测试。在这个配置中,我们将使用 Python 标准库中的tempfile模块,以便在文件中创建一个测试 SQLite 数据库,当测试结束时会自动删除。这样可以确保测试不会干扰我们的实际数据库。此外,该配置禁用了 WTForms CSRF 检查,以允许我们在测试中提交表单而无需 CSRF 令牌。

import tempfile

class TestConfig(Config):
    db_file = tempfile.NamedTemporaryFile()

    DEBUG = True
    DEBUG_TB_ENABLED = False

    SQLALCHEMY_DATABASE_URI = 'sqlite:///' + db_file.name

    CACHE_TYPE = 'null'
    WTF_CSRF_ENABLED = False

    CELERY_BROKER_URL = "amqp://guest:guest@localhost:5672//"
    CELERY_BACKEND_URL = "amqp://guest:guest@localhost:5672//"

    MAIL_SERVER = 'localhost'
    MAIL_PORT = 25
    MAIL_USERNAME = 'username'
    MAIL_PASSWORD = 'password'

测试路由功能

让我们构建我们的第一个测试用例。在这个测试用例中,我们将测试如果我们访问它们的 URL,路由函数是否成功返回响应。在项目目录的根目录中创建一个名为tests的新目录,然后创建一个名为test_urls.py的新文件,该文件将保存所有路由的单元测试。每个测试用例都应该有自己的文件,并且每个测试用例都应该专注于你正在测试的代码的一个区域。

test_urls.py中,让我们开始创建内置的 Pythonunittest库所需的内容。该代码将使用 Python 中的unittest库来运行我们在测试用例中创建的所有测试。

import unittest

class TestURLs(unittest.TestCase):
    pass

if __name__ == '__main__':
    unittest.main()

让我们看看当运行这段代码时会发生什么。我们将使用unittest库的自动查找测试用例的能力来运行测试。unittest库查找的模式是test*.py

$ python -m unittest discover

---------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

因为测试用例中没有测试,所以测试用例成功通过。

注意

测试脚本是从脚本的父目录而不是测试文件夹本身运行的。这是为了允许在测试脚本中导入应用程序代码。

为了测试 URL,我们需要一种在不实际运行服务器的情况下查询应用程序路由的方法,以便返回我们的请求。Flask 提供了一种在测试中访问路由的方法,称为测试客户端。测试客户端提供了在我们的路由上创建 HTTP 请求的方法,而无需实际运行应用程序的app.run()

在这个测试用例中,我们将需要测试客户端对象,但是在每个unittest中添加代码来创建测试客户端并没有太多意义,因为我们有setUp方法。setUp方法在每个单元测试之前运行,并且可以将变量附加到 self 上,以便测试方法可以访问它们。在我们的setUp方法中,我们需要使用我们的TestConfig对象创建应用程序对象,并创建测试客户端。

此外,我们需要解决三个问题。前两个在 Flask Admin 和 Flask Restful 扩展中,当应用程序对象被销毁时,它们内部存储的 Blueprint 对象不会被移除。第三,Flask SQLAlchemy 的初始化程序在webapp目录之外时无法正确添加应用程序对象:

class TestURLs(unittest.TestCase):
    def setUp(self):
        # Bug workarounds
        admin._views = []
        rest_api.resources = []

        app = create_app('webapp.config.TestConfig')
        self.client = app.test_client()

        # Bug workaround
        db.app = app

        db.create_all()

注意

在撰写本文时,之前列出的所有错误都存在,但在阅读本章时可能已经不存在。

除了setUp方法之外,还有tearDown方法,它在每次单元测试结束时运行。tearDown方法用于销毁setUp方法中创建的任何无法自动垃圾回收的对象。在我们的情况下,我们将使用tearDown方法来删除测试数据库中的表,以便每个测试都有一个干净的起点。

class TestURLs(unittest.TestCase):
    def setUp(self):
        …

    def tearDown(self):
        db.session.remove()
        db.drop_all()

现在我们可以创建我们的第一个单元测试。第一个测试将测试访问我们应用程序的根目录是否会返回302 重定向到博客主页,如下所示:

class TestURLs(unittest.TestCase):
    def setUp(self):
        …

    def tearDown(self):
        …

    def test_root_redirect(self):
        """ Tests if the root URL gives a 302 """

        result = self.client.get('/')
        assert result.status_code == 302
        assert "/blog/" in result.headers['Location']

每个单元测试必须以单词test开头,以告诉unittest库该函数是一个单元测试,而不仅仅是测试用例类中的某个实用函数。

现在,如果我们再次运行测试,我们会看到我们的测试被运行并通过检查:

$ python -m unittest discover
.
---------------------------------------------------------------------
Ran 1 tests in 0.128s

OK

编写测试的最佳方法是事先询问自己要寻找什么,编写assert语句,并编写执行这些断言所需的代码。这迫使您在开始编写测试之前询问自己真正要测试什么。为每个单元测试编写 Python 文档字符串也是最佳实践,因为每当测试失败时,它将与测试名称一起打印,并且在编写 50 多个测试后,了解测试的确切目的可能会有所帮助。

与使用 Python 的内置assert关键字不同,我们可以使用unittest库提供的一些方法。当这些函数内部的assert语句失败时,这些方法提供了专门的错误消息和调试信息。

以下是unittest库提供的所有特殊assert语句及其功能列表:

  • assertEqual(x, y): 断言 x == y

  • assertNotEqual(x, y): 断言 x != y

  • assertTrue(x): 断言 xTrue

  • assertFalse(x): 断言 xFalse

  • assertIs(x, y): 断言 xy

  • assertIsNot(x, y): 断言 x 不是 y

  • assertIsNone(x): 断言 xNone

  • assertIsNotNone(x): 断言 x 不是 None

  • assertIn(x, y): 断言 xy

  • assertNotIn(x, y): 断言 x 不在 y

  • assertIsInstance(x, y): 断言 isinstance(x, y)

  • assertNotIsInstance(x, y): 断言不是 isinstance(x, y)

如果我们想测试普通页面的返回值,单元测试将如下所示:

class TestURLs(unittest.TestCase):
    def setUp(self):
        …

    def tearDown(self):
        …

    def test_root_redirect(self):
        …

请记住,此代码仅测试 URL 是否成功返回。返回数据的内容不是这些测试的一部分。

如果我们想测试提交登录表单之类的表单,可以使用测试客户端的 post 方法。让我们创建一个test_login方法来查看登录表单是否正常工作:

class TestURLs(unittest.TestCase):
    …
    def test_login(self):
        """ Tests if the login form works correctly """

        test_role = Role("default")
        db.session.add(test_role)
        db.session.commit()

        test_user = User("test")
        test_user.set_password("test")
        db.session.add(test_user)
        db.session.commit()

        result = self.client.post('/login', data=dict(
            username='test',
            password="test"
        ), follow_redirects=True)

        self.assertEqual(result.status_code, 200)
        self.assertIn('You have been logged in', result.data)

对返回数据中字符串的额外检查是因为返回代码不受输入数据有效性的影响。post 方法将适用于测试本书中创建的任何表单对象。

现在您了解了单元测试的机制,可以使用单元测试来测试应用程序的所有部分。例如,测试应用程序中的所有路由,测试我们制作的任何实用函数,如sidebar_data,测试具有特定权限的用户是否可以访问页面等。

如果您的应用程序代码具有任何功能,无论多么小,都应该为其编写测试。为什么?因为任何可能出错的事情都会出错。如果您的应用程序代码的有效性完全依赖于手动测试,那么随着应用程序的增长,某些事情将被忽视。一旦有事情被忽视,就会将错误的代码部署到生产服务器上,这会让您的用户感到恼火。

用户界面测试

为了测试应用程序代码的高级别,并创建系统测试,我们将编写与浏览器一起工作的测试,并验证 UI 代码是否正常工作。使用一个名为 Selenium 的工具,我们将创建 Python 代码,纯粹通过代码来控制浏览器。您可以在屏幕上找到元素,然后通过 Selenium 对这些元素执行操作。单击它或输入按键。此外,Selenium 允许您通过访问元素的内容,例如其属性和内部文本,对页面内容执行检查。对于更高级的检查,Selenium 甚至提供了一个接口来在页面上运行任意 JavaScript。如果 JavaScript 返回一个值,它将自动转换为 Python 类型。

在触及代码之前,需要安装 Selenium:

$ pip install selenium

要开始编写代码,我们的 UI 测试需要在名为test_ui.py的测试目录中拥有自己的文件。因为系统测试不测试特定的事物,编写用户界面测试的最佳方法是将测试视为模拟典型用户流程。在编写测试之前,写下我们的虚拟用户将模拟的具体步骤:

import unittest

class TestURLs(unittest.TestCase):
    def setUp(self):
        pass

    def tearDown(self):
        pass

    def test_add_new_post(self):
        """ Tests if the new post page saves a Post object to the
            database

            1\. Log the user in
            2\. Go to the new_post page
            3\. Fill out the fields and submit the form
            4\. Go to the blog home page and verify that the post 
               is on the page
        """
        pass

现在我们知道了我们的测试要做什么,让我们开始添加 Selenium 代码。在setUptearDown方法中,我们需要代码来启动 Selenium 控制的 Web 浏览器,然后在测试结束时关闭它。

import unittest
from selenium import webdriver
class TestURLs(unittest.TestCase):
    def setUp(self):
        self.driver = webdriver.Firefox()
    def tearDown(self):
        self.driver.close()

这段代码生成一个由 Selenium 控制的新的 Firefox 窗口。当然,为了使其工作,你需要在计算机上安装 Firefox。还有其他浏览器的支持,但它们都需要额外的程序才能正常工作。Firefox 在所有浏览器中具有最好的支持。

在编写测试代码之前,让我们按照以下方式探索 Selenium API:

$ python
>>> from selenium import webdriver
>>> driver = webdriver.Firefox()
# load the Google homepage
>>> driver.get("http://www.google.com")
# find a element by its class
>>> search_field = driver.find_element_by_class_name("gsfi")
# find a element by its name
>>> search_field = driver.find_element_by_name("q")
# find an element by its id
>>> search_field = driver.find_element_by_id("lst-ib")
# find an element with JavaScript
>>> search_field = driver.execute_script(
 "return document.querySelector('#lst-ib')"
)
# search for flask
>>> search_field.send_keys("flask")
>>> search_button = driver.find_element_by_name("btnK")
>>> search_button.click()

这些是我们将要使用的 Selenium 的主要功能,但还有许多其他方法可以查找和与网页上的元素进行交互。有关可用功能的完整列表,请参阅 Selenium-Python 文档selenium-python.readthedocs.org

在编写测试时,Selenium 中有两个需要牢记的要点,否则你将遇到几乎无法从错误消息中调试的非常奇怪的错误:

  1. Selenium 的设计就像有一个实际的人控制浏览器一样。这意味着如果页面上看不到一个元素,Selenium 就无法与其交互。例如,如果一个元素覆盖了你想点击的另一个元素,比如一个模态窗口在按钮前面,那么按钮就无法被点击。如果元素的 CSS 将其显示设置为none或可见性设置为hidden,结果将是一样的。

  2. 屏幕上指向元素的所有变量都存储为指向浏览器中这些元素的指针,这意味着它们不存储在 Python 的内存中。如果页面在不使用get方法的情况下发生更改,比如点击链接并创建新的元素指针时,测试将崩溃。这是因为驱动程序将不断寻找先前页面上的元素,而在新页面上找不到它们。驱动程序的get方法清除所有这些引用。

在以前的测试中,我们使用测试客户端来模拟对应用程序对象的请求。然而,因为我们现在使用的是需要直接通过 Web 浏览器与应用程序进行交互的东西,我们需要一个实际运行的服务器。这个服务器需要在用户界面测试运行之前在一个单独的终端窗口中运行,以便它们有东西可以请求。为了做到这一点,我们需要一个单独的 Python 文件来使用我们的测试配置运行服务器,并设置一些模型供我们的 UI 测试使用。在项目目录的根目录下新建一个名为run_test_server.py的新文件,添加以下内容:

from webapp import create_app
from webapp.models import db, User, Role

app = create_app('webapp.config.TestConfig')

db.app = app
db.create_all()

default = Role("default")
poster = Role("poster")
db.session.add(default)
db.session.add(poster)
db.session.commit()

test_user = User("test")
test_user.set_password("test")
test_user.roles.append(poster)
db.session.add(test_user)
db.session.commit()

app.run()

现在我们既有了测试服务器脚本,又了解了 Selenium 的 API,我们终于可以为我们的测试编写代码了:

class TestURLs(unittest.TestCase):
    def setUp(self):
        …

    def tearDown(self):
        …

    def test_add_new_post(self):
        """ Tests if the new post page saves a Post object to the
            database

            1\. Log the user in
            2\. Go to the new_post page
            3\. Fill out the fields and submit the form
            4\. Go to the blog home page and verify that
               the post is on the page
        """
        # login
        self.driver.get("http://localhost:5000/login")

        username_field = self.driver.find_element_by_name(
            "username"
        )
        username_field.send_keys("test")

        password_field = self.driver.find_element_by_name(
            "password"
        )
        password_field.send_keys("test")

        login_button = self.driver.find_element_by_id(
            "login_button"
        )
        login_button.click()

        # fill out the form
        self.driver.get("http://localhost:5000/blog/new")

        title_field = self.driver.find_element_by_name("title")
        title_field.send_keys("Test Title")

        # find the editor in the iframe
        self.driver.switch_to.frame(
            self.driver.find_element_by_tag_name("iframe")
        )
        post_field = self.driver.find_element_by_class_name(
            "cke_editable"
        )
        post_field.send_keys("Test content")
        self.driver.switch_to.parent_frame()

        post_button = self.driver.find_element_by_class_name(
            "btn-primary"
        )
        post_button.click()

        # verify the post was created
        self.driver.get("http://localhost:5000/blog")
        self.assertIn("Test Title", self.driver.page_source)
        self.assertIn("Test content", self.driver.page_source)

这个测试中使用了我们之前介绍的大部分方法。然而,在这个测试中有一个名为switch_to的新方法。switch_to方法是驱动程序的上下文,允许选择iframe元素内的元素。通常情况下,父窗口无法使用 JavaScript 选择iframe内的任何元素,但因为我们直接与浏览器进行交互,我们可以访问iframe元素的内容。我们需要像这样切换上下文,因为在创建页面内的 WYSIWYG 编辑器中使用iframe。在iframe内选择元素完成后,我们需要使用parent_frame方法切换回父上下文。

现在你已经拥有了测试服务器代码和用户界面代码的测试工具。在本章的其余部分,我们将专注于工具和方法,以使您的测试更加有效,以确保应用程序的正确性。

测试覆盖率

现在我们已经编写了测试,我们必须知道我们的代码是否经过了充分的测试。测试覆盖率的概念,也称为代码覆盖率,是为了解决这个问题而发明的。在任何项目中,测试覆盖率表示在运行测试时执行了项目中多少百分比的代码,以及哪些代码行从未运行过。这给出了项目中哪些部分在我们的单元测试中没有被测试的想法。要将覆盖报告添加到我们的项目中,请使用以下命令使用 pip 安装覆盖库:

$ pip install coverage

覆盖库可以作为一个命令行程序运行,它将在测试运行时运行您的测试套件并进行测量。

$ coverage run --source webapp --branch -m unittest discover

--source标志告诉覆盖仅报告webapp目录中文件的覆盖率。如果不包括这个标志,那么应用程序中使用的所有库的百分比也将被包括在内。默认情况下,如果执行了if语句中的任何代码,就会说整个if语句已经执行。--branch标志告诉coverage禁用这一点,并测量所有内容。

coverage运行我们的测试并进行测量后,我们可以以两种方式查看其发现的报告。第一种是在命令行上查看每个文件的覆盖百分比:

$ coverage report
Name                               Stmts   Miss Branch BrMiss  Cover
--------------------------------------------------------------------
webapp/__init__                       51      0      6      0   100%
webapp/config                         37      0      0      0   100%
webapp/controllers/__init__            0      0      0      0   100%
webapp/controllers/admin              27      4      0      0    85%
webapp/controllers/blog               77     45      8      8    38%
webapp/controllers/main               78     42     20     16    41%
webapp/controllers/rest/__init__       0      0      0      0   100%
webapp/controllers/rest/auth          13      6      2      2    47%
webapp/controllers/rest/fields        17      8      0      0    53%
webapp/controllers/rest/parsers       19      0      0      0   100%
webapp/controllers/rest/post          85     71     44     43    12%
webapp/extensions                     56     14      4      4    70%
webapp/forms                          48     15     10      7    62%
webapp/models                         89     21      4      3    74%
webapp/tasks                          41     29      4      4    27%
--------------------------------------------------------------------
TOTAL                                638    255    102     87    54%

第二种是使用覆盖的 HTML 生成功能在浏览器中查看每个文件的详细信息。

$ coverage html

上述命令创建了一个名为htmlcov的目录。当在浏览器中打开index.html文件时,可以单击每个文件名以显示测试期间运行和未运行的代码行的详细情况。

测试覆盖率

在上面的截图中,打开了blog.py文件,覆盖报告清楚地显示了帖子路由从未执行过。然而,这也会产生一些错误的负面影响。由于用户界面测试未测试覆盖程序运行的代码,因此它不计入我们的覆盖报告。为了解决这个问题,只需确保测试用例中有测试,测试每个单独的函数,这些函数在用户界面测试中应该被测试。

在大多数项目中,目标百分比约为 90%的代码覆盖率。很少有项目的 100%代码是可测试的,随着项目规模的增加,这种可能性会减少。

测试驱动开发

现在我们已经编写了测试,它们如何融入开发过程?目前,我们正在使用测试来确保在创建某些功能后代码的正确性。但是,如果我们改变顺序,使用测试来从一开始就创建正确的代码呢?这就是测试驱动开发TDD)的主张。

TDD 遵循一个简单的循环来编写应用程序中新功能的代码:

测试驱动开发

此图像的来源是维基百科上的用户 Excirial

在使用 TDD 的项目中,你在实际构建任何控制你实际构建的代码之前,编写的第一件事是测试。这迫使项目中的程序员在编写任何代码之前规划项目的范围、设计和要求。在设计 API 时,它还迫使程序员从消费者的角度设计 API 的接口,而不是在编写所有后端代码之后设计接口。

在 TDD 中,测试旨在第一次运行时失败。TDD 中有一句话,如果你的测试第一次运行时没有失败,那么你实际上并没有测试任何东西。这意味着你很可能在编写测试之后测试被测试单元给出的结果,而不是应该给出的结果。

在第一次测试失败后,您不断编写代码,直到所有测试通过。对于每个新功能,这个过程都会重复。

一旦所有原始测试通过并且代码被清理干净,TDD 告诉你停止编写代码。通过仅在测试通过时编写代码,TDD 还强制执行“你不会需要它”(YAGNI)哲学,该哲学规定程序员只应实现他们实际需要的功能,而不是他们认为他们将需要的功能。在开发过程中,当程序员试图在没有人需要的情况下预先添加功能时,会浪费大量的精力。

例如,在我参与的一个 PHP 项目中,我发现了以下代码,用于在目录中查找图像:

$images = glob(
    $img_directory . "{*.jpg, *.jpeg, *.gif, *.png, *.PNG, *.Png, *.PnG, *.pNG, *.pnG, *.pNg, *.PNg}",
    GLOB_BRACE
);

在 PHP 中,glob 是一个函数,它查找目录中的内容,以找到与模式匹配的文件。我质问了编写它的程序员。他对.png扩展名的不同版本的解释是,某个用户上传了一个带有.PNG扩展名的文件,而函数没有找到它,因为它只寻找扩展名的小写版本。他试图解决一个不存在的问题,以确保他不必再次触及这段代码,我们可能会觉得浪费了一点时间,但这段代码是整个代码库的缩影。如果这个项目遵循 TDD,就会为大写文件扩展名添加一个测试用例,添加代码以通过测试,然后问题就解决了。

TDD 还提倡“保持简单,愚蠢”(KISS)的理念,这个理念规定从一开始就应该把简单作为设计目标。TDD 提倡 KISS,因为它需要小的、可测试的代码单元,这些单元可以相互分离,不依赖于共享的全局状态。

此外,在遵循 TDD 的项目中,测试始终保持最新的文档。编程的一个公理是,对于任何足够大的程序,文档总是过时的。这是因为当程序员在更改代码时,文档是最后考虑的事情之一。然而,通过测试,项目中的每个功能都有清晰的示例(如果项目的代码覆盖率很高)。测试一直在更新,因此展示了程序的功能和 API 应该如何工作的良好示例。

现在你已经了解了 Flask 的功能以及如何为 Flask 编写测试,你在 Flask 中创建的下一个项目可以完全使用 TDD。

总结

现在你已经了解了测试以及它对你的应用程序能做什么,你可以创建保证不会有太多错误的应用程序。你将花更少的时间修复错误,更多的时间添加用户请求的功能。

在下一章中,我们将通过讨论在服务器上将应用程序部署到生产环境的方式来完成这本书。

作为对读者的最后挑战,在进入下一章之前,尝试将你的代码覆盖率提高到 95%以上。

第十三章:部署 Flask 应用程序

现在我们已经到达了书的最后一章,并且在 Flask 中制作了一个完全功能的 Web 应用程序,我们开发的最后一步是使该应用程序对外开放。有许多不同的方法来托管您的 Flask 应用程序,每种方法都有其优缺点。本章将介绍最佳解决方案,并指导您在何种情况下选择其中一种。

请注意,在本章中,术语服务器用于指代运行操作系统的物理机器。但是,当使用术语 Web 服务器时,它指的是服务器上接收 HTTP 请求并发送响应的程序。

在您自己的服务器上部署

部署任何 Web 应用程序的最常见方法是在您可以控制的服务器上运行它。在这种情况下,控制意味着可以使用管理员帐户访问服务器上的终端。与其他选择相比,这种部署方式为您提供了最大的自由度,因为它允许您安装任何程序或工具。这与其他托管解决方案相反,其中 Web 服务器和数据库是为您选择的。这种部署方式也恰好是最便宜的选择。

这种自由的缺点是您需要负责保持服务器运行,备份用户数据,保持服务器上的软件最新以避免安全问题等。关于良好的服务器管理已经写了很多书。因此,如果您认为您或您的公司无法承担这种责任,最好选择其他部署选项之一。

本节将基于基于 Debian Linux 的服务器,因为 Linux 是远远最受欢迎的运行 Web 服务器的操作系统,而 Debian 是最受欢迎的 Linux 发行版(一种特定的软件和 Linux 内核的组合,作为一个软件包发布)。任何具有 bash 和名为 SSH 的程序(将在下一节介绍)的操作系统都适用于本章。唯一的区别将是安装服务器上软件的命令行程序。

这些 Web 服务器将使用名为Web 服务器网关接口WSGI)的协议,这是一种旨在允许 Python Web 应用程序与 Web 服务器轻松通信的标准。我们永远不会直接使用 WSGI,但我们将使用的大多数 Web 服务器接口都将在其名称中包含 WSGI,如果您不知道它是什么,可能会感到困惑。

使用 fabric 将代码推送到您的服务器

为了自动化设置和将应用程序代码推送到服务器的过程,我们将使用一个名为 fabric 的 Python 工具。Fabric 是一个命令行程序,它使用名为 SSH 的工具在远程服务器上读取和执行 Python 脚本。SSH 是一种协议,允许一台计算机的用户远程登录到另一台计算机并在命令行上执行命令,前提是用户在远程机器上有一个帐户。

要安装fabric,我们将使用pip如下:

$ pip install fabric

fabric命令是一组命令行程序,将在远程机器的 shell 上运行,本例中为 bash。我们将创建三个不同的命令:一个用于运行单元测试,一个用于根据我们的规格设置全新的服务器,一个用于让服务器使用git更新其应用程序代码的副本。我们将把这些命令存储在项目目录根目录下的一个名为fabfile.py的新文件中。

因为它是最容易创建的,让我们首先创建测试命令:

from fabric.api import local

def test():
    local('python -m unittest discover')

要从命令行运行此函数,我们可以使用fabric命令行界面,通过传递要运行的命令的名称来运行:

$ fab test
[localhost] local: python -m unittest discover
.....
---------------------------------------------------------------------
Ran 5 tests in 6.028s
OK

Fabric 有三个主要命令:localrunsudolocal函数在前面的函数中可见,run在本地计算机上运行命令。runsudo函数在远程计算机上运行命令,但sudo以管理员身份运行命令。所有这些函数都会通知 fabric 命令是否成功运行。如果命令未成功运行,这意味着在这种情况下我们的测试失败,函数中的任何其他命令都不会运行。这对我们的命令很有用,因为它允许我们强制自己不要将任何未通过测试的代码推送到服务器。

现在我们需要创建一个命令来从头开始设置新服务器。这个命令将安装我们的生产环境需要的软件,并从我们的集中式git存储库下载代码。它还将创建一个新用户,该用户将充当 web 服务器的运行者以及代码存储库的所有者。

注意

不要使用 root 用户运行您的 web 服务器或部署您的代码。这会使您的应用程序面临各种安全漏洞。

这个命令将根据您的操作系统而有所不同,我们将根据您选择的服务器在本章的其余部分中添加这个命令:

from fabric.api import env, local, run, sudo, cd

env.hosts = ['deploy@[your IP]']

def upgrade_libs():
    sudo("apt-get update")
    sudo("apt-get upgrade")

def setup():
    test()
    upgrade_libs()

    # necessary to install many Python libraries 
    sudo("apt-get install -y build-essential")
    sudo("apt-get install -y git")
    sudo("apt-get install -y python")
    sudo("apt-get install -y python-pip")
    # necessary to install many Python libraries
    sudo("apt-get install -y python-all-dev")

    run("useradd -d /home/deploy/ deploy")
    run("gpasswd -a deploy sudo")

    # allows Python packages to be installed by the deploy user
    sudo("chown -R deploy /usr/local/")
    sudo("chown -R deploy /usr/lib/python2.7/")

    run("git config --global credential.helper store")

    with cd("/home/deploy/"):
        run("git clone [your repo URL]")

    with cd('/home/deploy/webapp'):
        run("pip install -r requirements.txt")
        run("python manage.py createdb")

此脚本中有两个新的 fabric 功能。第一个是env.hosts赋值,它告诉 fabric 应该登录到的机器的用户和 IP 地址。其次,有与关键字一起使用的cd函数,它在该目录的上下文中执行任何函数,而不是在部署用户的主目录中。修改git配置的行是为了告诉git记住存储库的用户名和密码,这样您就不必每次希望将代码推送到服务器时都输入它。此外,在设置服务器之前,我们确保更新服务器的软件以保持服务器的最新状态。

最后,我们有一个将新代码推送到服务器的功能。随着时间的推移,这个命令还将重新启动 web 服务器并重新加载来自我们代码的任何配置文件。但这取决于您选择的服务器,因此这将在后续部分中填写。

def deploy():
    test()
    upgrade_libs()
    with cd('/home/deploy/webapp'):
        run("git pull")
        run("pip install -r requirements.txt")

因此,如果我们要开始在新服务器上工作,我们只需要运行以下命令:

$ fabric setup
$ fabric deploy

使用 supervisor 运行您的 web 服务器

现在我们已经自动化了更新过程,我们需要服务器上的一些程序来确保我们的 web 服务器以及如果您没有使用 SQLite 的话数据库正在运行。为此,我们将使用一个名为 supervisor 的简单程序。supervisor 的所有功能都是自动在后台进程中运行命令行程序,并允许您查看正在运行的程序的状态。Supervisor 还监视其正在运行的所有进程,如果进程死掉,它会尝试重新启动它。

要安装supervisor,我们需要将其添加到fabfile.py中的设置命令中:

def setup():
    …
    sudo("apt-get install -y supervisor")

告诉supervisor要做什么,我们需要创建一个配置文件,然后在部署fabric命令期间将其复制到服务器的/etc/supervisor/conf.d/目录中。当supervisor启动并尝试运行时,它将加载此目录中的所有文件。

在项目目录的根目录中新建一个名为supervisor.conf的文件,添加以下内容:

[program:webapp]
command=
directory=/home/deploy/webapp
user=deploy

[program:rabbitmq]
command=rabbitmq-server
user=deploy

[program:celery]
command=celery worker -A celery_runner 
directory=/home/deploy/webapp
user=deploy

注意

这是使 web 服务器运行所需的最低配置。但是,supervisor 还有很多配置选项。要查看所有自定义内容,请访问 supervisor 文档supervisord.org/

此配置告诉supervisordeploy用户的上下文中运行命令/home/deploy/webapp。命令值的右侧为空,因为它取决于您正在运行的服务器,并将填充到每个部分中。

现在我们需要在部署命令中添加一个sudo调用,将此配置文件复制到/etc/supervisor/conf.d/目录中,如下所示。

def deploy():
    …
    with cd('/home/deploy/webapp'):
        …
        sudo("cp supervisord.conf /etc/supervisor/conf.d/webapp.conf")

    sudo('service supervisor restart')

许多项目只是在服务器上创建文件然后忘记它们,但是将配置文件存储在我们的git存储库中,并在每次部署时复制它们具有几个优点。首先,这意味着如果出现问题,可以使用git轻松恢复更改。其次,这意味着我们不必登录服务器即可对文件进行更改。

注意

不要在生产中使用 Flask 开发服务器。它不仅无法处理并发连接,还允许在服务器上运行任意 Python 代码。

Gevent

让 Web 服务器运行起来的最简单的选择是使用一个名为 gevent 的 Python 库来托管您的应用程序。Gevent 是一个 Python 库,它提供了一种在 Python 线程库之外进行并发编程的替代方式,称为协程。Gevent 具有一个接口来运行简单且性能良好的 WSGI 应用程序。一个简单的 gevent 服务器可以轻松处理数百个并发用户,这比互联网上网站的用户数量多 99%。这种选择的缺点是它的简单性意味着缺乏配置选项。例如,无法向服务器添加速率限制或添加 HTTPS 流量。这种部署选项纯粹是为了那些您不希望接收大量流量的网站。记住 YAGNI;只有在真正需要时才升级到不同的 Web 服务器。

注意

协程有点超出了本书的范围,因此可以在en.wikipedia.org/wiki/Coroutine找到一个很好的解释。

要安装gevent,我们将使用pip

$ pip install gevent

在项目目录的根目录中新建一个名为gserver.py的文件,添加以下内容:

from gevent.wsgi import WSGIServer
from webapp import create_app

app = create_app('webapp.config.ProdConfig')

server = WSGIServer(('', 80), app)
server.serve_forever()

要在 supervisor 中运行服务器,只需将命令值更改为以下内容:

[program:webapp]
command=python gserver.py 
directory=/home/deploy/webapp
user=deploy

现在,当您部署时,gevent将通过在每次添加新依赖项后适当地 pip 冻结来自动安装,也就是说,如果您在每次添加新依赖项后都进行 pip 冻结。

Tornado

Tornado 是部署 WSGI 应用程序的另一种非常简单的纯 Python 方式。Tornado 是一个设计用来处理成千上万个同时连接的 Web 服务器。如果您的应用程序需要实时数据,Tornado 还支持 WebSockets,以实现与服务器的持续、长期的连接。

注意

不要在 Windows 服务器上生产使用 Tornado。Tornado 的 Windows 版本不仅速度慢得多,而且被认为是质量不佳的测试版软件。

为了将 Tornado 与我们的应用程序一起使用,我们将使用 Tornado 的WSGIContainer来包装应用程序对象,使其与 Tornado 兼容。然后,Tornado 将开始监听端口80的请求,直到进程终止。在一个名为tserver.py的新文件中,添加以下内容:

from tornado.wsgi import WSGIContainer
from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from webapp import create_app
app = WSGIContainer(create_app("webapp.config.ProdConfig"))
http_server = HTTPServer(app)
http_server.listen(80)
IOLoop.instance().start()

要在 supervisor 中运行 Tornado,只需将命令值更改为以下内容:

[program:webapp]
command=python tserver.py 
directory=/home/deploy/webapp
user=deploy

Nginx 和 uWSGI

如果您需要更高的性能或自定义,部署 Python Web 应用程序的最流行方式是使用 Web 服务器 Nginx 作为 WSGI 服务器 uWSGI 的前端,通过使用反向代理。反向代理是网络中的一个程序,它从服务器检索内容,就好像它们是从代理服务器返回的一样:

Nginx 和 uWSGI

Nginx 和 uWSGI 是以这种方式使用的,因为我们既可以获得 Nginx 前端的强大功能,又可以拥有 uWSGI 的自定义功能。

Nginx 是一个非常强大的 Web 服务器,通过提供速度和定制性的最佳组合而变得流行。Nginx 始终比其他 Web 服务器(如 Apache httpd)更快,并且原生支持 WSGI 应用程序。它实现这种速度的方式是通过几个良好的架构决策,以及早期决定他们不打算像 Apache 那样覆盖大量用例。功能集较小使得维护和优化代码变得更加容易。从程序员的角度来看,配置 Nginx 也更容易,因为没有一个需要在每个项目目录中用.htaccess文件覆盖的巨大默认配置文件(httpd.conf)。

其中一个缺点是 Nginx 的社区比 Apache 要小得多,因此如果遇到问题,您可能不太可能在网上找到答案。此外,有可能在 Nginx 中不支持大多数程序员在 Apache 中习惯的功能。

uWSGI 是一个支持多种不同类型的服务器接口(包括 WSGI)的 Web 服务器。uWSGI 处理应用程序内容以及诸如负载平衡流量等事项。

要安装 uWSGI,我们将使用pip

$ pip install uwsgi

为了运行我们的应用程序,uWSGI 需要一个包含可访问的 WSGI 应用程序的文件。在项目目录的顶层中创建一个名为wsgi.py的新文件,添加以下内容:

from webapp import create_app

app = create_app("webapp.config.ProdConfig")

为了测试 uWSGI,我们可以使用以下命令从命令行运行它:

$ uwsgi --socket 127.0.0.1:8080 \
--wsgi-file wsgi.py \
--callable app \
--processes 4 \
--threads 2

如果您在服务器上运行此操作,您应该能够访问8080端口并查看您的应用程序(如果您没有防火墙的话)。

这个命令的作用是从wsgi.py文件中加载 app 对象,并使其可以从8080端口的localhost访问。它还生成了四个不同的进程,每个进程有两个线程,这些进程由一个主进程自动进行负载平衡。对于绝大多数网站来说,这个进程数量是过剩的。首先,使用一个进程和两个线程,然后逐步扩展。

我们可以创建一个文本文件来保存配置,而不是在命令行上添加所有配置选项,这样可以带来与在 supervisor 部分列出的配置相同的好处。

在项目目录的根目录中的一个名为uwsgi.ini的新文件中添加以下代码:

[uwsgi]
socket = 127.0.0.1:8080
wsgi-file = wsgi.py
callable = app
processes = 4
threads = 2

注意

uWSGI 支持数百种配置选项,以及几个官方和非官方的插件。要充分利用 uWSGI 的功能,您可以在uwsgi-docs.readthedocs.org/上查阅文档。

现在让我们从 supervisor 运行服务器:

[program:webapp]
command=uwsgi uwsgi.ini
directory=/home/deploy/webapp
user=deploy

我们还需要在设置函数中安装 Nginx:

def setup():
    …
    sudo("apt-get install -y nginx")

因为我们是从操作系统的软件包管理器中安装 Nginx,所以操作系统会为我们处理 Nginx 的运行。

注意

在撰写本文时,官方 Debian 软件包管理器中的 Nginx 版本已经过时数年。要安装最新版本,请按照这里的说明进行操作:wiki.nginx.org/Install

接下来,我们需要创建一个 Nginx 配置文件,然后在推送代码时将其复制到/etc/nginx/sites-available/目录中。在项目目录的根目录中的一个名为nginx.conf的新文件中添加以下内容:

server {
    listen 80;
    server_name your_domain_name;

    location / {
        include uwsgi_params;
        uwsgi_pass 127.0.0.1:8080;
    }

    location /static {
        alias /home/deploy/webapp/webapp/static;
    }
}

这个配置文件的作用是告诉 Nginx 在80端口监听传入请求,并将所有请求转发到在8080端口监听的 WSGI 应用程序。此外,它对静态文件的任何请求进行了例外处理,并直接将这些请求发送到文件系统。绕过 uWSGI 处理静态文件可以大大提高性能,因为 Nginx 在快速提供静态文件方面非常出色。

最后,在fabfile.py文件中:

def deploy():
    …
    with cd('/home/deploy/webapp'):
        …
        sudo("cp nginx.conf "
             "/etc/nginx/sites-available/[your_domain]")
        sudo("ln -sf /etc/nginx/sites-available/your_domain "
             "/etc/nginx/sites-enabled/[your_domain]") 

    sudo("service nginx restart")

Apache 和 uWSGI

使用 Apache httpd 与 uWSGI 基本上具有相同的设置。首先,我们需要在项目目录的根目录中的一个名为apache.conf的新文件中创建一个 apache 配置文件:

<VirtualHost *:80>
    <Location />
        ProxyPass / uwsgi://127.0.0.1:8080/
    </Location>
</VirtualHost>

这个文件只是告诉 Apache 将所有端口为80的请求传递到端口为8080的 uWSGI Web 服务器。但是,此功能需要来自 uWSGI 的额外 Apache 插件,名为mod-proxy-uwsgi。我们可以在 set 命令中安装这个插件以及 Apache:

def setup():

    sudo("apt-get install -y apache2")
    sudo("apt-get install -y libapache2-mod-proxy-uwsgi")

最后,在deploy命令中,我们需要将我们的 Apache 配置文件复制到 Apache 的配置目录中:

def deploy():
    …
    with cd('/home/deploy/webapp'):
        …
        sudo("cp apache.conf "
             "/etc/apache2/sites-available/[your_domain]")
        sudo("ln -sf /etc/apache2/sites-available/[your_domain] "
             "/etc/apache2/sites-enabled/[your_domain]") 

    sudo("service apache2 restart")

在 Heroku 上部署

Heroku 是本章将要介绍的平台即服务PaaS)提供商中的第一个。PaaS 是提供给 Web 开发人员的一项服务,允许他们在由他人控制和维护的平台上托管他们的网站。以牺牲自由为代价,您可以确保您的网站将随着用户数量的增加而自动扩展,而无需您额外的工作。使用 PaaS 通常也比运行自己的服务器更昂贵。

Heroku 是一种旨在对 Web 开发人员易于使用的 PaaS,它通过连接已经存在的工具并不需要应用程序中的任何大更改来工作。Heroku 通过读取名为Procfile的文件来工作,该文件包含您的 Heroku dyno 基本上是一个坐落在服务器上的虚拟机将运行的命令。在开始之前,您将需要一个 Heroku 帐户。如果您只是想进行实验,可以使用免费帐户。

在目录的根目录中新建一个名为Procfile的文件,添加以下内容:

web: uwsgi uwsgi.ini 

这告诉 Heroku 我们有一个名为 web 的进程,它将运行 uWSGI 命令并传递uwsgi.ini文件。Heroku 还需要一个名为runtime.txt的文件,它将告诉它您希望使用哪个 Python 运行时(在撰写本文时,最新的 Python 版本是 2.7.10):

python-2.7.10

最后,我们需要对之前创建的uwsgi.ini文件进行一些修改:

[uwsgi]
http-socket = :$(PORT)
die-on-term = true
wsgi-file = wsgi.py
callable = app
processes = 4
threads = 2

我们将端口设置为 uWSGI 监听环境变量端口,因为 Heroku 不直接将 dyno 暴露给互联网。相反,它有一个非常复杂的负载均衡器和反向代理系统,因此我们需要让 uWSGI 监听 Heroku 需要我们监听的端口。此外,我们将die-on-term设置为 true,以便 uWSGI 正确监听来自操作系统的终止信号事件。

要使用 Heroku 的命令行工具,我们首先需要安装它们,可以从toolbelt.heroku.com完成。

接下来,您需要登录到您的帐户:

$ heroku login

我们可以使用 foreman 命令测试我们的设置,以确保它在 Heroku 上运行之前可以正常工作:

$ foreman start web

Foreman 命令模拟了 Heroku 用于运行我们的应用的相同生产环境。要创建将在 Heroku 服务器上运行应用程序的 dyno,我们将使用create命令。然后,我们可以推送到git存储库上的远程分支 Heroku,以便 Heroku 服务器自动拉取我们的更改。

$ heroku create
$ git push heroku master

如果一切顺利,您应该在新的 Heroku dyno 上拥有一个可工作的应用程序。您可以使用以下命令在新的标签页中打开新的 Web 应用程序:

$ heroku open

要查看 Heroku 部署中的应用程序运行情况,请访问mastering-flask.herokuapp.com/

使用 Heroku Postgres

正确地维护数据库是一项全职工作。幸运的是,我们可以利用 Heroku 内置的功能之一来自动化这个过程。Heroku Postgres 是由 Heroku 完全维护和托管的 Postgres 数据库。因为我们正在使用 SQLAlchemy,所以使用 Heroku Postgres 非常简单。在您的 dyno 仪表板上,有一个指向Heroku Postgres信息的链接。点击它,您将被带到一个页面,就像这里显示的页面一样:

使用 Heroku Postgres

点击URL字段,您将获得一个 SQLAlchemy URL,您可以直接复制到生产配置对象中。

在 Heroku 上使用 Celery

我们已经设置了生产 Web 服务器和数据库,但我们仍然需要设置 Celery。使用 Heroku 的许多插件之一,我们可以在云中托管 RabbitMQ 实例,同时在 dyno 上运行 Celery worker。

第一步是告诉 Heroku 在Procfile中运行您的 celery worker:

web: uwsgi uwsgi.ini
celery: celery worker -A celery_runner

接下来,要安装 Heroku RabbitMQ 插件并使用免费计划(名为lemur计划),请使用以下命令:

$  heroku addons:create cloudamqp:lemur

注意

要获取 Heroku 插件的完整列表,请转到elements.heroku.com/addons

在 Heroku Postgres 列出的仪表板上的相同位置,您现在将找到CloudAMQP

在 Heroku 上使用 Celery

点击它还会给您一个可复制的 URL 屏幕,您可以将其粘贴到生产配置中:

在 Heroku 上使用 Celery

在亚马逊网络服务上部署

亚马逊网络服务AWS)是由亚马逊维护的一组应用程序平台,构建在运行amazon.com的相同基础设施之上。为了部署我们的 Flask 代码,我们将使用亚马逊弹性 Beanstalk,而数据库将托管在亚马逊关系数据库服务上,我们的 Celery 消息队列将托管在亚马逊简单队列服务上。

在亚马逊弹性 Beanstalk 上使用 Flask

Elastic Beanstalk 是一个为 Web 应用程序提供许多强大功能的平台,因此 Web 开发人员无需担心维护服务器。

例如,您的 Elastic Beanstalk 应用程序将通过利用更多服务器自动扩展,因为同时使用您的应用程序的人数增加。对于 Python 应用程序,Elastic Beanstalk 使用 Apache 与mod_wsgi结合连接到 WSGI 应用程序,因此不需要额外的配置。

在我们开始之前,您将需要一个Amazon.com账户并登录aws.amazon.com/elasticbeanstalk。登录后,您将看到如下图所示的屏幕:

在亚马逊弹性 Beanstalk 上使用 Flask

点击下拉菜单选择 Python,如果您的应用程序需要特定的 Python 版本,请务必点击更改平台版本并选择您需要的 Python 版本。您将通过设置过程,并最终您的应用程序将在亚马逊的服务器上进行初始化过程。在此期间,我们可以安装 Elastic Beanstalk 命令行工具。这些工具将允许我们自动部署应用程序的新版本。要安装它们,请使用pip

$ pip install awsebcli

在我们部署应用程序之前,您将需要一个 AWS Id 和访问密钥。要做到这一点,请点击显示在页面顶部的用户名的下拉菜单,然后点击安全凭据

在亚马逊弹性 Beanstalk 上使用 Flask

然后,点击灰色框,上面写着访问密钥以获取您的 ID 和密钥对:

在亚马逊弹性 Beanstalk 上使用 Flask

一旦您拥有密钥对,请不要与任何人分享,因为这将使任何人都能完全控制您在 AWS 上的所有平台实例。现在我们可以设置命令行工具。在您的项目目录中,运行以下命令:

$ eb init

选择您之前创建的应用程序,将此目录与该应用程序绑定。我们可以通过运行以下命令来查看应用程序实例上正在运行的内容:

$ eb open

现在,您应该只看到一个占位应用程序。让我们通过部署我们的应用程序来改变这一点。Elastic Beanstalk 在您的项目目录中寻找名为application.py的文件,并且它期望在该文件中有一个名为 application 的 WSGI 应用程序,因此现在让我们创建该文件:

from webapp import create_app
application = create_app("webapp.config.ProdConfig")

创建了该文件后,我们最终可以部署应用程序:

$ eb deploy

这是在 AWS 上运行 Flask 所需的。要查看该书的应用程序在 Elastic Beanstalk 上运行,请转到masteringflask.elasticbeanstalk.com

使用亚马逊关系数据库服务

亚马逊关系数据库服务是一个在云中自动管理多个方面的数据库托管平台,例如节点故障时的恢复以及在不同位置保持多个节点同步。

要使用 RDS,转到服务选项卡,然后单击关系数据库服务。要创建数据库,请单击开始,然后按照简单的设置过程进行操作。

一旦您的数据库已配置并创建,您可以使用 RDS 仪表板上列出的端点变量以及数据库名称和密码来在生产配置对象中创建 SQLAlchemy URL:

使用亚马逊关系数据库服务

这就是在云上使用 Flask 创建一个非常弹性的数据库所需的全部步骤!

使用 Celery 与亚马逊简单队列服务

为了在 AWS 上使用 Celery,我们需要让 Elastic Beanstalk 实例在后台运行我们的 Celery worker,并设置简单队列服务SQS)消息队列。为了让 Celery 支持 SQS,它需要从pip安装一个辅助库:

$ pip install boto

在 SQS 上设置一个新的消息队列非常容易。转到服务选项卡,然后单击应用程序选项卡中的简单队列服务,然后单击创建新队列。在一个非常简短的配置屏幕之后,您应该看到一个类似以下的屏幕:

使用 Celery 与亚马逊简单队列服务

现在我们必须将CELERY_BROKER_URLCELERY_BACKEND_URL更改为新的 URL,其格式如下:

sqs://aws_access_key_id:aws_secret_access_key@

这使用了您在 Elastic Beanstalk 部分创建的密钥对。

最后,我们需要告诉 Elastic Beanstalk 在后台运行 Celery worker。我们可以在项目根目录下的一个新目录中的.ebextensions文件夹中使用.conf文件来完成这个操作(注意文件夹名称开头的句点)。在这个新目录中的一个文件中,可以随意命名,添加以下命令:

  celery_start: 
    command: celery multi start worker1 -A celery_runner

现在每当实例重新启动时,此命令将在服务器运行之前运行。

总结

正如本章所解释的,托管应用程序有许多不同的选项,每种选项都有其优缺点。选择一个取决于您愿意花费的时间和金钱以及您预期的用户总数。

现在我们已经到达了本书的结尾。我希望这本书对您理解 Flask 以及如何使用它轻松创建任何复杂度的应用程序并进行简单维护有所帮助。

posted @ 2024-05-20 16:53  绝不原创的飞龙  阅读(8)  评论(0编辑  收藏  举报