精通-Flask-Web-开发-全-

精通 Flask Web 开发(全)

原文:zh.annas-archive.org/md5/58ecb06b064d6a90560900fbe6eda4ec

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Flask 是一个设计精良的微框架,旨在提供创建 Web 应用程序所需的最小功能量。它确实做到了它设计要做的。与其他 Web 框架不同,Flask 没有捆绑整个生态系统,没有现成的功能来处理数据库、缓存、安全或表单处理。

这一概念的目标是允许程序员以任何他们想要的方式设计他们的应用程序或工具,不施加任何结构或设计。然而,由于 Flask 社区相当庞大,你可以找到各种各样的扩展,这些扩展可以帮助你利用 Flask 与大量技术相结合。本书的主要重点之一是介绍这些扩展,并找出它们如何帮助避免重复造轮子。这些扩展的最好之处在于,如果你不需要它们的额外功能,你就不需要包含它们,你的应用程序将保持小巧。

本书将帮助您构建应用程序,以便轻松扩展到任何规模。使用包和简单、可预测的命名空间对于保持可维护性和提高团队生产力至关重要。这就是本书的另一大重点是介绍如何使用 Flask 应用创建 模型-视图-控制器MVC)架构的原因。

现代应用程序必须超越良好的代码结构。安全性、依赖隔离、环境配置、开发/生产一致性以及负载扩展能力是必须考虑的因素。在整个本书中,你将学习如何解决这些问题,识别可能的风险,并提前思考。

本书融入了大量研究和大量关于在开发和部署 Web 应用程序时可能出错的第一手经验。我真诚地希望你会喜欢阅读它。

本书面向对象

本书理想的读者对象是希望使用 Flask 及其高级功能来创建企业级和轻量级应用的 Python 开发者。本书面向那些已有一定 Flask 经验,并希望将技能从入门级提升到精通级的人。

本书涵盖内容

第一章,入门指南,帮助读者使用 Python 项目的最佳实践设置 Flask 开发环境。你将获得一个非常基础的 Flask 应用骨架,这个骨架将在整本书中逐步构建。

第二章,使用 SQLAlchemy 创建模型,展示了如何结合 Flask 使用 Python 数据库库 SQLAlchemy 来创建数据库的对象化 API。

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

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

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

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

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

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

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

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

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

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

第十三章,部署 Flask 应用,解释了如何将完成的应用程序从开发状态部署到实时服务器上。

为了充分利用本书

要开始使用本书,您只需要选择一个文本编辑器,一个网络

浏览器,以及机器上安装的 Python。

Windows、Mac OS X 和 Linux 用户都应能够轻松地跟随

阅读本书的内容。

下载示例代码文件

您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入本书的名称,并遵循屏幕上的说明。

下载文件后,请确保使用最新版本的软件解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-Flask-Web-Development-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。去看看吧!

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块如下设置:

from flask import g
....
# Set some key with some value on a request context
g.some_key = "some_value"
# Get a key
v = g.some_key
# Get and remove a key
v = g.pop('some_key', "default_if_not_present")

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

from flask import Flask, render_template
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

db = SQLAlchemy()
migrate = Migrate()

任何命令行输入或输出都应如下编写:

$ source env/bin/activate
$ pip install -r requirements.txt

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要注意事项看起来是这样的。

小贴士和技巧看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发邮件。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将非常感谢。请访问www.packt.com/submit-errata,选择您的书,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解有关 Packt 的更多信息,请访问packt.com

第一章:入门指南

在本书的整个过程中,你将接触到多个概念,这些概念将使你能够构建一个完整的现代网络应用程序。你将从“Hello world”网页进步到一个完整的网络应用程序,该应用程序使用数据库、缓存、异步任务处理、身份验证、基于角色的访问、REST API 和国际化。你将学习一种全面的方法来构建你的应用程序,使其能够轻松扩展。为了在 SQL 和 NoSQL 技术之间做出选择,你将学习如何使用最常用的 Flask 扩展来帮助你利用多种技术,从发送电子邮件到使用社交媒体账户进行身份验证。在本书的结尾,你将学习如何编写测试、使用 Docker 和 Jenkins 构建现代的持续集成/交付管道、将你的应用程序部署到多个云服务,以及如何处理高可用性和扩展。我们将以简单实用的方法解决所有这些问题。

Flask 是我们将要使用的 Python 网络框架。它有一个非常精心设计的 API,易于学习,并且对你要使用的任何技术栈不做任何假设,因此不会妨碍你。Flask 有一个微小的足迹,但利用了一个包含数百个来自非常活跃和充满活力的社区包的扩展系统。

在本章中,你将学习如何设置你的开发环境并构建你的第一个 Flask 应用程序。我们将涵盖以下主题:

  • 设置和使用 Git,一个强大的版本控制系统

  • 学习 pip,Python 管理系统,以及如何使用不同的设置创建虚拟环境

  • 设置和学习 Docker 的基本事实

  • 构建第一个简单的 Flask 应用程序

使用 Git 进行版本控制

使用 Python 或任何其他语言需要你使用版本控制系统。版本控制系统是一个记录文件随时间变化的工具。这允许程序员回滚到文件的早期版本并更容易地识别错误。你可以测试新想法而不用担心破坏当前的代码,你的团队可以使用预定义的工作流程工作,而不会互相干扰。Git 是由 Linux 的创始人 Linus Torvalds 开发的。它是分布式的、轻量级的,并且具有完成工作的正确方式的功能。

安装 Git

安装 Git 非常简单。只需访问 www.git-scm.com/downloads 并点击正在运行的 操作系统OS),程序将开始下载并引导你完成基本的安装过程。

Windows 上的 Git

Git 最初是专门为 Unix 操作系统(例如,Linux 和 macOS X)开发的。因此,在 Windows 上使用 Git 并不流畅。在安装过程中,安装程序会询问你是否想将 Git 与正常的 Windows 命令提示符一起安装。不要选择此选项。选择默认选项,该选项将在你的系统上安装一种新的命令处理器,名为BashBourne-again shell),这是 Unix 系统使用的相同命令处理器。Bash 比默认的 Windows 命令行强大得多,这就是我们将在这本书的所有示例中使用的东西。

对于初学者来说,可以在linuxcommand.org找到 Bash 的良好介绍。

Git 基础

Git 是一个非常复杂的工具;本节将只涵盖本书所需的基本内容。

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

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

  • 哪些文件夹要跟踪

  • 何时保存代码的状态

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

在我们能够做任何事情之前,我们必须告诉 Git 在我们的目录中初始化一个新的git仓库。在你的终端上运行以下代码:

$ git init

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

$ git status

现在,我们可以保存我们的第一个commit,这是我们在运行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

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

注意,在先前的例子中,我已经通过添加注释# Changed to show the git diff command修改了main.py文件。

在每个 Git 仓库中包含的一个重要步骤是.gitignore文件。这个文件告诉 Git 忽略哪些文件。这样你就可以安全地提交和添加所有你的文件。以下是一些你可以忽略的常见文件:

  • Python 的字节码文件(*.pyc

  • 数据库(特别是我们使用 SQLLite 数据库文件的示例)(*.db)

  • 秘密(永远不要将秘密(密码、密钥等)推送到你的仓库)

  • IDE 元数据文件(.idea

  • Virtualenv目录(envvenv

这里是一个简单的gitignore文件示例:

*.pyc
*.pem
*.pub
*.tar.gz
*.zip
*.sql
*.db
secrets.txt
./tmp
./build/*
.idea/*
.idea
env
venv

现在,我们可以安全地将所有文件添加到git并提交它们:

 $ git add --all
 $ git status
 $ git commit -a -m "Added gitignore and all the projects missing 
    files"

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

现在,如果我们想查看之前的提交中的代码,我们应该首先运行以下命令:

$ git log commit cd88be37f12fb596be743ccba7e8283dd567ac05 (HEAD -> master)
Author: Daniel Gaspar
Date: Sun May 6 16:59:46 2018 +0100

Added gitignore and all the projects missing files
commit beb471198369e64a8ee8f6e602acc97250dce3cd
Author: Daniel Gaspar
Date: Fri May 4 19:06:57 2018 +0100

Our first commit

在我们的 commit 消息旁边的字符序列,beb4711,被称为我们的 hash。它是提交的唯一标识符,我们可以用它返回到保存的状态。现在,要将项目回滚到之前的状态,请运行以下命令:

$ git checkout beb4711

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

$ git checkout master

Git 分支和流程

版本控制分支是团队项目中一个重要的功能,它非常适合团队合作。开发者可以从特定的时间点、修订或标签创建新的代码分支。这样,开发新功能、创建发布版本以及修复错误或热修复都可以在团队审查和/或自动集成工具(如测试、代码覆盖率、代码检查工具)的支持下安全地进行。一个分支可以与其他分支合并,直到最终达到主代码线,称为 主分支

但让我们来做一个实际的练习。假设我们想要开发一个新功能。我们的第一个章节示例显示了传统的“Hello World”消息,但我们希望它对用户说“早上好”。首先,我们从名为 feature/good-morning 的特殊分支创建一个分支,目前它是主分支的副本,如下面的代码所示:

# Display our branches
$ git branch * master # Create a branch called feature/good-morning from master
$ git branch feature/good-morning
# Display our branches again
$ git branch  feature/good-morning
* master # Check out the new feature/good-morning branch
$ git checkout feature/good-morning 

这可以简化为以下内容:

$ git checkout -b feature/good-morning master

现在,让我们修改我们的代码,使其能够向访问特定 URL 的访客显示“早上好”,以及他们的名字。为此,我们修改 main.py,如下面的代码所示:

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

我们将 main.py 修改为以下内容:

@app.route('/username')
def home():
    return '<h1>Good Morning %s</h1>' % username

让我们看看我们做了什么:

$ git diff
diff --git a/main.py b/main.py
index 3e0aacc..1a930d9 100755
--- a/main.py
+++ b/main.py
@@ -5,9 +5,9 @@ app = Flask(__name__)
 app.config.from_object(DevConfig)

 # Changed to show the git diff command
-@app.route('/')
-def home():
- return '<h1>Hello World!</h1>'
+@app.route('/<username>')
+def home(username):
+ return '<h1>Good Morning %s</h1>' % username

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

看起来不错。让我们按照以下代码进行提交:

$ git commit -m "Display good morning because its nice"
[feature/good-morning d4f7fb8] Display good morning because its nice
 1 file changed, 3 insertions(+), 3 deletions(-)

现在,如果我们作为团队的一部分工作,或者我们的工作是开源的(或者如果我们只是想备份我们的工作),我们应该将我们的代码上传(推送)到集中的远程仓库。这样做的一种方式是将我们的代码推送到版本控制系统,如 BitbucketGitHub,然后向主分支提交一个 pull request。这个 pull request 将显示我们的更改。因此,可能需要其他团队成员的批准,以及这些系统可以提供的许多其他功能。

Flask 项目的 pull request 例子可以在 github.com/pallets/flask/pull/1767 找到。

对于我们的例子,我们只需将代码合并到主分支,如下面的代码所示:

# Get back to the master branch
$ git checkout master
Switched to branch 'master'
bash-3.2$ git log
commit 139d121d6ecc7508e1017f364e6eb2e4c5f57d83 (HEAD -> master)
Author: Daniel Gaspar
Date: Fri May 4 23:32:42 2018 +0100

 Our first commit
# Merge our feature into the master branch
$ git merge feature/good-morning
Updating 139d121..5d44a43
Fast-forward
 main.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)
bash-3.2$ git log
commit 5d44a4380200f374c879ec1f7bda055f31243263 (HEAD -> master, feature/good-morning)
Author: Daniel Gaspar
Date: Fri May 4 23:34:06 2018 +0100

Display good morning because its nice commit 139d121d6ecc7508e1017f364e6eb2e4c5f57d83
Author: Daniel Gaspar <daniel.gaspar@miniclip.com>
Date: Fri May 4 23:32:42 2018 +0100

Our first commit

如你所见,Git 默认使用快速前进策略。如果我们想保留一个额外的提交日志消息,提到合并本身,那么我们可以在 git merge 命令中使用 --no-ff 标志。这个标志将禁用快速前进合并策略。

更多详情,请访问 git-scm.com/book/en/v2/Git-Branching-Basic-Branching-and-Merging

现在想象一下,我们后悔我们的更改,并想将我们刚刚创建的功能回滚到早期版本。为此,我们可以使用以下代码:

$ git revert

使用 Git,你实际上可以删除你的提交,但这被认为是一种非常不好的做法。请注意,revert 命令并没有删除我们的合并,而是创建了一个带有还原更改的新提交。被认为是一个好习惯,不要重写过去。

所展示的是一个功能分支简单的工作流程。对于大型团队或项目,通常采用更复杂的流程来更好地隔离功能、修复和发布,并保持代码的稳定性。这就是使用 git-flow 流程时所提出的。

现在我们有了版本控制系统,我们准备介绍 Python 的包管理系统。

使用 pip 进行 Python 包管理

在 Python 中,程序员可以从其他程序员那里下载扩展标准 Python 库功能的库。正如你从使用 Flask 中已经知道的那样,Python 的许多强大功能都来自于其大量的社区创建的库。

然而,正确安装第三方库可能是一个巨大的麻烦。比如说,你想安装包 X。很简单:下载 ZIP 文件并运行 setup.py,对吧?但并不完全是这样。包 X 依赖于包 Y,而 Y 又依赖于 Z 和 Q。这些信息都没有在包 X 的网站上列出,但这些包需要安装才能使 X 正常工作。然后你必须一个接一个地找到所有这些包并安装它们,然后希望你安装的包本身不需要任何额外的包。

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

在 Windows 上安装 Python 包管理器

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

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

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

pip --help

Pip 应该已经打印了它的使用信息,如下面的截图所示:

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

一些 Linux 的 Python 安装没有包含 pip,Mac OS X 的安装默认也不包含 pip。如果你使用的是 Python 2.7,那么你可能需要安装 pip,但 pip 已经包含在 Python 3.4 及其后续版本中。你可以使用以下方法进行检查:

$ python3 -m pip list

如果你需要安装它,请从 bootstrap.pypa.io/get-pip.py 下载 get-pip.py 文件。

下载完成后,使用以下代码以提升权限运行它:

# Download and install pip
$ wget https://bootstrap.pypa.io/get-pip.py    
$ sudo python get-pip.py

一旦输入完成,pip 将会自动安装。

Pip 基础

我们现在将学习使用 Python 包管理器的基本命令。要使用 pip 安装一个包,请输入以下代码:

$ pip install [package-name]

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

$ pip install flask

一旦你完成这个步骤,所有你需要用于使用 Flask 的依赖项都将为你安装。

如果你想要删除你不再使用的包,请运行以下命令:

$ pip uninstall [package-name]

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

$ pip search [search-term]

现在我们已经安装了一些包,在 Python 社区中,这是一种礼貌的做法,创建一个包含运行项目所需所有包的列表,以便其他人可以快速安装每个必要的包。这也带来了额外的便利,即任何新加入你项目的人都能快速运行你的代码。

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

$ pip freeze > requirements.txt

这个命令究竟做了什么?pip freeze 命令会自动打印出已安装的包及其版本列表。在我们的例子中,它打印了以下内容:

click==6.7
Flask==0.12.4
itsdangerous==0.24
Jinja2==2.10
MarkupSafe==1.0
Werkzeug==0.14.1

> 操作符告诉 Bash 将最后一条命令打印的所有内容写入此文件。如果你查看你的项目目录,你可以看到一个名为 requirements.txt 的新文件,它包含了 pip freeze 的输出。

要安装此文件中的所有包,新的项目维护者必须运行以下代码,如下所示。通常,这也会用于部署项目的生产环境:

$ pip install -r requirements.txt

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

使用 virtualenv 进行依赖沙盒化

所以你已经安装了你新项目所需的所有包。太好了!但如果我们稍后开发第二个项目,它将使用相同包的新版本,会发生什么呢?还有,当你希望使用的库依赖于为第一个项目安装的库,但该库使用这些包的较旧版本时,会发生什么?当包的新版本包含破坏性更改时,升级它们可能需要在较旧的项目上做额外的工作,而你可能负担不起。因此,在我们的系统中,我们可能在项目之间有冲突的 Python 包。

我们还应该考虑自动构建环境,例如Jenkins,在那里我们想要运行测试。这些构建可能运行在其他项目正在构建的同一系统上,因此在构建作业期间,我们创建一个不共享于作业之间的 Python 包环境是至关重要的。这个环境是由我们之前创建的requirements.txt文件中的信息创建的。这样,多个 Python 应用程序可以在同一系统上构建和测试,而不会相互冲突。

幸运的是,有virtualenv这个工具,它可以沙盒化你的 Python 项目。virtualenv 的秘密在于欺骗你的计算机在项目目录中而不是在主 Python 目录中查找和安装包,这允许你将它们完全分开。

如果你使用 Python 3——我建议你这样做,因为 Python 2 的支持将在 2020 年结束——那么你不需要安装 virtualenv;你可以像运行包一样运行它,如下所示:

# Create a python 3 virtualenv
$ python3 -m venv env

现在我们有了 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

设置 Docker

您的开发项目通常需要比 Web 服务器应用程序层更多的东西;您肯定需要某种类型的数据库系统。您可能正在使用缓存、redis、带有Celery的工作者、一个消息队列系统,或者别的什么。通常,所有使您的应用程序正常工作的系统都统称为。一个简单的方法是轻松定义并快速生成所有这些组件是使用Docker容器。使用 Docker,您定义所有应用程序组件以及如何安装和配置它们,然后您可以与您的团队共享您的栈,并使用完全相同的规范将其发送到生产环境。

您可以从docs.docker.com/install/下载并安装 Docker。

首先,让我们创建一个非常简单的Dockerfile。此文件定义了如何设置您的应用程序。每一行都将作为容器层,以实现非常快的重建。一个非常简单的 Dockerfile 看起来如下所示:

FROM python:3.6.5
# Set the working directory to /app
WORKDIR /app
# Copy local contents into the container
ADD . /app
# Install all required dependencies
RUN pip install -r requirements.txt
EXPOSE 5000
CMD ["python", "main.py"]

接下来,让我们构建第一个容器镜像。我们将将其标记为chapter_1,以便于进一步的使用,如下面的代码所示:

$ docker build -t chapter_1 . 

然后,我们将运行它,如下面的代码所示:

$ docker run -p 5000:5000 chapter_1
# List all the running containers
$ docker container list

Docker 很简单,但它是一个复杂的工具,具有许多配置和部署容器的选项。我们将在第十三章,部署 Flask 应用程序中更详细地了解 Docker。

我们项目的开始

最后,我们可以进入我们的第一个 Flask 项目。为了在本书的结尾构建一个复杂的项目,我们需要一个简单的 Flask 项目作为起点。

简单的应用程序

Flask 非常强大,但绝对不会妨碍您。您可以使用它使用单个文件创建一个简单的 Web 应用程序。我们的目标是创建一个结构化的项目,使其可以扩展且易于理解。目前,我们将首先创建一个config文件。在名为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是因为在未来,将使用多个配置,当我们需要在不同配置之间切换时,手动更改每个变量是耗时的。

项目结构

我们创建了一个非常简单的项目结构,但它能否作为任何 Python 项目的基架。在第五章,高级应用程序结构中,我们将接触到更可扩展的结构,但现在,让我们回到我们的环境,如下面的代码所示:

Dockerfile # Instructions to configure and run our application on a container
requirements.txt # All the dependencies needed to run our application
/venv # We will not add this folder to our Git repo, our virtualenv
.gitignore # Instruction for Git to ignore files
main.py # Our main Flask application
config.py # Our configuration file

记得像下面的代码一样在 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 的命令行界面

为了使下一章对读者来说更容易理解,我们将探讨如何使用 Flask CLI(从版本 0.11 开始)。CLI 允许程序员创建在 Flask 的应用程序上下文中运行的命令——也就是说,允许修改Flask对象的 Flask 状态。Flask CLI 自带一些默认命令,可以在应用程序上下文中运行服务器和 Python shell。

让我们看看 Flask CLI 以及如何初始化它。首先,我们必须使用以下代码告诉它如何发现我们的应用程序:

$ export FLASK_APP=main.py

然后,我们将使用以下代码使用 Flask CLI 运行我们的应用程序:

$ flask run

现在,让我们进入应用程序上下文中的 shell,看看如何使用以下代码获取所有定义的 URL 路由:

$ flask shell Python 3.6.5 (v3.6.5:f59c0932b4, Mar 28 2018, 03:03:55)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
App: main [debug]
Instance: /chapter_1/instance >>> app.url_map
Map([<Rule '/' (OPTIONS, GET, HEAD) -> home>,
 <Rule '/static/<filename>' (OPTIONS, GET, HEAD) -> static>])

如你所见,我们已经有两条路由定义:显示"Hello World"句子的/路由和 Flask 创建的静态默认路由。一些其他有用的信息显示了 Flask 认为我们的模板和静态文件夹在哪里,如下面的代码所示:

>>> app.static_folder
/chapter_1/static'
>>> app.template_folder
'templates'

Flask CLI 使用 Flask 创建者的click库。它被设计成易于扩展,以便 Flask 扩展可以扩展它并实现当使用时可用的新命令。我们确实应该扩展它——我们自己扩展它使其更有用。这是为我们的应用程序创建管理命令的正确方式。想想你可以用来迁移数据库模式、创建用户、修剪数据等命令。

摘要

既然我们已经设置了我们的开发环境,我们就可以继续在 Flask 中实现高级应用程序功能。在我们能够进行任何可视化操作之前,我们需要显示的内容。这些内容将保存在数据库中。在下一章中,你将了解如何在 Flask 中与数据库一起工作,以及如何掌握它们。

第二章:使用 SQLAlchemy 创建模型

正如我们在上一章中看到的,模型是抽象数据和提供访问数据的通用接口的一种方式。在大多数 Web 应用程序中,数据是从 关系数据库管理系统RDBMS)存储和检索的,这是一个以行和列的表格格式存储数据的数据库,能够实现跨表的数据关系模型。一些例子包括 MySQL、Postgres、Oracle 和 MSSQL。

为了在我们的数据库上创建模型,我们将使用一个名为 SQLAlchemy 的 Python 包。SQLAlchemy 是最低级别的数据库 API,并在最高级别执行 对象关系映射ORM(对象关系映射器)是一种工具,允许开发人员使用面向对象的方法存储和检索数据,并解决对象关系不匹配——当使用面向对象编程语言编写的程序使用关系数据库管理系统时,经常会遇到的一组概念和技术难题。关系型和面向对象模型差异如此之大,以至于需要额外的代码和功能才能使它们有效地协同工作。这创建了一个虚拟对象数据库,并将数据库中的大量类型转换为 Python 中的类型和对象的混合。此外,编程语言,如 Python,允许您拥有不同的对象,它们相互持有引用,并获取和设置它们的属性。像 SQLAlchemy 这样的 ORM 帮助在将它们插入传统数据库时进行转换。

为了将 SQLAlchemy 集成到我们的应用上下文中,我们将使用 Flask SQLAlchemy。Flask SQLAlchemy 是在 SQLAlchemy 之上提供有用默认值和 Flask 特定功能的便利层。如果您已经熟悉 SQLAlchemy,那么您可以在不使用 Flask SQLAlchemy 的情况下自由使用它。

到本章结束时,我们将拥有我们博客应用的完整数据库模式,以及与该模式交互的模型。

在本章中,我们将涵盖以下主题:

  • 使用 SQLAlchemy 设计数据库表和关系

  • 创建、读取、更新和删除模型

  • 学习定义模型关系、约束和索引

  • 创建自动数据库迁移

设置 SQLAlchemy

为了完成本章中的练习,您需要一个正在运行的数据库,如果您还没有的话。如果您从未安装过数据库,或者您没有偏好,那么 SQLite 是初学者的最佳选择,或者如果您想快速启动一个概念验证。

SQLite 是一个快速、无需服务器即可工作且完全包含在一个文件中的 SQL 嵌入式数据库引擎。SQLite 还在 Python 中原生支持,因此如果您选择使用 SQLite,那么在 我们的第一个模型 部分的练习中,将自动为您创建一个 SQLite 数据库。

Python 包

Flask SQLAlchemy 可以与多个数据库引擎一起使用,例如 ORACLE、MSSQL、MySQL、PostgreSQL、SQLite 和 Sybase,但我们需要为这些引擎安装额外的特定包。现在,是时候通过为所有应用依赖项创建一个新的虚拟环境来引导我们的项目了。这个虚拟环境将用于我们的博客应用。输入以下代码:

$ virtualenv env

然后,在requirements.txt中添加以下代码以安装包:

flask-sqlalchemy

你还需要为所选数据库安装特定的包,这些包将作为 SQLAlchemy 的连接器。因此,在requirements.txt中添加特定于你的引擎的包,如下所示。SQLite 用户可以跳过此步骤:

    # MySQL
    PyMySQL
    # Postgres
    psycopg2
    # MSSQL
    pyodbc
    # Oracle
    cx_Oracle

最后,使用以下代码激活并安装依赖项:

$ source env/bin/activate
$ pip install -r requirements.txt

Flask SQLAlchemy

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

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

对于你之前安装的每个驱动程序,URI 将如下所示:

# SQLite connection string/uri is a path to the database file - relative or absolute.
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 import Flask
from flask_sqlalchemy import SQLAlchemy
from config import DevConfig

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 数字转换为正确的类型,然后再将其插入数据库中。

  • Boolean类型接受 Python 的TrueFalse语句,如果数据库有boolean类型,则将布尔值插入数据库。如果没有boolean类型在数据库中,SQLAlchemy 会自动在 Python 布尔值和数据库中的 0 或 1 之间进行转换。

  • DateDateTimeTime类型使用来自datetime原生库的同名 Python 类型,并将它们转换为数据库。

StringIntegerFloat类型接受一个额外的参数,告诉 SQLAlchemy 我们列的长度限制。

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

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

primary_key参数告诉 SQLAlchemy 该列具有主键索引。每个 SQLAlchemy 模型都需要一个主键才能正常工作。所有对象关系映射对象都通过会话中的身份映射与数据库行相关联,这是 SQLAlchemy 中实现的工作单元机制的核心。这就是为什么我们需要在模型中声明主键的原因。

SQLAlchemy 会假设你的表名是模型类名称的小写版本。然而,如果我们想将表命名为user以外的名称怎么办?为了告诉 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__函数,该函数接受列的名称和值作为关键字参数。

使用 ORM 命名表为user可能会导致问题,因为在 MySQL 中,user是一个保留字。使用 ORM 的一个优点是,你可以轻松地将你的引擎从 SQLite 迁移到 MySQL,然后到 ORACLE 等。一个非常简单的修复方法是使用前缀你的模式并使用。

创建用户表

使用 SQLAlchemy 进行繁重的工作,我们现在将在数据库中创建用户表。将manage.py更新为以下内容:

from main import app, db, User 

@app.shell_context_processor
def make_shell_context():
  return dict(app=app, db=db, User=User) 

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

这将允许我们在 Flask shell 中使用我们的模型,因为我们正在注入。现在运行 shell,并使用db.create_all()创建所有表,如下面的代码所示:

    # Tell Flask where to load our shell context
    $ export FLASK_APP=manage.py
 $ flask shell
    >>> db.create_all()

在您的数据库中,您现在应该看到一个名为 users 的表,其中包含指定的列。此外,如果您正在使用 SQLite,您现在应该能在文件结构中看到一个名为 database.db 的文件,如下面的代码所示:

$ sqlite3 database.db .tables user

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'>]

当数据库中的项目数量增加时,此查询过程会变慢。在 SQLAlchemy 中,就像在 SQL 中一样,我们有 limit 函数来指定我们希望处理的总行数:

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

默认情况下,SQLAlchemy 按照主键顺序返回记录。为了控制这一点,我们有一个 order_by 函数,其用法如下:

    # ascending
    >>> users = User.query.order_by(User.username).all()
    # descending
    >>> 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()。这是一个方便的方法,旨在启用大多数网站在显示长列表时使用的分页功能。第一个参数定义了查询应返回的页面,第二个参数定义了每页的项目数。因此,如果我们传递 110 作为参数,将返回前 10 个对象。

如果我们改为传递 210,则将返回对象 11–20,依此类推。分页方法与 first()all() 方法不同,因为它返回一个分页对象而不是模型列表。例如,如果我们想获取我们博客第一页的虚构 Post 对象的前 10 项,我们会使用以下方法:

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

此对象具有几个有用的属性,如下所示:

>>> page = User.query.paginate(1, 10)
# returns the entities 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 比较语句传递给查询,可以避免这种情况,如下所示:

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

这是一个简单的例子,但 query.filter 接受任何 Python 比较。对于常见的 Python 类型,如 integersstringsdates,可以使用 == 操作符进行相等比较。如果你有一个 integerfloatdate 列,也可以使用 ><<=>= 操作符传递不等式语句。

我们还可以使用 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 中模型之间的关系是两个或多个模型之间的链接,允许模型自动相互引用。这使得自然相关的数据,如文章的评论,可以很容易地从数据库及其相关数据中检索出来。这正是 RDBMS 中的 R 的来源,这也赋予了这种类型的数据库大量的能力。

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

SQLite 和 MySQL/MyISAM 引擎不强制执行关系约束。如果你在开发环境中使用 SQLite,而在生产环境中使用不同的引擎(如带有 innodb 的 MySQL),这可能会引起问题,但你可以告诉 SQLite 强制执行外键约束(这将带来性能上的惩罚)。

@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
    cursor = dbapi_connection.cursor()
    cursor.execute("PRAGMA foreign_keys=ON")
    cursor.close()

一对多关系

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

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的参数是用户 ID 字段的字符串表示。如果你已经决定用__table_name__来调用你的用户表,那么你必须更改这个字符串。这个字符串用于代替直接使用User.id的引用,因为在 SQLAlchemy 初始化期间,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选项作为动态的,我们可以将我们的关系列视为一个查询以及一个列表,如下所示:

    >>> 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)

注意前面代码中的__repr__方法签名。这是一个 Python 中的内置函数,用于返回对象的字符串表示。接下来是Comment模型,如下所示:

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函数设置我们的关系之前,这次它有一个次要参数。次要参数告诉 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 支持的其它类型的约束如下所示:

  • NOT NULL(确保某个属性包含数据)

  • UNIQUE(确保数据库表中的某个属性值始终是唯一的,该表包含模型数据)

  • DEFAULT(在未提供值时为属性设置默认值)

  • CHECK(用于指定值的范围)

使用 SQLAlchemy,你可以确保你的数据域限制是明确的,并且都在同一个地方,而不是分散在应用程序代码中。

让我们通过在数据上设置一些约束来改进我们的模型。首先,我们不应接受用户模型中用户名的 NULL 值,并确保用户名始终唯一。我们使用以下代码来完成:

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

同样的原则适用于我们的其他模型:Post必须始终有一个标题,Comment总是由某人创建,Tag总是有一个标题,并且这个标题值是唯一的。我们使用以下代码来设置这些约束:

...
class Post(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    title = db.Column(db.String(255), nullable=False)
...
class Comment(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    name = db.Column(db.String(255), nullable=False)
...
class Tag(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    title = db.Column(db.String(255), nullable=True, unique=True)
...

默认值非常好;它们确保数据质量,并使你的代码更短。我们可以让 SQLAlchemy 处理评论或帖子创建的日期时间戳,以下代码:

class Comment(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
...
    date = db.Column(db.DateTime(), default=datetime.datetime.now)
...

class Post(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
...
    publish_date = db.Column(db.DateTime(), default=datetime.datetime.now)

注意 SQLAlchemy 如何处理默认定义。这是一个强大的功能。我们传递了一个 Python 函数的引用,因此只要不需要参数(除了部分函数),我们就可以使用任何我们想要的 Python 函数。这个函数将在创建记录或更新时被调用,并且它的返回值用于列的值。当然,SQLAlchemy 也支持简单的标量值在默认定义中。

RDBMS 索引用于提高查询性能,但你应该小心使用它们,因为这会在INSERTUPDATEDELETE函数上带来额外的写入,以及存储的增加。仔细选择和配置索引超出了本书的范围,但请考虑这样一个事实:索引用于减少对某些表列的 O(N)查找,这些列可能经常被使用,或者位于具有大量行的表中,在生产中线性查找根本不可能。索引查询性能可以从对数级提高到 O(1)。这是以额外的写入和检查为代价的。

以下代码示例展示了使用 Flask SQLAlchemy 创建索引的方法:

...
class User(db.Model):
    id = db.Column(db.Integer(), primary_key=True)
    username = db.Column(db.String(255), nullable=False, index=True, unique=True)
...

以下代码展示了使用多个列的索引的示例:

db.Index('idx_col_example', User.username, User.password)

SQLAlchemy 会话的便利性

现在你已经了解了 SQLAlchemy 的力量以及 SQLAlchemy 会话对象是什么,以及为什么 Web 应用在没有任何会话的情况下不应该被创建。正如之前所述,会话可以简单地描述为一个跟踪我们模型中的更改并在我们告诉它时将它们提交到数据库的对象。然而,这不仅仅是这一点。

首先,会话也是事务的处理者。事务是一系列在提交时刷新到数据库中的更改集合。事务提供了许多隐藏的功能。例如,当对象有关系时,事务会自动确定哪些对象应该首先保存。你可能在前一节保存标签时注意到了这一点。当我们向帖子添加标签时,会话自动知道首先保存标签,尽管我们没有将它们添加为要提交的内容。如果我们使用原始 SQL 查询和数据库连接,我们将不得不跟踪哪些行与哪些其他行相关联,以避免保存一个指向不存在对象的键外键引用。

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

其次,会话确保数据库中同一行的引用不会出现两个不同的情况。这是通过确保所有查询都通过会话进行(Model.query 实际上是 db.session.query(Model)),并且如果该行已经在当前事务中查询过,那么将返回对该对象的指针而不是一个新的对象。如果这个检查不存在,两个代表同一行的对象可能会以不同的更改保存到数据库中。这会创建一些微妙的错误,这些错误可能不会立即被发现。

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

要深入了解会话,SQLAlchemy 的创建者 Mike Bayer 在 2012 年的 PyCon Canada 上发表了一次演讲。请参阅www.youtube.com/watch?v=PKAdehPHOMo上的The SQLAlchemy Session - In Depth

使用 Alembic 进行数据库迁移

网络应用的功能总是在不断变化,并且随着每个新功能的加入,我们需要更改数据库的结构。无论是添加或删除新列还是创建新表,我们的模型将在应用的生命周期中不断变化。然而,当数据库经常变化时,问题会迅速出现。当我们把更改从开发环境迁移到生产环境时,如何确保没有手动比较每个模型及其对应的表就完成了所有更改?假设你想回到你的 Git 历史记录中查看你的应用早期版本是否有你现在在生产环境中遇到的相同错误。在没有大量额外工作的前提下,你将如何将数据库更改回正确的模式?

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

Alembic 并不捕获所有可能的变化——例如,它不会记录 SQL 索引上的变化。每次迁移后,都鼓励读者回顾迁移文件并进行任何必要的修正。

我们不会直接使用 Alembic。相反,我们将使用 Flask-Migrate,这是一个专门为 SQLAlchemy 创建的扩展,并且与 Flask CLI 一起工作。您可以在以下代码中找到它,在 requirements.txt 文件中,如下所示:

Flask-Migrate

要开始,我们不需要向我们的 manage.py 文件中添加任何内容,因为 Flask-Migrate 已经通过其自己的 CLI 选项扩展了 Flask CLI,如下所示:

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

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

在我们的 main.py 中:

import datetime

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from config import DevConfig

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

db = SQLAlchemy(app)
migrate = Migrate(app, db)

要使用我们的应用程序和 SQLAlchemy 实例初始化 Migrate 对象,请运行以下代码:

    # Tell Flask where is our app
    $ export FLASK_APP=main.py
    $ flask db

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

    $ flask db init

这将在我们的目录中创建一个新的名为 migrations 的文件夹,用于存储所有历史记录。现在我们开始第一个迁移,如下所示:

    $ flask db migrate -m"initial migration"

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

要将迁移应用到数据库并更改架构,请运行以下代码:

$ flask db upgrade

如果我们想检查所有 SQLAlchemy 生成的 DDL 代码,则使用以下代码:

$ flask db upgrade --sql

要返回到上一个版本,使用 history 命令找到版本号,并将其传递给 downgrade 命令,如下所示:

$ flask db history
<base> -> 7ded34bc4fb (head), initial migration
$ flask db downgrade 7ded34bc4fb

如同 Git,一个哈希标记每个迁移。这是 Alembic 的主要功能,但它只是表面层次。尝试将您的迁移与 Git 提交对齐,以便在回滚提交时更容易降级或升级。

在本书的代码中,您将在每一章找到一个初始化脚本,该脚本将创建一个 Python 虚拟环境,安装所有声明的依赖项,并初始化数据库。请查看 init.sh Bash 脚本。

摘要

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

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

现在我们已经将数据以易于访问的格式整理好,在网页上显示信息变得容易多了。在本章中,我们将学习以下内容:

  • 使用 Flask 的内置模板语言 Jinja 动态创建我们的 SQLAlchemy 模型

  • 使用 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,则将其替换为default值,如下所示:

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

如果你希望用default值替换变量,并且变量评估为False,那么将True传递给可选的第二个参数,如下所示:

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

转义过滤器

如果传递的变量是 HTML 字符串,那么&<>'"字符将被打印为 HTML escape序列:

{{ "<h1>Title</h1>" | escape }} 
<h1>Title</h1> 

浮点数过滤器

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

{{ 75 | float }} 
75.0 

int过滤器

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

{{ 75.7 | int }} 
75 

连接过滤器

join过滤器将列表的元素与一个字符串连接起来,并且与同名的list方法的工作方式完全相同。它被这样给出:

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

长度过滤器

length过滤器与 Python 的len()函数扮演相同的角色。它被这样使用:

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

四舍五入过滤器

round过滤器将浮点数四舍五入到指定的精度,如下所示:

{{ 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选项始终向上舍入,无论小数部分是多少。

安全过滤器

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

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

这是一个必要的安全特性。当一个应用程序有允许用户提交任意文本的输入时,它就会创建一个漏洞,恶意用户可以利用这个漏洞输入 HTML 代码。例如,如果用户提交一个脚本标签作为评论,并且 Jinja 没有这个特性,那么脚本将在访问页面的所有浏览器上执行。

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

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

标题过滤器

title过滤器使我们能够使用title格式来大写一个字符串,如下所示:

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

转换为 JSON 过滤器

我们使用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 转义序列。以下是一个使用来自流行的 JavaScript MVC 框架Backbone.js的模型集合的示例:

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 | count_substring("string") }} 

我们需要编写一个具有以下签名的新的 Python 函数,其中第一个参数是管道变量:

def count_substring(variable, sub_string) 

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

@app.template_filter
def count_substring(string, sub_string): return string.count(sub_string)

要将此函数添加到Jinja2的可用过滤器列表中,我们必须注册它并将它添加到我们的main.py文件中jinja_env对象的filters字典中。为此,我们可以简单地使用一个装饰器来为我们处理此过程——@app.template_filter

注释

模板中的注释由{# #}定义,如下所示代码所示。它们将被 Jinja 忽略,并且不会出现在返回的 HTML 代码中:

{# Note to the maintainers of this code #} 

使用 if 语句

在 Jinja 中使用if语句与在 Python 中使用它们类似。任何返回或为布尔值的东西都决定了代码的流程,如下所示代码所示:

{%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时使用后置if

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

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

{% 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 当前项是否是迭代器中的第一个项
loop.last 如果当前项是迭代器中的最后一个
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 默认在模板中为您提供了几个函数和对象。

配置对象

Flask 通过以下方式在模板中提供当前config对象:

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

请求对象

Flask request对象指的是当前请求:

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

会话对象

Flask session对象如下所示:

{{ session.new }} 
True 

url_for()函数

url_for函数通过将路由函数名称作为参数返回路由的 URL,如下所示。这允许在不担心链接会断开的情况下更改 URL:

{{ url_for('home') }} 
/ 

在这里,home是注册为 Flask 端点的函数的名称,以及与其关联的相对 URL 根,因此在我们的main.py中,我们必须定义一个处理 HTTP 请求的函数,并使用装饰器app.route(rule, **options)将其注册到 Flask 上,如下所示:

@app.route('/')
def home():
...

如果我们的路由在 URL 中有位置参数,我们将它们作为kwargs传递。它们将为我们填充在结果 URL 中,如下所示:

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

使用我们用来处理请求的相应函数,我们将此方法限制为仅处理 GET 和 POST HTTP 请求,如下所示:

@app.route('/post/<int:post_id>', methods=('GET', 'POST'))
def post(post_id):
...

get_flashed_messages()函数

get_flashed_messages()函数返回通过 Flask 中的flash()函数传递的所有消息的列表。flash函数是一个简单的函数,它将消息队列——由 Python 元组(类别,消息)短语组成——供get_flashed_messages函数消费,如下所示:

{% 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 %}

正确的用户反馈非常重要,Flask 使得实现这一点变得非常简单——例如,在处理新的帖子条目时,我们希望让用户知道他的/她的帖子已正确保存。flash()函数接受三个不同的类别:infoerrorwarning。请参考以下代码片段:

@app.route('/post/<int:post_id>', methods=('GET', 'POST'))
def post(post_id):
...
    db.session.commit()
    flash("New post added.", 'info')
...

创建我们的视图

要开始,我们需要在项目目录中创建一个名为 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 中的 home 页面函数需要按发布日期排序的所有帖子以及侧边栏信息,如下所示:

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, app.config['POSTS_PER_PAGE'], False)
    recent, top_tags = sidebar_data()

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

注意,使用 app.config['POSTS_PER_PAGE'] 语句,我们可以配置它而无需更改代码,这是很棒的。它是一个主 Config 类的候选配置键,并让所有环境继承其值。

在这里,我们终于看到了 Flask 和 Jinja 如何结合在一起。Flask 函数 render_template 接收 templates 文件夹中文件的名称,并将所有 kwargs 作为变量传递给模板。此外,我们的 home 函数现在有多个路由来处理分页,如果没有在斜杠之后添加任何内容,则默认为第一页。

现在你已经拥有了编写视图函数所需的所有信息,让我们定义我们需要的第一个视图函数:

  • 使用 GET /post/<POST_ID> 通过 ID 渲染特定的帖子。这也会渲染所有最近的帖子和标签。

  • 使用 GET /posts_by_tag/<TAG_NAME> 通过特定的标签名称渲染所有帖子。这也会渲染所有最近的帖子和标签。

  • 使用 GET /posts_by_user/<USER_NAME> 通过特定用户渲染所有帖子。这也会渲染所有最近的帖子和标签。

这对应以下视图函数:

@app.route('/post/<int:post_id>') 
def post(post_id)
....
@app.route('/posts_by_tag/<string:tag_name>') 
def posts_by_tag(tag_name): 
...
@app.route('/posts_by_user/<string:username>') 
def posts_by_user(username): 
...

在 Flask SQLAlchemy 中,有两个方便的函数,在数据库中不存在条目时返回 HTTP 404,分别是 get_or_404first_or_404,因此在我们根据 ID 获取帖子时,如下代码所示:

@app.route('/post/<int:post_id>') 
def post(post_id)
    post = Post.query.get_or_404(post_id)

可以使用以下代码返回用户发布的所有帖子:

@app.route('/posts_by_user/<string:username>') 
def posts_by_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 
  ) 

然而,这并没有检查main.py文件中的posts_by_tag函数(请参阅本章提供的代码)。在你编写完所有视图之后,剩下的唯一事情就是编写模板。

编写模板和继承

由于这本书不专注于界面设计,我们将使用 CSS 库Bootstrap并避免编写自定义 CSS。如果你之前从未使用过它,Bootstrap 是一套默认 CSS 规则,可以使你的网站在所有浏览器和平台上都能良好工作,从桌面到移动设备。Bootstrap 有工具可以让你轻松控制你网站的布局。

我们将在页面加载时直接从它们的 CDN 下载 Bootstrap、JQuery 和 Font Awesome,但任何你可能需要的额外资源都应该包含在名为static的项目目录中。使用static/css为 CSS、static/js为 JavaScript、static/img为图像、static/fonts为字体是常见的做法。使用 Bootstrap 的最佳方式之一是下载其sass文件并使用sass进行自定义。

有关 SASS 和 Bootstrap 的官方文档,请访问getbootstrap.com/docs/4.0/getting-started/theming/

由于每个路由都将分配一个模板,每个模板都需要包含我们的元信息、样式表、常用 JavaScript 库等必要的 HTML 模板代码。为了使我们的模板DRY不要重复自己),我们将使用 Jinja 最强大的功能之一,模板继承。模板继承是指子模板可以将基础模板作为起点导入,并仅替换基础模板中标记的部分。您还可以从其他文件中包含完整的 Jinja 模板部分;这将允许您设置一些固定的默认部分。

基础模板

我们需要概述我们网站的基线布局,将其分为几个部分,并为每个部分指定一个特定的用途。以下图表是对布局的抽象描述:

其中一些部分将始终被渲染,你不想在每个模板中重复它们。这些部分的一些可能选项是导航栏、头部、消息和页脚。

我们将使用以下包含和块结构来维护我们的 DRY 原则并实现布局:

  • 包含导航栏:Jinja2 模板:navbar.html—渲染导航栏。

  • 头部块:带有网站名称的头部。已包含head.html Jinja2 模板。

  • 包含消息:Jinja2 模板:messages.html—为不同类别的用户渲染警报。

  • 主体块

    • 左侧主体块:通常,模板将覆盖此块。

    • 右侧主体块:这将显示最新的帖子标签。

  • 页脚块:Jinja2 模板:footer.html

注意,固定部分,即几乎总是会被渲染的部分,即使在块内部也已经包含了模板。基本模板会默认处理这些。如果出于某种原因你想覆盖这些,你只需在渲染模板上实现/继承它们的块即可。例如,假设你想要在某个页面上渲染整个主体部分,占据显示最新帖子标签的右侧主体部分的空间。一个好的候选者将是登录页面。

要开始我们的基本模板,我们需要一个基本的 HTML 骨架和之前概述的 Jinja2 块结构(参见以下代码片段中的高亮部分):


<!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, maximum-scale=1">
    <title>{% block title %}Blog{% endblock %}</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css" integrity="sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4" crossorigin="anonymous">
    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.10/css/all.css" integrity="sha384-+d0P83n9kaQMCwj8F4RJB66tzIwOKmrdb46+porD/OvrJ+37WqIM7UoBtwHO6Nlg" crossorigin="anonymous">
</head>
<body>
{% include 'navbar.html' %}
<div class="container">
    <div class="row row-lg-4">
        <div class="col">
            {% block head %}
 {% include 'head.html' %}
 {% endblock %}
        </div>
    </div>
    {% include 'messages.html' %}
 {% block body %}
    <div class="row">
        <div class="col-lg-9">
            {% block leftbody %}
 {% endblock %}
        </div>
        <div class="col-lg-3 rounded">
            {% block rightbody %}
 {% include 'rightbody.html' %}
 {% endblock %}
        </div>
    </div>
    {% endblock %}
 {% include 'footer.html' %}
</div>
</body>
<script src="img/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="img/popper.min.js" integrity="sha384-cs/chFZiN24E4KMATLdqdvsezGxaGsi4hLGOzlXwp5UZB1LY//20VyM2taTB4QvJ" crossorigin="anonymous"></script>
<script src="img/bootstrap.min.js" integrity="sha384-uefMccjFJAIv6A+rW+L4AHf99KvxDjWSu1z9VI8SKNVmz4sk7buKt/6v9KI65qnm" crossorigin="anonymous"></script>    </body>
</html>

这是提供的代码中 templates 目录中的 base.html 模板。首先,我们包含 Bootstrap 和 Font Awesome CSS,然后实现 HTML 主体部分,最后包含所有必要的 JavaScript 库。

子模板

现在我们已经概述了基本布局,我们需要实现所有扩展基本布局的子页面。看看我们是如何实现主页并继承/覆盖左侧主体块的,如下面的代码所示:

{% extends "base.html" %}
{% import 'macros.html' as macros %}
{% block title %}Home{% endblock %}
{% block leftbody %}
{{ macros.render_posts(posts) }}
{{ macros.render_pagination(posts, 'home') }}
{% endblock %}

意想不到地简单,这个模板扩展了基本模板并如预期那样,然后覆盖了 titleleftbody 块部分。在内部,leftbody 使用两个宏来渲染帖子及其分页。这些宏帮助我们重用 Jinja2 代码,并像函数一样使用它,同时也隐藏了一些复杂性。

render_posts 宏位于文件顶部导入的 macros.html 中。我们使用宏的方式与 Python 中的模块类似,如下面的代码所示:

{% macro render_posts(posts, pagination=True) %}
...
{% for post in _posts %}
<div >
    <h1>
        <a class="text-dark" href="{{ url_for('post', post_id=post.id) }}">{{ post.title }}</a>
    </h1>
</div>
<div class="row">
    <div class="col">
        {{ post.text | truncate(500) | safe }}
        <a href="{{ url_for('post', post_id=post.id) }}">Read More</a>
    </div>
</div>
{% endfor %}
{% endmacro %}

宏遍历每个帖子,在 post.title 上,有一个链接指向 Flask 端点 post,并带有相应的帖子 ID。如前所述,我们始终使用 url_for 生成正确的 URL,该 URL 引用 Flask 的端点。

我们在模板中使用了这个宏三次:渲染所有帖子、特定标签的所有帖子以及特定用户的所有帖子。

tag.html 模板渲染特定标签的所有帖子,如下面的代码所示:

{% extends "base.html" %}
{% import 'macros.html' as macros %}

{% block title %}{{ tag.title }}{% endblock %}
{% block leftbody %}
<div class="row">
    <div class="col bg-light">
        <h1 class="text-center">Posts With Tag {{ tag.title }}</h1>
    </div>
</div>
{{ macros.render_posts(posts, pagination=False) }}

{% endblock %}

如果你查看前面的代码中的 user.html 模板,你会看到它们几乎是相同的。这些模板是由 Flask 端点函数 posts_by_tagposts_by_user 调用的。在渲染模板时,它们传递 tag/user 对象和帖子列表的参数,就像我们之前看到的那样。

让我们看看现在博客网站的样子。在命令行中,调用 init.sh 来构建 Python 虚拟环境,然后迁移/创建我们的数据库并插入一些假数据,如下所示:

$ ./init.sh
.... $ source venv/bin/activate $ export FLASK_APP=main.py; flask run

在你的浏览器中打开 http://127.0.0.1:5000/。你应该看到以下内容:

图片

init.sh 命令行短语调用 test_data.py,该模块将假数据插入数据库。此 Python 模块使用 faker 库生成用户名、帖子文本和标签(使用颜色名称)的数据。

有关faker的更多详细信息,您可以访问faker.readthedocs.io/en/master/.

以下代码是从test_data.py中摘取的示例,它将用户插入数据库并返回一个用户对象列表,该列表被重复用于插入帖子:

import logging
from main import db
from main import User, Post, Tag
from faker import Faker
...

def generate_users(n):
    users = list()
    for i in range(n):
        user = User()
        user.username = faker.name()
        user.password = "password"
        try:
            db.session.add(user)
            db.session.commit()
            users.append(user)
        except Exception as e:
            log.error("Fail to add user %s: %s" % (str(user), e))
            db.session.rollback()
    return users

template文件夹包含以下模板,这些模板使用上述层次结构进行渲染:

  • base.html:所有其他模板都扩展自它

  • footer.html:由base.html包含

  • head.html:由base.html包含

  • messages.html:由base.html包含

  • navbar.html:由base.html包含

  • rightbody.html:由base.html包含

  • home.html:由home Flask 端点函数渲染

  • post.html:由post Flask 端点函数渲染

  • tag.html:由posts_by_tag端点函数渲染

  • user.html:由posts_by_user端点函数渲染

编写其他模板

现在您已经了解了继承的方方面面,并且知道哪些数据将发送到哪个模板,您就可以清楚地了解如何构建您的 Web 应用程序,以便轻松扩展并在每个页面上保持相同的样式和感觉。在本章中,我们还需要添加一个最终的功能——允许读者添加评论。为此,我们将使用 Web 表单。

Flask WTForms

在您的应用程序中添加表单似乎是一个简单的任务,但当您开始编写服务器端代码时,随着表单的复杂度增加,验证用户输入的任务也会越来越大。由于数据来自不可信的来源,并将被输入到数据库中,因此安全性至关重要。WTForms是一个库,通过检查输入与常见表单类型来为您处理服务器端表单验证。Flask WTForms 是一个建立在 WTForms 之上的 Flask 扩展,它添加了诸如 Jinja HTML 渲染等功能,并保护您免受诸如 SQL 注入和跨站请求伪造等攻击。此扩展已安装在您的虚拟环境中,因为它已在requirements.txt文件中声明。

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

要使 Flask WTForms 的安全措施正常工作,我们需要一个密钥。密钥是一串随机字符,将用于对需要验证其真实性的任何内容进行加密签名。这不能是任何字符串;它必须是随机的,并且具有特定的长度,这样暴力破解或字典攻击就无法在可接受的时间内破解它。要生成随机字符串,进入 Python 会话并输入以下内容:

$ python
>>> import os
>>> os.urandom(24)        '\xa8\xcc\xeaP+\xb3\xe8|\xad\xdb\xea\xd0\xd4\xe8\xac\xee\xfaW\x072@O3'

您应该为每个环境生成不同的密钥。只需复制os.urandom的输出并将其粘贴到每个环境的config类中,如下所示:

class ProdConfig(object): 
  SECRET_KEY = 'Your secret key here'
....
class DevConfig(object): 
  SECRET_KEY = 'The other secret key here'
....

WTForms 基础知识

WTForms 有三个主要部分——表单字段验证器。字段是输入字段的表示,并执行基本的类型检查,验证器是附加到字段上的函数,确保表单中提交的数据符合我们的约束。表单是一个包含字段和验证器的类,在POST请求上自行验证。让我们看看实际操作,以获得更好的理解。在main.py文件中,添加以下内容:

from flask_wtf import FlaskForm as 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.DateFieldfields.DateTimeField:表示 Python 的datedatetime对象,并接受一个可选参数格式,该参数接受一个strftime格式字符串以转换数据。

  • 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()

这些验证器都遵循 Pythonic 命名规范。因此,它们的作用相当直接。所有验证器都接受一个可选参数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
        try:
            db.session.add(new_comment)
            db.session.commit()
        except Exception as e:
            flash('Error adding your comment: %s' % str(e), 'error')
            db.session.rollback()
        else:
            flash('Comment added', 'info')
        return redirect(url_for('post', post_id=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,
        form=form
    )

首先,我们将POST方法添加到我们视图的允许方法列表中。然后创建我们表单对象的新实例。validate_on_submit()方法随后检查 Flask 请求是否为POST请求。如果是POST请求,它将请求表单数据发送到表单对象。如果数据通过验证,则validate_on_submit()返回True并将数据添加到form对象中。然后我们从每个字段中获取数据,填充一个新的评论,并将其添加到数据库中。注意我们不需要填写评论数据,因为我们已经在 SQLAlchemy 模型定义中为它设置了一个默认值——在这种情况下,是将在对象创建时评估的datetime.now函数。

确保我们用try/except块包裹所有的数据库调用也很重要,如果发生错误,则回滚会话事务并向用户发送适当的反馈。

注意最后的redirect Flask 调用到相同的端点,这次是HTTP GET。这意味着在用户插入一条新评论后,相同的页面会再次渲染,带有干净的表单并显示新添加的评论。

如果表单未通过验证,或者我们正在处理HTTP GET请求,我们将通过post_id从数据库中获取Post对象,收集所有相关的评论,并最终获取所有必要的侧边栏数据。

模板本身分为三个主要部分。第一个部分渲染帖子,第二个部分显示用户可以提交关于帖子的新评论的表单,第三个部分是我们渲染与帖子相关的所有评论的地方。让我们专注于第三个部分,如下面的代码所示:

<div class="p-4 shadow-sm">
    <div class="row">
        <div class="col">
            <h4>New Comment:</h4>
        </div>
    </div>
    <div class="row">
        <div class="col">
 <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>
    </div>
</div>

这里有几个新的变化。首先,我们声明一个 HTML 表单部分并使其提交(使用HTTP POST)到当前的帖子 ID 的post Flask 端点函数。

接下来,form.hidden_tag()方法自动添加了防止跨站请求伪造的措施。

然后,当调用 field.label 时,将为我们的输入自动创建一个 HTML 标签。这可以在我们定义 WTForm FlaskForm 类时进行自定义;如果不自定义,WTForm 将以美观的方式打印字段名称。

接下来,我们使用 field.errors 检查任何错误,如果有,我们将迭代所有错误并向用户渲染表单验证信息。最后,将字段本身作为方法调用将渲染该字段的 HTML 代码。

模板中的第三部分将显示以下内容:

图片

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

摘要

现在,仅仅两章之后,你已经拥有了一个功能齐全的博客。这是许多关于网络开发技术书籍的结束之处。然而,还有 10 章更多要学习,才能将你的实用博客变成用户真正用于他们网站的博客。

在下一章中,我们将专注于构建 Flask 应用程序的结构,以适应长期发展和更大规模的项目。

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

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

会话和全局变量

会话是 Flask 在请求之间存储信息的方式;为此,Flask 将使用之前设置的 SECRET_KEY 配置来应用 HMAC-SHA1 默认加密方法。因此,用户可以读取他们的会话 cookie,但不能修改它。Flask 还设置了一个默认的会话生命周期,默认为 31 天,以防止中继攻击;这可以通过使用配置键的 PERMANENT_SESSION_LIFETIME 配置键来更改。

在当今的现代化网络应用程序中,安全性至关重要;请仔细阅读 Flask 的文档,其中涵盖了各种攻击方法:flask.pocoo.org/docs/security/.

Flask 会话对象是一种特殊的 Python 字典,但您可以使用它就像使用普通的 Python 字典一样,如下所示:

from flask import session
...
session['page_loads'] = session.get('page_loads', 0) + 1
...

全局是一个线程安全的命名空间存储,用于在请求上下文中保持数据。在每个请求的开始时,创建一个新的全局对象,并在请求结束时销毁该对象。这是放置用户对象或任何需要在视图、模板或请求上下文中调用的 Python 函数之间共享的数据的正确位置。这是无需传递任何数据即可完成的。

使用 g(全局)非常简单,为了在请求上下文中设置一个键:

from flask import g
....
# Set some key with some value on a request context
g.some_key = "some_value"
# Get a key
v = g.some_key
# Get and remove a key
v = g.pop('some_key', "default_if_not_present")

请求设置和清理

当你的 WSGIWeb 服务器网关接口)处理请求时,Flask 会创建一个包含请求本身所有信息的请求上下文对象。此对象被推入一个包含其他重要信息的堆栈中,例如 Flask 的 appgsession 和闪存消息。

请求对象对任何正在处理请求的功能、视图或模板都是可用的;这无需传递请求对象本身。request 包含诸如 HTTP 头部、URI 参数、URL 路径、WSGI 环境等信息。

有关 Flask 请求对象的更详细信息,请参阅:flask.pocoo.org/docs/api/#incoming-request-data.

我们可以通过在请求创建时实现自己的钩子来轻松地向请求上下文添加更多信息。为此,我们可以使用 Flask 的装饰器函数 @app.before_requestg 对象。@app.before_request 函数在每次创建新请求之前执行。例如,以下代码为页面加载次数保持一个全局计数器:

import random
from flask import session, g

@app.before_request
def before_request():
    session['page_loads'] = session.get('page_loads', 0) + 1
    g.random_key = random.randrange(1, 10)

可以用 @app.before_request 装饰器装饰多个函数,它们都会在请求视图函数执行之前执行。还有一个装饰器,@app.teardown_request,它在每个请求结束后被调用。

初始化本章提供的示例代码,并观察 gsessionrequest 的数据如何变化。还要注意由 WTForm 设置的 csrf_token,以保护我们的表单。

错误页面

将浏览器的默认错误页面显示给最终用户会让人感到震惊,因为用户会失去你应用的所有上下文,他们必须点击后退按钮才能返回你的网站。当使用 Flask 的 abort() 函数返回错误时,要显示你自己的模板,请使用 errorhandler 装饰器函数:

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

errorhandler 也可以用来将内部服务器错误和 HTTP 500 状态码转换为用户友好的错误页面。app.errorhandler() 函数可以接受一个或多个 HTTP 状态码来定义它将操作哪个代码。通过返回一个元组而不是仅仅一个 HTML 字符串,你可以定义 Response 对象的 HTTP 状态码。默认情况下,这设置为 200recommend 方法在 第六章,保护你的应用 中有介绍。

基于类的视图

在大多数 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': 
            ... 

这可以是一个非常强大的方法。以渲染来自数据库表的表格列表的网页为例;它们几乎相同,因此是通用方法的良好候选者。尽管执行起来不是一件简单的事情,但实现它所花费的时间可以在未来为你节省时间。使用基于类的视图的初始骨架可能是这样的:

from flask.views import View

class GenericListView(View):

    def __init__(self, model, list_template='generic_list.html'):
        self.model = model
        self.list_template = list_template
        self.columns = self.model.__mapper__.columns.keys()
        # Call super python3 style
        super(GenericListView, self).__init__()

    def render_template(self, context):
        return render_template(self.list_template, **context)

    def get_objects(self):
        return self.model.query.all()

    def dispatch_request(self):
        context = {'objects': self.get_objects(),
                   'columns': self.columns}
        return self.render_template(context)

app.add_url_rule(
    '/generic_posts', view_func=GenericListView.as_view(
        'generic_posts', model=Post)
    )

app.add_url_rule(
    '/generic_users', view_func=GenericListView.as_view(
        'generic_users', model=User)
)

app.add_url_rule(
    '/generic_comments', view_func=GenericListView.as_view(
        'generic_comments', model=Comment)
)

有一些有趣的事情需要注意。首先,在类构造函数中,我们使用 SQLAlchemy 模型列初始化columns类属性;我们正在利用 SQLAlchemy 的模型自省能力来实现我们的通用模板。因此,列名将被传递到我们的通用模板中,这样我们就可以为任何我们抛给它的模型正确渲染一个格式良好的表格列表。

这是一个使用单个类视图处理所有模型列表视图的简单示例。

这就是模板的样式:

{% extends "base.html" %}
{% block body %}

<div class="table-responsive">
    <table class="table table-bordered table-hover">
    {% for obj in objects %}
        <tr>
        {% for col in columns %}
        <td>
        {{col}} {{ obj[col] }}
        </td>
        {% endfor %}
        </tr>
    {% endfor %}
    </table>
</div>

{% endblock %}

你可以通过运行本章提供的示例代码,然后直接访问声明的 URL 来访问这些视图:

  • http://localhost:5000/generic_users

  • http://localhost:5000/generic_posts

  • http://localhost:5000/generic_comments

你可能已经注意到我们的表格视图缺少表列标题。作为一个练习,我挑战你来实现它;你可以简单地渲染提供的columns类属性,或者更好的方法是使用标签/列映射来显示更友好的列名。

方法类视图

通常,当函数处理多个 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()函数还会在指定的static文件夹中查找静态文件。

使用以下方法将蓝图添加到我们的应用中:

app.register_blueprint(example) 

让我们将当前的应用转换为使用蓝图的应用。我们首先需要定义我们的蓝图,然后再定义所有路由:

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

现在,因为已经定义了templates文件夹,我们需要将所有的模板移动到templates文件夹下的一个名为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 文件中变量的名字。

摘要

在本章中,我们向您介绍了一些 Flask 的强大功能;我们看到了如何使用会话在请求之间存储用户数据,以及如何使用全局变量在请求上下文中保持数据。我们向您介绍了请求上下文的概念,并开始向您展示一些新功能,这些功能将使我们能够轻松地将我们的应用程序扩展到任何规模,使用蓝图和方法类视图。

我们现在让我们的应用在蓝图内部运行,但这给我们带来了什么?假设我们想要在我们的网站上添加一个照片分享功能,我们能够将所有的视图函数组合到一个包含自己的模板、静态文件夹和 URL 前缀的蓝图里,而无需担心会破坏网站其他部分的功能。

在下一章中,通过升级我们的文件和代码结构,蓝图将被进一步强化,将它们分离到不同的文件中。

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

我们的应用程序已经从一个非常简单的示例转变为一个可扩展的基础,在这个基础上可以轻松构建强大的功能。然而,让我们的应用程序完全驻留在单个文件中是不必要的,这会使我们的代码变得杂乱。这是 Flask 的一大优点;您可以在单个文件上编写一个小的 REST 服务或 Web 应用程序,或者一个完整的商业应用程序。框架不会妨碍您,也不会强制任何项目布局。

为了使应用程序代码更清晰易懂,我们将整个代码转换为一个 Python 模块,并将每个特性转换为一个独立的模块。这种模块化方法使您能够轻松且可预测地进行扩展,因此新特性将有一个明显的位置和结构。在本章中,您将学习以下最佳实践:

  • 创建一个易于扩展的模块化应用程序

  • 应用程序工厂模式

模块化应用程序

目前,您的文件夹结构应该如下所示(查看前一章提供的代码):

./ 
  config.py 
  database.db 
  main.py 
  manage.py 
  env/ 
  migrations/ 
    versions/ 
  templates/ 
    blog/ 

为了将我们的代码转换为一个更模块化的应用程序,我们的文件结构如下:

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

首先需要做的是在你的应用程序中创建一个文件夹,用于存放模块。在这个例子中,它将被命名为 webapp

接下来,对于我们应用中的每个模块,我们将创建相应的 Python 模块。如果该模块是一个使用 Web 模板和表单的经典 Web 应用程序,我们将创建以下文件:

./<MODULE_NAME>
  __init__.py -> Declare a python module
  controllers.py -> where our blueprint definition and views are
  models.py -> The module database models definitions
  forms.py -> All the module's web Forms 

理念是关注点的分离,因此每个模块将包含所有必要的视图(在 Flask 蓝图中声明并包含在内),Web 表单和模块。这种模块化结构将转化为 URI、模板和 Python 模块的可预测命名空间。继续以抽象方法进行推理,每个模块将具有以下特点:

  • Python 模块(包含 __init__.py 的文件夹)使用其名称:MODULE_NAME。在模块内部是一个 controllers Python 模块,它声明了一个名为 <MODULE_NAME>_blueprint 的蓝图,并将其附加到一个 URL,前缀为 /<MODULE_NAME>

  • templates 内名为 <MODULE_NAME> 的模板文件夹。

这种模式将使代码对其他团队成员来说非常可预测,并且非常容易更改和扩展。如果您想创建一个全新的特性,只需使用建议的结构创建一个新的模块,所有团队成员将立即猜出新特性的 URI 命名空间,其中声明了所有视图,以及为该特性定义的数据库模型。如果发现了一些错误,您可以轻松地确定查找错误的位置,并且有一个更受限制的代码库需要关注。

代码重构

起初,看起来变化很大,但您会发现,考虑到之前解释的结构,这些变化简单且自然。

首先,我们已经将我们的 SQLAlchemy 代码移动到blog 模块文件夹内的models.py文件中。我们只想移动模型定义,而不是任何数据库初始化代码。所有初始化代码都将保留在主应用程序模块webapp中的__init__.py文件内。导入部分和数据库相关对象创建如下所示:

from flask import Flask, render_template
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

db = SQLAlchemy()
migrate = Migrate()

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

def create_app(config):
...

主要应用程序模块将负责创建 Flask 应用程序(工厂模式,将在下一节中解释)和初始化 SQLAlchemy。

blog/models.py文件将导入初始化的db对象:

from .. import db

...
class User(db.Model):
...
class Post(db.Model):
...
class Comment(db.Model):
...
class Tag(db.Model):
...

接下来,应将CommentForm对象以及所有 WTForms 导入移动到blog/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数据函数需要移动到controllers文件夹中的blog/controllers.py文件。

现在的blog/controllers.py文件应如下所示:

from sqlalchemy import func
from flask import render_template, Blueprint, flash, redirect, url_for
from .models import db, Post, Tag, Comment, User, tags
from .forms import CommentForm

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

def sidebar_data():
...

因此,每当需要一个新的功能,且足够大,可以作为应用程序模块的候选时,就需要一个新的 Python 模块(包含__init__.py文件的文件夹)来包含该功能的名称,以及之前描述的文件。我们将把应用程序代码分解成逻辑组。

然后,我们需要将新功能蓝图导入到主__init__.py文件中,并在 Flask 中注册它:

from .blog.controllers import blog_blueprint
from .main.controllers import main_blueprint

...
app.register_blueprint(main_blueprint)
app.register_blueprint(blog_blueprint)

应用程序工厂

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

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

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

现在应用程序工厂的好处已经很明显了,让我们修改我们的__init__.py文件来实现一个:

from flask import Flask, render_template
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

db = SQLAlchemy()
migrate = Migrate()

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

def create_app(object_name):
    from .blog.controllers import blog_blueprint
    from .main.controllers import main_blueprint

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

    db.init_app(app)
    migrate.init_app(app, db)
    app.register_blueprint(main_blueprint)
    app.register_blueprint(blog_blueprint)
    app.register_error_handler(404, page_not_found)
    return app

文件中的更改非常简单:我们将代码包含在一个接受config对象并返回应用程序对象的函数中。为了使用环境变量中的正确配置来启动我们的应用程序,我们需要更改main.py

import os
from webapp import create_app

env = os.environ.get('WEBAPP_ENV', 'dev')
app = create_app('config.%sConfig' % env.capitalize())

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

我们还需要修改我们的manage.py文件,以便与create_app函数一起使用,如下所示:

import os
from webapp import db, migrate, create_app
from webapp.blog.models import User, Post, Tag

env = os.environ.get('WEBAPP_ENV', 'dev')
app = create_app('config.%sConfig' % env.capitalize())

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

当我们创建配置对象时,提到应用程序运行的环境可能会改变应用程序的配置。此代码提供了一个非常简单的功能示例,其中加载了一个环境变量,并确定要将哪个config对象提供给create_app函数。环境变量是进程环境的一部分,是动态的名称值。这些环境可以被多个进程、系统范围、用户范围或单个进程共享。它们可以使用以下语法在 Bash 中设置:

    $ export WEBAPP_ENV="dev"

使用此方法来读取变量:

    $ echo $WEBAPP_ENV
    dev

你也可以轻松地删除变量,如下所示:

    $ unset $WEBAPP_ENV
    $ echo $WEBAPP_ENV

在你的生产服务器上,你会将WEBAPP_ENV设置为prod。一旦你部署到生产环境(第十三章,部署 Flask 应用程序),以及当我们到达第十二章,测试 Flask 应用程序),该章节涵盖了测试我们的项目,这个设置的真正威力将变得更加清晰。

摘要

我们已经将我们的应用程序转换成了一个更加可管理和可扩展的结构,这将在我们进一步阅读本书并添加更多高级功能时节省我们很多麻烦。在下一章中,我们将向我们的应用程序添加登录和注册系统,以及其他使我们的网站更加安全的特性。

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

我们有一个基本功能齐全的博客应用程序,但它缺少一些关键功能,例如用户登录、注册功能以及从浏览器添加和编辑帖子的能力。用户身份验证功能可以通过多种方式实现,因此本章的每个后续部分都将演示一种互斥的方法来创建登录功能。每种身份验证方法可能具有不同的安全级别,或者可能适用于不同类型的应用程序,从公开的 Web 到企业后台办公室。

在本章中,我们将探讨以下主题:

  • 各种身份验证方法的简要概述:基本身份验证、远程用户、LDAP、数据库身份验证和 OpenID 和 Oauth

  • 如何利用 Flask 登录(数据库/cookie 身份验证)

  • 如何实现基于角色的访问控制RBAC)以区分功能并实现对普通博客用户的细粒度访问

如果您还没有这样做,请下载提供的代码,并使用init.sh脚本来创建virtualenv、数据库模式和测试数据。测试数据将创建三个用户,所有用户的密码都设置为password。每个用户将分别拥有以下权限:

  • user_default具有最小权限

  • user_poster具有作者权限

  • admin具有管理员权限

让我们先探讨一些非常简单的身份验证方法。

身份验证方法

身份验证方法是一个确认身份的过程。在应用程序的情况下,用户被分配一个用户名和一个秘密安全令牌(密码),并使用它们在应用程序本身上验证其身份。有几种身份验证方法和类型,用于不同类型的应用程序(如 API、公开的 Web、内网和政府)。我们将介绍最常用的身份验证类型——单因素。

基本身份验证

如其名所示,基本身份验证是 HTTP 协议本身实现的一种非常简单的身份验证方法。它是 RFC7617 的一部分。要使用它,我们可以配置我们的 Web 服务器(IIS、Apache 和 NGINX)来实现它,或者我们可以自己实现它。

有关如何配置 NGINX 进行基本身份验证的详细信息,请访问 docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/

基本身份验证协议经过以下一般步骤:

  1. 用户从服务器请求受保护的资源。

  2. 服务器响应401(未授权)和 HTTP 头WWW-Authenticate: Basic realm="Login required".

  3. 浏览器将为用户显示基本身份验证登录窗口,以便用户将用户名/密码发送回服务器。

  4. 用户提供的用户名和密码将以 Authorization: Basic <Username>:<Password> 的形式在 HTTP 标头中发送到服务器。username:password 将被 base64 编码。

Flask 将使我们能够轻松实现此协议,因为它将自动从 HTTP 标头中解码 base64 授权,并将用户名和密码作为 Request.authorization 对象的属性放置,如下面的代码所示:

def authenticate(username, password):
    return username == 'admin' and password == 'password'

@app.route('/basic-auth-page')
def basic_auth_page():
    auth = request.authorization
    if not auth or not authenticate(auth.username, auth.password)
        return Response('Login with username/password', 401, {'WWW-Authenticate': 'Basic realm="Login Required"'})
    return render_template('some_page.html')

这种认证类型非常简单,但不太安全。用户名和密码将在每次请求时发送到服务器,因此请确保您始终使用 HTTPS 正确加密它们的传输。此外,如您可能已经在前面示例的代码流程中注意到的,认证方法将在每次请求时被调用,因此它不太高效。然而,这对于非常简单的后台应用程序的内部使用或快速保护概念验证应用程序来说可能是一个不错的选择。

远程用户认证

在某些内部网络设置中,我们可以使用单点登录认证方法,其中 Web 服务器执行与安全相关的所有繁重工作。这可以使用 IIS 集成 Windows 认证Apache mod_auth_sspiApache Samba 或其他方法完成。设置超出了本书的范围。

您可以在 wiki.samba.org/index.php/Authenticating_Apache_against_Active_Directory 上查看如何使用 Apache Samba 设置此类认证的一些示例。

使用这种认证方法,Web 服务器将已认证的用户名作为环境密钥传递给 WSGIWeb 服务器网关接口),因此我们只需使用以下方法获取它:

 username = request.environ.get('REMOTE_USER')

对于我们的博客应用程序,我们只需检查用户是否在数据库中存在即可,因此不需要密码数据库字段。如果服务器上设置得当,这种认证方法可以被认为是安全的,并且在内部网络设置中可以非常方便,因为如果用户已经在域(例如,Active Directory)上进行了认证,将不再需要再次填写其登录名/密码(例如使用 Kerberos GSSAPI 或 Windows SSPI)。

LDAP 认证

LDAP轻量级目录访问协议)是一个由当前 RFC4511 描述的开放标准。其目的是在 IP 上实现分布式信息目录。此目录可以包含与用户、组和设备通常相关的不同类型的信息。它有一个固定模式描述每个对象的属性,但可以使用 LDIF 更改此模式。

Active Directory 是微软对 LDAP 的实现。您可以查看在 www.kouti.com/tables/userattributes.htm 上提供的基实现用户属性。

目录中的条目(例如,用户)通过一个唯一名称DN)来识别。例如,看看下面的代码:

CN=user1,OU=Marketing,DC=example,DC=com

DC短语是域组件,它标识了用户所在的域(LDAP 目录可以有域和子域的树状结构)。在我们的例子中,域是example.com。短语OU指的是用户所在的组织单元,而CN是其通用名称。

LDAP 实现了各种操作,如添加用户、搜索、删除等。仅就认证目的而言,我们只对BindSearch操作感兴趣。

要使用 LDAP,我们需要安装python-ldap,让我们首先使用以下代码进行安装:

$ pip install python-ldap

目前最常用的两个 LDAP 服务是OpenLDAP(开放和免费)和Microsoft Active Directory(商业)。它们的实现略有不同,主要是在用户属性方面。以下代码是 Active Directory 的一个示例。首先,我们需要定义一些配置键来连接和认证服务:

import ldap

LDAP_SERVER="ldap://example.com:389"
ROOT_DN="dc=example,dc=com"
SERVICE_USER="ServiceAccount"
SERVICE_PASSWORD="SOME_PASSWORD"
UID_FIELD_NAME="userPrincipalName" # specific for AD
USER_DOMAIN="example.com"

注意,我们在应用程序服务器和 LDAP 服务器之间使用的是非加密通信;我们可以通过使用数字证书并在LDAP_SERVER配置键上使用 LDAPS 来启用加密。

如果我们要将 LDAP 认证集成到我们的博客应用程序中,这些值将是我们在config.py配置中的良好候选者***。

接下来,我们将按照以下方式连接到并认证服务:

con = ldap.initialize(LDAP_SERVER)
con.set_option(ldap.OPT_REFERRALS, 0)
con.bind_s(SERVICE_USER, SERVICE_PASSWORD)

OPT_REFERRALS是针对 MSFT AD 的特定解决方案。请参阅python-ldap的常见问题解答以获取更多详细信息,请访问www.python-ldap.org/en/latest/faq.html

现在我们已经建立了认证连接,我们将搜索我们的用户以获取其用户名,如下面的代码所示。在 Active Directory 中,我们可以直接使用用户的用户名和密码进行绑定,但在 OpenLDAP 中这种方法会失败。这样,我们遵循的是在两个系统上都适用的标准方法:

username = username + '@' + USER_DOMAIN
filter_str = "%s=%s" % (UID_FIELD_NAME, username)
user = con.search_s(ROOT_DN,
                        ldap.SCOPE_SUBTREE,
                        filter_str,
                        ["givenName","sn","mail"])

一个完整的 LDAP 认证函数可能如下所示:

def ldap_auth(username, password):
    con = ldap.initialize(LDAP_SERVER)
    con.set_option(ldap.OPT_REFERRALS, 0)
    username = username + '@' + USER_DOMAIN
    con.bind_s(SERVICE_USER, SERVICE_PASSWORD)
    filter_str = "%s=%s" % (UID_FIELD_NAME, username)
    user = con.search_s(ROOT_DN,
                        ldap.SCOPE_SUBTREE,
                        filter_str,
                        ["givenName","sn","mail"])
    if user:
 print("LDAP got User {0}".format(user))
 # username = DN from search
 username = user[0][0]
 try:
 con.bind_s(username, password)
 return True
 except ldap.INVALID_CREDENTIALS:
 return False
    else:
        return False

最后,我们使用 LDAP 用户名进行最后的绑定以认证我们的用户(高亮显示的代码)。

数据库用户模型认证

数据库认证在面向互联网的应用程序中得到了广泛的应用。如果实施得当,它可以被认为是一种安全的方法。它具有易于添加新用户和没有对外部服务的依赖的优点。安全角色、组、细粒度访问权限和额外的用户属性也都保存在数据库中。这些可以很容易地更改,而无需任何外部依赖,并且可以在应用程序的范围内维护。

这种认证方法包括检查用户提交的用户名和密码与数据库中用户模型存储的属性是否匹配。但是,直到现在,我们的用户密码都存储在数据库中的纯文本形式。这是一个重大的安全漏洞。如果任何恶意用户能够访问数据库中的数据,他们可以登录到任何账户。这种安全漏洞的后果不仅限于我们的网站。互联网上有大量的人使用许多网站的通用密码。如果一个攻击者能够访问到一个电子邮件和密码组合,那么这个信息很可能被用来登录到 Facebook 账户,甚至银行账户。

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

这是我们将要使用的一种认证方法;进一步的实现细节将在本章后面描述。

OpenID 和 OAuth

随着时间的推移,将替代登录和注册选项集成到您的网站中变得越来越重要。每个月,都会有关于某个热门网站密码被盗的公告。实施以下登录选项意味着我们的网站数据库永远不会存储该用户的密码。验证由用户已经信任的大型品牌公司处理。通过使用社交登录,用户对所使用的网站的信任程度大大提高。用户的登录过程也变得更短,降低了进入应用程序的门槛。

社交认证用户的行为与普通用户相同,并且与基于密码的登录方法不同,它们都可以协同使用。

OpenID是一种开放标准的认证协议,允许一个网站上的用户通过任何实现该协议的第三方网站进行认证,这些网站被称为身份提供者。OpenID 登录通常表示为来自某个身份提供者的 URL,通常是网站的简介页面。希望使用这种认证方法的用户需要在至少一个 OpenID 提供者处已经注册。

要查看使用 OpenID 的网站完整列表,以及了解如何使用每个网站,请访问openid.net/get-an-openid/

在认证过程中,用户将被重定向到 OpenID 提供者,在那里用户可以进行认证——通常使用用户名/密码,但可以是任何其他方法——并询问他们是否信任该方(我们的应用程序)。如果用户信任我们的应用程序并成功认证,那么用户将被重定向回,并带有一个包含一些请求的用户信息的文档(如用户名或电子邮件)。然后发出一个最终请求来检查数据是否确实来自提供者。

OAuth 不是一个认证方法——它是一个访问委托方法。它主要是为了使第三方应用程序能够与 OAuth 提供者(Facebook、Twitter 等)交互而设计的。通过它,我们可以设计一个应用程序来与用户的 Facebook 账户交互,执行诸如代表用户发布、发送通知、检索他们的朋友列表等操作。

要开始使用 OAuth,我们首先需要在 OAuth 提供者上注册我们的应用程序,并使用其消费者密钥和秘密令牌。

对于 Facebook,我们需要在 developers.facebook.com 上注册我们的应用程序。一旦创建了一个新的应用程序,请查找列出您的应用程序 ID 和秘密密钥的面板,如下面的截图所示:

要创建一个 Twitter 应用程序并接收您的密钥,请访问 apps.twitter.com/。请这样做,因为我们将要使用这些密钥、令牌和配置信息来设置我们的博客应用程序进行 OAuth 伪认证。

OAuth 流程如下:

  1. 应用程序从 OAuth 提供者请求访问用户的资源。

  2. 用户将被重定向并授权请求的访问。

  3. 应用程序收到一个授权授予,并通过提供自己的凭据(密钥和令牌)以及收到的授权来请求访问令牌。

  4. 应用程序收到访问令牌(这将成为我们的认证方法)并可以进一步用于代表我们的用户与提供者 API 交互。

要了解完整的 OAuth 流程,请访问 flask-dance.readthedocs.io/en/latest/how-oauth-works.html#oauth-2

由于我们将在我们的应用程序中使用这两种方法,您将在以下章节中找到实现细节。

Flask-Login 概述

Flask-Login 是一个流行的 Flask 扩展,用于处理用户登录和注销的过程,正确处理 cookie 会话,甚至可以使用 HTTP 头的基本认证。它将为用户加载、头部认证、登录、注销、未经授权的事件等设置回调。

要开始使用 Flask-Login,我们首先需要在requirements.txt中将它声明为一个依赖项,如下面的代码所示:

...
Flask-Login
...

然后,我们需要更新我们的 Python 虚拟环境如下:

$ source venv/bin/activate
$ pip install -r requirements.txt

如果你已经执行了提供的init.sh脚本,那么就没有必要更新virtualenv。本章所需的所有依赖项已经安装。

要使用 Flask-Login 实现的会话和登录流程,我们需要做以下事情:

  • 修改用户模型并实现以下函数:

    • is_authenticated:这检查当前用户是否已认证

    • is_active:这检查用户是否活跃

    • is_anonymous:这支持对博客的匿名访问

    • get_id:这获取用户 ID

  • 初始化和配置登录管理器对象,声明以下内容:

    • 我们的登录视图所在的位置(URL)

    • 会话类型

    • 登录消息(闪现登录消息)

    • 匿名用户的特殊用户类

    • 注册并实现一个加载我们认证用户的函数

    • 一个通过其 ID 返回用户对象的函数

Flask-Login 对我们的认证方法无关紧要,因此认证系统本身需要实现。

设置

为了实现用户认证系统,我们将根据之前在第五章,高级应用结构中提出的规则,在我们的应用程序中开发一个新的模块。我们的应用程序结构将如下所示:

./
  config.py
  manage.py
  main.py
  config.py 
  database.db 
  webapp/ 
    __init__.py
    blog/
      __init__.py 
      controllers.py
      forms.py
      models.py
    auth/
 __init__.py
 controllers.py
 models.py
 forms.py
    main/
      __init__.py
      controllers.py
    templates/ 
      blog/
      auth/
  migrations/ 
    versions/ 

为了保持关注点分离的原则,我们将对注册每个模块蓝图的方式做简单修改。这是一件好事,而且现在这种必要性更加明显,因为在本章中,我们将使用许多新的扩展来实现安全性,并需要初始化它们、注册事件方法以及配置它们。所有这些安全引导程序最好都放在认证模块本身中。为了实现这一点,我们将在每个模块的__init__.py文件中为每个模块创建一个新的方法。让我们看看在博客和认证模块中是如何做到这一点的:

首先,让我们看看blog/__**init__**.py文件中的代码:

def create_module(app, **kwargs):
    from .controllers import blog_blueprint
    app.register_blueprint(blog_blueprint)

在认证模块中,我们将处理之前所述的 Flask-Login 配置和初始化。主要的 Flask-Login 对象是LoginManager对象。

让我们看看auth/__**init__**.py文件中的代码:

from flask_login import LoginManager 

login_manager = LoginManager()
login_manager.login_view = "auth.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) 

def create_module(app, **kwargs):
    ...
    login_manager.init_app(app)
    from .controllers import auth_blueprint
    app.register_blueprint(auth_blueprint)
    ...

上述配置值定义了哪个视图应被视为登录页面,以及用户成功登录后应显示的消息。将session_protection选项设置为strong可以更好地保护免受恶意用户篡改其 cookie。当识别出篡改的 cookie 时,该用户的会话对象将被删除,并强制用户重新登录。

load_user函数接受一个 ID 并返回User对象。当 cookie 验证通过时,Flask-Login 将使用我们的函数将用户拉入当前会话。

最后,在create_app方法本身中,我们只需对每个模块调用create_module,如下所示:

...

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

    db.init_app(app)
    migrate.init_app(app, db)

    from .auth import create_module as auth_create_module
    from .blog import create_module as blog_create_module
    from .main import create_module as main_create_module
    auth_create_module(app)
    blog_create_module(app)
    main_create_module(app)

    return app

为了实现一个认证系统,我们需要大量的设置代码。为了运行任何类型的认证,我们的应用程序需要以下元素:

  • 用户模型需要适当的密码散列

  • 需要实现一个系统来保持安全的用户会话上下文

  • 需要一个登录表单和一个注册表单来验证用户输入

  • 需要一个登录视图和一个注册视图(以及每个视图的模板)

更新模型

有许多散列算法,其中大多数不安全,因为它们容易被暴力破解。在暴力破解攻击中,黑客会不断地通过散列算法发送数据,直到找到匹配项。为了最好地保护用户密码,bcrypt 将成为我们选择的散列算法。bcrypt 故意设计为对计算机处理效率低下且速度慢(毫秒级而不是微秒级),从而使得暴力破解更困难。要将 bcrypt 添加到我们的项目中,需要安装 flask-bcrypt 包并将其添加到requirements.txt的依赖项中,如下所示:

...
flask-bcrypt
...

需要初始化flask-bcrypt包。这需要在auth模块的auth/__**init__**.py文件中完成,如下所示:

...
from flask.ext.bcrypt import Bcrypt 
bcrypt = Bcrypt()
...
def create_module(app, **kwargs):
    bcrypt.init_app(app)
    login_managet.init_app(app)

    from .controllers import auth_blueprint
    app.register_blueprint(auth_blueprint)
 ...

Bcrypt 现在可以使用了。为了使我们的User对象使用 bcrypt,我们将添加两个方法来设置密码和检查字符串是否与存储的散列匹配,如下所示:

from . import bcrypt

class User(db.Model):
    ...
    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模型可以安全地存储密码。我们还需要实现之前描述的 Flask-Login 方法来处理会话和认证流程。为此,我们首先需要定义我们的匿名用户对象。

auth/__**init__**.py文件中,输入以下内容:

from flask_login import AnonymousUserMixin

class BlogAnonymous(AnonymousUserMixin):
    def __init__(self):
        self.username = 'Guest'

然后在auth/models.py中的用户模型上添加我们的is_authenticated属性,如下所示。如果当前用户不是匿名用户,则表示已认证:

class User(db.model):
...
    @property
 def is_authenticated(self):
        if isinstance(self, AnonymousUserMixin):
            return False
        else:
            return True

然后我们添加is_active属性;我们不会使用它,但它检查用户是否完成了一些激活过程,例如电子邮件确认。否则,它允许网站管理员在不删除用户数据的情况下禁止用户。为此,我们将在用户模型模式定义上创建一个新的布尔属性,如下所示:

class User(db.model):
...
    @property
 def is_active(self):
        return True

最后,我们添加以下is_active属性和get_id方法,它们相当直观:

class User(db.model):
...
    @property
 def is_anonymous(self):
        if isinstance(self, AnonymousUserMixin):
            return True
        else:
            return False

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

接下来,我们的登录过程需要使用这些方法来创建新用户、检查密码以及检查用户是否已认证。

创建表单

需要三个表单:一个登录表单、一个注册表单以及我们帖子创建页面的表单。登录表单将包含用户名和密码字段。

下面的代码是auth/forms.py文件的代码:


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 user 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()方法检查散列。这是通过覆盖在表单POST请求上调用的validate()方法来完成的。在这里,我们首先检查用户是否在数据库中存在,如果存在,检查加密的密码是否匹配(这将导致成功登录)。

保护您的表单免受垃圾邮件

注册表单将包含一个用户名字段、一个带有确认字段的密码字段,以及一个名为reCAPTCHA的特殊字段。CAPTCHA是一种特殊的表单字段,用于检查填写表单的人是否真的是一个人,或者是一个正在垃圾邮件你的网站的自动化程序。reCAPTCHA字段是CAPTCHA字段的一种简单实现。reCAPTCHA方法已被集成到 WTForms 中,因为它是网络上最流行的实现。

要使用reCAPTCHA,您需要从www.google.com/recaptcha/intro/index.html获取reCAPTCHA登录。因为reCAPTCHA是谷歌的产品,您可以使用您的谷歌账户登录。

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

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

请记住永远不要向公众展示这些密钥。因为这些密钥仅注册到localhost,所以在这里展示没有任何问题。

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

class Config(object):
    SECRET_KEY = '736670cb10a600b695a55839ca3a5aa54a7d7356cdef815d2ad6e19a2031182b'
    RECAPTCHA_PUBLIC_KEY = "6LdKkQQTAAAAAEH0GFj7NLg5tGicaoOus7G9Q5Uw"
    RECAPTCHA_PRIVATE_KEY = '6LdKkQQTAAAAAMYroksPTJ7pWhobYb88fTAcxcYn'

以下代码是我们在auth/forms.py中的注册表单:

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 

注意我们是如何通过覆盖validate方法来防止用户两次注册自己的。这是我们之前解释的添加额外表单验证逻辑的正确方法。

创建后的表单将只包含标题的文本输入和帖子内容的文本区域输入。因此,blog/forms.py将包含以下内容:

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

创建视图

登录和注册视图将创建我们的表单对象并将它们传递到模板中。在LoginForm验证了用户的凭据后,我们将使用 Flask-Login 来实际登录用户。

auth/controllers.py控制器中,我们将找到login视图,如下面的代码所示:

...
from flask_login import login_user, logout_user
...
 @auth_blueprint.route('/login', methods=['GET', 'POST'])
@oid.loginhandler
def login():
    form = LoginForm()
    ...
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.username.data).one()
        login_user(user, remember=form.remember.data)
        ...
        flash("You have been logged in.", category="success")
        return redirect(url_for('main.index'))

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

logout视图非常简单,将用户重定向到主索引页面,如下所示:

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

register视图仅用于注册数据库用户,并将用户重定向到登录页面,以便他们可以立即登录,如下所示:

@auth_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) 

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

您的注册页面应该看起来像以下截图:

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

...
from flask_login import login_required, current_user
from .forms import CommentForm, PostForm
...
@blog_blueprint.route('/new', methods=['GET', 'POST'])
@login_required
def new_post(): 
  form = PostForm() 
  if form.validate_on_submit(): 
    new_post = Post()
    new_post.user_id = current_user.id
    new_post.title = form.title.data
    new_post.text = form.text.data 
    db.session.add(new_post) 
    db.session.commit()
    flash("Post added", info)
    return redirect(url_for('blog.post', post_id=new_post.id)
return render_template('new.html', form=form)

我们正在使用 Flask-Login 装饰器@login_required来保护我们的视图,以确保只有经过身份验证的用户可以提交新帖子。接下来,使用代理方法current_user,我们获取当前登录的用户 ID,以便将帖子与用户关联。

new.html模板需要 JavaScript 文件来支持 WYSIWYG 编辑器;CKEditor安装和使用都非常简单。现在,我们可以创建new.html文件,命名为templates/blog/new.html

{% extends "base.html" %}
{% block title %}Post Creation{% endblock %}
{% block body %}
<div class="p-4 shadow-sm">
    <div class="row">
        <div class="col">
            <h1>Create a New Post</h1>
        </div>
    </div>

<div class="row">
    <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>
</div>
{% endblock %}

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

这就是将用户输入存储为 HTML 在数据库中的所有所需内容。因为我们已经在我们的帖子模板中传递了safe过滤器,所以 HTML 代码在我们的帖子页面上显示正确。edit.html模板与new.html模板类似。唯一的区别是form标签的打开方式,如下所示:

<form method="POST" action="{{ url_for('.edit_post', id=post.id) 
   }}"> 
...  
</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> 
  ... 
  {% if current_user == post.user_id %}
  <div class="row"> 
    <div class="col-lg-2"> 
    <a href="{{ url_for('.edit_post', id=post.id) }}" class="btn 
       btn-primary">Edit</a> 
  </div> 
  {% endif %}
</div> 

再次使用current_user代理来获取当前登录的用户,这次是在 Jinja2 模板中,这样我们只向之前创建博客帖子的用户显示编辑按钮。

最后,我们应该在主导航栏中添加一个创建新帖子的条目。我们还应该查看如何启用和禁用登录、注销和注册选项。在templates/navbar.html中输入以下内容:

{% if current_user.is_authenticated %}
<li class="nav-item">
    <a class="nav-link" href="{{url_for('auth.logout')}}">
    <i class="fa fa-fw fa-sign-out"></i>Logout</a>
</li>
{% else %}
<li class="nav-item">
    <a class="nav-link" href="{{url_for('auth.login')}}">
    <i class="fa fa-fw fa-sign-in"></i>Login</a>
</li>
<li class="nav-item">
    <a class="nav-link" href="{{url_for('auth.register')}}">
    <i class="fa fa-fw fa-sign-in"></i>Register</a>
</li>
{% endif %}

OpenID

为了将 OpenID 身份验证集成到我们的应用程序中,我们将使用一个名为Flask-OpenID的新 Flask 扩展,该扩展由Flask的创建者本身实现。像往常一样,需要将扩展添加到requirements.txt文件中,如下所示:

... Flask-OpenID ...

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

  • 新的表单对象

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

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

auth/__init__.py文件中,OpenID对象可以按照以下方式初始化:

...
from flask_openid import OpenID
...  
oid = OpenID() 

create_module函数中,oid对象注册到app对象,如下所示:

def create_module(app, **kwargs):
    ...
    oid.init_app(app)
    ...

新的form对象只需要OpenID提供者的 URL。在auth/forms.py中输入以下内容:

from wtforms.validators import DataRequired, Length, EqualTo, URL
class OpenIDForm(Form): 
  openid = StringField('OpenID URL', [DataRequired(), URL()]) 

在登录和注册视图中,将初始化OpenIDForm(),如果数据有效,将发送登录请求。在auth/views.py中输入以下内容:

...

@auth_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 监听来自提供者的认证信息。使用 OpenID,登录和注册是相同的。可以从登录表单创建用户,也可以从注册表单登录。同一字段出现在两个页面上,以避免使用户困惑。

为了处理用户创建和登录,需要在auth/__init__.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 the user does not exist create it
    if user is None: 
      user = User(username) 
      db.session.add(user)
      db.session.commit() 
    login_user(user)
    return redirect(url_for('main.index')) 

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

OAuth

要使用 Facebook 和 Twitter 登录,将使用如前所述的OAuth协议。我们的应用程序不会直接使用 OAuth;相反,将使用另一个名为Flask Dance的 Flask 扩展。在requirements.txt中输入以下内容:

...
flask-dance
...

如前所述,OAuth 协议需要在每个提供者的开发者页面上预先创建一个应用程序。在我们的应用程序创建后,我们将为每个提供者拥有一个密钥和秘密令牌。目前,我们将保持这些凭据在配置文件中不变。稍后,我们将使用环境变量来处理它们。因此,在config.py配置文件中,添加以下内容:

...
class Config(object):
    ...
    TWITTER_API_KEY = "XXX"
    TWITTER_API_SECRET = "XXXX"
    FACEBOOK_CLIENT_ID = "YYYY"
    FACEBOOK_CLIENT_SECRET = "YYYY"

现在我们已经准备好初始化和配置我们的 OAuth 扩展。Flask-Dance将帮助我们为每个我们想要添加的提供者创建一个新的 Flask 蓝图。再次强调,auth/__init__.py是配置所有我们的认证扩展的地方,如下所示:

...
from flask_dance.contrib.twitter import make_twitter_blueprint, twitter
from flask_dance.contrib.facebook import make_facebook_blueprint, facebook
...
def create_module(app, **kwargs):
...
    twitter_blueprint = make_twitter_blueprint(
        api_key=app.config.get("TWITTER_API_KEY"),
        api_secret=app.config.get("TWITTER_API_SECRET"),
    )
    app.register_blueprint(twitter_blueprint, url_prefix="/auth/login")

    facebook_blueprint = make_facebook_blueprint(
        client_id=app.config.get("FACEBOOK_CLIENT_ID"),
        client_secret=app.config.get("FACEBOOK_CLIENT_SECRET"),
)
    app.register_blueprint(facebook_blueprint, url_prefix="auth/login"
...

Flask-Dance 将为我们创建以下路由:

  • /auth/login/twitter/authorized: 在这里,用户在 Twitter 授权成功后将被重定向

  • /auth/login/twitter: 这是 Twitter OAuth 的初始登录视图

  • /auth/login/facebook/authorized

  • /auth/login/facebook

在成功完成登录/授权后,我们需要在 Flask-Login 中登录用户;如果数据库中不存在用户,则添加他们。为此,我们注册了授权信号事件。在auth/__init__.py中输入以下内容:

...
from flask_dance.consumer import oauth_authorized
...
@oauth_authorized.connect
def logged_in(blueprint, token):
    from .models import db, User
    if blueprint.name == 'twitter':
 username = session.get('screen_name')
 elif blueprint.name == 'facebook':
 resp = facebook.get("/me")
 username = resp.json()['name']
    user = User.query.filter_by(username=username).first()
    if not user:
        user = User()
        user.username = username
        db.session.add(user)
        db.session.commit()

    login_user(user)
    flash("You have been logged in.", category="success")

@oauth_authorized是 Flask-Dance 提供的装饰器,我们用它来注册我们的函数以处理授权后的信号。这是一个针对所有我们的提供者的通用信号处理器,因此我们需要知道我们目前正在处理哪个提供者。我们需要知道这一点,因为我们需要获取我们的用户名,而每个提供者将以不同的方式公开不同的用户信息。在 Twitter 上,我们将使用提供者已经返回的screen_name键,并且 Flask-Dance 已经将其推送到我们的 Flask 会话对象。但在 Facebook 上,我们需要进一步请求 Facebook 的 API 来获取用户名。

在开发过程中,你可能不会使用 HTTPS。这将触发使用OAuth2时的错误。为了解决这个问题,你必须告诉oauthlib接受不安全的连接。在命令行中输入$ export OAUTHLIB_INSECURE_TRANSPORT=1

最后,在注册和登录模板中,我们提供了以下链接以启动登录过程:

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

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

基于角色的访问控制(RBAC)

为了实现一个简单的基于角色的访问控制系统,我们需要创建一个新的数据库实体Role模型,它将需要一个与我们的User模型的多对多关系,以便用户可以有多个角色。

使用我们第二章中的代码,使用 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):
    ...
    roles = db.relationship(
 'Role',
        secondary=roles,
        backref=db.backref('users', lazy='dynamic')
 )

    def __init__(self, username=""):
        default = Role.query.filter_by(name="default").one()
 self.roles.append(default)
        self.username = username

    ...
    def has_role(self, name):
 for role in self.roles:
 if role.name == name:
 return True
        return False
...
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)

此外,当创建用户时,总是将其插入一个默认角色。注意has_role方法将帮助我们轻松地检查用户是否具有某个角色;这对于模板将很有用。

我们的测试数据 Python 脚本已经用管理员、发布者和默认角色填充了Role模型。

接下来,我们需要一个装饰器函数来在我们的视图中启用 RBAC。Python 的装饰器函数非常有用,安全是一个它们可以受欢迎的上下文。没有它们,我们就必须一遍又一遍地编写相同的代码(违反 DRY 原则)。我们需要一个装饰器函数,它接受一个参数——在我们的情况下,是角色名称——然后检查用户是否有所需的角色。如果没有,它返回HTTP 403。这是通过以下代码启用的:

import functools
...
def has_role(name):
    def real_decorator(f):
        def wraps(*args, **kwargs):
            if current_user.has_role(name):
                return f(*args, **kwargs)
            else:
                abort(403)
        return functools.update_wrapper(wraps, f)
    return real_decorator

需要functools.update_wrapper以确保装饰的函数返回函数定义而不是包装器定义;没有它,我们将失去 Flask 的路由定义。

现在,我们已经准备好保护我们的新帖子视图和编辑视图。由于只有具有发布者角色的用户可以访问它们,因此现在使用has_access装饰器非常简单。

查看以下auth/__init__.py文件:

...
from ..auth import has_role 
...
@blog_blueprint.route('/new, methods=['GET', 'POST']) 
@login_required 
@has_role('poster') 
def new_post(id): 
    ... 

我们还可以在视图中添加用户检查,以确保只有创建了帖子的用户才能实际编辑它。我们已经禁用了编辑选项,但用户总是可以通过在浏览器中直接输入 URL 来访问视图。

前往名为blog/controllers.py的文件:

@blog_blueprint.route('/edit/<int:id>', methods=['GET', 'POST'])
@login_required
@has_role('poster')
def edit_post(id):
    post = Post.query.get_or_404(id)
    # We want admins to be able to edit any post
    if current_user.id == post.user.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.title.data = post.title
        form.text.data = post.text
        return render_template('edit.html', form=form, post=post)
    abort(403)

此外,在导航栏中,我们只想向具有发布者角色的用户显示“新建文章”选项。

摘要

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

第七章:在 Flask 中使用 NoSQL

NoSQL(代表Not Only SQL)数据库是指任何非关系型数据存储。它通常侧重于速度和可扩展性。过去七年中,NoSQL 一直在 Web 开发世界中掀起风暴。像 Netflix 和 Google 这样的大公司已经宣布,他们正在将许多服务迁移到 NoSQL 数据库,许多较小的公司也效仿了它们的做法。

本章将与本书的其他部分有所不同,因为它不会主要关注 Flask。在关于 Flask 的书中关注数据库设计可能看起来有些奇怪,但选择适合您应用程序的正确数据库可能是您在设计技术堆栈时可以做出的最重要的决定。在绝大多数 Web 应用程序中,数据库是瓶颈,因此您选择的数据库将决定您应用程序的整体速度。亚马逊进行的一项研究表明,即使 100 毫秒的延迟也会导致销售额下降 1%,因此速度始终应该是 Web 开发者关注的重点之一。在程序员社区中,关于 Web 开发者选择流行的 NoSQL 数据库但并未真正理解数据库在管理方面的要求的恐怖故事也很多。这导致大量数据丢失和崩溃,进而意味着客户流失。总的来说,可以说您为应用程序选择的数据库可能是您的应用程序成功与失败之间的区别。

在本章中,我们将通过检查每种类型的 NoSQL 数据库以及 NoSQL 与传统数据库之间的差异,来说明 NoSQL 数据库的优点和缺点。

NoSQL 数据库类型

NoSQL 是一个总称,用于描述在数据库中存储数据的非传统方法。绝大多数 NoSQL 数据库都不是关系型数据库——与 RDBMS 不同——这意味着它们通常无法执行诸如JOIN之类的操作。还有许多其他特性将 SQL 数据库与 NoSQL 数据库区分开来。使用 NoSQL 数据库,我们有能力不强制实施固定模式——例如,MongoDB 上的集合可以包含不同的字段,因此它们可以接受任何类型的文档。使用 NoSQL,您可以(并且应该)利用反规范化,在存储和速度之间做出权衡。

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

键值存储

键值型 NoSQL 数据库在功能上与 Python 中的字典非常相似。单个值与一个键相关联,并通过该键访问。同样,像 Python 字典一样,大多数键值数据库的读取速度不受条目数量的影响。高级程序员可能会将这称为O(1)读取。在一些键值存储中,一次只能检索一个键,而不是传统 SQL 数据库中的多行。在大多数键值存储中,值的内文是不可查询的,但键是可查询的。值只是二进制块:它们可以是任何东西,从字符串到电影文件。然而,一些键值存储提供默认类型,如字符串、列表、集合和字典,同时仍然提供添加二进制数据的选择。

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

最流行的键值存储包括RedisRiakAmazon DynamoDB

文档存储

文档存储是 NoSQL 数据库中最受欢迎的类型之一,通常用于替代 RDBMS。数据库将数据存储在称为文档的键值对集合中。这些文档是无模式的,这意味着没有文档遵循任何其他文档的结构。此外,在文档创建后,可以附加额外的键。文档存储可以存储JSON、BSON 和 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 数据库中最快的一种类型,因为它们是为大型应用程序设计的。它们的主要优势是能够通过智能地跨多个服务器分配数据来处理数以 TB 计的数据,同时仍然保持非常快的读写速度。

列族存储也最难理解,部分原因是由于列族存储的术语,因为它们使用了与 RDBMSes 相同的许多术语,但含义却截然不同。为了清楚地理解什么是列族存储,让我们直接进入一个例子。让我们在一个典型的列族存储中创建一个简单的 用户到帖子 关联。

首先,我们需要一个用户表。在列族存储中,数据通过唯一键存储和访问,就像键值存储一样,但内容由非结构化列组成,就像文档存储一样。考虑以下用户表:

Jack John
全名 简介 位置 全名 简介
Jack Stouffer 这是我关于我的介绍 美国,密歇根州 John Doe 这是我关于我的介绍

注意,每个键都包含列,这些列也是键值对。此外,每个键不需要具有相同数量或类型的列。每个键可以存储数百个独特的列,或者它们可以具有相同数量的列,以简化应用程序开发。这与键值存储不同,每个键可以存储任何类型的数据。这与文档存储也略有不同,文档存储可以在每个文档中存储类型,如数组和字典。现在让我们创建我们的帖子表:

Post/1 Post/2
标题 日期 文本 标题 日期 文本
Hello World 2015-01-01 帖子文本... 仍在这里 2015-02-01 帖子文本...

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

其次,数据不能按列的内容排序。数据只能按键排序,这就是为什么帖子键是整数的原因。这允许帖子以它们被输入的顺序返回。这不是用户表的要求,因为不需要按顺序排列用户。

第三,没有JOIN运算符,我们无法查询包含用户键的列。根据我们当前的架构,无法将帖子与用户关联起来。为了创建这个功能,我们需要一个包含用户到帖子关联的第三个表,如下所示:

Jack
Posts Posts/1 Post/1
Posts/2 Post/2

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

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

如果这个查询过程看起来很绕,那是因为它确实如此,而且这是设计使然。列族存储的限制性质使其能够如此快速地处理大量数据。通过移除如按值和列名搜索等特性,列族存储能够处理数百 TB 的数据。说 SQLite 对于程序员来说比典型的列族存储更复杂,并不夸张。

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

最受欢迎的列族存储是 BigTableCassandraHBase

图数据库

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

节点类似于对象的实例,通常是一组键值对或 JSON 文档的集合。节点可以通过标签来标记它们属于某个类别——例如,用户或组。在定义了节点之后,可以在节点之间创建任意数量的单向关系,称为链接,并具有它们自己的属性。例如,如果我们的数据有两个用户节点,并且两个用户彼此认识,我们会在它们之间定义两个知道链接来描述这种关系,如下面的图所示。这将允许你查询知道一个用户的所有人或者一个用户知道的所有人:

图片

图数据库还允许你通过链接的属性进行查询。这使你能够轻松创建其他情况下复杂的查询,例如搜索 2001 年 10 月被一个用户标记为已知的所有用户。图数据库可以跟随节点到节点的链接来创建更复杂的查询。如果这个示例数据集有更多组,我们可以查询我们认识的人加入但尚未加入的组。否则,我们可以查询与用户在同一组但用户不认识的人。图数据库中的查询还可以跟随大量链接来回答复杂问题,例如“纽约哪些提供汉堡并拥有三星或更高评价的餐厅,我的朋友喜欢过?”

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

图数据库作为应用程序的主要数据存储使用的情况非常罕见。大多数图存储的使用中,每个节点都充当它们主数据库中数据的一部分的表示,通过存储其唯一标识符和一些其他识别信息来实现。

最受欢迎的图数据库是 Neo4jInfoGrid

RDBMS 与 NoSQL 的比较

NoSQL 是一个工具,就像任何工具一样,有一些特定的用例它做得很好,而有些用例其他工具可能更适合。没有人会用螺丝刀敲钉子;这是可能的,但用锤子会让工作更容易。NoSQL 数据库的一个大问题是,当 RDBMS 也能很好地解决问题,甚至更好时,人们仍然采用它们。

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

RDBMS 数据库的优势

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

数据完整性

完整性也是 RDBMS 最大的卖点之一。RDBMS 有几种方法来确保输入数据库的数据不仅正确,而且数据丢失实际上是不存在的。这些方法结合起来形成了所谓的ACID(代表原子性一致性隔离性持久性)。ACID 是一组规则,用于保证事务的安全处理。

原子性原则要求每个事务要么全部完成,要么全部不完成。如果事务的一部分失败,整个事务就会失败。这就像以下来自Python 之禅的引言:

“错误绝不应该默默无闻。除非明确地被压制。”

如果更改或输入的数据存在问题,事务不应该继续运行,因为后续的操作很可能需要前面的操作成功。

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

隔离性原则要求,如果数据库为了加速写入而并发运行事务,那么事务的结果应该与它们串行运行时相同。这主要是一条针对数据库程序员的规则,并不是网络开发者需要担心的事情。

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

速度和规模

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

工具

当评估一种编程语言时,支持或反对采用它的最强论点是其社区的大小和活跃度。一个更大、更活跃的社区意味着如果你遇到困难时会有更多帮助,以及更多开源工具可供你在项目中使用。

数据库也是如此。例如 MySQL 和 PostgreSQL 这样的 RDBMSes,为商业环境中使用的几乎每种语言都提供了官方库,为其他所有内容提供了非官方库。例如 Excel 这样的工具可以轻松地从这些数据库中下载最新数据,并允许用户将其视为任何其他数据集。每个数据库都有几个免费的桌面 GUI,其中一些由数据库的赞助公司官方支持。

诺斯克数据库的优势

许多人使用诺斯克数据库的主要原因是其速度优势,这种优势超过了传统数据库。开箱即用的许多诺斯克数据库可以超越 RDBMSes。然而,经过良好调整和适当扩展的带有读从机的 SQL 数据库可以超越诺斯克数据库。许多诺斯克数据库,尤其是文档存储,为了可用性牺牲了一致性。这意味着它们可以处理许多并发读取和写入,但这些写入可能相互冲突。然而,这并不简单,正如我们将在 CAP 定理中看到的那样。

诺斯克数据库吸引人们的第二个特点是它处理未格式化数据的能力。将数据存储在 XML 或 JSON 中允许每个文档具有任意结构。存储用户设计数据的应用程序从采用诺斯克数据库中受益匪浅。例如,一款允许玩家将自定义关卡提交到某个中央存储库的视频游戏现在可以将数据存储在可查询的格式中,而不是二进制 blob 中。

诺斯克数据库吸引人们的第三个特点是创建一个协同工作的数据库集群非常容易。没有JOIN操作符或仅通过键访问值,与关系型数据库管理系统(RDBMSes)相比,将数据分散到服务器上变得相当简单。这是因为JOIN操作符需要扫描整个表,即使它分布在许多不同的服务器上。当文档或键可以通过像其唯一标识符的起始字符这样简单的算法分配给服务器时,JOIN操作符会变得更慢——例如,以字母 A–H 开头的所有内容都发送到服务器 1,I–P 发送到服务器 2,Q–Z 发送到服务器 3。这使得查找连接客户端的数据位置非常快。

接下来,我们将简要解释 CAP 定理,以便您了解数据库分布式系统背后的基本问题。

CAP 定理

CAP定理代表一致性可用性分区容错性,并指出分布式系统无法保证所有三者,因此必须做出权衡。

以下列表显示了在分布式系统中这些保证的确切含义:

  • 一致性:保证集群中的每个节点返回最新的写入并保持线性一致性

  • 可用性:每个非故障节点都能以非错误响应响应请求

  • 分区容错性:系统在网络中断/延迟的情况下仍能继续运行

该定理表明,在出现网络分区的情况下,分布式系统必须在一致性和可用性之间做出选择,因此,在网络分区的情况下,系统必须分为两大类,CP 和 AP。

对这样一个分布式系统的简单可视化将是两个实例在各自的数据中心并发地为许多客户端提供服务。一个客户端发送:将键值a:0写入server1。然后server1a:0发送给server2server2将确认信息发送回server1server1再将确认信息发送回客户端。这在上面的图中展示:

图片

假设发生网络分区,这阻止了server1server2通信。同时,client1请求server1a:0改为a:1。如果系统追求一致性,那么它将拒绝这个事务,因为它不能将写入发送到server2,而server2将拒绝任何事务,因为它可以提供脏读,而我们追求的是一致性。这种关系在上面的图中展示:

图片

如果我们想要追求可用性,我们必须放宽一致性。在今天的数据库 RDBMS 或 NoSQL 中,系统不是 100%的 CP 或 AP,但它们可以被配置为在一定程度上更加或更少地放宽其一致性和可用性。

虽然不是 100%正确,但 MongoDB 旨在实现一致性和分区容错性。在集群架构中,MongoDB 使用单主设置,这意味着单个节点可以接受写入。通过在大多数其他节点与当前主节点失去联系时具有切换能力,它避免了单点故障SPOF)。这通过以下原因降低了一致性来提高可用性:

  • 如果你使用单个节点,那么在 MongoDB 中对同一系统的读写将使其成为一个非常一致的系统,但如果你使用异步复制的多个实例进行读取,那么整个系统最终将变得一致

  • 当旧的主节点恢复后,它将以从节点的身份重新加入集群,并且它可能有的所有脏写操作都将被回滚

何时使用哪种数据库

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

其次,人们认为你必须为你的应用程序只使用一个数据存储。许多应用程序可以从使用多个数据存储中受益。以 Facebook 复制品为例。它可以使用 MySQL 来存储用户数据,Redis 来存储会话数据,文档存储来存储人们相互分享的测验和调查数据,以及图数据库来实现查找朋友的功能。

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

如果应用程序功能需要存储在指定时间后自动删除的数据,或者如果数据不需要被搜索,那么你应该使用键值存储。

如果应用程序功能涉及在两个或多个数据集之间找到或描述复杂的关系,那么你应该使用图存储。

如果应用程序功能需要保证写入安全性,或者如果每个条目都需要符合指定的模式,或者需要在数据库中使用 JOIN 操作符比较不同的数据集,或者对输入的数据有约束,那么你应该使用 RDBMS。

MongoDB 在 Flask 中

MongoDB 是最受欢迎的 NoSQL 数据库。MongoDB 也是 Flask 和 Python 的一般情况下支持最好的 NoSQL 数据库。因此,我们的示例将专注于 MongoDB。

MongoDB 是一种文档存储的 NoSQL 数据库。文档存储在集合中,允许将相似的文档分组,但存储在集合中的文档之间没有必要有相似性。文档由一个名为 BSON(即 Binary JSON)的 JSON 扩展定义。BSON 允许将 JSON 以二进制格式而不是字符串格式存储,从而节省大量空间。BSON 还区分了几种不同的存储数字的方式,例如 32 位整数和双精度浮点数。

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

安装 MongoDB

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

要运行 MongoDB,请进入 Bash 并运行以下命令:

$ mongod

这将使服务器在窗口打开期间一直运行。

使用 Docker,您可以轻松启动 MongoDB 服务器,而无需在您的计算机上安装任何其他东西。

要在 Docker 上启动 MongoDB 服务器,请输入以下命令:

$docker run -d -p 27017:27017 mongo:3.2.20-jessie
$docker container list CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
4c6706af399b mongo:3.2.20-jessie "docker-entrypoint.s…" About a minute ago Up About a minute 0.0.0.0:27017->27017/tcp silly_ardinghelli

设置 MongoEngine

如果您正在遵循本书提供的示例代码(您可以在github.com/PacktPublishing/Hands-On-Web-Development-with-Flask找到),那么您需要做的就是创建一个新的 Python 虚拟环境并安装所有必要的依赖项。您会注意到提供的init.shrequirements.txt。在init.sh中,我们包含了所有必要的命令来帮助我们设置,如下面的代码所示:

if [ ! -d "venv" ]; then
    virtualenv venv
fi
source venv/bin/activate
pip install -r requirements.txt

当然,我们的requirements.txt文件包含了以下必要的包:

Flask
Flask-MongoEngine

__init__.py文件中,将创建一个mongo对象,它代表我们的数据库,如下面的代码所示:

from flask_mongoengine import MongoEngine

mongo = MongoEngine

def create_app(object_name):
...
    mongo.init_app(app)
...

在我们的应用程序运行之前,config.py中的DevConfig对象需要设置mongo连接的参数:

MONGODB_SETTINGS = { 
  'db': 'local', 
  'host': 'localhost', 
  'port': 27017 
} 

这些是全新 MongoDB 安装的默认设置。

定义文档

MongoEngine 是一个基于 Python 对象系统的 ORM,专门为 MongoDB 设计。不幸的是,没有支持所有 NoSQL 驱动器的 SQLAlchemy 风格的包装器。在 RDBMS 中,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 将返回引用的文档。

  • 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,那么设置的任何额外字段都将被视为DynamicField并与文档一起保存,如下所示:

class Post(mongo.DynamicDocument): 
  title = mongo.StringField(required=True, unique=True) 
  text = mongo.StringField() 
  ... 

为了展示极端情况(不推荐这样做),以下类是完全有效的;它没有必需的字段,允许设置任何字段:

class Post(mongo.DynamicDocument): 
  pass 

最后一种文档类型是EmbeddedDocument。一个EmbeddedDocument简单地说是一个传递给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字典的集合键,如下所示:

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变量使用排序键来设置集合的默认排序,如下面的代码所示。当以-开头时,它告诉 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 关键字。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: 值不在列表中

  • mod: 值 % a == b——a 和 b 作为(a, b)传递

  • all: 提供的值列表中的每个项目都在字段中

  • size: 列表的大小

  • exists: 字段存在值

MongoEngine 还提供了以下运算符来测试字符串值:

  • exact: 字符串等于值

  • iexact: 字符串等于值(不区分大小写)

  • contains: 字符串包含值

  • icontains: 字符串包含值(不区分大小写)

  • startswith: 字符串以值开头

  • istartswith: 字符串以值开头(不区分大小写)

  • endswith: 字符串以值结尾

  • iendswith: 字符串以值结尾(不区分大小写)Update

这些运算符可以组合起来创建与前面章节中创建的相同强大的查询。例如,要查找所有在 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

要更新对象,请在查询结果上调用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 值添加到名为 tagsListField 中,对于所有带有 MongoEngine 标签的 Post 文档,如下所示:

>>> 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

要删除一个文档实例,调用其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,并使用user字段进行过滤。这与 SQLAlchemy 在幕后所做的是同一件事,但我们正在手动执行,如下所示:

>>> user = User.objects.first()
>>> Post.objects(user__id=user.id)

创建一对一关系的第二种方式是使用EmbeddedDocumentFieldEmbeddedDocument,如下所示:

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!'>]

注意,没有在comment变量上调用save方法。这是因为评论文档不是一个真正的文档;它只是DictField的一个抽象。此外,请注意,文档的大小只能达到 16 MB,因此请小心每个文档上的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()

对于每个用户对象的角色列表,我们使用ReferenceField(Role)ListField来引用,如下面代码中高亮显示的文本所示:

...
class Role(mongo.Document):
    name = mongo.StringField(max_length=64, required=True, unique=True)
    description = mongo.StringField()
...

class User(mongo.Document):
    username = mongo.StringField(required=True)
    password = mongo.StringField()
    roles = mongo.ListField(mongo.ReferenceField(Role))
...

利用 NoSQL 的力量

为了展示 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子类视为帖子。我们的博客应用将有四种帖子类型:一篇普通博客帖子、一篇图片帖子、一篇视频帖子和一篇引用帖子。这些在以下代码中展示:

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字段。quoteblog帖子内容都将共享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/controllers.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) 

渲染我们的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>

所需的只是将此替换为以下内容:

<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 数据库。我们讨论了 CAP 定理及其对现代数据库系统的影响。

以我们的应用程序模型为基础,通过展示如何简单设置复杂的关系和继承,展示了 MongoDB 和 MongoEngine 的强大功能。

在下一章中,我们的博客应用将通过一个为其他希望使用我们的网站构建自己服务的程序员设计的功能进行扩展——即 RESTful 端点。

第八章:构建 RESTful API

表征状态转移REST)是一种用于实现 Web 服务的架构风格。它由 Roy Fielding 在 2000 年的博士论文中定义。REST 旨在实现系统之间统一和预定义操作的标准。这些系统可以是客户端浏览器、移动应用程序、运行并行工作进程的服务器——你名之。通过使用 HTTP 方法,REST 是平台和编程语言无关的,并且解耦客户端和服务器,以便更容易地进行开发。这通常用于需要从服务器拉取或更新用户信息的 Web单页应用程序SPAs)。REST 还用于为外部开发者提供一个通用的接口来访问用户数据。例如,Facebook 和 Twitter 在其应用程序程序接口(或 API)中使用 REST。

你可以在www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm查看 Roy Fielding 关于 REST 的原始论文。

在本章中,你将学习以下主题:

  • HTTP 协议:请求、响应、方法、头部和 URI 格式

  • 如何构建 REST 服务

  • 如何使用 JWT 保护 REST 服务

什么是 REST?

在深入了解 REST 之前,由于它是一种系统间通信的风格,让我们首先快速了解一下它所使用的实际协议,这本书的整个基础。

HTTP

超文本传输协议HTTP)是一种属于第 7 层(应用层)的请求-响应协议。这一层与应用程序本身进行交互。属于第 7 层的其他协议还包括简单邮件传输协议SMTP)、网络文件系统NFS)、文件传输协议FTP)等。

HTTP 被设计为供客户端(用户代理)从服务器请求资源使用。这些资源可以是 HTML 文件或任何其他内容,如 JSON、XML 或媒体文件。这些资源请求由网络使用统一资源定位符URLs)标识。

URL 是一种特定的 URI 类型,由以下元素组成:

<scheme>://<authority>/<path>/<query><fragment>

之前的 <authority> 部分:

<userinfo>@<host>:<port>

以下是我们应用程序的一个示例 URL:

http://someserver.com:5000/blog/user/user1?title=sometitle#1

让我们分别列出这个的元素:

方案 HTTP
authority.host someserver.com
authority.port 5000
path blog/user/user1
query title=sometitle
fragment 1

接下来,我们将快速查看一个用户代理向服务器发送的 HTTP 请求消息。这是一个来自 Mozilla 浏览器的 GET 请求,如下面的代码中高亮显示的文本所示:

GET /blog/user/user1 HTTP/1.1
Host: someserver.com
Accept: image/gif, image/jpeg, */*
Accept-Language: en-us
Accept-Encoding: gzip, deflate
User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)
Cookie: cookie1=somevalue; cookie2=othervalue; session:dsofksdfok439349i3sdkfoskfoskfosdkfo
(blank line)

因此,一个 HTTP 请求由以下部分组成:

  • 请求行:进一步由<Request method> <Request URI> <HTTP version>组成

  • 请求头: 包含客户端接受的信息、用户代理、cookies 以及甚至基本的认证凭证

  • 一个空白行: 将头部与主体部分分开

  • 请求体: 可选

接受的 HTTP 请求方法有GETHEADPOSTPUTDELETECONNECTOPTIONSTRACEPATCH。REST 规范将使用它们来识别应用程序类型操作。

HTTP 响应请求看起来如下:

HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 1330
Server: Werkzeug/0.14.1 Python/2.7.10
Date: Thu, 19 Jul 2018 11:14:16 GMT
{ "author": "user1" ... }

它由以下元素组成:

  • 状态行: 响应的状态

  • 响应头: 包含有关内容类型、长度、服务器类型(在我们的例子中,是 Flask 的开发服务器本身)、日期以及是否可以发送 set-cookie 操作的信息

  • 一个空白行

  • 响应体: 在我们的例子中,这是一个 JSON 响应,可能是 REST 服务响应

状态响应代码对 REST 也非常重要。它们分为以下类别:

  • 信息性: 1XX

  • 成功: 2XX

  • 重定向: 3XX

  • 客户端错误: 4XX

  • 服务器错误: 5XX

有关状态响应代码的更多详细信息,请参阅 RFC2616 在www.w3.org/Protocols/rfc2616/rfc2616-sec10.html

REST 定义和最佳实践

在深入了解 REST 之前,让我们看看一个例子。有一个客户端——在这种情况下,是一个网页浏览器——和一个服务器,客户端通过 HTTP 向服务器发送请求以获取一些模型,如下所示:

图片

服务器将随后响应一个包含所有模型的文档,如下所示:

图片

客户端可以通过以下PUT HTTP 请求修改服务器上的数据:

图片

然后,服务器将响应它已修改数据。这是一个非常简化的例子,但它将作为 REST 定义的背景。

与严格的规范不同,REST 定义了一套对通信的约束,以定义一种可以以多种方式实施的方法。这些约束源于与其他通信协议(如远程过程调用RPC或简单对象访问协议SOAP)多年来的试验和错误。这些协议因为其严格性、冗长性以及难以用于创建 API 的事实而被废弃。这些问题被识别出来,REST 的约束被创建出来,以防止这些问题再次发生。

REST 提供了以下指导性约束:

  • 客户端和服务器之间的关注点分离: 只要 API 不改变,客户端和服务器应该能够独立地演变或改变。

  • 无状态:处理请求所需的所有必要信息都存储在请求本身或客户端中。服务器无状态的例子是 Flask 中的session对象。session对象不在服务器上存储其信息,而是在客户端的 cookie 中存储。cookie 与每个请求一起发送,以便服务器解析并确定是否在 cookie 中存储了请求资源的必要数据,而不是服务器为每个用户存储会话信息。 |

  • 统一接口:这个约束有许多不同的部分,如下所述: |

    • 接口是基于资源构建的,在我们的案例中是模型。 |

    • 服务器发送的数据不是服务器中的实际数据,而是一种表示。例如,每个请求都发送数据的 JSON 抽象,而不是实际的数据库。 |

    • 服务器发送的数据足以允许客户端修改服务器上的数据。在先前的示例中,传递给客户端的 ID 扮演了这一角色。 |

    • API 提供的每个资源都必须以相同的方式进行表示和访问。例如,一个资源不能以 XML 表示,而另一个以 JSON 表示。 |

  • 分层系统:负载均衡器、代理、缓存以及其他服务器和服务可以在客户端和服务器之间执行,只要最终结果与它们不存在时相同。这提高了性能、可扩展性和可用性。 |

  • 缓存性:客户端可以缓存响应,因此服务器必须定义响应是否可缓存。这可以提高性能。 |

当一个系统遵守所有这些约束时,它被认为是 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请求的响应可能如下所示: |

HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 1330
Server: Werkzeug/0.14.1 Python/2.7.10
Date: Thu, 19 Jul 2018 11:14:16 GMT

{ "id": 100, "title": "Some blog post title" }

在 RESTful API 中,返回正确的 HTTP 状态代码与响应数据一起,也非常重要,以便通知客户端服务器上实际发生了什么,而无需客户端解析返回的消息。以下是 RESTful API 中使用的 HTTP 代码列表及其含义: |

HTTP 代码 名称 含义
200 OK HTTP 的默认代码。请求成功,并返回了数据。
201 已创建 请求成功,并在服务器上创建了一个新资源。
204 无内容 请求成功,但响应没有返回内容。
400 错误请求 请求被拒绝,因为客户端存在某些感知到的错误——要么是请求格式不正确,要么是缺少所需的数据。
401 未授权 请求被拒绝,因为客户端未认证,在再次请求此资源之前应该进行认证。
403 禁止 请求被拒绝,因为客户端没有权限访问此资源。这与401代码不同,401代码假设用户未认证。403代码表示无论认证与否,资源都不可访问。
404 未找到 请求的资源不存在。
405 方法不允许 请求被拒绝,因为 HTTP 方法对于 URL 不可用。
500 内部服务器错误 当 Web 服务器遇到意外条件,阻止其满足客户端请求时,会以这个状态码响应。
501 未实现 当不支持处理请求所需的功能时显示此错误。当服务器不识别请求方法时,这是适当的响应。
502 网关错误 当服务器作为网关或代理并从上游服务器收到无效响应时。
503 服务不可用 目前无法处理请求,因为服务器暂时过载或维护。
504 网关超时 未从上游服务器及时收到响应。

设置 RESTful Flask API

在我们的应用程序中,我们将创建一个 RESTful 接口来访问我们数据库中的博客文章数据。数据表示将作为 JSON 发送。数据将通过使用前面表格中的通用形式检索和修改,但 URI 将是/api/posts

如果您还没有下载并访问本章提供的示例代码,也没有查看 API 的 Flask URL 映射,那么在应用程序的根目录中可以找到一个简单的方法,如下面的代码所示:

$ # Initialise the virtual environment and database with test data
$ ./init.sh
$ # Activate the python virtual environment
$ source venv/bin/activate
$ export FLASK_APP=main.py
$ echo app.url_map | flask shell | grep api ..
 <Rule '/auth/api' (POST, OPTIONS) -> auth.api>,
 <Rule '/api/post' (HEAD, GET, PUT, POST, OPTIONS, DELETE) -> postapi>,
 <Rule '/api/post/<post_id>' (HEAD, GET, PUT, POST, OPTIONS, DELETE) 
 -> postapi>,

我们将为 API 实现一个认证端点以及创建博客文章 CRUD API 所需的端点。

我们可以使用标准的 Flask 视图来创建 API,但 Flask 扩展Flask Restful可以使任务变得更加简单,并将帮助我们遵守完整的 REST 兼容性(RESTful)。

要将此新依赖项包含到我们的应用程序中,您可以在requirements.txt文件中找到以下内容:

... 
Flask-Restful ...

我们将创建一个新的模块用于 API。应用程序结构如下面的代码所示:

./
  main.py
  config.py
  ...
  webapp/
    blog/
    main/
    auth/
    api/
 __init__.py      blog/
 controlers.py
          fields.py
 parsers.py
    templates/
    static/

再次强调,我们的想法是构建一个易于扩展的应用程序。这次,对于每个添加的应用程序模块——比如博客、共享照片,等等——我们将在api/模块内部创建一个新的模块,其中定义了所有 API 逻辑。另一种方法可能是将 REST API 包含在每个模块中。

就像所有其他模块一样,api/__init__.py中有一个create_module函数,它处理主工厂函数create_app的初始化。PostApi类也将使用Api对象的add_resource()方法定义其路由。

这可以在提供的代码文件api/__init__.py中看到,如下所示:

from flask_restful import Api
from .blog.controllers import PostApi

rest_api = Api()

def create_module(app, **kwargs):
    rest_api.add_resource(
        PostApi,
        '/api/post',
        '/api/post/<int:post_id>',
    )
    rest_api.init_app(app)

这也可以在__init__.py文件中的create_app函数中看到,如下所示:

...
def create_app(object_name):
...
    from api import create_module as api_create_module
    ...
    api_create_module(app)

    return app

我们Post API 的控制逻辑和视图存储在名为api/blog的新文件夹中的controllers.py文件中。在controllers.py内部,我们将创建 API 本身,如下所示:

from flask_restful import Resource 

class PostApi(Resource): 
  ...

在 Flask Restful 中,每个 REST 资源都定义为继承自Resource对象的一个类。这与第四章中展示的MethodView对象类似,使用蓝图创建控制器,任何继承自Resource对象的类都使用以 HTTP 方法命名的函数定义其逻辑。例如,当GET HTTP 方法击中PostApi类时,将执行get方法。

JWT 认证

为了解决我们的认证问题,可以使用Flask-Login并检查登录的 cookie 数据。然而,这要求希望使用我们 API 的开发者通过网页界面登录他们的程序。我们也可以让开发者将登录数据与每个请求一起发送,但良好的设计实践是在绝对必要时才发送敏感信息。相反,我们的 API 将提供一个auth/api端点,允许他们发送登录凭证并获取一个访问令牌。

对于认证机制,我们将使用JSON Web TokenJWT)在用户登录时为我们的 API 消费者创建访问令牌。JWT 令牌声明了哪个用户已登录,从而节省服务器对数据库进行认证的另一个调用。此令牌内部编码了过期日期,不允许在过期后使用该令牌。这意味着即使令牌被恶意用户窃取,它也只有在客户端需要重新认证之前的一段时间内才有用。一如既往,请确保使用 HTTPS 来加密所有客户端-服务器连接。

为了利用这个特性,我们将使用另一个 Flask 扩展——Flask-JWT-extended。你将在requirements.txt文件中找到其依赖项的声明,如下所示:

...
flask-jwt-extended
...

扩展的初始化将在auth模块中进行。

查看以下auth/__init__.py文件:

from flask_jwt_extended import JWTManager
...
jwt = JWTManager()
...
def create_module(app, **kwargs):
    ...
    jwt.init_app(app)
    ...

接下来,我们使用以下辅助函数在同一个文件中认证用户:


def authenticate(username, password):
    from .models import User
    user = User.query.filter_by(username=username).first()
    if not user:
        return None
    # Do the passwords match
    if not user.check_password(password):
        return None
    return user

登录端点的定义可以在auth/controllers.py中找到,如下面的代码所示:

@auth_blueprint.route('/api', methods=['POST'])
def api():
    if not request.is_json:
        return jsonify({"msg": "Missing JSON in request"}), 400

    username = request.json.get('username', None)
 password = request.json.get('password', None)
    if not username:
        return jsonify({"msg": "Missing username parameter"}), 400
    if not password:
        return jsonify({"msg": "Missing password parameter"}), 400
 user = authenticate(username, password)
    if not user:
        return jsonify({"msg": "Bad username or password"}), 401
    # Identity can be any data that is json serializable
    access_token = create_access_token(identity=user.id)
    return jsonify(access_token=access_token), 200

首先,我们验证请求是否包含 JSON 正文。为此,我们使用 Flask 中的request.is_json函数。接下来,我们使用request.json.get从 JSON 正文中提取用户名和密码。然后我们使用之前的帮助函数authenticate检查用户的凭证。最后,我们使用用户名作为我们的身份返回 JWT 访问令牌。

我们 API 的用户必须将从这个资源收到的令牌传递给任何需要用户凭证的方法。为了测试此代码,将使用一个名为curl的工具。Curl 是 Bash 中包含的一个命令行工具,它允许创建和操作 HTTP 请求。要测试它,使用curl实用程序首先登录,如下面的代码所示:

$ curl -H "Content-Type: application/json" -d '{"username":"user1","password":"password"}' http://localhost:5000/auth/api {
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIyOGZjMDNkOC0xY2MyLTQwZDQtODJlMS0xMGQ0Mjc2YTk1ZjciLCJleHAiOjE1MzIwMTg4NDMsImZyZXNoIjpmYWxzZSwiaWF0IjoxNTMyMDE3OTQzLCJ0eXBlIjoiYWNjZXNzIiwibmJmIjoxNTMyMDE3OTQzLCJpZGVudGl0eSI6InVzZXIxIn0.Cs-ANWq0I2M2XMrZpQof-_cX0gsKE7U4UG1t1rB0UoY"
}

然后,我们使用-H标志发送请求头,表明内容正文是 JSON,并使用-d标志发送请求正文数据。接下来,我们可以使用令牌来访问受 API 保护的资源,如下所示:

$ export ACCESS="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIyOGZjMDNkOC0xY2MyLTQwZDQtODJlMS0xMGQ0Mjc2YTk1ZjciLCJleHAiOjE1MzIwMTg4NDMsImZyZXNoIjpmYWxzZSwiaWF0IjoxNTMyMDE3OTQzLCJ0eXBlIjoiYWNjZXNzIiwibmJmIjoxNTMyMDE3OTQzLCJpZGVudGl0eSI6InVzZXIxIn0.Cs-ANWq0I2M2XMrZpQof-_cX0gsKE7U4UG1t1rB0UoY"
$ curl -H "Authorization: Bearer $ACCESS" http://localhost:5000/api/post

注意访问令牌是如何在请求头中按照Authorization: Bearer <TOKEN>约定发送的。如果我们尝试在没有令牌的情况下访问相同的资源,我们会得到以下结果:

$ curl -v http://localhost:5000/api/post
...
< HTTP/1.0 401 UNAUTHORIZED
...
{
  "msg": "Missing Authorization Header"
}

如预期,我们得到了 HTTP 401状态码。为了保护 API 端点,我们只需使用flask-jwt-extended装饰器@jwt_required,并且为了获取用户名,我们使用get_jwt_identity()函数。

flask-jwt-extended装饰器提供了一些额外的功能,例如令牌过期、刷新令牌端点的功能以及许多配置选项。您可以在flask-jwt-extended.readthedocs.io/en/latest/了解更多信息。

GET 请求

对于我们的某些GETPUTDELETE请求,我们的 API 将需要修改的帖子的 ID。

发送到客户端的数据必须是Post对象的表示,那么我们的Post对象将如何转换呢?Flask Restful 提供了一种通过fields对象和marshal_with函数装饰器将任何对象转换为 JSON 的方法。

输出格式化

输出格式是通过创建一个表示基本类型的field对象的字典来定义的。字段的键定义了字段将尝试转换的属性。通过将字典传递给marshal_with装饰器,get方法尝试返回的任何对象都将首先使用该字典进行转换。这也适用于对象列表。让我们看看实现此 API 端点的一种简单方法。以下示例代码考虑了分页,但我们将在稍后向您展示它是如何工作的。

看看以下api/blog/controllers.py文件:

import datetime

from flask import abort
from flask_restful import Resource, fields, marshal_with
from flask_jwt_extended import jwt_required, get_jwt_identity
from webapp.blog.models import db, Post, Tag
from webapp.auth.models import User
...

post_fields = {
 'id': fields.Integer(),
    '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')
}

class PostApi(Resource):
    @marshal_with(post_fields)
    @jwt_required
    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 格式显示。注意字段声明中的HTMLField。问题是 API 不应该从帖子创建表单中的 WYSIWYG 编辑器返回 HTML。如前所述,服务器不应该关心 UI,HTML 纯粹是输出规范。为了解决这个问题,我们需要一个自定义字段对象,它将从字符串中删除 HTML。在api/blog/文件夹中的新文件fields.py中,我们有以下内容:

try:
 # Try python3
    from html.parser import HTMLParser
except Exception as e:
 # Nop python2
    from HTMLParser import HTMLParser

from flask_restful import fields

class HTMLStripper(HTMLParser):
    fed = list()

    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))

异常块是为了考虑 Python2 和 Python3 的兼容性,因为 Python3 中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对象转换为字符串。format关键字参数指定字符串应该是ISO8601日期还是RFC822日期。

  • fields.Float:将值转换为浮点数的字符串表示。

  • fields.Integer:将值转换为整数的字符串表示。

  • fields.Nested:允许通过另一个字段对象字典表示嵌套对象。

  • fields.List:与 MongoEngine API 非常相似,这个字段接受另一个字段类型作为参数,并尝试将值列表转换为字段类型的 JSON 列表。

  • fields.Boolean:将值转换为boolean参数的字符串表示。

返回的数据中增加了两个字段:作者和标签。评论将被省略,因为它们应该包含在其自己的资源下。

author字段使用field类的属性关键字参数。这允许表示对象的任何属性,而不仅仅是基本级别的属性。因为标签的多对多关系返回一个对象列表,所以不能使用相同的解决方案来处理标签。现在可以使用NestedField类型在ListField内部和另一个字段字典返回一个标签字典列表。这给 API 的最终用户带来了额外的便利,使他们能够像有标签 API 一样轻松地进行查询。

请求参数

当向资源的根发送 GET 请求时,我们的 API 当前发送数据库中的所有 Post 对象。如果对象数量较少或使用 API 的人数较少,这是可以接受的。然而,如果任一数量增加,API 将对数据库造成大量压力。与网络界面类似,API 也应该分页。

为了实现这一点,我们的 API 需要接受一个名为 pageGET 查询字符串参数,该参数指定要加载的页面。Flask Restful 提供了一种方法来获取请求数据并解析它。如果缺少必要的参数,或者类型不匹配,Flask Restful 将自动创建一个 JSON 错误消息。在 api/blog/ 文件夹中的新文件 parsers.py 中,你可以找到以下代码:

...
from flask_restful import reqparse 
...
post_get_parser = reqparse.RequestParser() 
post_get_parser.add_argument( 
  'page', 
  type=int, 
  location=['args', 'headers'], 
  required=False,
) 

当请求没有 post ID 键时,以下代码是我们应该在 PostApi 类中拥有的代码:

from .parsers import post_get_parser 
...
class PostApi(Resource): 
  @marshal_with(post_fields)
  @jwt_required 
  def get(self, post_id=None):
    if post_id: 
      .. 
      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, current_app.config.get('POSTS_PER_PAGE', 10)) 
      ...
      return posts.items 

在前面的示例中,RequestParser 在查询字符串或请求头中查找 page 变量,并返回该页面的 Post 对象。同样,我们使用与网络视图页面版本相同的值来配置页面大小。我们使用 current_app Flask 代理来获取我们的配置中的任何值。

使用 RequestParser 创建解析器对象后,可以使用 add_argument 方法添加参数。add_argument 的第一个参数是要解析的参数的键,但 add_argument 还接受许多关键字参数,如下所示列表所示:

  • action: 解析器在成功解析值后对值执行的操作。两个可用的选项是 storeappendstore 选项将解析值添加到返回的字典中。append 选项将解析值添加到字典中列表的末尾。

  • case_sensitive: 这是一个布尔参数,用于允许或禁止键不区分大小写。

  • choices: 这类似于 MongoEngine,是参数允许值的列表。

  • default: 如果请求中缺少参数,则生成的值。

  • dest: 这是添加解析值到返回数据中的键。

  • help: 这是当验证失败时返回给用户的消息。

  • ignore: 这是一个布尔参数,用于允许或禁止类型转换失败。

  • location: 这表示查找数据的位置。可用的位置如下:

    • args 用于在 GET 查询字符串中查找

    • headers 用于在 HTTP 请求头中查找

    • form 用于在 HTTP POST 数据中查找

    • cookies 用于在 HTTP 钩子中查找

    • json 用于查找任何发送的 JSON

    • files 用于在 POST 文件数据中查找

  • required: 这是一个布尔参数,用于确定该参数是否为可选的。

  • store_missing: 这是一个布尔参数,用于确定如果参数不在请求中,是否应该存储默认值。

  • type: 这是将传递的值转换为的 Python 类型。

使用 Flask Restful 解析器,向 API 添加新参数非常简单。例如,让我们添加一个用户参数,允许我们搜索所有由用户创建的帖子。首先,在api/blog/parsers.py文件中,我们有以下内容:

post_get_parser = reqparse.RequestParser()
post_get_parser.add_argument('page', type=int, location=['args', 'headers'])
post_get_parser.add_argument('user', type=str, location=['args', 'headers'])

然后,在api/blog/controllers.py文件中,我们有以下内容:

class PostApi(Resource):
    @marshal_with(post_fields)
    @jwt_required
    def get(self, post_id=None):
        if post_id:
            ...
            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, current_app.config.get('POSTS_PER_PAGE', 10))
            else:
                posts = Post.query.order_by(
                    Post.publish_date.desc()
        ).paginate(page, current_app.config.get('POSTS_PER_PAGE', 10))
          return posts.items

当从Resource中调用 Flask 的abort函数时,Flask Restful 将自动创建一个错误消息,并带有状态码返回。

为了简化测试 API,我们使用curl,但你可以自由使用任何其他可用的工具与 HTTP API 交互。在从我们的认证端点请求访问令牌后,请求post并使用id=1,如下所示:

$ curl -H "Authorization: Bearer $ACCESS" "http://localhost:5000/api/post/1"

或者你可以这样请求所有帖子:

$ curl -H "Authorization: Bearer $ACCESS" "http://localhost:5000/api/post"

注意,响应只获取第一页,正如预期的那样。现在让我们请求第二页,如下所示:

$ curl -H "Authorization: Bearer $ACCESS" "http://localhost:5000/api/post?page=2"

最后,你可以这样请求特定用户的帖子:

$ curl -H "Authorization: Bearer $ACCESS" "http://localhost:5000/api/post?user=user1"

POST 请求

REST 中的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",
    location=('json', 'values')
)
post_post_parser.add_argument(
    'text',
    type=str,
    required=True,
    help="Body text is required",
    location=('json', 'values')
)
post_post_parser.add_argument(
    'tags',
    type=str,
    action='append',
    location=('json', 'values')
)

接下来,我们创建了一个名为add_tags_to_post的辅助函数,用于向帖子添加标签。如果标签不存在,它将它们添加到数据库中。我们将在POSTPUT请求中使用它——这里没有新内容,只是一个简单的 SQLAlchemy 辅助函数,帮助我们使代码更简洁。

接下来,PostApi类需要一个post方法来处理传入的请求。post方法将使用给定的标题和正文文本值。如果存在tags键,则将标签添加到帖子中,如果传递的标签不存在,则会创建新的标签,如下面的代码所示:

import datetime 
from .parsers import ( 
  post_get_parser, 
  post_post_parser 
) 
from webapp.models import db, User, Post, Tag 
class PostApi(Resource): 
  ... 
  @jwt_required
  def post(self, post_id=None):  
      args = post_post_parser.parse_args(strict=True) 
      new_post = Post(args['title'])
      new_post.user_id = get_jwt_identity()
      new_post.text = args['text'] 
      if args['tags']:
        add_tags_to_post(post, args['tags']) 
      db.session.add(new_post) 
      db.session.commit()
      return {'id': new_post.id}, 201

return语句中,如果返回了一个元组,第二个参数被视为状态码。还有一个第三个值,通过传递一个字典作为额外的头部值。此外,请注意我们使用的get_jwt_identity函数,用于从 JWT 令牌中获取用户 ID。这是在登录阶段设置的,我们使用用户 ID 来设置 JWT 身份。

要传递POST变量,使用d标志,如下所示:

$ curl -X POST -H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" -d '{"title":"Text Title", "text":"Some text"}' "http://localhost:5000/api/post" {
    "id": 310
}

应返回新创建帖子的 ID。如果你去浏览器,你应该能看到我们使用你用来生成认证令牌的用户创建的新帖子。

PUT 请求

如本章开头表格中所示,PUT请求用于更改现有资源的值。就像post方法一样,我们应该做的第一件事是在parsers.py中创建一个新的解析器,如下所示:

post_put_parser = reqparse.RequestParser()
post_put_parser.add_argument(
    'title',
    type=str,
    location=('json', 'values')
)
post_put_parser.add_argument(
    'text',
    type=str,
    location=('json', 'values')
)
post_put_parser.add_argument(
    'tags',
    type=str,
    action='append',
    location=('json', 'values')
)

put方法的逻辑与post方法非常相似。主要区别是每个更改都是可选的,任何不提供post_id的请求都会被拒绝,如下面的代码所示:

...
def add_tags_to_post(post, tags_list):
    for item in tags_list:
        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)
...

    @jwt_required
    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)
        if get_jwt_identity() != post.user_id:
            abort(403)
        if args['title']:
            post.title = args['title']
        if args['text']:
            post.text = args['text']
        if args['tags']:
            print("Tags %s" % args['tags'])
            add_tags_to_post(post, args['tags'])

        db.session.merge(post)
        db.session.commit()
        return {'id': post.id}, 201

还要注意的是,就像我们对待网页视图控制器一样,我们拒绝任何非博客文章创作者本人提出的更改博客文章的请求。

为了测试这个方法,curl 也可以使用 -X 标志创建 PUT 请求,如下所示:

$ curl -X PUT -H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \
 -d '{"title": "Modified From REST", "text": "this is from REST", "tags": ["tag1","tag2"]}' \
http://localhost:5000/api/post/5

删除请求

最后,在下面的代码中,我们有 DELETE 请求,这是四种支持的方法中最简单的一个。与 delete 方法的主要区别是它不返回任何内容,这是 DELETE 请求的接受标准:

@jwt_required
def delete(self, post_id=None):
    if post_id:
        abort(400)
    post = Post.query.get(post_id)
    if not post:
        abort(404)
    if get_jwt_identity() != post.user_id:
        abort(401)
    db.session.delete(post)
    db.session.commit()
    return "", 204

再次提醒,我们可以使用以下方法进行测试:

$ curl -X DELETE -H "Authorization: Bearer $ACCESS"
http://localhost:5000/api/post/102

如果一切删除成功,你应该收到 204 状态码,并且不应该有任何内容显示。

在我们完全离开 REST 之前,有一个最后的挑战要测试你对 Flask Restful 的理解。尝试创建一个 API,不仅可以从 http://localhost:5000/api/comments 进行修改,而且允许开发者通过使用 URL 格式 http://localhost:5000/api/post/<int:post_id>/comments 仅修改特定文章的评论。

摘要

我们的 Post API 现在已经是一个完整的特性。如果开发者想的话,他们可以使用这个 API 创建桌面或移动应用程序,而无需使用 HTML 抓取,这是一个非常漫长且繁琐的过程。给那些希望将你的网站作为平台使用的开发者提供这样的能力,将增加你网站的知名度,因为他们实际上会通过他们的应用程序或网站给你提供免费广告。

在下一章中,我们将使用流行的程序 Celery 来异步运行程序和任务,与我们的应用程序一起使用。

第九章:使用 Celery 创建异步任务

在创建 Web 应用时,保持处理请求所需的时间低于或大约 50 毫秒至关重要。在每秒请求率中等到高的 Web 应用或 Web 服务中,响应时间变得更加重要。想象一下请求就像需要至少以与流速相同的速度处理的液体流,否则就会溢出。任何可以避免的服务器上的额外处理都应该避免。然而,在 Web 应用中,对需要超过几秒钟的操作的要求相当常见,尤其是在涉及复杂的数据库操作或图像处理时。

在构建一个能够水平扩展的应用时,应该能够将所有重型处理过程从 Web 服务器层解耦,并将它们耦合到一个可以独立扩展自己的工作层。

为了保护我们的用户体验和网站可靠性,将使用名为 Celery 的任务队列将这些操作从 Flask 进程中移出。

在本章中,我们将涵盖以下主题:

  • 使用 Docker 运行 RabbitMQ 和 Redis

  • Celery 和 Flask 集成

  • 学习识别应该在 Web 服务器外部运行的进程

  • 从简单的异步任务到复杂的流程创建和调用多种类型的任务

  • 将 Celery 作为带有 beats 的调度器使用

什么是 Celery?

Celery 是一个用 Python 编写的异步任务队列。Celery 通过 Python 的多进程库并发运行多个任务,这些任务是由用户定义的函数。Celery 从一个称为 broker 的消息队列接收消息,以启动任务,通常称为消息队列,如下面的图所示:

图片

消息队列 是一个专门设计用来在生产者进程和消费者进程之间发送数据的系统。生产者进程 是任何创建要发送到队列的消息的程序,而 消费者进程 是任何从队列中取出消息的程序。从生产者发送的消息存储在一个 先进先出FIFO)队列中,其中最早的项目首先被检索。消息存储直到消费者接收消息,之后消息被删除。消息队列提供实时消息,不依赖于轮询,这意味着持续检查进程的状态。当消息从生产者发送时,消费者正在监听它们与消息队列的连接以获取新消息;消费者不是不断联系队列。这种差异类似于 AJAXWebSockets 之间的差异,AJAX 需要持续与服务器保持联系,而 WebSockets 只是一个双向的持续通信流。

可以用传统的数据库替换消息队列。Celery 甚至内置了对 SQLAlchemy 的支持,以允许这样做。然而,使用数据库作为 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

要在我们的virtualenv上安装 Celery,我们需要将其添加到我们的requirements.txt文件中:

... 
Celery...

如往常一样,使用提供的init.sh脚本,或者使用这里解释的步骤在 Python 虚拟环境中创建和安装所有依赖项。

我们还需要一个 Flask 扩展来帮助处理 Celery 的初始化:

$ pip install Flask-Celery-Helper

Flask 文档指出,Flask 扩展对于 Celery 是不必要的。然而,当你的应用使用应用工厂组织时,使 Celery 服务器与 Flask 的应用上下文协同工作是很重要的。因此,我们将使用Flask-Celery-Helper来完成这项繁重的工作。

接下来,RabbitMQ 需要启动并运行。为了轻松完成此操作,我们将使用 Docker 容器。请确保您已安装并正确设置了 Docker;如果没有,请查看第一章,入门,以获取说明。首先,我们需要一个非常简单的 Dockerfile:

FROM rabbitmq:3-management

ENV RABBITMQ_ERLANG_COOKIE "SWQOKODSQALRPCLNMEQG"
ENV RABBITMQ_DEFAULT_USER "rabbitmq"
ENV RABBITMQ_DEFAULT_PASS "rabbitmq"
ENV RABBITMQ_DEFAULT_VHOST "/"

构建和运行带有管理界面的 RabbitMQ Docker 镜像只需要这些步骤。我们使用的是 Docker Hub 上的镜像,您可以在hub.docker.com/_/rabbitmq/下载。访问 Hub 页面以获取更多配置细节。

接下来,让我们构建我们的镜像,执行以下命令:

$ docker build -t blog-rmq .

-t 标志用于给我们的镜像添加一个友好的名称;在这种情况下,blog-rmq。然后使用以下命令在后台运行新创建的镜像:

$ docker run -d -p 15672:15672 -p 5672:5672 blog-rmq

-d 标志用于在后台(守护进程)运行容器。-p 标志用于容器和我们的主机/桌面之间的端口映射。

让我们检查它是否正常运行:

$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6eb2ab1da516 blog-rmq "docker-entrypoint.s…" 13 minutes ago Up 14 minutes 4369/tcp, 5671/tcp, 0.0.0.0:5672->5672/tcp, 15671/tcp, 25672/tcp, 0.0.0.0:15672->15672/tcp xenodochial_kepler

让我们来看看 RabbitMQ 的管理界面。在您的浏览器中,导航到 http://localhost:15672 并使用 Dockerfile 上配置的凭据登录。在这种情况下,我们的用户名是 rabbitmq,密码也是 rabbitmq

如果您需要更多信息,RabbitMQ 在 www.rabbitmq.com/download.html 维护了针对每个操作系统的详细安装和配置说明列表。

安装 RabbitMQ 后,打开一个终端窗口并运行以下命令:

$ rabbitmq-server

在 Celery 中创建任务

如前所述,Celery 任务只是执行某些操作的用户定义函数。但在编写任何任务之前,我们的 Celery 对象需要被创建。这是 Celery 服务器将导入以处理运行和调度所有任务的对象。

至少,Celery 需要一个配置变量来运行,那就是连接到消息代理。连接的定义方式与 SQLAlchemy 连接相同;也就是说,作为一个 URL。存储我们任务结果的后端也被定义为 URL,如下面的代码所示:

class DevConfig(Config): 
    DEBUG = True 
    SQLALCHEMY_DATABASE_URI = 'sqlite:///../database.db' 
    CELERY_BROKER_URL = "amqp://rabbitmq:rabbitmq@localhost//" 
    CELERY_RESULT_BACKEND = "amqp://rabbitmq:rabitmq@localhost//" 

__init__.py 文件中,将初始化来自 Flask-Celery-HelperCelery 类:

from flask_celery import Celery
... 
celery = Celery()
...
def create_app(object_name):
...
    celery.init_app(app)
...

因此,为了使我们的 Celery 进程能够与数据库和任何其他 Flask 扩展一起工作,它需要在我们的应用程序上下文中运行。为此,Celery 需要为每个进程创建我们应用程序的新实例。像大多数 Celery 应用程序一样,我们需要一个 Celery 工厂来创建应用程序实例并在其上注册我们的 Celery 实例。在一个名为 celery_runner.py 的新文件中,位于顶级目录——与 manage.py 所在的位置相同——我们有以下内容:

import os
from webapp import create_app
from celery import Celery

def make_celery(app):
    celery = Celery(
        app.import_name,
        broker=app.config['CELERY_BROKER_URL'],
        backend=app.config['CELERY_RESULT_BACKEND']
    )
    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('config.%sConfig' % env.capitalize())

celery = make_celery(flask_app)

make_celery函数将每个 Celery 任务的调用都包装在 Python 的with块中。这确保了每次调用任何 Flask 扩展都将像与我们的应用程序一起工作一样工作。同时,确保不要将 Flask 应用程序实例命名为app,因为 Celery 试图将任何名为appcelery的对象导入为 Celery 应用程序实例。所以将你的 Flask 对象命名为app会导致 Celery 试图将其用作 Celery 对象。

现在我们可以编写我们的第一个任务了。它将是一个简单的任务;一个只返回传递给它的任何字符串的任务。我们在博客模块目录中有一个新文件,名为tasks.py。在这个文件中,找到以下内容:

from .. import celery

@celery.task() 
def log(msg): 
    return msg

现在,最后一部分是要在一个新的终端窗口中运行 Celery 进程,这被称为工作进程。同样,这是将监听我们的消息代理以接收启动新任务的命令的过程:

$ celery worker -A celery_runner --loglevel=info

loglevel标志在那里,所以你会在终端窗口中看到任务已被接收,并且其输出可用的确认信息。

现在,我们可以向我们的 Celery 工作进程发送命令。打开 Flask shell 会话,如下所示:

$ export FLASK_APP=main.py $ flask shell    
>>> from webapp.blog.tasks import log
>>> log("Message")
Message
>>> result = log.delay("Message")

函数可以被调用,就像它是任何其他函数一样,这样做将在当前进程中执行该函数。然而,在任务上调用delay方法将向工作进程发送消息,以使用给定的参数执行该函数。

在运行 Celery 工作进程的终端窗口中,你应该看到如下类似的内容:

Task webapp.blog.tasks.log succeeded in 0.0005873600021s: 'Message'

与任何异步任务一样,可以使用ready方法来判断任务是否成功完成。如果为True,则可以使用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.blog.tasks import log
>>> log.apply_async(["Message"], countdown=600)

countdown 并不保证任务将在 600 秒后运行。countdown 选项仅表示任务在 x 秒后将可供处理。如果所有工作进程都忙于其他任务,则它不会立即运行。

apply_async 提供的另一个关键字参数是 eta 参数。eta 通过 Python datetime 对象传递,指定任务应该运行的确切时间。同样,eta 不可靠:

>>> import datetime
>>> from webapp.blog.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 

让我们看看 签名 的实际应用来理解它。打开 Flask shell 并输入以下内容:

# Export FLASK_APP if you haven't already
$ export FLASK_APP=main.py
$ flask shell
>>> from celery import signature
>>> from webapp.blog.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.blog.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.blog.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() 320 # chains can be nested 
>>> func = ( multiply.s(10) | multiply.s(2) | (multiply.s(4) | multiply.s(5)) ) 
>>> result = func.delay(16) 
>>> result.get() 6400

和弦

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.blog.tasks.log', 
        'schedule': datetime.timedelta(seconds=30), 
        'args': ("Message",) 
    }, 
} 

这个配置变量定义了log任务应该每 30 秒运行一次,并将args元组作为参数传递。任何timedelta对象都可以用来定义运行任务的间隔。

要运行周期性任务,需要一个名为beat的专用工作者。在另一个终端窗口中,运行以下命令:

$ celery -A celery_runner beat

如果你现在查看主 Celery 工作者的控制台输出,你应该现在每 30 秒看到一个日志事件。

如果你的任务需要在更具体的间隔上运行;比如说,例如,在六月的每个星期二凌晨 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')

该对象有以下参数:

  • minute

  • hour

  • day_of_week

  • day_of_month

  • month_of_year

这些参数可以接受各种输入。使用纯整数时,它们的工作方式与 timedelta 对象类似,但也可以接受字符串和列表。当传递一个列表时,任务将在列表中的每个时刻执行。当传递一个形式为 */x 的字符串时,任务将在模运算返回零的每个时刻执行。这两种形式也可以组合成一个由逗号分隔的整数和除法字符串。

监控 Celery

当我们的代码推送到服务器时,我们的 Celery 工作者不会在终端窗口中运行——而是作为后台任务运行。因此,Celery 提供了许多命令行参数来监控你的 Celery 工作者和任务的状态。这些命令的形式如下:

$ celery -A celery_runner <command>

查看您工作者状态的 主要任务如下:

  • status: 这将打印正在运行的工作者以及它们是否处于活动状态。

  • result: 当传递一个任务 ID 时,这将显示任务的返回值和最终状态。

  • purge: 使用这个,将删除代理中的所有消息。

  • inspect active: 这列出了所有活动任务。

  • inspect scheduled: 这列出了所有带有 eta 参数已安排的任务。

  • inspect registered: 这列出了所有等待处理的任务。

  • inspect stats: 这将返回一个包含当前运行中的工作者和代理的静态信息的字典。

基于 Web 的监控使用 Flower

Flower是一个基于 Web 的 Celery 实时管理工具。在 Flower 中,可以监控所有活跃的、排队的和完成的任务。Flower 还提供了关于每个任务在队列中等待了多久以及执行了多久,以及每个任务的参数的图表和统计数据。

要安装flower,使用以下pip命令:

$ pip install flower

要运行它,只需将flower当作 Celery 命令来运行,如下所示:

$ celery flower -A celery_runner --loglevel=info

现在,打开你的浏览器到http://localhost:5555。最好在任务运行时熟悉界面,所以请在命令行中输入以下内容:

>>> export FLASK_APP=manage.py
>>> flask shell
>>> from webapp.blog.tasks import *
>>> from celery import chord, group
>>> sig = chord(  group(multiply.s(i, i+5) for i in xrange(10000)),  log.s() )
>>> sig.delay()

你的 worker 进程现在将开始处理 10,000 个任务。在任务运行时浏览不同的页面,看看flower如何与你的 worker 交互,如图所示:

图片

创建提醒应用

让我们来看看 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]) 

现在,我们需要一个任务,该任务将向模型中的位置发送电子邮件。在我们的blog/tasks.py文件中查找以下任务:

@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'] = current_app.config['SMTP_FROM']
    msg['To'] = reminder.email
    try:
        smtp_server = smtplib.SMTP(current_app.config['SMTP_SERVER'])
        smtp_server.starttls()
        smtp_server.login(current_app.config['SMTP_USER'], 
        current_app.config['SMTP_PASSWORD'])
        smtp_server.sendmail("", [reminder.email], msg.as_string())
        smtp_server.close()
        return
    except Exception as e:
        self.retry(exc=e)

注意,我们的任务接受一个主键,而不是一个模型。这是为了防止竞争条件,因为传递的模型在 worker 最终处理它时可能已经过时。你还需要将占位符电子邮件和登录详情替换成你自己的登录信息。

当用户创建一个提醒模型时,我们如何调用我们的任务?我们将使用一个名为events的 SQLAlchemy 功能。SQLAlchemy 允许我们在模型上注册回调,当我们的模型发生特定变化时会被调用。我们的任务将使用after_insert事件,该事件在将新数据输入数据库后调用,无论模型是全新的还是正在更新。

blog/tasks.py中,我们需要一个回调:

def on_reminder_save(mapper, connect, self): 
    remind.apply_async(args=(self.id,), eta=self.date) 

现在,在blog/__init__.py中,我们将在我们的模型上注册回调:

from sqlalchemy import event
from .models import db, Reminder
from .tasks import on_reminder_save

def create_module(app, **kwargs):
    event.listen(Reminder, 'after_insert', on_reminder_save)
    from .controllers import blog_blueprint
    app.register_blueprint(blog_blueprint)

现在,每次模型被保存时,都会注册一个任务,该任务将向我们的用户发送电子邮件。

创建每周摘要

我们的博客有很多不使用 RSS 的人,他们更喜欢使用邮件列表。我们需要一种方法在每周结束时创建一篇新帖子的列表,以增加我们网站的流量。为了解决这个问题,我们将创建一个摘要任务,该任务将在每周六上午 10 点由一个 beat worker 调用。

首先,在blog/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(7 - date.weekday())
    else:
        date = date - datetime.timedelta(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'] = current_app.config['SMTP_FROM']

    try:
        smtp_server = smtplib.SMTP(current_app.config['SMTP_SERVER'])
        smtp_server.starttls()
        smtp_server.login(current_app.config['SMTP_USER'], 
        current_app.config['SMTP_PASSWORD'])
        smtp_server.sendmail("", [""], msg.as_string())
        smtp_server.close()

        return
    except Exception as e:
        self.retry(exc=e)

我们还需要在我们的config.py配置对象中添加一个周期性调度来管理我们的任务:

from celery.schedules import crontab
...
CELERYBEAT_SCHEDULE = { 'weekly-digest': { 'task': 'blog.tasks.digest', 'schedule': crontab(day_of_week=6, hour='10') }, }

我们还需要配置我们的 SMTP 服务器,以便我们能够发送电子邮件。这可以通过使用 Gmail 或您的公司电子邮件凭证来完成。将您选择的账户信息添加到 config.py 中的配置对象:

...
SMTP_SERVER = "smtp.gmail.com"
SMTP_USER = "sometestemail@gmail.com"
SMTP_PASSWORD = "password"
SMTP_FROM = "from@flask.com"
...

最后,我们需要我们的电子邮件模板。不幸的是,电子邮件客户端中的 HTML 已经非常过时。每个电子邮件客户端都有不同的渲染错误和怪癖,唯一的办法是打开所有客户端中的电子邮件。许多电子邮件客户端甚至不支持 CSS,而那些支持 CSS 的客户端支持的选择器和属性也非常有限。为了弥补这一点,我们不得不使用 10 年前的网络开发方法;也就是说,使用内联样式设计表格。下面是我们的 digest.html 文件:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
   "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 扩展

正如我们在整本书中看到的那样,Flask 被设计得尽可能小,同时仍然提供创建 Web 应用程序所需的灵活性和工具。然而,有很多功能是许多 Web 应用程序共有的,这意味着许多应用程序将需要执行相同任务的代码。为了解决这个问题,并避免重复造轮子,人们为 Flask 创建了扩展,我们在整本书中已经看到了许多 Flask 扩展。本章将重点介绍一些更有用的 Flask 扩展,它们的内容不足以单独成章,但将为您节省大量时间和挫折。

在本章中,你将学习以下内容:

  • 开发一个具有出色后端性能指标的调试工具栏

  • 使用 Redis 或 memcached 进行页面缓存

  • 创建一个具有所有模型 CRUD 功能的管理后台

  • 启用国际化(i18n),并将您的网站翻译成多种语言

  • 容易发送电子邮件

Flask CLI

在 第一章 “入门”中,我们介绍了一些基本功能,并学习了如何使用 Flask CLI。现在,我们将看到如何充分利用这一功能。

在 Flask CLI 中,你可以创建自定义命令,在应用程序上下文中运行。Flask CLI 本身使用 Click,这是一个由 Flask 的创建者开发的库,用于创建具有复杂参数的命令行工具。

要了解有关 Click 的更多详细信息,请查看文档,可在 click.pocoo.org 找到。

我们的目标是创建一组命令,帮助我们管理和部署我们的 Flask 应用程序。首先要解决的问题是我们将在哪里以及如何创建这些命令行函数。由于我们的 CLI 是一个应用程序的全局实用工具,我们将将其放置在 webapp/cli.py

import logging
import click
from .auth.models import User, db

log = logging.getLogger(__name__)

def register(app):
 @app.cli.command('create-user')
 @click.argument('username')
 @click.argument('password')
    def create_user(username, password):
        user= User()
        user.username = username
        user.set_password(password)
        try:
            db.session.add(user)
            db.session.commit()
            click.echo('User {0} Added.'.format(username))
        except Exception as e:
            log.error("Fail to add new user: %s Error: %s" 
            % (username, e))
            db.session.rollback()
...

我们将在 register 函数内部开发所有我们的函数,这样我们就不需要从主模块导入我们的 Flask 应用程序。这样做会导致循环依赖导入。接下来,请注意以下我们使用的装饰器:

  • @app.cli.command 注册了我们的函数有一个新的命令行命令;如果没有传递参数,那么 Click 将假设函数的名称。

  • @click.argument 添加了一个命令行参数;在我们的情况下,用于用户名和密码(用于创建用户凭据)。参数是位置命令行选项。

我们在 main.py 中注册了所有的命令行函数。注意以下片段中突出显示的文本,其中我们调用了之前创建的 register 方法:

import os
from webapp import create_app
from webapp.cli import register

env = os.environ.get('WEBAPP_ENV', 'dev')
app = create_app('config.%sConfig' % env.capitalize())
register(app)

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

从命令行界面(CLI)开始,让我们尝试我们刚刚创建的命令,如下所示:

# First we need to export our FLASK_APP env var
$ export FLASK_APP=main.py
$ flask create-user user10 password
User user10 Added.
$ flask run
 * Serving Flask app "main"
2018-08-12 20:25:43,031:INFO:werkzeug: * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

接下来,你可以打开你的网络浏览器,并使用新创建的 user10 凭据登录我们的博客。

提供的代码还包括一个 list-users 命令,但它的实现现在对你来说应该很直观,这里不再进行额外解释。让我们关注一个简单且实用的函数,用于显示我们应用程序的所有路由:

@app.cli.command('list-routes')
def list_routes():
    for url in app.url_map.iter_rules():
        click.echo("%s %s %s" % (url.rule, url.methods, url.endpoint))

list-routes 命令列出了在 app 对象上注册的所有路由及其关联的 URL。这在调试 Flask 扩展时非常有用,因为它使得检查其蓝图注册是否工作变得非常简单。

Flask Debug Toolbar

Flask Debug Toolbar 是一个 Flask 扩展,通过将调试工具添加到你的应用程序的网页视图中来帮助开发。它为你提供了关于视图渲染代码的瓶颈以及渲染视图所需的 SQLAlchemy 查询次数等信息。

和往常一样,我们将使用 pip 安装 Flask Debug Toolbar 并将其添加到我们的 requirements.txt 文件中:

$ source venv/bin/activate
(venv) $ pip install -r requirements

接下来,我们需要将 Flask Debug Toolbar 添加到 webapp/__init__.py 文件中。由于在本章中我们将大量修改此文件,以下是到目前为止文件的开始部分,以及初始化 Flask Debug Toolbar 的代码:

...
from flask_debugtoolbar import DebugToolbarExtension 

...
debug_toolbar = DebugToolbarExtension()
...
def create_app(config):
...
    debug_toolbar.init_app(app)
...

这就是将 Flask Debug Toolbar 启用并运行所需的所有内容。如果你的应用配置中的 DEBUG 变量设置为 true,则工具栏将显示。如果 DEBUG 没有设置为 true,则工具栏不会注入到页面中:

图片

在屏幕的右侧,你会看到工具栏。每个部分都是一个链接,它将在页面上显示一个值表。要获取渲染视图时调用的所有函数的列表,请点击 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_mongoengine.panels.
         MongoDebugPanel' 
    ] 
    DEBUG_TB_INTERCEPT_REDIRECTS = False 

这将在工具栏中添加一个与默认 SQLAlchemy 面板非常相似的面板。

Flask 缓存

在 第七章 “使用 Flask 与 NoSQL”,我们了解到页面加载时间是决定你的 Web 应用成功或失败的最重要因素之一。尽管我们的页面不经常更改,而且新帖子也不会经常发布,但我们仍然每次用户浏览器请求页面时都会渲染模板并查询数据库。

Flask 缓存通过允许我们存储视图函数的结果并返回存储的结果,而不是再次渲染模板来解决此问题。首先,我们需要在我们的虚拟环境中安装 Flask 缓存。这已经在运行 init.sh bash 脚本时完成。init.sh 脚本将首先安装 requirements.txt 中声明的所有依赖项:

...
Flask-Caching
...

接下来,在 webapp/__init__.py 中初始化它,如下所示:

from flask_caching import Cache 
...
cache = Cache()
... 
def create_app(config):
...
    cache.init_app(app)
...

在我们开始缓存视图之前,我们需要告诉 Flask 缓存我们希望如何存储新函数的结果:

class DevConfig(Config): 

    CACHE_TYPE = 'simple'

simple 选项告诉 Flask 缓存将结果存储在内存中的 Python 字典中,这对于 Flask 应用程序的大多数情况是足够的。我们将在本节后面介绍更多类型的缓存后端。

缓存视图和函数

为了缓存视图函数的结果,只需将装饰器添加到任何函数中:

...
from .. import cache
...

@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, 
    current_app.config['POSTS_PER_PAGE'], False)
    recent, top_tags = sidebar_data()

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

timeout 参数指定缓存结果应该持续多少秒,然后函数应该再次运行并存储。为了确认视图实际上正在被缓存,请检查调试工具栏中的 SQLAlchemy 部分。我们还可以通过激活分析器并比较前后时间来查看缓存对页面加载时间的影响。在作者的顶级笔记本电脑上,主要博客页面渲染需要 34 毫秒,主要是由于对数据库进行的八次不同查询。但是,在激活缓存后,这减少到 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 缓存正确存储非视图函数的结果是必要的。对于每个缓存的函数,这需要是唯一的,否则函数的结果将相互覆盖。此外,请注意,此函数的超时设置为两小时,而不是之前的 60 秒。这是因为此函数的结果不太可能像视图函数那样发生变化,如果数据已过时,这并不是一个很大的问题。

缓存带参数的函数

然而,正常的缓存装饰器不考虑函数参数。如果我们使用正常的缓存装饰器缓存了一个带有参数的函数,它将为每个参数集返回相同的结果。为了解决这个问题,我们使用 memoize 函数:

...
from .. import db, cache
...

class User(db.Model):
... 
    @cache.memoize(60)
    def has_role(self, name):
        for role in self.roles:
            if role.name == name:
                return True
        return False

Memoize存储传递给函数的参数以及结果。在上面的例子中,memoize被用来存储verify_auth_token方法的返回结果,该方法被多次调用,并且每次都会查询数据库。这个方法可以安全地进行 memoization,因为它每次在传入相同的令牌时都会返回相同的结果。唯一的例外是,如果在函数存储的 60 秒内用户对象被删除,但这非常不可能。

请务必小心,不要对依赖于全局作用域变量或不断变化数据的函数进行memoize或缓存。这可能会导致一些非常微妙的错误,在最坏的情况下,甚至会导致数据竞争。最适合进行 memoization 的函数被称为纯函数。纯函数是指当传入相同的参数时,将产生相同结果的函数。无论函数运行多少次,结果都不会改变。纯函数也没有任何副作用,这意味着它们不会改变全局作用域变量。这也意味着纯函数不能执行任何 I/O 操作。虽然verify_auth_token函数不是纯函数,因为它执行数据库 I/O,但这是可以接受的,因为,如前所述,底层数据发生变化的可能性非常小。

在我们开发应用程序时,我们不希望缓存视图函数,因为结果会不断变化。为了解决这个问题,将CACHE_TYPE变量设置为null,在生产配置中,将CACHE_TYPE变量设置为简单,这样当应用程序部署时,一切都会按预期工作:

class ProdConfig(Config): 

    CACHE_TYPE = 'simple'

class DevConfig(Config): 

    CACHE_TYPE = 'null' 

使用查询字符串缓存路由

一些路由,如我们的homepost路由,通过 URL 传递参数并返回特定于这些参数的内容。如果这些路由被缓存,我们会遇到问题,因为第一个渲染的路由将返回所有请求,无论 URL 参数如何。这个问题的解决方案相当简单。缓存方法中的key_prefix关键字参数可以是字符串或函数,它将被执行以动态生成键。

这意味着可以创建一个函数,该函数会根据 URL 参数创建一个相关的键,这样每个请求只有在之前调用过该特定参数组合时才会返回缓存的页面。在blog/controllers.py文件中,找到以下函数:

def make_cache_key(*args, **kwargs):
    path = request.path
    args = str(hash(frozenset(request.args.items())))
    messages = str(hash(frozenset(get_flashed_messages())))
    return (path + args + messages).encode('utf-8')

我们使用此函数通过混合 URL 路径、参数和 Flask 消息来创建缓存键。这将防止用户登出时消息不显示。我们将在主页视图和按 ID 显示帖子时使用这种类型的缓存键生成。

现在,每个单独的帖子页面将被缓存 10 分钟。

使用 Redis 作为缓存后端

如果传递给缓存函数的视图函数数量或唯一参数的数量太大,以至于内存无法处理,你可以为缓存使用不同的后端。正如在 第七章 中提到的 使用 NoSQL 与 Flask,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 库。在执行了 init.sh 脚本之后,这个库就已经安装好了,我们执行这个脚本是为了设置本章的工作环境。你可以在 requirements.txt 中找到这个库:**

...
redis
...

如果你想测试你的 Redis 缓存,我们准备了一个包含 RabbitMQ 和 Redis 的 Docker composer 文件。要启动它,只需在 CLI 上执行以下命令:

# Start dockers for RMQ and Redis in the background
$ docker-compose up -d Creating rabbitmq ... doneCreating redis ... done # Check the currently active containers
$ docker container list
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
3266cbdee1d7 redis "docker-entrypoint.s…" 43 seconds ago Up 58 seconds 0.0.0.0:6379->6379/tcp redis
64a99718442c rabbitmq:3-management "docker-entrypoint.s…" 43 seconds ago Up 58 seconds 4369/tcp, 5671/tcp, 0.0.0.0:5672->5672/tcp, 15671/tcp, 25672/tcp, 0.0.0.0:15672->15672/tcp rabbitmq

记得使用以下生产配置来测试你的应用程序:

$ export WEBAPP_ENV=prod
$ export FLASK_APP=main.py
$ flask run

使用 memcached 作为缓存后端

就像 Redis 后端一样,memcached 后端提供了一种存储结果的替代方式,如果存储限制变得过于限制性。与 Redis 相比,memcached 是设计用来缓存对象以供后续使用并减少数据库负载的。Redis 和 memcached 都服务于相同的目的,选择哪一个取决于个人偏好。要使用 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

网络应用程序的另一个瓶颈是需要下载页面上的 CSS 和 JavaScript 库的 HTTP 请求的数量。额外的文件只能在页面 HTML 加载和解析之后下载。为了解决这个问题,许多现代浏览器会一次性下载许多这些库,但浏览器可以发起的并发请求数量是有限的。

服务器上可以执行几个操作来减少下载这些文件所需的时间。开发者用来解决这个问题的主要技术是将所有的 JavaScript 库合并成一个文件,所有的 CSS 库合并成另一个文件,同时从生成的文件中移除所有的空白和回车符(也称为 minification)。这减少了多个 HTTP 请求的开销,并且可以将文件大小减少高达 30%。另一种技术是告诉浏览器使用特殊的 HTTP 头部信息本地缓存文件,这样文件只有在发生变化时才会再次加载。这些操作手动执行可能会很繁琐,因为它们需要在每次部署到服务器后执行。

幸运的是,Flask Assets 实现了所有讨论的技术。Flask Assets 通过提供一个文件列表和连接它们的方式工作,然后在模板中添加一个特殊的控制块,代替正常的链接和脚本标签。然后,Flask Assets 将添加一个指向新生成文件的linkscript标签。要开始使用,需要安装 Flask Assets。我们还需要安装cssminjsmin——你可以在requirements.txt中找到这些依赖项。

现在,需要创建要连接的文件集合,即所谓的包。在ewebapp/__init__.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/bootst
 rap.min.css>

用以下代码替换前面的片段:

{% assets "main_css" %} 
<link rel="stylesheet" type="text/css" href="{{ ASSET_URL }}" 
 /> 
{% endassets %} 

同样,在base.html文件中找到以下内容:

<script 
 src="img/> .min.js"></script><script 
 src="img/> ap.min.js"></script>

再次,用以下代码替换前面的代码:

{% assets "main_js" %} 
<script src="img/{{ ASSET_URL }}"></script> 
{% endassets %} 

现在,如果你重新加载页面,所有的 CSS 和 JavaScript 都将由 Flask Assets 处理。

Flask Admin

在第六章,“保护您的应用”,我们创建了一个接口,允许用户创建和编辑博客文章,而无需使用 CLI。这对于展示章节中提到的安全措施是足够的,但仍然没有通过接口删除文章或为文章分配标签的方法。我们也没有为普通用户隐藏的删除或编辑评论的方法。我们的应用需要的是一个功能齐全的管理员界面,类似于 WordPress 界面。这对于应用来说是一个如此常见的需求,以至于出现了一个名为 Flask Admin 的 Flask 扩展,旨在帮助开发者轻松创建管理员界面。再次,我们可以在requirements.txt的依赖列表中找到 Flask Admin。

由于我们将创建一个包含表单、视图和模板的完整管理员界面,Flask Admin 是我们应用中新的模块的好候选者。首先,看看我们新的应用结构:

./
  webapp/
    admin/
 __init__.py
 forms.py
 controllers.py
    api/
    auth/
    blog/
    templates/
      admin/
 ...      auth/
      blog/
      ...
 ...

如同往常,我们需要在我们的webapp/admin/__init__.py文件中创建create_module函数:

...
from flask_admin import Admin 
...
admin = Admin()

def create_module(app, **kwargs):
    admin.init_app(app)
    ....

然后,在主webapp/__init__.py文件中调用create_module函数:


def create_app(object_name): 
    ...
    from .admin import create_module as admin_create_module
    ...
    admin_create_module(app)

Flask Admin 通过在admin对象上注册定义一个或多个路由的视图类来工作。Flask Admin 主要有三种类型的视图:ModelViewFileAdminBaseView。接下来,我们将看到如何使用这些视图并对其进行自定义。

最后,我们向管理员界面添加一个导航栏选项,并且只将其渲染给具有管理员角色的用户。因此,在templates/navbar.html文件中,插入以下内容:

{% if current_user.is_authenticated and current_user.has_role('admin') %}
<li class="nav-item">
    <a class="nav-link" href="{{url_for('admin.index')}}">
    Admin<span class="sr-only">(current)</span></a>
</li>
{% endif %}

创建基本管理页面

BaseView类允许将正常的 Flask 页面添加到您的admin界面中。这通常是 Flask Admin 设置中最少使用的视图类型,但如果您希望包含类似使用 JavaScript 图表库的自定义报告,您可以使用基础视图单独完成。正如预期的那样,我们将在admin/controllers.py文件中定义我们的视图:

from flask.ext.admin import BaseView, expose 

class CustomView(BaseView): 
    @expose('/')
    @login_required
    @has_role('admin')
    def index(self): 
        return self.render('admin/custom.html') 

    @expose('/second_page')
    @login_required
    @has_role('admin')
    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_module函数中完成,遵循与 API 模块相同的结构和逻辑:

...
from .controllers import CustomView 
...
def create_module(object_name):
    ,,,
    admin.add_view(CustomView(name='Custom'))

name 关键字参数指定在 admin 接口顶部的导航栏上使用的标签应读取为“自定义”。在你将 CustomView 注册到 admin 对象之后,你的 admin 接口现在应该在导航栏中有一个第二个链接,如下面的截图所示:

图片

创建数据库管理页面

Flask Admin 的主要优势在于你可以通过提供 SQLAlchemy 或 MongoEngine 模型给 Flask Admin 来自动创建数据的管理页面。创建这些页面非常简单;在 admin.py 文件中,你只需要编写以下代码:

from flask_admin.contrib.sqla import ModelView 
# or, if you use MongoEngine 
# from flask_admin.contrib.mongoengine import ModelView 

class CustomModelView(ModelView): 
    pass

然后,在 admin/__init__.py 文件中,注册数据库 session 对象以及你希望使用的模型类,如下所示:

from flask_admin import Admin
from .controllers import CustomView, CustomModelView 
from webapp.blog.models import db, Reminder, Post, Comment, Tag
from webapp.auth.models import User, Role 

admin = Admin()
def create_module(app, **kwargs): 
    admin.init_app(app)
    admin.add_view(CustomView(name='Custom'))
    models = [User, Role, Comment, Tag, Reminder]

    for model in models: 
       admin.add_view(CustomModelView(model, db.session, 
       category='models'))
...

category 关键字告诉 Flask Admin 将具有相同类别值的所有视图放入导航栏上的同一个下拉菜单中。如果你现在打开浏览器,你会看到一个名为“模型”的新下拉菜单,其中包含指向数据库中所有表的管理页面的链接,如下所示:

图片

为每个模型生成的接口提供了很多功能。可以创建新的帖子,并且可以批量删除现有的帖子。所有字段都可以通过这个接口设置,包括作为可搜索下拉菜单实现的关系字段。datedatetime 字段甚至有自定义的 JavaScript 输入和下拉日历菜单。总的来说,这是对在 第六章 中创建的手动接口的一个巨大改进,保护你的应用

增强 post 页面的管理功能

虽然这个接口在质量上有了巨大的提升,但仍然缺少一些功能。我们不再有原始接口中可用的 WYSIWYG 编辑器,但通过启用一些更强大的 Flask Admin 功能,这个页面可以得到改进。

要将 WYSIWYG 编辑器重新添加到 post 创建页面,我们需要一个新的 WTForms 字段,因为 Flask Admin 使用 Flask WTF 构建其表单。我们还需要用这个新字段类型覆盖 post 编辑和创建页面中的 textarea 字段。首先需要做的是在 admin/forms.py 文件中创建新的字段类型,使用 textarea 字段作为基础,如下所示:

from wtforms import ( 
    widgets, 
    TextAreaField
) 

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 %} 

最后,要将我们新创建的自定义视图添加到 Flask-Admin 中,我们需要将其添加到 admin/__init__.py 文件中的 create_module 函数中:

def create_module(app, **kwargs):
    ...
    admin.add_view(PostView(Post, db.session, category='Models'))
    ...    

继承模板的尾部位于文件末尾。一旦模板创建完成,你的 post 编辑和创建页面应该看起来像这样:

图片

创建文件系统管理页面

大多数 admin 接口都覆盖的一个常见功能是能够从网络访问服务器的文件系统。幸运的是,Flask Admin 通过 FileAdmin 类包含了这个功能:

class CustomFileAdmin(FileAdmin):
    pass

现在,只需将新类导入到你的 admin/__init__.py 文件中,并传入你希望从网络访问的路径:

admin.add_view(CustomFileAdmin(app.static_folder,'/static/',name='Static Files'))

保护 Flask Admin

目前,整个 admin 界面对全世界都是可访问的——让我们来修复这个问题。CustomView 中的路由可以像任何其他路由一样进行保护,如下所示:

class CustomView(BaseView): 
    @expose('/') 
    @login_required 
    @has_role('admin') 
    def index(self): 
        return self.render('admin/custom.html') 

    @expose('/second_page') 
    @login_required 
    @has_role('admin') 
    def second_page(self): 
        return self.render('admin/second_page.html') 

要保护 ModeViewFileAdmin 子类,它们需要定义一个名为 is_accessible 的方法,该方法要么返回 true,要么返回 false

class CustomModelView(ModelView): 
    def is_accessible(self): 
        return current_user.is_authenticated and 
               current_user.has_role('admin') 

class CustomFileAdmin(FileAdmin): 
    def is_accessible(self): 
        return current_user.is_authenticated and 
               current_user.has_role('admin') 

由于我们在 第六章 中正确设置了认证,保护你的应用,这个任务变得非常简单。

Flask-Babel

在本节中,我们将探讨一种为我们的博客启用国际化的方法。这是构建支持多语言的全局网站的一个基本功能。我们将再次使用 Flask-Babel 扩展,它是由 Flask 的作者创建的。像往常一样,我们将确保这个依赖项存在于我们的 requirements.txt 文件中:

...
Flask-Babel
...

Flask-Babel 使用 Babel Python 库进行国际化(i18n)和本地化,并添加了一些实用工具和 Flask 集成。要使用 Flask-Babel,首先我们需要在 babel/babel.cfg 文件中配置 Babel:

[python: webapp/**.py]
[jinja2: webapp/templates/**.html]
encoding = utf-8
extensions=jinja2.ext.autoescape,jinja2.ext.with_

我们将 Babel 配置为仅在 webapp 目录中查找要翻译的 Python 文件,并从 webapp/templates 目录中的 Jinja2 模板中提取文本。

然后,我们需要在 webapp/translations 上创建一个翻译目录,其中将包含我们支持的所有语言的翻译。

Babel 附带一个名为 pybabel 的命令行实用程序。我们将使用它来设置我们博客将支持的所有语言,以及触发提取过程、更新和编译。首先,要创建一种新语言,输入以下命令:

$ pybabel init -i ./babel/messages.pot -d ./webapp/translations -l pt 

葡萄牙语,或 pt,已经在提供的支持代码中初始化,但你可以尝试创建一种新语言。只需将 pt 改为其他语言。之后,你可以检查 webapp/translations,应该会看到 Babel 已经创建了一个包含我们语言代码的新目录。此目录包含一个 messages.po 文件,我们将在此文件中编写提取文本所需的翻译,以及 messages.mo 的编译版本。

接下来,为了触发 Babel 在我们的应用程序中搜索要翻译的文本,使用以下命令:

$ pybabel extract -v -F ./babel/babel.cfg -o ./babel/messages.pot .

这将更新 messages.pot 主文件,其中包含所有需要翻译的文本。然后,我们告诉 Babel 使用以下命令更新所有支持语言的 messages.po 文件:

$ pybabel update -i ./babel/messages.pot -d webapp/translations

现在,messages.po 文件将包含类似以下内容:

# Portuguese translations for PROJECT.
# Copyright (C) 2018 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2018.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
...

#: webapp/templates/head.html:5
msgid "Welcome to this Blog"
msgstr ""

#: webapp/templates/macros.html:57
msgid "Read More"
msgstr ""

...

在这里,翻译者需要使用从 msgid 翻译的文本更新 msgstr。从英语翻译到某种目标语言。完成此操作后,我们将告诉 Babel 编译 messages.po 文件,并使用以下命令生成更新的 messages.mo 文件:

$ pybabel compile -d ./webapp/translations

Babel 如何识别我们应用程序中要翻译的文本?很简单——Jinja2 已经为 Babel 准备好了,所以在我们的模板中,我们只需输入以下内容:

<h1>{{_('Some text to translate')}}</h1>

_('text')gettext 函数的别名,如果存在翻译,则返回字符串的翻译,对于可能变为复数的文本使用 ngettext

对于 Flask 集成,我们将创建一个名为 webapp/babel 的新模块。这是我们初始化扩展的地方。为此,在 babel/__init__.py 文件中添加以下内容:

from flask import has_request_context, session
from flask_babel import Babel

babel = Babel()
...
def create_module(app, **kwargs):
    babel.init_app(app)
    from .controllers import babel_blueprint
    app.register_blueprint(babel_blueprint)

然后,我们需要定义一个函数,该函数返回当前的区域代码给 Flask-Babel。最佳位置是在 babel/__init__.py 文件中添加它:

...
@babel.localeselector
def get_locale():
    if has_request_context():
        locale = session.get('locale')
        if locale:
            return locale
        session['locale'] = 'en'
        return session['locale']
...

我们将使用会话来保存当前选定的区域设置,如果没有设置,我们将回退到英语。我们的函数用 @babel.localeselector 装饰器装饰,以在 Flask-Babel 上注册我们的函数。

接下来,我们需要定义一个端点,可以通过调用它来切换当前选定的语言。此端点将会将会话区域设置为新的语言并重定向到主页。通过在 babel/controllers.py 文件中添加以下代码来完成此操作:

from flask import Blueprint, session, redirect, url_for

babel_blueprint = Blueprint(
    'babel',
    __name__,
    url_prefix="/babel"
)

@babel_blueprint.route('/<string:locale>')
def index(locale):
    session['locale'] = locale
    return redirect(url_for('blog.home'))

最后,我们将为我们的用户提供一种更改当前语言的方式。这将在导航栏中完成。为此,将以下内容添加到 templates/navbar.html 文件中:

...
<ul class="navbar-nav ml-auto">
    <li class="nav-item dropdown">
        <a class="nav-link dropdown-toggle" href="#" 
        id="navbarDropdown" role="button" data-toggle="dropdown">
            Lang
        </a>
        <div class="dropdown-menu">
            <a class="dropdown-item" href="{{url_for('babel.index', 
            locale='en')}}">en</a>
            <a class="dropdown-item" href="{{url_for('babel.index', 
            locale='pt')}}">pt</a>
        </div>
    </li>
...
</ul>

新的导航栏选项将带我们到带有选定语言的 Babel 索引端点。任何我们想要支持的新语言都应该添加到这里。最后,我们只需在我们的主 __init__.py 文件上调用 Babel 的 create_module 函数即可。

def create_app():
...
    from babel import create_module as babel_create_module
...
    babel_create_module(app)

就这样。我们现在已经设置了所有必要的配置,以支持我们博客应用程序上的任何语言。

图片

Flask Mail

本章将要介绍的最后一个 Flask 扩展是 Flask Mail,它允许你从 Flask 的配置中连接和配置你的 SMTP 客户端。Flask Mail 还将有助于简化 第十二章,测试 Flask 应用程序 中的应用程序测试。第一步是使用 pip 安装 Flask Mail。你应该在本章中已经完成了这个步骤,在我们的 init.sh 脚本中,所以让我们检查我们的依赖文件,以确保以下内容:

...
Flask-Mail
...

flask_mail 将通过读取 app 对象中的配置变量来连接我们选择的 SMTP 服务器,因此我们需要将这些值添加到我们的 config 对象中:

class DevConfig(Config): 

    MAIL_SERVER = 'localhost' 
    MAIL_PORT = 25 
    MAIL_USERNAME = 'username' 
    MAIL_PASSWORD = 'password' 

最后,在 _init_.py 中初始化 mail 对象:

...
from flask_mail import Mail
...
mail = Mail()

def create_app(object_name): 
...
    mail.init_app(app)
...

要了解 Flask Mail 如何简化我们的电子邮件代码,请考虑以下内容——这个代码片段是我们创建在 第九章,使用 Celery 创建异步任务 中的 Remind 任务,但使用 Flask Mail 而不是标准库的 SMTP 模块:

from flask_mail import Message
from .. import celery, mail

@celery.task(
    bind=True,
    ignore_result=True,
    default_retry_delay=300,
    max_retries=5
)
def remind(self, pk):
    logs.info("Remind worker %d" % pk)
    reminder = Reminder.query.get(pk)
    msg = Message(body="Text %s" % str(reminder.text), 
    recipients=[reminder.email], subject="Your reminder")
    try:
        mail.send(msg)
        logs.info("Email sent to %s" % reminder.email)
        return
    except Exception as e:
        logs.error(e)
        self.retry(exc=e)

摘要

本章中的任务使我们能够显著扩展我们应用程序的功能。我们现在拥有了一个功能齐全的管理员界面,浏览器中的一个有用的调试工具,两个可以大大加快页面加载时间的工具,以及一个使发送电子邮件不那么头疼的实用工具。

如本章开头所述,Flask 是一个基础框架,允许你挑选和选择你需要的功能。因此,重要的是要记住,在你的应用程序中并不需要包含所有这些扩展。如果你是唯一一个在应用程序上工作的内容创作者,CLI 可能就是你所需要的,因为添加这些功能会占用开发时间(当它们不可避免地出现问题时,还会占用维护时间)。这个警告是在本章末尾给出的,因为许多 Flask 应用程序变得难以管理的主要原因之一就是它们包含了太多的扩展,测试和维护所有这些扩展变成了一项非常庞大的任务。

在下一章中,你将学习扩展的内部工作原理,以及如何创建你自己的扩展。

第十一章:构建你自己的扩展

从这本书的第一章开始,我们就一直在我们的应用中添加 Flask 扩展,以便添加新功能并节省我们花费大量时间重新发明轮子的时间。到目前为止,这些 Flask 扩展是如何工作的还一直是个谜。

在本章中,我们将学习以下主题:

  • 如何创建两个简单的 Flask 扩展,以便更好地理解 Flask 内部机制,并允许你使用自己的功能扩展 Flask。

  • 如何扩展 Jinja

  • 如何创建一个 Python 包,准备发布到 PyPI

创建一个 YouTube Flask 扩展

首先,我们将要创建的第一个扩展是一个简单的扩展,它允许使用以下标签在 Jinja 模板中嵌入 YouTube 视频:

{{ youtube(video_id) }} 

video_id 对象是任何 YouTube URL 中 v 后面的代码。例如,在 URL https://www.youtube.com/watch?v=_OBlgSz8sSM 中,video_id 对象是 _OBlgSz8sSM

目前,这个扩展的代码位于 __init__.py 文件中。然而,这只是为了开发和调试目的。当代码准备好分享时,它会被移动到它自己的项目目录中。

任何 Flask 扩展都需要的是将在 app 对象上初始化的对象。这个对象将处理将其 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)
        app.add_template_global(youtube)

    def register_blueprint(self, app):
        module = Blueprint(
            "youtube",
            __name__,
            url_prefix='youtube',
            template_folder="templates"
        )
        app.register_blueprint(module)
        return module

到目前为止,这段代码所做的唯一事情是在 app 对象上初始化一个空的蓝图。

注意用粗体标记的代码。在 YouTube 类中,我们必须在 init_app 方法中注册函数到 Jinja。现在我们可以在模板中使用 youtube Jinja 函数了。

下一步需要的代码是视频的表示。以下是一个处理 Jinja 函数参数并渲染 HTML 以在模板中显示的类:

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

    @property 
    def html(self): 
      return Markup(render_template('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 

最后,我们必须创建将视频添加到页面的 HTML。在 templates 目录下名为 youtube 的新文件夹中,创建一个名为 video.html 的新 HTML 文件,并向其中添加以下代码:

<iframe 
  class="{{ video.cls }}" 
  width="560" 
  height="315" 
  src="img/{{ video.video_id }}" 
  frameborder="0" 
  allowfullscreen> 
</iframe> 

这就是嵌入 YouTube 视频到模板中所需的所有代码。现在让我们来测试一下。在 __init__.py 文件中,在 Youtube 类定义下方初始化下面的 Youtube 类:

youtube = Youtube()

__init__.py 中,使用包含初始化类的 youtube_ext 变量,并使用我们创建的 init_app 方法将其注册到应用上:

def create_app(object_name): 
    ... 
    youtube.init_app(app) 

现在,作为一个简单的例子,将 youtube 函数添加到 templates/blog/home.html 的博客主页顶部:

{% extends "base.html" %}
{% import 'macros.html' as macros %}
{% block title %}Home{% endblock %}
{% block leftbody %}

{{ youtube("_OBlgSz8sSM") }}

{{ macros.render_posts(posts) }}
{{ macros.render_pagination(posts, 'blog.home') }}
{% endblock %}

这将产生以下结果:

图片

创建 Python 包

为了让我们的新 Flask 扩展可供他人使用,我们必须将我们迄今为止编写的代码创建成一个可安装的 Python 包。首先,我们需要在当前应用程序目录之外创建一个新的项目目录。我们需要两样东西:一个 setup.py 文件,稍后我们将填写它,以及一个名为 flask_youtube 的文件夹。在 flask_youtube 目录中,我们将有一个 __init__.py 文件,其中包含我们为扩展编写的所有代码。这包括 YoutubeVideo Python 类。

此外,在 flask_youtube 目录内,我们还需要一个 templates 目录,它包含我们放在应用程序 templates 目录中的 youtube 目录。

为了将此代码转换为 Python 包,我们使用名为 setuptools 的库。现在,setuptools 是一个 Python 包,它允许开发者轻松地为他们的代码创建可安装的包。setuptools 将代码打包,以便 pipeasy_install 可以自动安装它们,甚至可以将您的包上传到 Python 包索引PyPI)。

我们通过 pip 安装的所有包都来自 PyPI。要查看所有可用的包,请访问 pypi.python.org/pypi

您只需填写 setup.py 文件即可获得此功能:

from setuptools import setup, find_packages

setup(
    name='Flask-YouTube',
    version='0.4',
    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(),
    include_package_data=True,
    package_data = {
        'templates': ['*']
    },
    zip_safe=False,
    classifiers=[
        'Development Status :: 5 - Production/Stable',
        'Environment :: Web Environment',
        'Intended Audience :: Developers',
        'License :: OSI Approved :: BSD License',
        'Operating System :: OS Independent',
        'Programming Language :: Python',
        'Topic :: Software Development :: Libraries :: Python Modules'
    ]
)

此代码使用 setuptoolssetup 函数来查找您的源代码,并确保安装您的代码的机器具有所需的包。大多数属性都是相当自解释的,除了包属性,它使用 setuptoolsfind_packages 函数。

package 属性查找源代码中哪些部分是包的一部分。我们使用 find_packages 方法自动查找要包含的代码部分。这是基于一些合理的默认值,例如查找包含 __init__.py 文件的目录,并排除常见的文件扩展名。

我们还必须声明一个清单文件,setuptools 将使用它来了解如何创建我们的包。这包括清理文件的规则,以及哪些不是 Python 模块的文件夹需要包含:

MANIFEST.in

prune *.pyc
recursive-include flask_youtube/templates *

虽然这不是强制性的,但此设置还包含有关作者和许可证的元数据,如果我们将它上传到 PyPI,这些信息将包含在 PyPI 页面上。setup 函数中还有许多可用的自定义选项,所以我鼓励您阅读 pythonhosted.org/setuptools/ 的文档。

您现在可以通过运行以下命令在您的机器上安装此包:

$ python setup.py build
$ python setup.py install

这会将您的代码安装到您的 Python packages 目录中,或者如果您正在使用 virtualenv,它将安装到本地的 packages 目录。然后,您可以通过以下代码导入您的包:

from flask_youtube import Youtube

创建包含视频的博客帖子

我们现在将扩展我们的博客,以便用户可以在他们的帖子中包含视频。这是一个很好的功能,并且对于展示如何创建一个包含数据库模式更改和迁移的新功能很有用,同时还可以对 Jinja2 和 WTForms 进行评论。

首先,我们需要在 blog/models.py 文件中的 Post SQLAlchemy 模型上添加一个名为 youtube_id 的新列(以下为高亮代码):

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

现在,我们能够存储用户的 YouTube ID,以便与他们的帖子一起使用。接下来,我们需要在我们的 Post 表单中包含我们的新字段。因此,在 blog/forms.py 文件中,我们添加以下内容:

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

现在我们需要更改 editnew_post 控制器:

blog/controllers.py:

...
def new_post():
    form = PostForm()
    if form.validate_on_submit():
        new_post = Post()
        ...
        new_post.youtube_id = form.youtube_id.data
        ...
        db.session.add(new_post)
...

我们正在将 SQLAlchemy 模型属性 Post.youtube_id 设置为表单中的 youtube_id 字段,对于 edit 方法,当表单已提交(POST HTTP 方法)时,情况相同,而当表单显示时则相反:

blog/controllers.py:

...
def edit_post(id):
    post = Post.query.get_or_404(id)
    # We want admins to be able to edit any post
    if current_user.id == post.user.id:
        form = PostForm()
        if form.validate_on_submit():
            ...
            post.youtube_id = form.youtube_id.data
            ...
            db.session.add(post)
            db.session.commit()
            return redirect(url_for('.post', post_id=post.id))
        form.title.data = post.title
        form.youtube_id.data = post.youtube_id
        form.text.data = post.text
        return render_template('edit.html', form=form, post=post)
    abort(403)
...

最后,我们只需在我们的 Jinja2 模板中包含这个新字段。在我们的 templates/blog/post.html 中,如果数据库中存在该字段,我们将渲染此字段:

{% if post.youtube_id %}
<div class="row">
    <div class="col">
        {{ youtube(post.youtube_id) | safe }}
    </div>
</div>
{% endif %}

最后,我们更改我们的新帖子并编辑帖子模板。只需查找提供的代码中的更改。

使用 Flask 扩展修改响应

因此,我们创建了一个扩展,为我们的模板添加了新的功能。但我们是怎样创建一个在请求级别修改我们应用行为的扩展的呢?为了演示这一点,让我们创建一个扩展,通过压缩响应的内容来修改 Flask 的所有响应。这是网络开发中的一种常见做法,为了加快页面加载时间,因为使用 gzip 等方法压缩对象非常快且相对便宜,从 CPU 的角度来看。通常,这会在服务器级别处理。所以,除非您希望仅使用 Python 代码托管您的应用,这是可能的,并且将在第十三章[部署 Flask 应用]中介绍,这个扩展在现实世界中实际上并没有太多用途。

为了实现这一点,我们将使用 Python 标准库中的 gzip 模块在每次请求处理完毕后压缩内容。我们还需要在响应中添加特殊的 HTTP 头部,以便浏览器知道内容已被压缩。我们还需要检查 HTTP 请求头部,以确定浏览器是否可以接受压缩内容。

就像之前一样,我们的内容最初将驻留在 __init__.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 事件上注册一个新的函数,以便我们的扩展可以压缩结果。

after_request 方法是扩展真正逻辑发挥作用的地方。首先,它通过查看请求头中的 Accept-Encoding 值来检查浏览器是否接受 gzip 编码。如果浏览器不接受 gzip,或者浏览器没有返回成功的响应,函数就直接返回内容,不对内容进行任何修改。然而,如果浏览器接受我们的内容并且响应是成功的,那么内容将被压缩。我们使用另一个名为 BytesIO 的标准库类,它允许文件流在内存中写入和存储,而不是存储在中间文件中。这是必要的,因为 GzipFile 对象期望写入文件对象。

数据压缩后,我们将响应对象的数据设置为压缩的结果,并在响应中设置必要的 HTTP 头部值。最后,gzip 内容被返回给浏览器,然后浏览器对其进行解压缩,显著加快页面加载时间。

为了在浏览器中测试功能,你必须禁用 Flask Debug Toolbar,因为在编写本文时,它的代码中存在一个错误,它期望所有响应都编码为 UTF-8。

如果你重新加载页面,看起来应该没有不同。然而,如果你使用你选择的浏览器的开发者工具检查响应,你会看到它们已经被压缩。

摘要

现在我们已经研究了不同类型的 Flask 扩展的两个不同示例,你应该对大多数我们使用的 Flask 扩展的工作方式有非常清晰的理解。利用你现在的知识,你应该能够为你的特定应用程序添加任何额外的 Flask 功能。

在下一章中,我们将探讨如何将测试添加到我们的应用程序中,以消除我们做出的代码更改是否破坏了应用程序功能的功能性猜测。

第十二章:测试 Flask 应用程序

在整本书中,每当我们对我们的应用程序代码进行修改时,我们都必须手动将受影响的网页加载到浏览器中,以测试代码是否正确工作。随着应用程序的增长,这个过程变得越来越繁琐,尤其是如果你更改了低级且到处使用的代码,比如 SQLAlchemy 模型代码。

为了自动化验证我们的代码按预期工作的过程,我们将使用 Python 的一个内置功能来编写测试,通常称为单元测试或集成测试,这些测试将与我们的应用程序代码进行比对。

在本章中,你将学习以下内容:

  • 使用 Python 的 unittest 库编写简单测试

  • 测试安全性,并验证登录和基于角色的访问

  • 编写 REST API 的测试

  • 测试用户界面

  • 测量测试覆盖率

什么是单元测试?

测试一个程序非常简单。它只涉及开发代码来运行程序中的特定部分,并指定你期望的结果,然后将结果与程序实际运行的结果进行比较。如果结果相同,测试通过。如果结果不同,测试失败。通常,这些测试是在 CI 服务器上创建 Pull Request 时运行的,这样所有 PR 的审查者都可以立即检查请求的更改是否破坏了某些内容。

在程序测试中,主要有三种类型的测试。单元测试是验证单个代码片段(如函数)正确性的测试。其次是集成测试,它测试程序中各种单元协同工作的正确性。最后一种测试是端到端测试,它一次性测试整个系统的正确性,而不是单个部分。还存在许多其他类型的测试,其中一些包括负载测试、安全测试和恢复测试。

在本章中,我们将使用单元测试和端到端测试来验证我们的代码按计划工作。

这使我们来到了代码测试的一些基本规则:确保你的测试可以真正失败,编写只测试一个东西的简单测试函数,并使测试代码易于阅读和编写。

测试是如何工作的?

让我们从一个非常简单的 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语句组合成类内部的独立函数来工作的。这个类内部的测试函数集合被称为测试用例。测试用例内部的每个函数应该只测试一件事情,这是单元测试背后的主要思想。在单元测试中只测试一件事情迫使你必须单独验证每一块代码,而不会忽略你代码的任何功能。如果你正确编写单元测试,你最终会得到很多很多测试。虽然这看起来可能过于冗长,但它会为你节省未来的麻烦。

在此配置中,我们将使用内存引擎数据库的 SQLite,这使我们能够保证测试不会干扰我们的实际数据库。此外,该配置禁用了 WTForms 的 CSRF 检查,以便我们可以在测试中提交表单而不需要 CSRF 令牌:

class TestConfig(Config):

    DEBUG = True
    DEBUG_TB_ENABLED = False
    SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    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中,让我们开始创建内置 Python unittest库所需的内容。代码将使用 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方法在每次单元测试之前运行,并且可以将其变量附加到自身,以便测试方法可以访问它们。在我们的setUp方法中,我们需要使用TestConfig对象创建应用程序对象并创建测试客户端。

此外,还有三个我们需要解决的错误。前两个在 Flask Admin 和 Flask Restful 扩展中,它们在应用到的应用程序对象被销毁时没有删除内部存储的 Blueprint 对象。第三,Flask SQLAlchemy 的初始化器在webapp目录外没有正确添加应用程序对象。

class TestURLs(unittest.TestCase):

    def setUp(self):
        admin._views = []
        rest_api.resources = []

        app = create_app('config.TestConfig')
        self.client = app.test_client()
        db.app = app
        db.create_all()

这里列出的所有错误在编写时都存在,但当你阅读这一章时可能已经不存在了。

除了setUp方法外,还有一个tearDown方法,它在每次单元测试结束时都会运行。tearDown方法的目标是销毁在setUp方法中创建的任何无法自动删除或关闭的对象。在我们的例子中,我们将使用tearDown方法来关闭和删除我们的数据库会话,如下所示:

class TestURLs(unittest.TestCase): 
    def setUp(self): 
        ... 

    def tearDown(self): 
        db.session.remove()

现在我们可以创建我们的第一个单元测试。第一个测试将检查访问我们应用程序的根是否返回一个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): 断言y包含x

  • 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): 
        ... 

    def test_blog_home(self): 
        """ Tests if the blog home page returns successfully """ 
        result = self.client.get('/blog/')
        self.assertEqual(result.status_code, 200)

记住,前面的代码只测试了 URL 是否成功返回。返回数据的内容不是这些测试的一部分。

测试安全性

测试安全性显然非常重要——如果你将你的应用程序暴露在网络上,你可以确信你的安全性将会受到严重的测试,而且不是出于正确的理由。如果你的安全措施没有得到正确保护,所有的受保护端点都将被测试和利用。首先,我们应该测试我们的登录和注销过程。

如果我们想要测试提交表单,例如登录表单,我们可以使用测试客户端的 post 方法。让我们创建一个test_login方法来查看登录表单是否工作正常:

class TestURLs(unittest.TestCase):
....

    def _insert_user(self, username, password, role_name):
        test_role = Role(role_name)
        db.session.add(test_role)
        db.session.commit()

        test_user = User(username)
        test_user.set_password(password)
        db.session.add(test_user)
        db.session.commit()

    def test_login(self):
        """ Tests if the login form works correctly """

        result = self.client.post('/auth/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方法将适用于测试我们在整本书中创建的任何表单对象。

现在,让我们创建一个失败的登录尝试:

def test_failed_login(self):
    self._insert_user('test', 'test', 'default')
    result = self.client.post('/auth/login', data=dict(
        username='test',
        password="badpassword"
    ), follow_redirects=True)

    self.assertEqual(result.status_code, 200)
    self.assertIn('Invalid username or password', result.data)
    result = self.client.get('/blog/new')
    self.assertEqual(result.status_code, 302)

在前面的代码片段中,我们确保使用失败的凭证进行的登录尝试不会给用户带来成功的登录,并且在同一测试中,我们还确保失败的登录不会给用户足够的权限来添加新的博客文章。这看起来可能很微不足道,而且很容易实现,但如前所述,你应该使每个测试尽可能简单,并且每个测试只测试一件事情,但目标是覆盖所有功能和可能性。

另一个重要测试的例子是覆盖已登录用户的未授权访问:

def test_unauthorized_access_to_admin(self):
    self._insert_user('test', 'test', 'default')
    result = self.client.post('/auth/login', data=dict(
        username='test',
        password="test"
    ), follow_redirects=True)
    result = self.client.get('/admin/customview/')
    self.assertEqual(result.status_code, 403)

在这里,我们确保低权限用户无法访问我们应用程序中的高权限区域:管理员界面。

测试 REST API

仍然在安全性的背景下,我们现在将学习如何测试我们的 REST API。记住,我们已经实现了 JWT 安全性,因此对于每个请求,我们需要使用之前获取的访问令牌。

JWT 身份验证测试应该看起来像这样:

def test_api_jwt_login(self):
    self._insert_user('test', 'test', 'default')
    headers = {'content-type': 'application/json'}
    result = self.client.post('/auth/api', headers=headers, data='{"username":"test","password":"test"}')
    self.assertEqual(result.status_code, 200)

def test_api_jwt_failed_login(self):
    self._insert_user('test', 'test', 'default')
    headers = {'content-type': 'application/json'}
    result = self.client.post('/auth/api', headers=headers, data='{"username":"test","password":"test123"}')
    self.assertEqual(result.status_code, 401)

在这里需要注意的一些重要细节包括我们如何设置 HTTP 头为 JSON 格式,以及我们如何在 HTTP POST 方法中传递 JSON 有效载荷——这将在我们所有的 REST API 测试中发生。

接下来,让我们看看如何开发一个针对新帖子 REST API 的测试。/api/post是博客文章的端点,POST HTTP 方法是向博客应用程序添加新帖子的方法。如果这还不清楚,请回顾第八章,构建 RESTful API

def test_api_new_post(self):
    self._insert_user('test', 'test', 'default')
    headers = {'content-type': 'application/json'}
    result = self.client.post('/auth/api', headers=headers, data='{"username":"test","password":"test"}')
    access_token = json.loads(result.data)['access_token']
 headers['Authorization'] = "Bearer %s" % access_token
    result = self.client.post('api/post', headers=headers, data='{"title":"Text Title","text":"Changed"}')
    self.assertEqual(result.status_code, 201)

这同样是一个简单的测试来开发——注意我们如何使用/auth/api端点从我们的认证 JWT API 请求访问令牌,以及我们如何使用它来调用/api/post。预期的访问令牌被用来构造 HTTP 授权头,形式为Authorization: Bearer <ACCESS_TOKEN>。这可能在每个 API 测试中重复可能有点繁琐,所以确保编写一个辅助函数来保持你的代码“DRY”——即“不要重复自己”。

现在您已经了解了单元测试的机制,您可以使用单元测试来测试您应用程序的所有部分。这可以包括测试应用程序中的所有路由;测试我们制作的任何实用函数,例如sidebar_data;以及测试所有可能的角色和受保护页面的组合。

如果您的应用程序代码有一个功能,无论多小,您都应该为它编写一个测试。为什么?因为任何可能出错的事情都会出错。如果您的应用程序代码的有效性完全依赖于手动测试,那么随着您的应用程序的增长,某些东西可能会被忽略。当某些东西被忽略时,损坏的代码就会被部署到生产服务器上,这会令您的用户感到烦恼。

用户界面测试

为了测试我们应用程序代码的高级功能并创建系统测试,我们将编写与浏览器一起工作的测试,并验证 UI 代码是否正常工作。使用一个名为 Selenium 的工具,我们将创建 Python 代码,该代码可以连接到浏览器并完全通过代码控制它。这是通过在屏幕上查找元素,然后通过 Selenium 对这些元素执行操作来实现的。点击它或输入按键。此外,Selenium 还允许您通过提供对元素内容的访问来对页面内容进行检查,例如它们的属性和内部文本。对于更高级的检查,Selenium 甚至有一个可以运行页面上的任意 JavaScript 的接口。如果 JavaScript 返回一个值,它将被自动转换为 Python 类型。

在我们接触代码之前,需要安装 Selenium。确保您的虚拟环境已激活,并且 Selenium 已包含在requirements.txt文件中:

...    
selenium
...

要开始编写代码,我们的 UI 测试需要在tests目录中有一个自己的文件,命名为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 将控制的网络浏览器,然后在测试结束时关闭它:

import unittest 
from selenium import webdriver

class TestURLs(unittest.TestCase): 
    def setUp(self): 
        self.driver = webdriver.Chrome() 

    def tearDown(self): 
        self.driver.close()

此代码通过 Selenium 控制创建一个新的 Firefox 窗口。当然,为了使其工作,您需要在您的计算机上安装 Firefox。Selenium 确实支持其他浏览器,但使用其他浏览器需要额外的程序才能正确工作。因此,Firefox 在所有浏览器中具有最好的支持。

在我们编写测试代码之前,让我们按照以下方式探索 Selenium API:

    $ python
    >>> from selenium import webdriver
    >>> driver = webdriver.Chrome()
    # 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 陷阱,否则你可能会遇到非常奇怪的错误,这些错误几乎不可能从错误消息中调试:

  • Selenium 被设计成好像有一个真实的人正在控制浏览器。这意味着,如果页面上的某个元素不可见,Selenium 就无法与之交互。例如,如果某个元素覆盖了你希望点击的另一个元素——比如说,一个模态窗口在按钮前面——那么按钮就无法被按下。如果元素的 CSS 将其 display 设置为 none,或者将其 visibility 设置为 hidden,结果将相同。

  • 所有指向屏幕上元素的变量都存储为浏览器中这些元素的指针,这意味着它们不是存储在 Python 的内存中。如果页面在没有使用 get 方法的情况下发生变化,例如当点击链接并创建新的元素指针时,测试将崩溃。这是因为驱动程序将不断寻找前一个页面上的元素,但在新页面上找不到它们。驱动程序的 get 方法清除所有这些引用。

在前面的测试中,我们使用了测试客户端来模拟对应用程序对象的请求。然而,因为我们现在使用的是需要通过浏览器直接与应用程序交互的东西,我们需要一个实际的服务器正在运行。这个服务器需要在运行用户界面测试之前在单独的终端窗口中运行,以便后者有东西可以请求。为此,我们需要一个单独的 Python 文件来运行服务器,并使用我们的测试配置,还需要为我们的 UI 测试设置一些模型。在项目目录的根目录中,创建一个名为 run_test_server.py 的新文件,并添加以下内容:

from webapp import create_app 
from webapp.models import db, User, Role 

app = create_app('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 的了解,我们最终可以编写我们的测试代码了:

import time
import unittest
from selenium import webdriver

class TestURLs(unittest.TestCase):
    def setUp(self):
        self.driver = webdriver.Chrome()

    def tearDown(self):
        self.driver.close()

    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/auth/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")

        #Locate the CKEditor iframe
        time.sleep(3)
        basic_page_body_xpath = "//div[contains(@id, 'cke_1_contents')]/iframe"
        ckeditor_frame = self.driver.find_element_by_xpath(basic_page_body_xpath)

        #Switch to iframe
        self.driver.switch_to.frame(ckeditor_frame)
        editor_body = self.driver.find_element_by_xpath("//body")
        editor_body.send_keys("Test content")
        self.driver.switch_to.default_content()

        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)

if __name__ == "__main__":
    unittest.main()

大部分这个测试使用了我们之前介绍的方法。然而,在这个测试中有一个新的方法,名为 switch_toswitch_to 方法是驱动程序上下文的一部分,允许选择 iframe 元素内的元素。通常情况下,使用 JavaScript,父窗口无法选择 iframe 元素内的任何元素,但因为我们直接与浏览器本身进行交互,我们可以访问 iframe 元素的内部内容。我们需要切换这样的联系人,因为帖子创建页面内的 WYSIWYG 编辑器使用 iframe 来创建自身。在完成选择 iframe 内的元素后,我们需要使用 parent_frame 方法切换回父上下文。

你现在拥有了完全测试你的服务器代码和用户界面代码所需的工具。在接下来的章节中,我们将重点关注工具和方法,以便使你的测试在确保应用程序正确性方面更加有效。

测试覆盖率

现在我们已经编写了测试,我们必须知道我们的代码是否得到了充分的测试。测试覆盖率(也称为代码覆盖率)的概念是为了解决这个问题而发明的。在任何项目中,测试覆盖率表示在运行测试时项目中的代码执行百分比,以及哪些行从未运行。这给出了一个关于哪些项目部分没有被我们的单元测试测试的想法。为了将覆盖率报告添加到我们的项目中,使用 pip 安装覆盖率库,并确保它包含在 requirements.txt 中:

 (venv)$ pip install coverage

覆盖率库可以作为命令行程序运行,在测试运行时执行你的测试套件并获取其测量结果:

    $ coverage run --source webapp --branch -m unittest discover

--source 标志告诉 coverage 只报告 webapp 目录中文件的测试覆盖率。如果不包括它,所有在应用程序中使用的库的百分比也会包括在内。默认情况下,如果 if 语句中的任何代码被执行,整个 if 语句就被说成是执行了。--branch 标志告诉 coverage 禁用此功能,并测量一切。

coverage 运行我们的测试并获取其测量结果后,我们可以通过两种方式查看其发现报告。第一种是在命令行上查看每个文件的覆盖率百分比:

$ coverage report
...
# You will get a full detailed report of your test coverage, breakdown by python file name coverage, and with the line numbers missed by your test
...

TOTAL 729 312 118 10 56%

查看报告的第二种方式是使用 coverage 的 HTML 生成能力,在浏览器中查看每个文件的详细分解,使用以下命令:

    $ coverage html

前一个命令创建了一个名为 htmlcov 的目录。当在浏览器中打开 index.html 文件时,可以点击每个文件名来查看测试期间哪些行被执行,哪些行没有执行:

图片

在前面的屏幕截图中,打开了 blog/controllers.py 文件,覆盖率报告清楚地显示帖子路由从未被执行。然而,这也带来了一些错误的否定。由于用户界面测试没有测试由覆盖率程序运行的代码,它不计入我们的覆盖率报告。为了解决这个问题,确保你在测试用例中对每个将被用户界面测试测试的单独函数都有测试。

在大多数项目中,目标覆盖率百分比约为 90%。很少有项目会有 100% 的代码可测试,并且随着项目规模的增加,这种可能性会降低。

测试驱动开发

现在我们已经编写了测试,如何将它们集成到开发过程中呢?目前,我们使用测试来确保在创建功能后代码的正确性。但是,如果我们颠倒顺序,从一开始就使用测试来创建正确的代码,会怎么样呢?这正是测试驱动开发TDD)所倡导的。

TDD 遵循一个简单的循环来编写你应用程序中新的功能代码:

图片

在使用 TDD 的项目中,你首先编写的是测试,而不是控制你实际构建的任何代码。这迫使项目中的程序员在编写任何代码之前规划项目的范围、设计和需求。在设计 API 时,这也迫使程序员从消费者的角度设计 API 的接口(或合同),而不是在所有后端代码编写完毕后再设计接口。

在 TDD 中,测试被设计成第一次运行时就会失败。在 TDD 中有一句话,如果你的测试第一次运行时没有失败,那么你实际上并没有真正进行测试。这意味着你很可能是测试了测试单元的功能,而不是在编写测试之后如何应该工作。

在你的测试第一次失败后,你然后会持续编写代码,直到所有测试通过。这个过程会为每个新功能重复进行。

一旦所有原始测试通过并且代码重构完成,TDD 会告诉你停止编写代码。通过仅在测试通过之前编写代码,TDD 还强制执行了YAGNI(你不会需要它)哲学,该哲学指出程序员应该只实现他们实际需要的功能,而不是他们认为将来需要的功能。当程序员试图预先添加不需要的功能时,在开发过程中会浪费大量的精力。

TDD 还提倡KISS(保持简单,傻瓜)的理念,这规定从开始设计时,简单性应该是一个设计目标。TDD 提倡 KISS,因为它要求编写小的、可测试的代码单元,这些单元可以彼此分离,并且不依赖于共享的全局状态。

此外,在遵循 TDD(测试驱动开发)的项目中,测试过程中始终存在最新的文档。编程的一个公理是,对于任何足够大的程序,文档总是会过时。这是因为当程序员更改代码时,文档往往是他们最后考虑的事情。然而,通过测试,项目中每个功能模块都有明确的示例(如果项目有较高的代码覆盖率)。测试会不断更新,因此,展示了程序功能和 API 应该如何工作的良好示例。

现在你已经了解了 Flask 的功能以及如何为 Flask 编写测试,你接下来在 Flask 中创建的项目可以完全使用 TDD(测试驱动开发)方法来完成。

摘要

现在你已经了解了测试以及它能为你的应用程序带来什么,你可以创建出几乎零缺陷的应用程序。你将花费更少的时间来修复缺陷,更多的时间来添加用户请求的功能。

作为对读者的最终挑战,在进入下一章之前,尝试让你的代码覆盖率超过 95%。

在下一章中,我们将通过介绍你将应用程序部署到服务器上的生产环境的方法来结束本书。

第十三章:部署 Flask 应用程序

现在我们已经到达了这本书的最后一章,并制作了一个完全功能性的 Flask 网络应用程序,我们开发周期的最后一步是将应用程序提供给全世界。托管您的 Flask 应用程序有众多不同的方法,每种方法都有其自身的优缺点。本章将涵盖最佳解决方案,并指导您在何种情况下选择一种方法而不是另一种。

在本章中,我们将涵盖以下内容:

  • 对最常用的网络服务器和网关接口的简要介绍

  • 如何在各种云服务上部署

  • 如何构建 Docker 镜像

  • 如何使用 Docker Compose 描述服务

  • 如何使用 AWS CloudFormation (IaC) 描述您的基础设施

  • 如何设置和使用 CI/CD 系统以轻松构建、测试、审查和部署我们的应用程序

网络服务器和网关接口

在本节中,我们将快速介绍最常用的网络服务器和Web Server Gateway InterfacesWSGI),以及它们之间的区别和配置。WSGI 是位于网络服务器和 Python 应用程序本身之间的一种与应用程序无关的层。

Gevent

获取网络服务器运行的最简单选项是使用名为 gevent 的 Python 库来托管您的应用程序。Gevent 是一个 Python 库,它为 Python 线程库之外提供了另一种并发编程方式,称为协程。Gevent 提供了一个运行 WSGI 应用程序的接口,它既简单又具有良好性能。一个简单的 gevent 服务器可以轻松处理数百个并发用户,这比互联网上网站的用户多出 99%。这种选择的缺点是它的简单性意味着缺乏配置选项。例如,无法向服务器添加速率限制或添加 HTTPS 流量。这种部署选项纯粹适用于您不期望接收大量流量的网站。记住 YAGNI:只有当您真的需要时,才升级到不同的网络服务器。

协程超出了本书的范围,但可以在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 将在每次部署时通过运行您的 requirements.txt 自动为您安装;也就是说,如果您在添加每个新依赖项后正确地使用 pip 冻结。

Tornado

Tornado 是另一种仅使用 Python 部署 WSGI 应用程序的方法。Tornado 是一个设计用来处理数千个并发连接的网络服务器。如果您的应用程序需要实时数据,Tornado 还支持 WebSocket 以实现到服务器的持续、长连接。

不要在 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()

要以管理员的权限运行 Tornado,只需更改命令值为以下内容:

    [program:webapp]
    command=python tserver.py 
    directory=/home/deploy/webapp
    user=deploy

Nginx 和 uWSGI

如果您需要更好的性能或更多自定义选项,最流行的部署 Python 网络应用程序的方式是使用 Nginx 网络服务器作为基于 WSGI 的 uWSGI 服务器的代理前端。反向代理是一种网络程序,它从服务器获取客户端的内容,就像它从代理本身返回一样。这个过程在以下图中展示:

图片

NginxuWSGI的使用方式是这样的,因为这样我们既获得了 Nginx 前端的强大功能,又具有 uWSGI 的定制性。

Nginx是一个非常强大的网络服务器,因其提供了速度和定制的最佳组合而变得流行。Nginx 始终比其他网络服务器,如 Apache 的 httpd,要快,并且具有对 WSGI 应用程序的原生支持。它之所以能够达到这种速度,是因为开发者做出了几个良好的架构决策,并且没有像 Apache 那样试图覆盖大量用例。后一点是在 Nginx 开发早期就做出的决定。具有较小的功能集使得维护和优化代码变得更加容易。从程序员的视角来看,配置 Nginx 也更容易,因为没有巨大的默认配置文件(httpd.conf),每个项目目录中都可以用.htaccess文件覆盖。

uWSGI是一个支持多种不同类型服务器接口的网络服务器,包括 WSGI。uWSGI 处理应用程序内容的提供,以及诸如在多个不同进程和线程之间进行流量负载均衡等事情。

要安装 uWSGI,我们将使用以下pip命令:

    $ pip install uwsgi

为了运行我们的应用程序,uWSGI 需要一个包含可访问 WSGI 应用程序的文件。在项目目录顶层名为wsgi.py的文件中。

要测试 uWSGI,我们可以通过以下命令从命令行界面CLI)运行它:

    $ uwsgi --socket 127.0.0.1:8080 
    --wsgi-file wsgi.py 
    --callable app 
    --processes 4 
    --threads 2 

如果您在自己的服务器上运行此程序,您应该能够访问端口 8080 并看到您的应用程序(如果您没有防火墙的话)。

此命令的作用是从 wsgi.py 文件中加载 app 对象,并使其在 localhost 的端口 8080 上可访问。它还启动了四个不同的进程,每个进程有两个线程,这些进程由主进程自动负载均衡。对于绝大多数网站来说,这样的进程数量是过度的。为了开始,使用一个进程和两个线程,然后根据需要扩展。

我们不必在 CLI 上添加所有配置选项,可以创建一个文本文件来保存我们的配置,这为我们提供了与 Gevent 部分中提到的 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,操作系统将为我们处理 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 在快速服务静态文件方面非常出色。

Apache 和 uWSGI

使用 Apache httpd 与 uWSGI 的设置基本上是相同的。首先,我们需要一个 Apache 配置文件,因此让我们在我们的项目目录根目录下创建一个名为 apache.conf 的新文件,并添加以下代码:

<VirtualHost *:80> 
    <Location /> 
        ProxyPass / uwsgi://127.0.0.1:8080/ 
    </Location> 
</VirtualHost> 

此文件仅告知 Apache 将所有端口 80 上的请求转发到监听端口 8080 的 uWSGI 网络服务器。然而,此功能需要从 uWSGI 获取一个额外的 Apache 插件,名为 mod-proxy-uwsgi

接下来,我们将介绍几种在 平台即服务PaaS)和 基础设施即服务IaaS)工具上部署我们的应用程序的解决方案。您将学习如何创建几种不同类型的环境,并使我们的示例 Blog 应用程序对全世界可用。

在 Heroku 上部署

Heroku 是本章将要介绍的第一个 平台即服务PaaS)提供商。PaaS 是一种服务,提供给网络开发者,使他们能够将网站托管在由他人控制和维护的平台之上。以牺牲一些自由为代价,你可以获得保证,你的网站将自动根据网站的用户数量进行扩展,而无需你做额外的工作。然而,使用 PaaS 工具可能会比运行自己的服务器更昂贵。

Heroku 是一种 PaaS 工具,旨在通过挂钩到现有的工具来为网络开发者提供易用性,而不需要应用程序进行任何大的更改。Heroku 通过读取名为 Procfile 的文件来工作,该文件包含你的 Heroku dyno(基本上是位于服务器上的虚拟机)将要运行的命令。在我们开始之前,你需要一个 Heroku 账户。如果你只想进行实验,有一个免费账户可供使用。

在目录的根目录下,在名为 Procfile 的新文件中,我们有以下内容:

web: uwsgi heroku-uwsgi.ini

这告诉 Heroku 我们有一个名为 web 的进程,该进程将运行 uWSGI 命令并传递 uwsgi.ini 文件。Heroku 还需要一个名为 runtime.txt 的文件,该文件将告诉 Heroku 你希望使用哪个 Python 运行时——在撰写本文时,最新的 Python 版本是 3.7.0:

python-3.7.0

接下来,确保 uwsgi 已存在于 requirements.txt 文件中。

最后,我们需要对之前创建的 uwsgi.ini 文件进行一些修改:

    [uwsgi]
    http-socket = :$(PORT)
    die-on-term = true
    wsgi-file = wsgi.py
    callable = app
    processes = 4
    threads = 2

我们将 uWSGI 监听的端口设置为环境变量 port,因为 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 命令。然后,我们可以将 Heroku 推送到 Git 仓库上的远程分支,以便 Heroku 服务器自动拉取我们的更改:

$ heroku create
$ git push heroku master

如果一切顺利,你现在应该有一个在新的 Heroku dyno 上运行的应用程序。你可以使用以下命令打开新标签页,访问你的新网络应用程序:

$ heroku open

要查看在 Heroku 部署中的应用程序运行情况,请访问 mastering-flask.herokuapp.com/.

使用 Heroku Postgres

正确维护数据库是一项全职工作。幸运的是,我们可以使用 Heroku 的内置功能来自动化这一过程。Heroku Postgres 提供了一个由 Heroku 维护和完全托管的数据库。因为我们使用 SQLAlchemy,所以使用 Heroku Postgres 非常简单。在您的 dyno 仪表板中,有一个链接到您的 Heroku Postgres 信息。通过点击它,您将进入一个类似于以下截图的页面:

图片

通过点击 URL 字段,您将获得一个 SQLAlchemy URL,您可以直接将其复制到您的生产配置对象中。

在 Heroku 上使用 Celery

我们已经设置了生产 Web 服务器和数据库,但我们仍然需要设置 Celery。使用 Heroku 的众多插件之一,我们可以在云中托管一个 RabbitMQ 实例,同时在 dyno 上运行 Celery 工作进程。第一步是告诉 Heroku 在Procfile中运行您的 Celery 工作进程:

web: uwsgi heroku-uwsgi.ini celery: celery worker -A celery_runner

接下来,为了使用免费计划(lemur计划)安装 Heroku RabbitMQ 插件,请使用以下命令:

$  heroku addons:create cloudamqp:lemur

要获取 Heroku 附加组件的完整列表,请访问elements.heroku.com/addons

在仪表板上列出 Heroku Postgres 的位置,您现在将找到 CloudAMQP:

图片

点击 CloudAMQP 也会给您一个带有 URL 的屏幕,您可以将它复制并粘贴到您的生产配置中:

图片

在 Amazon Web Services 上部署

亚马逊网络服务AWS)是由亚马逊维护的一组服务,建立在运行 Amazon.com 的相同基础设施之上。在本节中,我们将使用 Amazon Elastic Beanstalk 来部署我们的 Flask 代码,而数据库将托管在亚马逊的关系数据库服务RDS)上,我们的 Celery 消息队列将托管在亚马逊的简单队列服务SQS)上。

在 Amazon Elastic Beanstalk 上使用 Flask

Elastic Beanstalk 是一个为 Web 应用程序提供的平台,它为开发者提供了许多强大的功能,因此他们不必担心维护服务器。例如,随着同时使用您应用程序的人数增加,您的 Elastic Beanstalk 应用程序将自动通过利用更多服务器进行扩展。对于 Python 应用程序,Elastic Beanstalk 使用 Apache,结合mod_wsgi连接到 WSGI 应用程序——如果您的部署简单,负载中等或较低,则不需要额外配置。

在我们开始之前,您需要一个 Amazon.com 账户来登录控制台。接下来,您需要安装awscli并使用您的凭证配置它——您必须生成 AWS 访问密钥和密钥:前往 AWS 控制台,选择 IAM 服务,选择您的用户,然后选择“安全凭证”选项卡,点击“创建访问密钥”。接下来,我们需要安装 awsebcli 来从命令行管理 Elastic Beanstalk:

$ pip install awsebcli --upgrade --user

接下来,从我们项目的根目录开始,我们将配置 CLI 并创建一个新的 Elastic Beanstalk 应用程序:

$ eb init Enter Application Name
(default is "Chapter-13"): myblog Application myblog has been created.

It appears you are using Python. Is this correct?
(Y/n): Y Select a platform version.
1) Python 3.6
2) Python 3.4
3) Python 3.4 (Preconfigured - Docker)
4) Python 2.7
5) Python
(default is 1): 1 Cannot setup CodeCommit because there is no Source Control setup, continuing with initialization
Do you want to set up SSH for your instances?
(Y/n): Y Select a keypair.
1) aws-sshkey
2) [ Create new KeyPair ]
(default is 1): 1 

Elastic Beanstalk 会查找项目目录中的 application.py 文件,并期望在该文件中找到一个名为 application 的 WSGI 应用程序:

import os
from webapp import create_app
from webapp.cli import register

env = os.environ.get('WEBAPP_ENV', 'dev')
application = create_app('config.%sConfig' % env.capitalize())
register(application)

接下来,我们将创建一个开发环境。每个 Elastic Beanstalk 应用程序可以包含一个或多个环境。但就目前情况来看,我们的应用程序将会失败——我们需要告诉 Elastic Beanstalk 如何在 Python 的虚拟环境中安装 Flask-YouTube 并初始化数据库。为此,我们需要扩展默认设置。

在根目录下,我们需要一个名为 .ebextensions 的目录。这是我们在其中创建大量额外配置和设置脚本的地方。在 .ebextensions 中,我们创建两个将在部署后阶段运行的 shell 脚本。因此,在 .ebextensions/10_post_deploy.config 文件中,添加以下代码:

files:
   "/opt/elasticbeanstalk/hooks/appdeploy/post/01_install_flask_youtube.sh":
        mode: "000755"
        owner: root
        group: root
        content: |
            #!/usr/bin/env bash

            cd /opt/python/current/app
            . /opt/python/current/env
            source /opt/python/run/venv/bin/activate
            sh install_flask_youtube.sh

    "/opt/elasticbeanstalk/hooks/appdeploy/post/02_migrate_database.sh":
        mode: "000755"
        owner: root
        group: root
        content: |
            #!/usr/bin/env bash
...

使用 YAML 语法,我们告诉 Elastic Beanstalk 创建两个 shell 脚本来安装 Flask-YouTube 并创建或迁移数据库。这些文件的位置是特殊的——/opt/elasticbeanstalk/hooks/appdeploy/post 是我们可以放置在部署后执行的脚本的地方。这些脚本按字母顺序执行。同时,请注意以下位置:

  • /opt/python/current/app: 这是应用程序的部署位置。

  • /opt/python/current/env: 这是一个包含 Elastic Beanstalk 上定义的环境变量的文件。

  • /opt/python/run/venv: 这是指定的 Python 的 virtualenv,也是 Elastic Beanstalk 安装所有定义的依赖项的地方。

现在,为了创建我们的环境,运行以下命令:

$ eb create myblog-dev
$ # Setup this environment variable
$ eb setenv WEBAPP_ENV=Dev

最后,在环境完成基础设施和部署配置后,我们可以使用以下命令检查我们的应用程序:

$ eb open

要部署我们应用程序的新版本,我们只需运行此命令:

$ eb deploy

注意,我们的开发环境使用 SQLite,因此数据库位于 Web 服务器上的一个文件中。在每次部署或实例重建时,此数据库都会被重新创建。

使用 Amazon RDS

Amazon RDS 是一个云数据库托管平台,它自动管理多个方面,例如节点故障时的恢复、计划备份和主/从设置。

要使用 RDS,请转到 AWS 控制台的“服务”选项卡,并点击“关系数据库服务”。

现在,创建并配置一个新的数据库——确保在公开访问选项中选择否。选择与实例相同的 VPC,并仔细登记您的管理员凭证。现在,等待几分钟以创建实例。之后,选择您的实例,转到详细配置,并找到端点字段——它应该看起来像 myblog.c7pdwgffmbqdm.eu-central-1.rds.amazonaws.com。我们的生产配置使用系统环境变量来设置数据库 URI,因此我们必须配置 Elastic Beanstalk 以设置 DB_URI 环境变量。

要使用这些环境变量,我们需要将博客的 config.py 文件更改为使用实际的 OS 环境变量,如下所示:

class ProdConfig(Config):
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    SQLALCHEMY_DATABASE_URI = os.environ.get('DB_URI', '')

    CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', '')
    CELERY_RESULT_BACKEND = os.environ.get('CELERY_BROKER_URL', '')

    CACHE_TYPE = 'redis'
    CACHE_REDIS_HOST = os.environ.get('REDIS_HOST', '')
    CACHE_REDIS_PORT = '6379'
    CACHE_REDIS_PASSWORD = ''
    CACHE_REDIS_DB = '0'

确保您的实例可以连接到数据库。如果您选择了安全组默认选项和 RDS 创建,那么向导将为您创建一个安全组(默认名称为 'rds-launch-wizard')。在 EC2 上,编辑此安全组并打开 3306 端口到您的实例的 VPC CIDR。

.ebextensions 中查看 01_env.config——这是我们设置环境变量的地方:

option_settings:
  aws:elasticbeanstalk:application:environment:
    WEBAPP_ENV: Prod
    DB_URI: mysql://admin:password@myblog.c4pdwhkmbyqm.eu-central-1.rds.amazonaws.com:3306/myblog
    CELERY_BROKER_URL: sqs://sqs.us-east-1.amazonaws.com/arn:aws:sqs:eu-central-1:633393569065:myblog-sqs/myblog-sqs

最后,让我们使用以下命令创建生产环境:

$ eb create myblog-prod 

使用 Celery 和 Amazon SQS

为了在 AWS 上使用 Celery,我们需要让我们的 Elastic Beanstalk 实例在后台运行 Celery 工作进程,并设置 SQS 消息队列。为了使 Celery 支持 SQS,它需要从 pip 安装一个辅助库。再次确认我们的 requirements.txt 文件包含 boto3 包。Elastic Beanstalk 将查看此文件并从中创建一个虚拟环境。

在 SQS 上设置新的消息队列非常简单。转到“服务”选项卡,然后在“应用程序”选项卡中点击“简单队列服务”,然后点击创建新队列。在非常简短的配置屏幕后,你应该会看到一个类似于以下屏幕的界面:

图片

接下来,我们必须让我们的实例访问新创建的 SQS。最简单的方法是编辑 Elastic Beanstalk 默认实例配置文件(不过这并不推荐——您应该创建一个单独的实例配置文件,并使用 .ebextensions 选项设置将所有实例与其关联)。默认 IAM 实例配置文件名为 aws-elasticbeanstalk-ec2-role。转到 IAM 服务,然后选择角色,然后选择 aws-elasticbeanstalk-ec2-role 角色。接下来,点击添加内联策略并按照向导为新建的 SQS 提供访问权限。

现在我们必须将我们的 CELERY_BROKER_URL 改为新 URL,其格式如下:

$ eb setenv CELERY_BROKER_URL=sqs://sqs.us-east-1.amazonaws.com/arn:aws:sqs:us-east-1:<AWS_ACCOUNT_ID>:myblog-sqs/myblog-sqs

AWS_ACCOUNT_ID 的值更改为您的 AWS 账户 ID。

最后,我们需要告诉 Elastic Beanstalk 在后台运行 Celery 工作进程。再一次,我们可以在.ebextensions中这样做。创建一个名为11_celery_start.config的文件,并将以下代码插入其中:

commands:
    celery_start:
        command: |
              #!/usr/bin/env bash
              cd /opt/python/current/app
              . /opt/python/current/env
              source /opt/python/run/venv/bin/activate
              celery multi start worker1 -A celery_runner

注意,这种 Celery 工作部署存在于 Web 服务器上(这并不推荐),并且会随着 Web 服务器按需扩展。更好的选择是探索 Elastic Beanstalk 的工作功能,但这将意味着对功能进行彻底的重构,我们可能会遭受后续的供应商锁定。

使用 Docker

Docker 是 Docker, Inc. 在 2013 年创建的一种基于容器的技术。容器技术并不新鲜,Unix 操作系统上已经存在一段时间了,1982 年就有 chroot,2004 年有 Solaris Zones,AIX 或 OS400 系统上也有 WPAR(尽管 WPAR 更像是一种虚拟化技术而不是容器)。后来,Linux 集成了两个重要的特性:namespaces,它隔离了 OS 功能名称,以及 cgroups,这是一个受配置和资源限制约束的进程集合。这些新特性催生了 Linux 容器,那么为什么还要使用 Docker 呢?

主要是因为 Docker 使配置定义变得简单。使用非常容易编写的 Dockerfile,您可以描述如何配置您的容器并使用它创建一个新的镜像。每个 Dockerfile 行都会使用 UnionFS 创建一个新的文件系统层,这使得更改非常快速地应用,并且同样容易回滚和前进到更改。此外,Docker, Inc. 创建了一个开放的镜像仓库,您可以在其中找到几乎所有 Linux 软件的质量镜像。我们已经在第九章,使用 Celery 创建异步任务中使用了一些这些镜像。

Docker 已经获得了巨大的关注和炒作。其中一些最好的特性如下:

  • 解决来自操作系统的依赖问题:由于我们正在将一个薄的操作系统打包到您的容器镜像中,因此可以安全地假设在您的笔记本电脑上运行的内容在生产环境中也能运行。

  • 容器非常轻量,用户可以在同一虚拟机或硬件主机上运行多个容器,这可以降低运营成本并提高效率。

  • 容器启动非常快,使您的基础设施能够快速扩展,例如,如果您需要处理工作负载的增加。

  • 开发者可以轻松地使用容器与其他开发者共享他们的应用程序。

  • Docker 支持 DevOps 原则:开发者和运维人员可以在镜像和架构定义上共同工作,使用 Dockerfile 或 Docker Compose。

如果我们考虑 Docker 容器与虚拟机提供的功能差异,让我们记住容器共享相同的内核并且通常运行单个进程,而虚拟机运行一个完整的客户操作系统:

这种架构使容器非常轻量且快速启动。

创建 Docker 镜像

在前面的章节中,我们的 Blog 应用程序已经从简单的三层架构发展到多层架构。我们现在需要处理 web 服务器、数据库、缓存系统和队列。我们将定义每个这些层为 Docker 容器。

首先,让我们从我们的 web 服务器和 Flask 应用程序开始。为此,我们将使用 Nginx 前端,以及后端使用的 WSGI,称为 uWSGI。

Dockerfile 是一个包含特殊指令的文本文件,我们使用它来指定我们的 Docker 镜像以及如何运行它。构建过程将逐个执行命令,在每个命令上创建一个新的层。一些最常用的 Dockerfile 命令包括以下内容:

  • FROM**: **指定我们的新镜像基于的基础镜像。我们可以从一个非常薄的操作系统开始,例如 Alpine,或者直接从 RabbitMQ 镜像开始。

  • EXPOSE: 通知 Docker 容器监听指定的网络端口/协议。

  • ENV: 设置环境变量。

  • WORKDIR: 为 Dockerfile 建立基本目录。

  • RUN: 在新层上运行 bash Linux 命令。这通常用于安装额外的包。

  • COPY: 从本地文件系统复制文件或目录到 Docker 镜像。

  • CMD: 只能有一个 CMD 实例。它指定了如何运行容器。

  • ENTRYPOINT: 这与 CMD 有相同的目标,但在 Docker 中是一个脚本。

要查看 Dockerfile 命令的完整参考,请参阅docs.docker.com/engine/reference/builder/#usage文档。

我们为 Docker 部署的目录结构将是以下内容:

/
  deploy/
  docker/
    docker-compose.yml -> Compose file
    ecs-docker-compose.yml -> Specific compose file for AWS ECS
    Dockerfile_frontend -> Dockerfile for the frontends
    Dockerfile_worker -> Dockerfile for the workers
    prod.env -> Production environment variables
    worker_entrypoing.sh -> entrypoint for the celery worker
  supervisor_worker.sh -> Supervisor conf file for the celery worker
  uwsgi.ini -> Conf. file for uWSGI

我们将要创建的镜像将用于 Docker Compose(本章后面将详细介绍),因此它们不能独立工作。如果您不想使用 Docker Compose,对镜像进行少量修改即可使其工作——您只需更改prod.env文件即可。

首先,让我们为我们的 web 服务器创建一个 Dockerfile。我们将使用一个已经包含 NGINX 和 uWSGI 的先前镜像,这样我们就可以节省安装和配置它们的工作。我们的Dockerfile_frontend是包含创建前端镜像定义的 Dockerfile:

FROM tiangolo/uwsgi-nginx:python3.6

# Create and set directory where the code will live
RUN mkdir /srv/app
WORKDIR /srv/app

# Copy our code
COPY . .
# Install all python packages required
RUN pip install -r requirements.txt
RUN sh install_flask_youtube.sh

# Setup NGINX and uWSGI
COPY ./deploy/uwsgi.ini /etc/uwsgi/uwsgi.ini
ENV NGINX_WORKER_OPEN_FILES 2048
ENV NGINX_WORKER_CONNECTIONS 2048
ENV LISTEN_PORT 80

EXPOSE 80

首先,在前面的片段中,我们将我们的镜像基于uwsgi-nginx:python3.6,这意味着我们将使用 Python 3.6。接下来,我们创建并设置应用程序将驻留的目录——这将是/srv/app。然后,我们使用COPY . .将所有本地内容(myblog 代码)复制到镜像本身。接下来,我们复制 WSGI 的配置文件,最后配置 NGINX 将使用的工人数。最后,我们通知 Docker 该镜像将在端口 80 上监听,使用EXPOSE 80

接下来,让我们看一下我们的 Celery 工作器 Dockerfile:

FROM ubuntu
RUN  apt-get update && \
     apt-get install -y supervisor python3-pip python3-dev libmysqlclient-dev mysql-client
RUN mkdir /srv/app
WORKDIR /srv/app
COPY . .
RUN pip3 install -r requirements.txt
RUN sh install_flask_youtube.sh

COPY ./deploy/supervisor_worker.conf /etc/supervisor/conf.d/celery_worker.conf
COPY ./deploy/docker/worker_entrypoint.sh .
ENTRYPOINT ["sh", "./worker_entrypoint.sh"]

这次,我们的基础镜像将是 Ubuntu(特别是,一个非常薄的 Ubuntu 版本用于 Docker)。我们将使用 supervisor Python 包来监控和启动我们的 Celery 进程,所以如果 Celery 由于某种原因崩溃,supervisor 将重新启动它。因此,在操作系统级别,我们正在安装 supervisor、Python 3 和 MySQL 客户端包。看看前面代码块中的 worker_entrypoint.sh shell 脚本,我们在那里做一些有趣的事情:

  • 我们正在等待 MySQL 变得可用。当使用 Docker Compose 时,我们可以定义每个任务(即每个 Docker 容器)启动的顺序,但我们没有方法知道服务是否已经可用。

  • 接下来,我们使用 Flask CLI 和 Alembic 创建或迁移我们的数据库。

  • 最后,我们将测试数据插入到我们的数据库中(简单来说,对读者来说是个好东西),这样当你启动应用程序时,它已经处于可工作状态,并且已经存在一些假帖子数据。

要构建和创建我们的镜像,请在项目根目录的 shell 中执行以下 Docker 命令:

$ docker build -f deploy/docker/Dockerfile_frontend -t myblog:latest . 

这将创建一个名为 myblog 的镜像,带有 latest 标签。作为生产最佳实践的组成部分,你应该使用项目版本给你的镜像打标签,也使用 git 标签。这样,我们总能确保代码在哪个镜像中;例如,myblog:1.0myblog:1.1 之间有什么变化。

最后,使用以下命令创建 Celery 工作进程镜像:

$ docker build -f deploy/docker/Dockerfile_worker -t myblog_worker:latest . 

现在我们已经创建了自定义镜像,我们可以进入下一节,在那里我们将定义所有基础设施并将容器相互连接。

Docker Compose

Docker Compose 是定义我们多层应用程序的工具。这是我们定义运行应用程序所需的所有服务、配置它们并将它们连接在一起的地方。

Docker Compose 基于 YAML 文件,所有定义都发生在这里,所以让我们直接进入它,看看 deploy/docker/docker-compose.yaml 文件:

version: '3'
services:
  db:
    image: mysql:5.7
    env_file:
      - prod.env
  rmq:
    image: rabbitmq:3-management
    env_file:
      - prod.env
    ports:
      - 15672:15672
  redis:
      image: redis
  worker:
    image: myblog_worker:latest
    depends_on:
      - db
      - rmq
    env_file:
      - prod.env
  frontend:
    image: myblog
    depends_on:
      - db
      - rmq
    env_file:
      - prod.env
    restart: always
    ports:
      - 80:80

在 Docker Compose 中,我们定义了以下服务:

  • mysql:这是基于 Docker Hub 社区版的 MySQL 5.7 镜像。所有自定义配置都使用环境变量完成,如 prod.env 文件中定义的那样。

  • rmq:Rabbit MQ 基于我们定制的 Docker Hub 社区镜像,创建用户凭据、cookies 和 VHOST。这将安装管理界面,可以通过 http://localhost:15672 访问。

  • redis:这是我们缓存用的 Redis 服务。

  • 工作进程:这使用我们之前构建的 myblog_worker Docker 镜像。

  • 前端:这使用我们之前构建的 myblog_worker Docker 镜像。

这是一个非常简单的 composer 定义。注意 depends_on,其中我们定义了哪些服务依赖于其他服务。例如,我们的前端服务将依赖于数据库和 Rabbit MQ。ports 键是一个公开端口的列表;在这种情况下,前端端口 80 将由 Docker 主机上的端口 80 公开。这样,我们就可以通过 Docker 主机的 IP 端口 80 或通过在 Docker 主机前面使用负载均衡器来访问我们的应用程序。在已经安装 Docker 的机器上,您可以通过 http://localhost 访问应用程序。

使用 prod.env 文件非常重要,因为这样我们可以为不同的环境定义不同的配置,同时仍然使用相同的 compose 文件。在环境中使用相同的 compose 文件遵守了十二要素应用规则中关于使基础设施组件在所有环境中相同的规定。

让我们看看 prod.env 文件:

WEBAPP_ENV=Prod
DB_HOST=db
DB_URI=mysql://myblog:password@db:3306/myblog
CELERY_BROKER_URL=amqp://rabbitmq:rabbitmq@rmq//
REDIS_HOST=redis
MYSQL_ROOT_PASSWORD=rootpassword
MYSQL_DATABASE=myblog
MYSQL_USER=myblog
MYSQL_PASSWORD=password
RABBITMQ_ERLANG_COOKIE=SWQOKODSQALRPCLNMEQG
RABBITMQ_DEFAULT_USER=rabbitmq
RABBITMQ_DEFAULT_PASS=rabbitmq
RABBITMQ_DEFAULT_VHOST=/

此文件环境变量将设置实际的操作系统级别环境变量,以便在应用程序的配置文件中简单使用。这将符合来自 https://12factor.net/ 的十二要素应用规则之一。

在顶部,我们使用 WEBAPP_ENV=Prod 设置我们的应用程序生产配置环境。

MYSQL_* 变量是我们配置 MySQL 5.7 容器的地方。我们设置了 root 密码,并设置了一个初始数据库来创建(如果需要)为该数据库创建用户和密码。

需要注意的是,REDIS_HOSTDB_URICELERY_BROKER_URL 这些变量正在使用每个容器实际使用的实际主机名来与其他容器进行通信。默认情况下,这些是服务名称,这使得一切变得相当简单。因此,前端容器使用 db 网络主机名来访问数据库。

最后,让我们开始我们的应用程序:

$ docker-compose -f deploy/docker/docker-compose.yml up

等待所有容器启动,然后打开您的浏览器并转到 http://localhost

在 AWS 上部署 Docker 容器

要在 AWS 上部署,我们将使用 Amazon Elastic Container ServiceECS)。ECS 是一个为 Docker 提供可扩展集群的服务,无需安装任何软件来编排您的容器。它基于 AWS Auto Scaling GroupsASG),该组使用 Docker 安装实例进行扩展或缩减。这种扩展是由监控指标触发的,例如 CPU 使用率或网络负载。ECS 还会将所有容器从某个原因终止或服务受损的实例迁移出来。因此,ECS 充当集群的角色。之后,ASG 将启动一个新实例来替换有故障的实例。

CloudFormation 基础

AWS 提供了许多服务,每个服务都有许多配置选项。您还需要将这些服务连接起来。为了有效地和可靠地创建、配置、更新或销毁这些服务,我们将向您展示如何使用 AWS 的IaC基础设施即代码)技术,称为 CloudFormation。CloudFormation并非复杂的技术,而是遵循所有 AWS 服务和配置选项的扩展。CloudFormation 的详细信息和操作可能需要一本书来专门介绍。

CloudFormation 是一个扩展的数据结构,您可以使用 JSON 或 YAML 编写。我说扩展,因为可以使用引用、函数和条件。一个 CloudFormation 文件由以下部分组成:

AWSTemplateFormatVersion: "version date"
Description: "Some description about the stack"
Parameters: Input parameters to configure the stack
Metadata: Aditional data about the template, also useful to group parameters on the UI
Mappings: Data mappings definitions
Conditions: Setup conditions to setup resources or configuration
Transform: Mainly used for AWS serverless
Resources: Resource definitions, this is the only required section
Output: Section to output data, you can use it return the DNS name to access the created application

让我们快速查看提供的 CloudFormation 文件./deploy/docker/cfn_myblog.yml。我们将逐个遵循所有 CloudFormation 部分。首先,让我们检查参数部分:

...
Parameters:
  ApplicationName:
    Description: The application name
    Type: String
    Default: ecs001
  Environment:
    Description: Application environment that will use the Stack
    Type: String
    Default: prod
    AllowedValues:
    - dev
    - stg
    - prod
  InstanceType:
    Description: Which instance type should we use to build the ECS cluster?
    Type: String
    Default: t2.medium
...

不深入细节,在这个文件中,一个输入参数由一个名称定义,可能包含描述、类型、默认值和接受值的规则。所有这些值将在配置我们的基础设施时被引用。这些值将在部署或更新 CloudFormation 堆栈时填写。

接下来,看看映射部分:

...
Mappings:
  AWSRegionToAMI:
    us-east-2:
        AMI: ami-b86a5ddd
    us-east-1:
        AMI: ami-a7a242da
    us-west-2:
        AMI: ami-92e06fea
...

这只是一个方便的数据结构,用于将 AWS 区域映射到 AMI。AMI 是我们用于我们的 Docker VMs 的基础操作系统镜像。每个区域中的 AMI 都有一个不同的标识符,因此我们需要将它们映射出来,以便我们的堆栈可以在任何 AWS 区域上部署。在我们的案例中,我们将使用 Amazon ECS 优化的 Linux。

现在,让我们考虑元数据部分:

...
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
    - Label:
        default: System Information (Tags)
      Parameters:
      - Environment
      - ApplicationName
    - Label:
        default: Networking
      Parameters:
      - VPC
      - Subnets
...

在这里,我们声明了一个Interface来分组我们的参数。这只是为了让参数以更美观的方式显示给将要部署堆栈的人。记住,参数部分是一个字典,且该字典的键是无序的。

主要且更为重要的部分是资源。我们不会对此进行详细说明,而是快速概述我们将要创建的主要基础设施资源及其连接方式。首先,对于数据库,我们将使用另一个 AWS 服务,称为RDS,并创建一个 MySQL 服务器:

Resources:
...
DB:
 Type: AWS::RDS::DBInstance
  Properties:
    AllocatedStorage: "30"
    DBInstanceClass: "db.t2.medium"
    Engine: "MariaDB"
    EngineVersion: "10.2.11"
    MasterUsername: !Ref DBUsername
    MasterUserPassword: !Ref DBPassword
    DBSubnetGroupName: !Ref DBSubnetGroup
    VPCSecurityGroups:
      - Ref: DBSecurityGroup

每个资源都有一个类型。对于 RDS,这是AWS::RDS:DBInstance。每种类型都有自己的特定属性集。注意!Ref如何声明来自其他资源或参数的值。"DBUsername"和"DBPassword"是参数,但"DBSubnetGroup"和"DBSecurityGroup"是由 CloudFormation 创建的资源,用于设置数据库的网络 ACL 和子网放置。

ECS 集群资源声明如下:

ECSCluster:
  Type: "AWS::ECS::Cluster"
  Properties:
    ClusterName: !Sub ${Environment}-${ApplicationName}

ECSAutoScalingGroup:
  Type: AWS::AutoScaling::AutoScalingGroup
  Properties:
...

ECSLaunchConfiguration:
  Type: AWS::AutoScaling::LaunchConfiguration
  Properties:
...

ECSRole:
  Type: AWS::IAM::Role
  Properties:
...
ECSInstanceProfile:
  Type: AWS::IAM::InstanceProfile
  Properties:
...
ECSServiceRole:
  Type: AWS::IAM::Role
  Properties:
...

所有这些定义都属于 ECS 集群。这个集群可以用于部署许多不同的应用程序,因此在这些定义上声明单独的 CloudFormation 文件或使用嵌套堆栈是有意义的。为了简化部署,我们将使用单个文件来创建我们的应用程序。首先,我们创建 ECS 集群,并设置其名称为 EnvironmentApplicationName 参数的连接。这是通过 !Sub CloudFormation 函数完成的。

接下来,我们声明我们集群的 自动扩展组ASG),并设置 AWS 将如何为属于此 ASG 的每个实例进行配置。这些是 ECSAutoScalingGroupECSLaunchConfiguration 资源。最后,使用 ECSRoleECSInstanceProfileECSServiceRole 来设置 ECS 集群获取 Docker 镜像、与 AWS 负载均衡器(ELB)、S3 等进行工作所需的安全权限。这些权限是 AWS 作为示例使用的标准,当然也可以降低权限级别。

现在,针对我们的应用,我们将定义 ECS 服务和 ECS 任务定义。任务定义是我们定义一个或多个容器定义的地方,这些定义引用要使用的 Docker 镜像,并包括环境变量。然后,ECS 服务引用一个 ECS 任务定义,并且可以将其与负载均衡器关联,并设置部署配置选项,例如性能限制和自动扩展选项(是的,ECS 集群可以根据负载变化进行扩展或缩减,但我们的容器也可以独立地进行扩展或缩减):

FrontEndTask:
  DependsOn: WorkerTask
  Type: "AWS::ECS::TaskDefinition"
  Properties:
    ContainerDefinitions:
      -
        Name: "frontend"
        Image: !Ref DockerFrontEndImageArn
        Cpu: "10"
        Memory: "500"
        PortMappings:
          -
            ContainerPort: "80"
            HostPort: "80"
        Environment:
          -
            Name: "WEBAPP_ENV"
            Value: !Ref Environment
          -
            Name: "CELERY_BROKER_URL"
            Value: !Sub "amqp://${RMQUsername}:${RMQPassword}@${ELBRMQ.DNSName}:5672//"
          -
            Name: "DB_URI"
            Value: !Sub "mysql://${DBUsername}:${DBPassword}@${DB.Endpoint.Address}:3306/myblog"
          -
            Name: "REDIS_HOST"
            Value: !Sub ${ELBRedis.DNSName}

这是我们的前端容器的任务定义。你可能注意到,这是我们已经看到的 Docker Compose 服务的 CloudFormation 版本。我们为我们的容器声明一个名称,Name: "frontend",稍后将在负载均衡器中引用。接下来,image:!Ref DockerFrontEndImageArn 是对输入参数的引用。这将使我们能够轻松部署我们博客应用程序的新版本。Docker 的端口映射在 PortMappings 中声明,这是一个键值对的列表,重复了 ContainerPortHostPort 的键。环境也是一个键值对的列表,在这里我们从我们创建的其他资源中为 DB、RMQ 和 Redis 进行“连接”。例如,这里是如何使用 DB_URI 的:

-
            Name: "DB_URI"
            Value: !Sub "mysql://${DBUsername}:${DBPassword}@${DB.Endpoint.Address}:3306/myblog"

这个 Value 是我们构建数据库 URI 的地方,使用我们已知的 !Sub 函数和 DBUsername 以及 DBPassword 的引用。DB.Endpoint.Address 是我们如何引用 AWS 为我们新创建的 MySQL 服务器创建的 DNS 名称。

在服务定义中,我们将我们的容器与 AWS 弹性负载均衡器关联,并做一些部署配置:

MyBlogFrontendService:
  Type: "AWS::ECS::Service"
  Properties:
    Cluster: !Ref ECSCluster
    DeploymentConfiguration:
      MaximumPercent: 200
      MinimumHealthyPercent: 50
    DesiredCount: 2
    TaskDefinition: !Ref FrontEndTask
    LoadBalancers:
      -
        ContainerName: 'frontend'
        ContainerPort: 80
        LoadBalancerName: !Ref ELBFrontEnd
  DependsOn:
    - ECSServiceRole
    - FrontEndTask

首先,我们声明这个服务将在我们新创建的 ECS 集群上运行,使用Cluster: !Ref ECSCluster。然后,使用DeploymentConfigurationDesiredCount,我们说明这个服务将以两个容器(为了高可用性)启动,并允许它在 4 到 1 之间进行扩展和缩减。这遵循以下公式:

  • 最大容器数量 = DesiredCount * (MaximumPercent / 100)

  • 最小容器数量 = DesiredCount * (MinimumPercent / 100)

因此,将公式应用于我们的案例,我们得到以下结果:

  • 4 = 2 * (200/100)

  • 1 = 2 * (50/100)

通过TaskDefinition: !RefFrontEndTask,我们说明这个服务使用我们之前的前端任务定义。最后,通过LoadBalancers键属性,我们将我们的服务与负载均衡器绑定。这意味着我们两个新创建的容器将均匀地接收来自用户的请求,并且随着容器的创建,新的容器将自动在负载均衡器上注册。

最后,让我们看看负载均衡器定义:

ELBFrontEnd:
  Type: AWS::ElasticLoadBalancing::LoadBalancer
  Properties:
    SecurityGroups:
    - Fn::GetAtt:
      - ELBFrontEndSecurityGroup
      - GroupId
    Subnets:
      Ref: Subnets
    Scheme: internet-facing
    CrossZone: true
    Listeners:
    - LoadBalancerPort: '80'
      InstancePort: '80'
      Protocol: HTTP
      InstanceProtocol: HTTP
    HealthCheck:
      Target: TCP:80
      HealthyThreshold: '2'
      UnhealthyThreshold: '3'
      Interval: '10'
      Timeout: '5'

这是一个 AWS 经典 ELB 定义,其中我们将 ELB 与网络安全组关联,这大致相当于防火墙。这是通过SecurityGroups键属性完成的。接下来,我们定义 ELB 将在哪些子网中提供服务。每个子网都在不同的 AWS 可用区中创建,每个可用区代表 AWS 区域中的一个数据中心(每个区域包含两个或多个数据中心,或可用区)。然后,我们定义这个 ELB 将通过Scheme: internet-facing暴露给互联网。对于Listeners,我们说 ELB 的 80 端口映射到 Docker 主机的 80 端口。最后,我们定义服务的健康检查,以及这种检查将持续的时间。

docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-elb.html查看 ELB CloudFormation 定义的更多详细信息。

我们还在 CloudFormation 提供的./deploy/docker/cfn_myblog.yml YAML 文件中创建了以下资源:

  • 为 ELBs 和 Docker 主机设置几个安全组

  • 我们 myblog Celery 工作者的任务定义和相应的服务

  • 我们 RabbitMQ 容器的任务定义和相应的服务

  • 我们 Redis 容器的任务定义和相应的服务

  • Redis 容器的负载均衡器

  • RabbitMQ 的负载均衡器

使用 RabbitMQ 的负载均衡器是一种获取服务发现功能的经济方式——在单个实例上平衡负载似乎很奇怪,但如果我们的 RabbitMQ 所在位置的 Docker 主机因某种原因崩溃,那么 RabbitMQ 容器将创建在另一个 Docker 主机上,并且应用程序需要能够动态地找到它。

创建和更新一个 CloudFormation 堆栈

我们可以使用控制台或 CLI 创建和部署我们的 CloudFormation 堆栈。要使用控制台创建,请选择 AWS CloudFormation 服务,然后点击创建堆栈按钮。你会看到以下表单:

图片

选择“上传模板到 Amazon S3”选项,然后从提供的代码中选择deploy/docker/cfn_myblog.yaml文件,并点击下一步。现在,我们需要按照以下方式填写堆栈参数:

  • Stack Name: 提供一个名称来识别此堆栈;使用你想要的任何名称。

  • Environment: 选择此堆栈的生产、预发布和开发环境。

  • ApplicationName: 在这里,使用你想要用来识别 ECS 集群的任何名称。

  • VPC: 选择一个 AWS VPC。

  • Subnets: 从下拉菜单中选择属于 VPC 的所有子网(如果你有公共和私有子网,请只选择公共子网,记住 ELB 是面向互联网的)。

  • ClusterSize: 这是 ECS 集群的大小;在这里保留默认设置2

  • InstanceType: 这是 Docker 主机使用的 AWS 实例类型。

  • KeyName: 这是 AWS 密钥对,需要是我们之前创建的。我们可以使用私钥通过 SSH 连接到 Docker 主机。

  • DockerFrontEndImageArn: 这是上传我们前端 Docker 镜像的 ECR 存储库的 ARN。

  • DockerWorkerImageArn: 这是上传我们工作 Docker 镜像的 ECR 存储库的 ARN。

  • DBUsername, DBPassword, RMQUsername, 和 RMQPassword: 这些都是数据库和 RabbitMQ 的凭证;选择你想要的任何值。

填写所有参数后,点击下一步。出现一个选项表单——再次点击下一步。出现一个带有我们的参数和可能的堆栈更改的审查页面。在这里,我们需要检查我承认 AWS CloudFormation 可能会创建具有自定义名称的 IAM 资源选项,并点击创建。所有资源的创建将需要几分钟时间——等待 CREATE_COMPLETED 状态。要检查我们的应用程序,只需转到输出选项卡并点击 URL。

现在,让我们看看我们如何轻松地开发和部署代码更改。首先,进行简单的代码更改。例如,在webapp/templates/head.html文件中,找到以下行:

...
<h1><a class="text-white" href="{{ url_for('blog.home') }}">My Blog</a></h1>
...

现在,将前面的行更改为以下内容:

...
<h1><a class="text-white" href="{{ url_for('blog.home') }}">My Blog v2</a></h1>
...

然后创建一个新的 Docker 镜像,并使用v2进行标记,如下所示:

$ docker build -f deploy/docker/Dockerfile_frontend -t myblog:v2 .

接下来,使用以下命令将此镜像推送到 AWS ECR:

$ ecs-cli push myblog:v2

然后,转到 AWS 控制台并选择我们之前创建的堆栈。在操作中,选择更新堆栈。在第一个表单中,选择使用当前模板。然后,在输入参数中,我们需要更改DockerFrontEndImageArn——使用新标签更新它,并在其后加上:v2后缀。新的 ARN 应类似于这样:XXXXXXXX.dkr.ecr.eu-central-1.amazonaws.com/myblog:v2然后,点击下一步,在选项表单中再次点击下一步。在预览表单中,注意在预览您的更改部分,更新器会准确地识别需要更新的内容。在这种情况下,FrontEndTaskMyBlogFrontendService被选中进行更新,所以让我们更新它们。在我们等待 UPDATE_COMPLETE 状态时,只需继续使用应用程序——注意没有停机时间发生。一到两分钟后,注意我们的博客显示的主要标题为 My Blog v2。

在下一节中,我们将看到如何将这种方法与现代 CI/CD 系统集成,以构建、运行测试、检查代码质量,并在不同的环境中部署。

快速构建和部署高可用性应用

无论我们的 Web 应用是在云端还是数据中心,我们都应该追求可靠性。可靠性可以通过多种方式影响用户,无论是通过停机、数据丢失、应用错误、响应时间下降,甚至是用户部署延迟。接下来,我们将介绍一些方面,帮助您思考架构和可靠性,以便您提前规划以处理问题,例如故障或负载增加。首先,我们将介绍您快速且可靠部署所需的必要步骤。

可靠地构建和部署

在今天竞争激烈的市场中,我们需要快速且轻松地构建和部署。但我们的部署速度也必须保证可靠性。实现这一目标的一个步骤是使用脚本或 CI/CD 工具进行自动化。

为了帮助我们设置整个流程,我们应该使用 CI/CD 工具,如 Jenkins、Bamboo、TeamCity 或 Travis。首先,CI/CD 究竟是什么?

CI代表持续集成,是指将许多开发者做出的软件更改集成到主仓库的过程——当然,这样做要快速且可靠。让我们从下到上列举我们需要的东西:

  • 首先,使用源代码控制和版本控制系统至关重要,例如 Git,以及一个良好建立且内部定义的分支模型,例如GitFlow。这将让我们清楚地看到代码更改,并能够在功能或热修复级别接受和测试它们。这将使我们能够轻松回滚到之前的版本。

  • 在批准任何由 pull request 提出的合并之前,请确保设置测试的自动触发和代码审查。然后,pull-request 审查员在批准合并之前可以做出更明智的决定。失败的测试当然是我们希望在合并最终进入生产环境的代码之前看到的警告信号。快速失败,不要害怕经常失败。

如前所述,我们有几个工具来自动化此过程。一种简单的方法是使用 GitHub、Travis 和 landscape.io。你可以在所有三个平台上自由创建账户并尝试它们。之后,只需在你的仓库中创建以下两个文件。

创建一个.travis.yml文件,它应该包含以下内容:

language: python
python:
  - "3.6"
  - "3.3"
  - "2.7"
install:
  - "pip install --upgrade"
  - "pip -V"
  - "pip install -r requirements.txt"
  - "pip install coveralls"
script:
  - coverage run --source webapp --branch -m unittest discover
after_success:
  coveralls

这是我们需要的所有内容,以便在每次提交时自动运行自动化测试。此外,我们的测试将使用 Python 版本 3.6、3.3 和 2.7 独立运行。GitHub 和 Travis 集成还将给我们提供每次 pull request 的测试结果。

对于代码质量控制,landscape.io 与 GitHub(其他工具包括 flake8、Sonarqube 和 Codacy 等)非常容易使用。

要设置 landscape.io,我们只需在我们的项目根目录下创建以下.landscape.yml文件:

ignore-paths:
  - migrations
  - deploy
  - babel

通过将每个分支自动合并到 develop 分支,例如,可以实现进一步的自动化,但我们需要一个第三方工具来自动化 GitHub 上的此过程。

CD代表持续交付,它基于缩短的开发周期和实际交付变更。这必须快速且可靠地完成,并且应该始终考虑回滚。为了帮助我们定义和执行此过程,我们可以使用Jenkins/Blue Ocean 管道

使用 Jenkins 管道,我们可以定义从构建到部署的整个管道过程。此过程使用位于我们项目根目录的Jenkinsfile定义。首先,让我们从 CLI 创建并启动我们的 Jenkins CI 服务器,如下所示:


docker run \
  --rm \
  -u root \
  -p 8080:8080 \
  -v jenkins-data:/var/jenkins_home \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v "$HOME":/home \
  jenkinsci/blueocean

在启动时,Docker 的输出将显示以下内容:

...
INFO: Pre-instantiating singletons in org.springframework.beans.factory.support.DefaultListableBeanFactory@340c828a: defining beans [filter,legacy]; root of factory hierarchy
Sep 16, 2018 11:39:39 AM jenkins.install.SetupWizard init
INFO:

*************************************************************
*************************************************************
*************************************************************

Jenkins initial setup is required. An admin user has been created and a password generated.
Please use the following password to proceed to installation:

476c3b81f2gf4n30a7f9325568dec9f7

This may also be found at: /var/jenkins_home/secrets/initialAdminPassword

*************************************************************
*************************************************************
*************************************************************

从输出中复制密码,并通过访问http://localhost:8080在浏览器中打开 Jenkins。在启动时,Jenkins 将要求一次性密码——粘贴由 Docker 输出提供的密码。接下来,Jenkins 将要求你进行一些初始配置。这包括创建一个管理员用户,并安装插件(在我们的示例中,你可以简单地接受建议的插件)。

要设置自动构建和部署我们的 Docker 镜像到 AWS ECR 的方法,我们需要一个额外的插件,称为 Amazon ECR。要安装此插件,请转到“管理 Jenkins”,然后选择“管理插件”,并点击“可用”选项卡以查看可用和尚未安装的插件列表。从该列表中选择 Amazon ECR 插件,并最终点击“不重启安装”选项。

接下来,我们必须配置一组凭据,以便 Jenkins 可以在 AWS 上进行身份验证并推送我们新构建的 Docker 镜像。为此,在左侧菜单中选择凭据,然后选择 Jenkins 凭据范围和全局凭据。现在,在左侧面板中选择添加凭据并填写以下信息:

  • 类型:AWS 凭据

  • 范围:全局

  • ID:ecr-credentials

  • 描述:ecr-credentials

  • 访问密钥 ID:使用你之前章节中创建的 AWS 访问密钥 ID 来推送你的 Docker 镜像

  • 密钥访问密钥:使用你之前章节中创建的 AWS 密钥访问密钥来推送你的 Docker 镜像

由于安全原因,最好选择 IAM 角色方法。然而,为了简化,我们在这里使用 AWS 密钥。如果你仍然想使用 AWS 密钥,请记住永远不要在自动化过程中使用你的个人密钥——相反,为该过程创建一个具有受限和管理权限的特定用户。

现在我们已经准备好创建我们的第一个 CI/CD 管道。按照以下步骤操作:

  1. 在主页上,选择创建新作业链接

  2. 在“输入项目名称”的输入框中,写myblog

  3. 选择多分支管道选项。然后点击确定

在作业配置中,你需要填写以下字段:

  • 分支源:为你的 GitHub 账户创建新的 Jenkins 凭据,或者使用你从私有 Git 仓库的凭据设置。然后,选择这本书的 GitHub 仓库,或者使用你的私有仓库 URL。

  • 然后,目前,请移除所有行为,除了“发现分支”,如下所示:

图片

在“构建配置”作业部分,如果你使用这本书的 GitHub 仓库,将“脚本路径”更改为Chapter-13/Jenkinsfile。这是必需的,因为仓库是按章节组织的,而Jenkinsfile不在仓库的根目录中。

这就是全部,因为繁重的工作是通过Jenkinsfile管道定义完成的。让我们看看这个文件:

pipeline {
    agent any

    parameters {
        string(description: 'Your AWS ECR URL: http://<AWS ACCOUNT NUMBER>.dkr.ecr.<REGION>.amazonaws.com', name: 'ecrURL')
    }

    environment {
        CHAPTER = 'Chapter-13'
        ECRURL = "${params.ecrURL}"
        ECRCRED = 'ecr:eu-central-1:ecr-credentials'
    }
...

Jenkins 管道定义为你提供了大量的配置选项。我们甚至可以使用嵌入其中的 Groovy 脚本。请查看文档以获取更多详细信息,文档可在jenkins.io/doc/book/pipeline/jenkinsfile/找到。

pipeline主部分,我们为你创建了一个手动参数,以便填写应推送图像的 AWS ECR URL。此部分还配置了一些必要的环境变量,使我们的阶段更加动态。

接下来,让我们看看管道阶段部分:

....
stages {
    stage('Build') {
        steps {
            echo "Building"
            checkout scm
        }
    }
    stage('Style') {
        agent {
            docker 'python:3'
        }

        steps {
            sh '''
                #!/bin/bash

                cd "${CHAPTER}"
                python -m pip install -r requirements.txt
                cd Flask-YouTube
                python setup.py build
                python setup.py install
                cd ..
                python -m pip install flake8
                flake8 --max-line-length 120 webapp
            '''
        }
    }
...

stages部分将包含构建、测试、检查和部署我们的应用程序所需的所有阶段。使用stage('Build')声明的构建只是使用checkout scm执行我们的仓库的检出。

风格阶段,我们将使用flake8检查代码风格。我们假设一个关键的风格问题足以使管道失败,并且永远不会部署应用程序。要运行它,我们告诉 Jenkins 使用docker 'python:3'命令运行一个 Python 3 的 Docker 容器,并在其中安装所有必要的依赖项并运行flake8对代码进行检查。

接下来,你将找到一个测试阶段,它与风格阶段非常相似。请注意,我们可以轻松地定义用于 Python 3 和 2.7 的测试,使用特定的 Docker 容器来运行它。

Docker 构建阶段如下:

stage('Build docker images') {
    agent any
    steps {
        echo 'Creating new images...'
        script {
             def frontend = docker.build("myblog:${env.BUILD_ID}", "-f ${CHAPTER}/deploy/docker/Dockerfile_frontend ${CHAPTER}")
             def worker = docker.build("myblog_worker:${env.BUILD_ID}", "-f ${CHAPTER}/deploy/docker/Dockerfile_worker ${CHAPTER}")
        }
    }
}

在这个阶段,我们使用 Groovy 为前端和 Celery 工作进程构建镜像。这些镜像将被生成并带有 Jenkins 构建标识符,我们可以将其用作env.BUILD_ID环境变量。

在最终阶段,我们将新创建的镜像推送到 AWS ECR Docker 镜像存储库,如下所示:

stage('Publish Docker Image') {
    agent any
    steps {
        echo 'Publishing new images...'
        script {
            docker.withRegistry(ECRURL, ECRCRED)
            {
                docker.image("myblog:${env.BUILD_ID}").push()
                docker.image("myblog_worker:${env.BUILD_ID}").push()
            }
        }
    }
}

最后,为了运行我们的任务,选择“myblog”任务,然后选择“master”,在左侧面板中选择“带参数构建”。填写您的 AWS ECR URL(此 URL 的格式为http://<ACCOUNT_NUMBER>.dkr.ecr.<REGION>.amazonaws.com),然后点击构建。构建完成后,我们只需更新我们的 CloudFormation,以包含新创建的 Docker 镜像。

一个很好的最终阶段将是更新之前部署的 CloudFormation,使用我们在本书中之前测试的脚本过程。为此,我们可以使用“pipeline: AWS steps”插件。

创建高度可用的可扩展应用程序

高可用性HA)和可扩展性是一个越来越重要的主题。它应该从开发阶段一直考虑到发布阶段。单体架构,其中所有组成应用程序的功能和服务都无法分离或安装在单个实例上,将无法抵抗故障,也无法扩展。垂直扩展只能走这么远,而且在故障的情况下,将增加恢复时间,以及用户的影响。这是一个重要且复杂的问题,正如你可能猜到的,没有单一的解决方案可以解决这个问题。

要考虑 HA,我们必须持悲观态度。记住——故障无法消除,但可以识别故障点,并应制定恢复计划,以便停机时间只需几秒或几分钟,而不是几小时甚至几天。

首先,让我们考虑我们的博客应用程序的所有组件,并识别无状态的组件:

  • 前端: Web 服务器和 uWSGI – 无状态

  • Celery 工作进程: Celery – 无状态

  • 消息队列: RabbitMQ 或 AWS SQS – 有状态

  • 缓存: Redis – 有状态

  • 数据库: SQL 或 NoSQL – 有状态

我们的首要目标是识别我们应用程序中的所有单点故障SPOF),并尝试消除它们。为此,我们必须考虑冗余:

  • 前端: 这是一个无状态服务,直接接收来自用户的请求。我们可以使用负载均衡器来平衡这些请求,并且始终至少有两个实例。如果一个实例失败了,另一个立即开始接收所有负载。看起来不错?也许吧,但单个实例能支持所有负载吗?巨大的响应时间也是一种失败,所以考虑一下——你可能至少需要三个实例。接下来,你的负载均衡器也会失败吗?当使用某种基于云的负载均衡器,如 AWS ELB 或 ALB 时,这不是问题,但如果你没有使用这些,那么在这个层面上也要设置冗余。

  • Celery 工作进程: 工作进程是无状态的,完全失败不会立即对用户产生影响。只要恢复是自动完成的,或者失败可以轻松识别,并且一个失败的实例可以快速用新实例替换,你至少可以有一个实例。

  • 消息队列: 如果使用 AWS SQS 或 CloudMQ,已经考虑了失败的情况。如果没有,集群化的 RabbitMQ 可以是一个选择,或者你可以确保消息丢失是一个选项,并且 RabbitMQ 的替换是自动的,或者至少可以快速执行。

  • 缓存: 确保你有多于一个的 memcached 实例(使用集群键分片),或者你的应用程序可以优雅地处理失败。记住,memcached 的替换会带来一个冷缓存,这可能会对你的数据库产生巨大影响,这取决于你的负载。

  • 数据库: 确保你有一个 SQL 或 NoSQL 从属/集群就绪,以替换失败的 master 的写入。

包含状态的层更成问题,即使是小的失败(几秒或毫秒)也可能是不可避免的。热备用或冷备用应该被考虑。在负载测试的同时测试你所有服务的系统故障非常有用。冗余就像一个软件特性——如果不测试,它可能已经损坏了。

可以通过负载测试来验证扩展性。在生产管道发布过程中包含它是一个非常好的主意。Locust是一个出色的 Python 工具,可以实施高度可配置的负载测试,可以扩展到任何你想要的负载级别。这类测试是验证你的高可用性设置的好机会。在模拟预期负载的同时关闭实例,并负载测试直到你的堆栈崩溃。这样你将知道你的极限——在它在生产中崩溃之前知道什么会先崩溃,将有助于你测试性能调整。

Locust Python 包的文档可在docs.locust.io/en/stable/找到。

使用云基础设施,如 AWS、Azure 和 GCP 进行扩展,全部关于自动化。你需要自动设置你的实例,以便监控指标可以自动触发新虚拟机或 Docker 容器的创建。

最后,请确保您定期备份您的数据库。备份之间的时间差是可能数据丢失的点,所以请识别它并报告回来。同样,恢复您的生产备份也非常重要——如果未经测试,那么它们可能已经损坏。

监控和收集日志

监控所有系统和组件,收集操作系统级别的指标,并生成应用程序指标。您有很好的工具来做这件事,包括 DataDog;NewRelic;StatsD、Graphana、InfluxDB 和 Prometheus 的组合;以及 ELK。

根据指标阈值设置故障警报。非常重要的一点是不要过度设置您创建的警报数量——确保一个关键警报确实意味着系统已关闭或严重受损。设置时间图表,以便您可以提前识别问题或升级需求。

从操作系统、应用程序和云服务中收集日志。解析、结构化和向您的日志添加元数据丰富了您的数据,并使适当的日志聚合、过滤和图表成为可能。能够轻松地根据特定用户、IP 或国家过滤所有日志是一个进步。

日志收集在云环境中变得更加重要,在容器中更是如此,因为它们是短暂的,并将您的应用程序分解成微服务,因此当发生某些事情时,您的日志可能已经不存在,或者您可能需要手动浏览数十个,甚至数千个日志文件来找出发生了什么。这越来越难以做到。然而,有许多好的解决方案:您可以使用 ELK(ElasticSearch、logstash 和 Kibana)或 EFK(ElasticSearch、Fluentd 和 Kibana)堆栈、Sumo logic 或 DataDog。

摘要

正如本章所解释的,托管您的应用程序有许多不同的选项,每个选项都有其自身的优缺点。选择哪一个取决于您愿意投入的时间和金钱,以及您预期的总用户数量。

现在,我们已经到达了本书的结尾。我希望这本书在构建您对 Flask 的理解以及如何用它来创建任何复杂度的应用程序,既简单又易于维护方面有所帮助。

网络应用程序开发是一个快速发展的领域,涉及不同的技术和概念。不要止步于此——继续提高您的 Python 技能,了解用户体验设计,提高您对 CSS 和 HTML 的知识,掌握 SQL 和查询性能,并使用 Flask 和 JavaScript 开发单页应用程序。本书的每一章都是进一步了解知识的邀请。

posted @ 2025-09-23 21:56  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报