Flask-框架学习手册-全-

Flask 框架学习手册(全)

原文:zh.annas-archive.org/md5/A6963809F66F360038656FE5292ADA55

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎阅读《学习 Flask》,这本书将教会您使用 Flask 构建 Web 应用程序所需的必要技能,这是一个轻量级的 Python Web 框架。本书采用了一个以示例驱动的方法,旨在让您快速入门。实际示例与适量的背景信息相结合,以确保您不仅了解 Flask 开发的如何,还了解为什么。

Flask 最初是由 Armin Ronacher 在 2010 年作为复杂的愚人节恶作剧的一部分发布的。该项目吹嘘自己是“下一代 Python 微型 Web 框架”,并讽刺了类似微框架所流行的功能。尽管 Flask 原本是一个恶作剧,但作者们对该项目引起了许多人的严肃兴趣感到意外。

Flask 是一个建立在两个优秀库之上的微框架:Jinja2 模板引擎和 Werkzeug WSGI 工具包。尽管与其他框架(如 Django 和 Pylons)相比,Flask 是一个相对较新的框架,但它已经获得了大量忠实的追随者。Flask 为常见的 Web 开发任务提供了强大的工具,并鼓励采用自己的库来处理其他一切,使程序员有灵活性来选择最佳组件来构建他们的应用程序。每个 Flask 应用程序都是不同的,正如项目的文档所述,“Flask 很有趣”。

Flask 微框架在设计和 API 方面代表了与大多数其他流行的 Python Web 框架的不同,这导致许多新手开发人员问:“构建应用程序的正确方法是什么?” Flask 对于我们开发者应该如何构建应用程序并没有提供强烈的意见。相反,它提供了关于构建应用程序所需的意见。Flask 可以被认为是一组对象和函数,用于处理常见的 Web 任务,如将 URL 路由到代码、处理请求数据和渲染模板。虽然 Flask 提供的灵活性令人振奋,但它也可能导致混乱和糟糕的设计。

本书的目的是帮助您将这种灵活性视为机会。在本书的过程中,我们将构建并逐步增强一个由 Flask 驱动的博客网站。通过向网站添加新功能来介绍新概念。到本书结束时,我们将创建一个功能齐全的网站,您将对 Flask 及其常用扩展和库生态系统有着扎实的工作知识。

本书涵盖的内容

《第一章》《创建您的第一个 Flask 应用程序》以大胆宣言“Flask 很有趣”开始,这是当您查看官方 Flask 文档时看到的第一件事情之一,在本章中,您将了解为什么许多 Python 开发人员都同意这一观点。

《第二章》《使用 SQLAlchemy 的关系数据库》指出,关系数据库是几乎所有现代 Web 应用程序构建的基石。我们将使用 SQLAlchemy,这是一个强大的对象关系映射器,可以让我们抽象出多个数据库引擎的复杂性。在本章中,您将了解您早期选择的数据模型将影响随后代码的几乎每个方面。

《第三章》《模板和视图》涵盖了框架中最具代表性的两个组件:Jinja2 模板语言和 URL 路由框架。我们将完全沉浸在 Flask 中,看到我们的应用程序最终开始成形。随着我们在本章的进展,我们的应用程序将开始看起来像一个真正的网站。

第四章 表单和验证,向您展示如何使用表单直接通过由流行的 WTForms 库处理的网站修改博客内容。这是一个有趣的章节,因为我们将添加各种与网站交互的新方式。我们将创建与我们的数据模型一起工作的表单,并学习如何接收和验证用户数据。

第五章 用户认证,解释了如何向您的网站添加用户认证。能够区分一个用户和另一个用户使我们能够开发一整套新的功能。例如,我们将看到如何限制对创建、编辑和删除视图的访问,防止匿名用户篡改网站内容。我们还可以向用户显示他们的草稿帖子,但对其他人隐藏。

第六章 建立管理仪表板,向您展示如何为您的网站构建一个管理仪表板,使用优秀的 Flask-Admin。我们的管理仪表板将使特定选定的用户能够管理整个网站上的所有内容。实质上,管理站点将是数据库的图形前端,支持创建、编辑和删除应用程序表中的行的操作。

第七章 AJAX 和 RESTful API,使用 Flask-Restless 为博客应用程序创建 RESTful API。RESTful API 是一种强大的访问应用程序的方式,通过提供高度结构化的数据来表示它。Flask-Restless 与我们的 SQLAlchemy 模型非常配合,它还处理复杂的任务,如序列化和结果过滤。

第八章 测试 Flask 应用,介绍了如何编写覆盖博客应用程序所有部分的单元测试。我们将利用 Flask 的测试客户端来模拟“实时”请求。我们还将看到 Mock 库如何简化测试复杂的交互,如调用数据库等第三方服务。

第九章 优秀的扩展,教您如何使用流行的第三方扩展增强您的 Flask 安装。我们在整本书中都使用了扩展,但现在我们可以探索额外的安全性或功能,而几乎不费吹灰之力,可以很好地完善您的应用程序。

第十章 部署您的应用程序,教您如何安全地以自动化、可重复的方式部署您的 Flask 应用程序。我们将看看如何配置常用的 WSGI 能力服务器,如 Apache 和 Nginx,以及 Python Web 服务器 Gunicorn,为您提供多种选择。然后,我们将看到如何使用 SSL 安全地部分或整个网站,最后使用配置管理工具来自动化我们的部署。

本书所需内容

虽然 Python 在大多数操作系统上都能很好地运行,而且我们在本书中尽量保持了与操作系统无关的方法,但建议在使用本书时使用运行 Linux 发行版或 OS X 的计算机,因为 Python 已经安装并运行。Linux 发行版可以安装在计算机上或虚拟机中。几乎任何 Linux 发行版都可以,任何最新版本的 Ubuntu 都可以。

这本书适合谁

这本书适合任何想要将他们对 Python 的知识发展成可以在 Web 上使用的人。Flask 遵循 Python 的设计原则,任何了解 Python 甚至不了解 Python 的人都可以轻松理解。

约定

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

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:“我们可以通过使用include指令来包含其他上下文。”

代码块设置如下:

from app import api
from models import Comment

api.create_api(Comment, methods=['GET', 'POST'])

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

{% block content %}
  {{ entry.body }}

  <h4 id="comment-form">Submit a comment</h4>
 {% include "entries/includes/comment_form.html" %}
{% endblock %}

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

(blog) $ python manage.py db upgrade
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.migration] Running upgrade 594ebac9ef0c -> 490b6bc5f73c, empty message

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这样的方式出现在文本中:“您应该在空白白色页面上看到消息Hello, Flask显示。”

注意

警告或重要提示会出现在这样的框中。

提示

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

第一章:创建您的第一个 Flask 应用程序

Flask 很有趣。这是您在查看官方 Flask 文档时看到的第一件事情之一,而在本书的过程中,您将了解为什么这么多 Python 开发人员同意这一观点。

在本章中,我们将:

  • 简要讨论 Flask 框架的特点

  • 设置开发环境并安装 Flask

  • 实现一个最小的 Flask 应用程序并分析其工作原理

  • 尝试常用 API 和交互式调试器

  • 开始着手博客项目,该项目将在本书的过程中逐步增强

什么是 Flask?

Flask 是一个用 Python 编写的轻量级 Web 框架。Flask 最初是一个愚人节玩笑,后来成为 Python Web 框架世界中备受欢迎的黑马。它现在是创业公司中最广泛使用的 Python Web 框架之一,并且正在成为大多数企业快速简单解决方案的完美工具。在其核心,它提供了一组强大的库,用于处理最常见的 Web 开发任务,例如:

  • URL 路由,使 URL 映射到您的代码变得容易

  • 使用 Jinja2 进行模板渲染,这是最强大的 Python 模板引擎之一。

  • 会话管理和保护 Cookie

  • HTTP 请求解析和灵活的响应处理

  • 交互式基于 Web 的调试器

  • 易于使用的灵活应用程序配置管理

本书将通过实际的实例教您如何使用这些工具。我们还将讨论 Flask 中未包含的常用第三方库,例如数据库访问和表单验证。通过本书的学习,您将准备好使用 Flask 处理下一个大型项目。

自由伴随着责任

正如文档所述,Flask 很有趣,但在构建大型应用程序时可能会具有挑战性。与 Django 等其他流行的 Python Web 框架不同,Flask 不强制规定模块或代码的结构方式。如果您有其他 Web 框架的经验,您可能会惊讶于在 Flask 中编写应用程序感觉像编写 Python 而不是框架样板。

本书将教您使用 Flask 编写清晰、表达力强的应用程序。随着本书的学习,您不仅将成为熟练的 Flask 开发人员,还将成为更强大的 Python 开发人员。

设置开发环境

Flask 是用 Python 编写的,因此在我们开始编写 Flask 应用程序之前,我们必须确保已安装 Python。大多数 Linux 发行版和最新版本的 OSX 都预装了 Python。本书中的示例将需要 Python 2.6 或 2.7。有关安装 Python 的说明,请访问www.python.org

如果这是您第一次使用 Python,网上有许多优秀的免费资源可供使用。我建议阅读Learn Python The Hard Way,作者是Zed Shaw,可在learnpythonthehardway.org免费在线阅读。还想了解更多?您可以在resrc.io/list/10/list-of-free-programming-books/#python找到大量免费的 Python 资源。

您可以通过从命令提示符运行 Python 交互解释器来验证 Python 是否已安装并且您拥有正确的版本:

$ python
Python 2.7.6 (default, Nov 26 2013, 12:52:49)
[GCC 4.8.2] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>>

在提示符(>>>)中键入exit()并按Enter离开解释器。

支持 Python 3

本书将包含兼容 Python 2 和 Python 3 的代码。不幸的是,由于 Python 3 相对于 Python 2 仍然相对较新,本书中使用的并非所有第三方包都保证与 Python 3 无缝工作。许多人正在努力使流行的开源库与两个版本兼容,但在撰写本文时,仍有一些库尚未移植。为了获得最佳结果,请确保您在系统上安装的 Python 版本为 2.6 或更高。

安装 Python 包

现在您已经确保 Python 正确安装,我们将安装一些流行的 Python 包,这些包将在本书的过程中使用。

我们将系统范围内安装这些包,但一旦它们安装完成,我们将专门在虚拟环境中工作。

安装 pip

事实上,Python 包安装程序是pip。我们将在整本书中使用它来安装 Flask 和其他第三方库。

如果您已经安装了setuptools,您可以通过运行以下命令来安装pip

$ sudo easy_install pip

安装完成后,请验证pip是否正确安装:

$ pip --version
pip 1.2.1 from /usr/lib/python2.7/site-packages/pip-1.2.1-py2.7.egg (python 2.7)

版本号可能会发生变化,因此请参考官方说明,网址为www.pip-installer.org/en/latest/installing.html

安装 virtualenv

安装了 pip 之后,我们可以继续安装任何 Python 开发人员工具包中最重要的工具:virtualenv。Virtualenv 可以轻松创建隔离的 Python 环境,其中包括它们自己的系统和第三方包的副本。

为什么使用 virtualenv?

Virtualenv 解决了与包管理相关的许多问题。想象一下,您有一个使用非常早期版本的 Flask 构建的旧应用程序,您想使用最新版本的 Flask 构建一个新项目。如果 Flask 是系统范围内安装的,您将被迫要么升级旧项目,要么针对旧的 Flask 编写新项目。如果两个项目都使用 virtualenv,那么每个项目都可以运行自己的 Flask 版本,而不会有冲突或问题。

Virtualenv 可以轻松控制项目使用的第三方包的版本。

另一个考虑因素是,通常需要提升权限(sudo pip install foo)才能在系统范围内安装包。通过使用 virtualenv,您可以创建 Python 环境并像普通用户一样安装包。如果您正在部署到共享托管环境或者在没有管理员权限的情况下,这将非常有用。

使用 pip 安装 virtualenv

我们将使用 pip 来安装virtualenv;因为它是一个标准的 Python 包,所以可以像安装其他 Python 包一样安装。为了确保virtualenv被系统范围内安装,运行以下命令(需要提升的权限):

$ sudo pip install virtualenv
$ virtualenv --version
1.10.1

版本号可能会发生变化,因此请参考virtualenv.org上的官方说明。

创建您的第一个 Flask 应用程序

现在我们已经安装了适当的工具,我们准备创建我们的第一个 Flask 应用程序。首先,在一个方便的地方创建一个目录,用于保存所有的 Python 项目。在命令提示符或终端中,导航到您的项目目录;我的是/home/charles/projects,或者在基于 Unix 的系统中简写为~/projects

$ mkdir ~/projects
$ cd ~/projects

现在我们将创建一个virtualenv。下面的命令将在您的项目文件夹中创建一个名为hello_flask的新目录,其中包含一个完整的、隔离的 Python 环境。

$ virtualenv hello_flask

New python executable in hello_flask/bin/python2.
Also creating executable in hello_flask/bin/python
Installing setuptools............done.
Installing pip...............done.
$ cd hello_flask

如果列出hello_flask目录的内容,您将看到它创建了几个子目录,包括一个包含 Python 和 pip 副本的bin文件夹(在 Windows 上是Scripts)。下一步是激活您的新 virtualenv。具体的说明因使用 Windows 还是 Mac OS/Linux 而有所不同。要激活您的 virtualenv,请参考以下截图:

创建您的第一个 Flask 应用

创建 hello_flask virtualenv

当您激活一个virtualenv时,您的 PATH 环境变量会被临时修改,以确保您安装或使用的任何软件包都受限于您的virtualenv

在您的 virtualenv 中安装 Flask

现在我们已经验证了我们的virtualenv设置正确,我们可以安装 Flask 了。

当您在虚拟环境中时,永远不应该使用管理员权限安装软件包。如果在尝试安装 Flask 时收到权限错误,请仔细检查您是否正确激活了您的virtualenv(您的命令提示符中应该看到(hello_flask))。

(hello_flask) $ pip install Flask

当 pip 下载 Flask 包及其相关依赖项并将其安装到您的 virtualenv 时,您将看到一些文本滚动。Flask 依赖于一些额外的第三方库,pip 将自动为您下载和安装这些库。让我们验证一下是否一切都安装正确:

(hello_flask) $ python
>>> import flask
>>> flask.__version__
'0.10.1'
>>> flask
<module 'flask' from '/home/charles/projects/hello_flask/lib/python2.7/site-packages/flask/__init__.pyc'>

恭喜!您已经安装了 Flask,现在我们准备开始编码。

Hello, Flask!

hello_flask virtualenv 中创建一个名为app.py的新文件。使用您喜欢的文本编辑器或 IDE,输入以下代码:

from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return 'Hello, Flask!'

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

保存文件,然后通过命令行运行app.py来执行它。您需要确保已激活hello_flask virtualenv:

$ cd ~/projects/hello_flask
(hello_flask) $ python app.py
* Running on http://127.0.0.1:5000/

打开您喜欢的 Web 浏览器,导航到显示的 URL(http://127.0.0.1:5000)。您应该在一个空白的白色页面上看到消息Hello, Flask!。默认情况下,Flask 开发服务器在本地运行在127.0.0.1,绑定到端口5000

Hello, Flask!

您的第一个 Flask 应用程序。

理解代码

我们刚刚创建了一个非常基本的 Flask 应用程序。要理解发生了什么,让我们逐行分解这段代码。

from flask import Flask

我们的应用程序通过导入Flask类开始。这个类代表一个单独的 WSGI 应用程序,是任何 Flask 项目中的核心对象。

WSGI 是 Python 标准的 Web 服务器接口,在 PEP 333 中定义。您可以将 WSGI 视为一组行为和方法,当实现时,允许您的 Web 应用程序与大量的 Web 服务器一起工作。Flask 为您处理所有实现细节,因此您可以专注于编写 Web 应用程序。

app = Flask(__name__)

在这一行中,我们在变量app中创建了一个应用程序实例,并将其传递给我们模块的名称。变量app当然可以是任何东西,但是对于大多数 Flask 应用程序来说,app是一个常见的约定。应用程序实例是诸如视图、URL 路由、模板配置等的中央注册表。我们提供当前模块的名称,以便应用程序能够通过查看当前文件夹内部找到资源。这在以后当我们想要渲染模板或提供静态文件时将会很重要。

@app.route('/')
def index():
 return 'Hello, Flask!'

在前面的几行中,我们指示我们的 Flask 应用程序将所有对/(根 URL)的请求路由到这个视图函数(index)。视图只是一个返回某种响应的函数或方法。每当您打开浏览器并导航到我们应用程序的根 URL 时,Flask 将调用这个视图函数并将返回值发送到浏览器。

关于这些代码行有一些需要注意的事项:

  • @app.route是上面定义的app变量的 Python 装饰器。这个装饰器(app.route)包装了下面的函数,这种情况下是index,以便将特定 URL 的请求路由到特定视图。这里选择index作为函数的名称,因为它是 Web 服务器使用的第一个页面的通用名称。其他示例可能是主页或主要。装饰器是 Python 开发人员丰富且有趣的主题,所以如果您对它们不熟悉,我建议使用您喜欢的搜索引擎找到一个好的教程。

  • index函数不带任何参数。如果您来自其他 Web 框架,并且期望有一个请求对象或类似的东西,这可能看起来有点奇怪。在接下来的示例中,我们将看到如何从请求中访问值。

  • index函数返回一个普通的字符串对象。在后面的示例中,我们将看到如何渲染模板以返回 HTML。

  • 以下行使用调试模式下内置的开发服务器执行我们的应用程序。if语句是一个常见的 Python 约定,确保只有在通过 python app.py运行脚本时才会运行应用程序,如果我们尝试从另一个 Python 文件导入此应用程序,则不会运行。

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

路由和请求

现在我们的 Flask 应用程序并不那么有趣,所以让我们看看我们可以以不同方式为我们的 Web 应用程序添加更有趣的行为。一种常见的方法是添加响应式行为,以便我们的应用程序将查看 URL 中的值并处理它们。让我们为我们的 Hello Flask 应用程序添加一个名为hello的新路由。这个新路由将向出现在 URL 中的人显示问候语:

from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return 'Hello, Flask!'

@app.route('/hello/<name>')
def hello(name):
    return 'Hello, %s' % name

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

再次运行我们的应用程序并在 Web 浏览器中打开它。现在我们可以导航到 URL,比如http://127.0.0.1/hello/Charlie,并看到我们的自定义消息:

路由和请求

我们的 Flask 应用程序显示自定义消息

在前面的示例中,我们添加的路由指定了一个参数:name。这个参数也出现在函数声明中作为唯一的参数。Flask 自动将 URL/hello/Charliehello视图进行匹配;这被称为映射。然后将字符串Charlie作为参数传递给我们的视图函数。

如果我们导航到http://127.0.0.1:5000/hello/而没有指定名称会发生什么?正如您所看到的,Flask 开发服务器将返回404响应,表示 URL 与任何已知路由不匹配。

路由和请求

Flask 404 页面

从请求中读取值

除了 URL 之外,值可以通过查询字符串传递给您的应用程序。查询字符串由任意键和值组成,这些键和值被附加到 URL 上,使用问号:

URL 参数值
/hello/?name=Charlie name: Charlie
/hello/?name=Charlie&favorite_color=green name: Charliefavorite_color: green

为了在视图函数中访问这些值,Flask 提供了一个请求对象,该对象封装了关于当前 HTTP 请求的各种信息。在下面的示例中,我们将修改我们的hello视图,以便通过查询字符串传递的名称也能得到响应。如果在查询字符串或 URL 中未指定名称,我们将返回 404。

from flask import Flask, abort, request

app = Flask(__name__)

@app.route('/')
def index():
    return 'Hello, Flask!'

@app.route('/hello/<name>')
@app.route('/hello/')
def hello(name=None):
    if name is None:
        # If no name is specified in the URL, attempt to retrieve it
        # from the query string.
        name = request.args.get('name')
        if name:
            return 'Hello, %s' % name
    else:
        # No name was specified in the URL or the query string.
        abort(404)

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

正如您所看到的,我们已经为我们的hello视图添加了另一个路由装饰器:Flask 允许您将多个 URL 路由映射到同一个视图。因为我们的新路由不包含名称参数,我们需要修改视图函数的参数签名,使name成为可选参数,我们通过提供默认值None来实现这一点。

我们视图的函数体也已经修改为检查 URL 中是否存在名称。如果未指定名称,我们将中止并返回404页面未找到状态码。

从请求中读取值

使用查询字符串问候某人

调试 Flask 应用程序

不可避免的是,迟早我们会在我们的代码中引入一个 bug。由于 bug 是不可避免的,作为开发人员,我们所能希望的最好的事情就是有助于我们快速诊断和修复 bug 的好工具。幸运的是,Flask 自带了一个非常强大的基于 Web 的调试器。Flask 调试器使得在错误发生的瞬间内省应用程序的状态成为可能,消除了需要添加打印语句或断点的必要。

这可以通过在运行时告诉 Flask 应用程序以debug模式运行来启用。我们可以通过几种方式来做到这一点,但实际上我们已经通过以下代码做到了这一点:

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

为了尝试它,让我们通过制造一个拼写错误来引入hello_flask应用程序中的一个 bug。在这里,我只是从变量name中简单地删除了末尾的 e:

@app.route('/hello/<name>')
@app.route('/hello/')
def hello(name=None):
    if nam is None:
        # No name was specified in the URL or the query string.
        abort(404)

当我们启动开发服务器并尝试访问我们的视图时,现在会出现调试页面:

调试 Flask 应用程序

在 Web 浏览器中运行的 Flask 交互式调试器

这个代码列表被称为Traceback,它由调用堆栈组成,即在实际错误之前的嵌套函数调用列表。Traceback 通常提供了一个很好的线索,可以解释发生了什么。在底部我们看到了我们有意打错的代码行,以及实际的 Python 错误,这是一个NameError异常,告诉我们nam未定义。

调试 Flask 应用程序

Traceback 详细显示了我们的拼写错误和错误的描述。

真正的魔力发生在你把鼠标放在高亮的行上时。在右侧,你会看到两个小图标,代表终端和源代码文件。点击Source Code图标将展开包含错误行的源代码。这对于解释错误时建立一些上下文非常有用。

终端图标最有趣。当你点击Terminal图标时,一个小控制台会出现,带有标准的 Python 提示符。这个提示符允许你实时检查异常发生时本地变量的值。尝试输入name并按Enter——它应该显示在 URL 中指定的值(如果有的话)。我们还可以通过以下方式检查当前请求参数:

调试 Flask 应用程序

使用调试控制台内省变量

当你在章节中工作并进行实验时,能够快速诊断和纠正任何 bug 将是一项非常有价值的技能。我们将在第八章中回到交互式调试器,测试 Flask 应用程序,但现在要知道它的存在,并且可以在代码中断时和地方使用它进行内省。

介绍博客项目

在本书的其余部分,我们将构建、增强和部署一个对程序员友好的博客站点。这个项目将介绍你最常见的 Web 开发任务,比如使用关系数据库、处理和验证表单数据,以及(每个人都喜欢的)测试。在每一章中,你将通过实际的、动手编码的项目学习一个新的技能。在下表中,我列出了核心技能的简要描述,以及博客相应的功能:

技能 博客站点功能
使用 SQLAlchemy 的关系数据库 Flask-SQLAlchemy 在关系数据库中存储条目和标签。执行各种查询,包括分页、日期范围、全文搜索、内连接和外连接等。
表单处理和验证 Flask-WTF 使用表单创建和编辑博客条目。在后面的章节中,我们还将使用表单来让用户登录站点并允许访问者发表评论。
使用 Jinja2 模板渲染 Jinja2 创建一个干净、可扩展的模板集,适当时使用继承和包含。
用户认证和管理仪表板 Flask-Login 将用户帐户存储在数据库中,并将帖子管理页面限制为注册用户。构建一个管理面板,用于管理帖子、用户帐户,并显示页面浏览量、IP 地理位置等统计信息。
Ajax 和 RESTful APIsFlask-API 构建一个 Ajax 驱动的评论系统,该系统将显示在每个条目上。使用 RESTful API 公开博客条目,并构建一个简单的命令行客户端,用于使用 API 发布条目。
单元测试 unittest 我们将为博客构建一个完整的测试套件,并学习如何模拟真实请求并使用模拟简化复杂的交互。
其他 跨站点请求伪造CSRF)保护,Atom feeds,垃圾邮件检测,异步任务执行,部署,安全套接字层SSL),托管提供商等等。

规范

当开始一个大型项目时,拥有一个功能规范是个好主意。对于博客网站,我们的规范将简单地是我们希望博客具有的功能列表。这些功能是基于我在构建个人博客时的经验:

  • 条目应该使用基于 web 的界面输入。对于格式,作者可以使用Markdown,这是一种轻量级、外观吸引人的标记语言。

  • 图片可以上传到网站,并轻松地嵌入到博客条目中。

  • 条目可以使用任意数量的标签进行组织。

  • 该网站应支持多个作者。

  • 条目可以按发布顺序显示,也可以按月份、标签或作者列出。条目的长列表将被分页。

  • 条目可以保存为草稿,并由其作者查看,但在发布之前其他人无法查看。

  • 访问者可以在条目上发表评论,评论将被检查是否为垃圾邮件,然后由作者自行决定是否应该保持可见。

  • 所有帖子都将提供 Atom feeds,包括每个作者和标签的单独 feeds。

  • 可以使用 RESTful API 访问条目。作者将获得一个 API 令牌,允许他们使用 API 修改条目。

虽然这个列表并不详尽,但它涵盖了我们博客网站的核心功能,你将有希望发现它既有趣又具有挑战性。在本书的最后,我将提出一些你可能添加的其他功能的想法,但首先你需要熟悉使用 Flask。我相信你迫不及待地想要开始,所以让我们设置我们的博客项目。

创建博客项目

让我们从在我们的工作目录中创建一个新项目开始;在我的笔记本电脑上是/home/charles/projects,或者在 Unix 系统中是~/projects,简称为。这正是我们创建hello_flask应用程序时所做的事情:

$ cd ~/projects
$ mkdir blog
$ cd blog

然后,我们需要设置我们的virtualenv环境。这与我们之前所做的不同,因为这是一种更有结构的使用虚拟环境的方式:

$ virtualenv blog

下一步将是将 Flask 安装到我们的虚拟环境中。为此,我们将激活虚拟环境,并使用pip安装 Flask:

$ source blog/bin/activate
(blog) $ pip install Flask

到目前为止,所有这些对你来说应该都有些熟悉。但是,我们可以创建一个名为app的新文件夹,而不是为我们的应用程序创建单个文件,这是完全可以的,对于非常小的应用程序来说是有意义的,这样可以使我们的应用程序模块化和更加合乎逻辑。在该文件夹内,我们将创建五个空文件,分别命名为__init__.pyapp.pyconfig.pymain.pyviews.py,如下所示:

mkdir app
touch app/{__init__,app,config,main,views}.py

这个最后的命令使用了你的 shell 的一个小技巧,来创建括号内的多个文件名。如果你使用版本控制,你会希望将app目录视为你的代码库的根目录。app 目录将包含博客应用的源代码、模板和静态资源。如果你还没有使用版本控制,现在是一个很好的时机来尝试一下。Pro Git是一个很好的资源,可以免费在git-scm.com/book上获取。

我们刚刚创建的这些文件是什么?正如你将看到的,每个文件都有重要的作用。希望它们的名称能够提供关于它们作用的线索,但这里是每个模块责任的简要概述:

__init__.py 告诉 Python 将 app/目录作为 Python 包使用
app.py Flask 应用
config.py 我们的 Flask 应用的配置变量
main.py 执行我们应用的入口点
views.py 应用的 URL 路由和视图

一个简单的 Flask 应用

让我们用最少量的代码填充这些文件,以创建一个可运行的 Flask 应用程序。这将使我们的项目在第二章中处于良好的状态,我们将开始编写代码来存储和检索数据库中的博客条目。

我们将从config.py模块开始。这个模块将包含一个Configuration类,指示 Flask 我们想要在DEBUG模式下运行我们的应用。将以下两行代码添加到config.py模块中:

class Configuration(object):
    DEBUG = True

接下来我们将创建我们的 Flask 应用,并指示它使用config模块中指定的配置值。将以下代码添加到app.py模块中:

from flask import Flask

from config import Configuration  # import our configuration data.

app = Flask(__name__)
app.config.from_object(Configuration)  # use values from our Configuration object.

视图模块将包含一个映射到站点根 URL 的单个视图。将以下代码添加到views.py中:

from app import app

@app.route('/')
def homepage():
    return 'Home page'

你可能注意到,我们仍然缺少对app.run()的调用。我们将把这段代码放在main.py中,这将作为我们应用的入口点。将以下代码添加到main.py模块中:

from app import app  # import our Flask app
import views

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

我们不调用app.run(debug=True),因为我们已经指示 Flask 在Configuration对象中以调试模式运行我们的应用。

你可以通过执行以下命令行来运行应用程序:

$ python main.py
 * Running on http://127.0.0.1:5000/
* Restarting with reloader

一个简单的 Flask 应用

从小小的开始...

放大

除了Configuration类之外,大部分代码对你来说应该很熟悉。我们基本上是将hello_flask示例中的代码分离成了几个模块。可能每个文件只写两三行代码看起来有些愚蠢,但随着我们项目的增长,你会看到这种早期组织的承诺是如何得到回报的。

你可能已经注意到,这些文件有一个内部的优先级,根据它们被导入的顺序—这是为了减轻循环导入的可能性。循环导入发生在两个模块相互导入并且因此根本无法被导入时。在使用 Flask 框架时,很容易创建循环导入,因为很多不同的东西依赖于中心应用对象。为了避免问题,有些人只是把所有东西放到一个单一的模块中。这对于较小的应用程序来说是可以的,但在一定规模或复杂性之后就无法维护了。这就是为什么我们将我们的应用程序分成几个模块,并创建一个单一的入口点来控制导入的顺序。

导入流程

当你从命令行运行 python main.py时,执行就开始了。Python 解释器运行的第一行代码是从app模块导入app对象。现在我们在app.py内部,它导入了 Flask 和我们的Configuration对象。app.py模块的其余部分被读取和解释,然后我们又回到了main.pymain.py的第二行导入了views模块。现在我们在views.py内部,它依赖于app.py@app.route,实际上已经从main.py中可用。随着views模块的解释,URL 路由和视图被注册,然后我们又回到了main.py。由于我们直接运行main.py,'if'检查将评估为True,我们的应用程序将运行。

导入流程

执行 main.py 时的导入流程

摘要

到目前为止,你应该已经熟悉了为 Python 项目设置新的虚拟环境的过程,能够安装 Flask,并创建了一个简单的应用程序。在本章中,我们讨论了如何为项目创建虚拟环境,并使用pip安装第三方包。我们还学习了如何编写基本的 Flask 应用程序,将请求路由到视图,并读取请求参数。我们熟悉了交互式调试器以及 Python 解释器如何处理导入语句。

如果你已经熟悉本章大部分内容,不用担心;很快事情会变得更具挑战性。

在下一章中,你将了解如何使用关系数据库来存储和检索博客条目。我们将为项目添加一个新模块来存储我们的数据库特定代码,并创建一些模型来表示博客条目和标签。一旦我们能够存储这些条目,我们将学习如何以各种方式通过过滤、排序和聚合来读取它们。更多信息,请参考以下链接:

第二章:使用 SQLAlchemy 的关系数据库

关系数据库是几乎每个现代 Web 应用程序构建的基石。学会以表和关系的方式思考你的应用程序是一个干净、设计良好的项目的关键之一。正如你将在本章中看到的,你早期选择的数据模型将影响代码的几乎每个方面。我们将使用 SQLAlchemy,一个强大的对象关系映射器,允许我们在 Python 内部直接与数据库交互,抽象出多个数据库引擎的复杂性。

在本章中,我们将:

  • 简要概述使用关系数据库的好处

  • 介绍 SQLAlchemy,Python SQL 工具包和对象关系映射器

  • 配置我们的 Flask 应用程序使用 SQLAlchemy

  • 编写一个模型类来表示博客条目

  • 学习如何从数据库保存和检索博客条目

  • 执行查询-排序、过滤和聚合

  • 为博客条目构建标记系统

  • 使用 Alembic 创建模式迁移

为什么使用关系数据库?

我们应用程序的数据库远不止是我们需要保存以备将来检索的东西的简单记录。如果我们只需要保存和检索数据,我们可以轻松地使用纯文本文件。事实上,我们希望能够对我们的数据执行有趣的查询。而且,我们希望能够高效地做到这一点,而不需要重新发明轮子。虽然非关系数据库(有时被称为 NoSQL 数据库)非常受欢迎,并且在 Web 世界中有其位置,但关系数据库早就解决了过滤、排序、聚合和连接表格数据的常见问题。关系数据库允许我们以结构化的方式定义数据集,从而保持数据的一致性。使用关系数据库还赋予我们开发人员自由,可以专注于我们应用程序中重要的部分。

除了高效执行特别查询外,关系数据库服务器还会执行以下操作:

  • 确保我们的数据符合模式中规定的规则

  • 允许多人同时访问数据库,同时保证底层数据的一致性

  • 确保数据一旦保存,即使应用程序崩溃也不会丢失

关系数据库和 SQL,与关系数据库一起使用的编程语言,是值得一整本书来讨论的话题。因为这本书致力于教你如何使用 Flask 构建应用程序,我将向你展示如何使用一个被 Python 社区广泛采用的用于处理数据库的工具,即 SQLAlchemy。

注意

SQLAlchemy 抽象了许多编写 SQL 查询的复杂性,但深入理解 SQL 和关系模型是无法替代的。因此,如果你是 SQL 的新手,我建议你查看在线免费提供的色彩丰富的书籍Learn SQL the Hard WayZed Shaw,网址为sql.learncodethehardway.org/

介绍 SQLAlchemy

SQLAlchemy 是一个在 Python 中处理关系数据库非常强大的库。我们可以使用普通的 Python 对象来表示数据库表并执行查询,而不是手动编写 SQL 查询。这种方法有许多好处,如下所示:

  • 你的应用程序可以完全使用 Python 开发。

  • 数据库引擎之间的微小差异被抽象掉了。这使你可以像使用轻量级数据库一样做事情,例如,在本地开发和测试时使用 SQLite,然后在生产环境中切换到为高负载设计的数据库(如 PostgreSQL)。

  • 数据库错误更少,因为现在在你的应用程序和数据库服务器之间有两层:Python 解释器本身(这将捕捉明显的语法错误)和 SQLAlchemy,它有明确定义的 API 和自己的错误检查层。

  • 由于 SQLAlchemy 的工作单元模型有助于减少不必要的数据库往返,所以您的数据库代码可能会变得更加高效。SQLAlchemy 还有用于高效预取相关对象的设施,称为急加载。

  • 对象关系映射ORM)使您的代码更易于维护,这是一种被称为不要重复自己DRY)的愿望。假设您向模型添加了一个列。使用 SQLAlchemy,每当您使用该模型时,该列都将可用。另一方面,如果您在整个应用程序中手写 SQL 查询,您将需要逐个更新每个查询,以确保包含新列。

  • SQLAlchemy 可以帮助您避免 SQL 注入漏洞。

  • 出色的库支持:正如您将在后面的章节中看到的,有许多有用的库可以直接与您的 SQLAlchemy 模型一起工作,提供诸如维护界面和 RESTful API 之类的功能。

希望您在阅读完这个列表后感到兴奋。如果这个列表中的所有项目现在对您来说都没有意义,不要担心。当您阅读本章和后续章节时,这些好处将变得更加明显和有意义。

现在我们已经讨论了使用 SQLAlchemy 的一些好处,让我们安装它并开始编码。

注意

如果您想了解更多关于 SQLAlchemy 的信息,在开源应用程序的架构中有一整章专门讨论了它的设计,可以免费在线阅读,网址是aosabook.org/en/sqlalchemy.html

安装 SQLAlchemy

我们将使用pip将 SQLAlchemy 安装到博客应用的虚拟环境中。正如您在上一章中所记得的,要激活您的虚拟环境,只需切换到source并执行activate脚本:

$ cd ~/projects/blog
$ source blog/bin/activate
(blog) $ pip install sqlalchemy
Downloading/unpacking sqlalchemy
…
Successfully installed sqlalchemy
Cleaning up...

您可以通过打开 Python 解释器并检查 SQLAlchemy 版本来检查您的安装是否成功;请注意,您的确切版本号可能会有所不同。

$ python
>>> import sqlalchemy
>>> sqlalchemy.__version__
'0.9.0b2'

在我们的 Flask 应用中使用 SQLAlchemy

SQLAlchemy 在 Flask 上运行得非常好,但 Flask 的作者发布了一个名为Flask-SQLAlchemy的特殊 Flask 扩展,它提供了许多常见任务的辅助功能,并可以避免我们以后不得不重新发明轮子。让我们使用pip来安装这个扩展:

(blog) $ pip install flask-sqlalchemy
…
Successfully installed flask-sqlalchemy

Flask 为对构建扩展感兴趣的开发人员提供了一个标准接口。随着这个框架的流行,高质量的扩展数量也在增加。如果您想查看一些更受欢迎的扩展,可以在 Flask 项目网站上找到一个精选列表,网址是flask.pocoo.org/extensions/

选择数据库引擎

SQLAlchemy 支持多种流行的数据库方言,包括 SQLite、MySQL 和 PostgreSQL。根据您想要使用的数据库,您可能需要安装一个包含数据库驱动程序的额外 Python 包。下面列出了 SQLAlchemy 支持的一些流行数据库以及相应的 pip-installable 驱动程序。一些数据库有多个驱动程序选项,所以我首先列出了最流行的一个。

数据库 驱动程序包
SQLite 不需要,自 Python 2.5 版本起已包含在 Python 标准库中
MySQL MySQL-python, PyMySQL(纯 Python),OurSQL
PostgreSQL psycopg2
Firebird fdb
Microsoft SQL Server pymssql, PyODBC
Oracle cx-Oracle

SQLite 与 Python 一起标准提供,并且不需要单独的服务器进程,因此非常适合快速启动。在接下来的示例中,为了简单起见,我将演示如何配置博客应用以使用 SQLite。如果您有其他数据库想法,并且希望在博客项目中使用它,请随时使用pip在此时安装必要的驱动程序包。

连接到数据库

使用您喜欢的文本编辑器,打开我们博客项目(~/projects/blog/app/config.py)的config.py模块。我们将添加一个特定于 SQLAlchemy 的设置,以指示 Flask-SQLAlchemy 如何连接到我们的数据库。以下是新的行:

import os
class Configuration(object):
 APPLICATION_DIR = os.path.dirname(os.path.realpath(__file__))
    DEBUG = True
 SQLALCHEMY_DATABASE_URI = 'sqlite:///%s/blog.db' % APPLICATION_DIR

SQLALCHEMY_DATABASE_URI包括以下部分:

dialect+driver://username:password@host:port/database

因为 SQLite 数据库存储在本地文件中,我们需要提供的唯一信息是数据库文件的路径。另一方面,如果您想连接到本地运行的 PostgreSQL,您的 URI 可能看起来像这样:

postgresql://postgres:secretpassword@localhost:5432/blog_db

注意

如果您在连接到数据库时遇到问题,请尝试查阅 SQLAlchemy 关于数据库 URI 的文档:docs.sqlalchemy.org/en/rel_0_9/core/engines.html

现在我们已经指定了如何连接到数据库,让我们创建一个负责实际管理我们数据库连接的对象。这个对象由 Flask-SQLAlchemy 扩展提供,并且方便地命名为SQLAlchemy。打开app.py并进行以下添加:

from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy

from config import Configuration

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

这些更改指示我们的 Flask 应用程序,进而指示 SQLAlchemy 如何与我们应用程序的数据库通信。下一步将是创建一个用于存储博客条目的表,为此,我们将创建我们的第一个模型。

创建 Entry 模型

模型是我们想要存储在数据库中的数据表的数据表示。这些模型具有称为的属性,表示数据中的数据项。因此,如果我们要创建一个Person模型,我们可能会有用于存储名字、姓氏、出生日期、家庭地址、头发颜色等的列。由于我们有兴趣创建一个模型来表示博客条目,我们将为标题和正文内容等内容创建列。

注意

请注意,我们不说People模型或Entries模型 - 即使它们通常代表许多不同的对象,模型是单数。

使用 SQLAlchemy,创建模型就像定义一个类并指定分配给该类的多个属性一样简单。让我们从我们博客条目的一个非常基本的模型开始。在博客项目的app/目录中创建一个名为models.py的新文件,并输入以下代码:

import datetime, re
from app import db

def slugify(s):
    return re.sub('[^\w]+', '-', s).lower()

class Entry(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(100))
    slug = db.Column(db.String(100), unique=True)
    body = db.Column(db.Text)
    created_timestamp = db.Column(db.DateTime, default=datetime.datetime.now)
    modified_timestamp = db.Column(
        db.DateTime,
        default=datetime.datetime.now, 
        onupdate=datetime.datetime.now)

    def __init__(self, *args, **kwargs):
        super(Entry, self).__init__(*args, **kwargs)  # Call parent constructor.
        self.generate_slug()

    def generate_slug(self):
        self.slug = ''
        if self.title:
            self.slug = slugify(self.title)

    def __repr__(self):
        return '<Entry: %s>' % self.title

有很多事情要做,所以让我们从导入开始,然后逐步进行。我们首先导入标准库datetimere模块。我们将使用datetime获取当前日期和时间,使用re进行一些字符串操作。接下来的导入语句引入了我们在app.py中创建的db对象。您可能还记得,db对象是SQLAlchemy类的一个实例,它是 Flask-SQLAlchemy 扩展的一部分。db对象提供了访问我们需要构建Entry模型的类的功能,这只是前面几行。

Entry模型之前,我们定义了一个辅助函数slugify,我们将使用它为我们的博客条目提供一些漂亮的 URL(在第三章中使用,模板和视图)。slugify函数接受一个字符串,比如关于 Flask 的帖子,并使用正则表达式将可读的字符串转换为 URL,因此返回a-post-about-flask

接下来是Entry模型。我们的Entry模型是一个普通的类,扩展了db.Model。通过扩展db.Model,我们的Entry类将继承各种我们将用于查询数据库的帮助程序。

Entry模型的属性是我们希望存储在数据库中的名称和数据的简单映射,并列在下面:

  • id:这是我们数据库表的主键。当我们创建一个新的博客条目时,数据库会自动为我们设置这个值,通常是每个新条目的自增编号。虽然我们不会明确设置这个值,但当你想要引用一个模型到另一个模型时,主键会派上用场,这一点你将在本章后面看到。

  • title:博客条目的标题,存储为具有最大长度为 100 的String列。

  • slug:标题的 URL 友好表示,存储为具有最大长度为 100 的String列。该列还指定了unique=True,因此没有两个条目可以共享相同的 slug。

  • body:帖子的实际内容,存储在Text列中。这与TitleSlugString类型不同,因为你可以在这个字段中存储任意多的文本。

  • created_timestamp:博客条目创建的时间,存储在DateTime列中。我们指示 SQLAlchemy 在首次保存条目时自动填充这一列的当前时间。

  • modified_timestamp:博客条目上次更新的时间。当我们保存一个条目时,SQLAlchemy 会自动使用当前时间更新这个列。

注意

对于标题或事物名称等短字符串,String列是合适的,但当文本可能特别长时,最好使用Text列,就像我们为条目正文所做的那样。

我们已经重写了类的构造函数(__init__),这样,当创建一个新模型时,它会根据标题自动为我们设置 slug。

最后一部分是__repr__方法,用于生成我们的Entry类实例的有用表示。__repr__的具体含义并不重要,但允许你在调试时引用程序正在处理的对象。

最后需要添加一小段代码到 main.py,这是我们应用程序的入口点,以确保模型被导入。将以下突出显示的更改添加到 main.py 中:

from app import app, db
import models
import views

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

创建 Entry 表

为了开始使用Entry模型,我们首先需要在我们的数据库中为它创建一个表。幸运的是,Flask-SQLAlchemy 带有一个很好的辅助程序来做这件事。在博客项目的app目录中创建一个名为scripts的新子文件夹。然后创建一个名为create_db.py的文件:

(blog) $ cd app/
(blog) $ mkdir scripts
(blog) $ touch scripts/create_db.py

将以下代码添加到create_db.py模块中。这个函数将自动查看我们编写的所有代码,并根据我们的模型在数据库中为Entry模型创建一个新表:

import os, sys
sys.path.append(os.getcwd())
from main import db

if __name__ == '__main__':
    db.create_all()

app/目录内执行脚本。确保虚拟环境是激活的。如果一切顺利,你应该看不到任何输出。

(blog) $ python create_db.py 
(blog) $

注意

如果在创建数据库表时遇到错误,请确保你在 app 目录中,并且在运行脚本时虚拟环境是激活的。接下来,确保你的SQLALCHEMY_DATABASE_URI设置中没有拼写错误。

使用 Entry 模型

让我们通过保存一些博客条目来尝试我们的新Entry模型。我们将在 Python 交互式 shell 中进行此操作。在这个阶段,让我们安装IPython,这是一个功能强大的 shell,具有诸如制表符补全(默认的 Python shell 没有的功能)。

(blog) $ pip install ipython

现在检查我们是否在app目录中,让我们启动 shell 并创建一些条目,如下所示:

(blog) $ ipython

In []: from models import *  # First things first, import our Entry model and db object.
In []: db  # What is db?
Out[]: <SQLAlchemy engine='sqlite:////home/charles/projects/blog/app/blog.db'>

注意

如果你熟悉普通的 Python shell 但不熟悉 IPython,一开始可能会有点不同。要注意的主要事情是In[]指的是你输入的代码,Out[]是你放入 shell 的命令的输出。

IPython 有一个很棒的功能,允许你打印关于对象的详细信息。这是通过输入对象的名称后跟一个问号(?)来完成的。内省Entry模型提供了一些信息,包括参数签名和表示该对象的字符串(称为docstring)的构造函数。

In []: Entry?  # What is Entry and how do we create it?
Type:       _BoundDeclarativeMeta
String Form:<class 'models.Entry'>
File:       /home/charles/projects/blog/app/models.py
Docstring:  <no docstring>
Constructor information:
 Definition:Entry(self, *args, **kwargs)

我们可以通过将列值作为关键字参数传递来创建Entry对象。在前面的示例中,它使用了**kwargs;这是一个快捷方式,用于将dict对象作为定义对象的值,如下所示:

In []: first_entry = Entry(title='First entry', body='This is the body of my first entry.')

为了保存我们的第一个条目,我们将其添加到数据库会话中。会话只是表示我们在数据库上的操作的对象。即使将其添加到会话中,它也不会立即保存到数据库中。为了将条目保存到数据库中,我们需要提交我们的会话:

In []: db.session.add(first_entry)
In []: first_entry.id is None  # No primary key, the entry has not been saved.
Out[]: True
In []: db.session.commit()
In []: first_entry.id
Out[]: 1
In []: first_entry.created_timestamp
Out[]: datetime.datetime(2014, 1, 25, 9, 49, 53, 1337)

从前面的代码示例中可以看出,一旦我们提交了会话,将为我们的第一个条目分配一个唯一的 id,并将created_timestamp设置为当前时间。恭喜,您已创建了您的第一个博客条目!

尝试自己添加几个。在提交之前,您可以将多个条目对象添加到同一个会话中,因此也可以尝试一下。

注意

在您进行实验的任何时候,都可以随时删除blog.db文件,并重新运行create_db.py脚本,以便使用全新的数据库重新开始。

对现有条目进行更改

修改现有的Entry时,只需进行编辑,然后提交。让我们使用之前返回给我们的 id 检索我们的Entry,进行一些更改,然后提交。SQLAlchemy 将知道需要更新它。以下是您可能对第一个条目进行编辑的方式:

In []: first_entry = Entry.query.get(1)
In []: first_entry.body = 'This is the first entry, and I have made some edits.'
In []: db.session.commit()

就像那样,您的更改已保存。

删除条目

删除条目与创建条目一样简单。我们将调用db.session.delete而不是调用db.session.add,并传入我们希望删除的Entry实例。

In []: bad_entry = Entry(title='bad entry', body='This is a lousy entry.')
In []: db.session.add(bad_entry)
In []: db.session.commit()  # Save the bad entry to the database.
In []: db.session.delete(bad_entry)
In []: db.session.commit()  # The bad entry is now deleted from the database.

检索博客条目

虽然创建、更新和删除操作相当简单,但当我们查看检索条目的方法时,真正有趣的部分开始了。我们将从基础知识开始,然后逐渐深入到更有趣的查询。

我们将使用模型类上的特殊属性进行查询:Entry.query。该属性公开了各种 API,用于处理数据库中条目的集合。

让我们简单地检索Entry表中所有条目的列表:

In []: entries = Entry.query.all()
In []: entries  # What are our entries?
Out[]: [<Entry u'First entry'>, <Entry u'Second entry'>, <Entry u'Third entry'>, <Entry u'Fourth entry'>]

如您所见,在此示例中,查询返回了我们创建的Entry实例的列表。当未指定显式排序时,条目将以数据库选择的任意顺序返回给我们。让我们指定我们希望以标题的字母顺序返回给我们条目:

In []: Entry.query.order_by(Entry.title.asc()).all()
Out []:
[<Entry u'First entry'>,
 <Entry u'Fourth entry'>,
 <Entry u'Second entry'>,
 <Entry u'Third entry'>]

接下来是如何按照最后更新时间的逆序列出您的条目:

In []: oldest_to_newest = Entry.query.order_by(Entry.modified_timestamp.desc()).all()
Out []:
[<Entry: Fourth entry>,
 <Entry: Third entry>,
 <Entry: Second entry>,
 <Entry: First entry>]

过滤条目列表

能够检索整个博客条目集合非常有用,但是如果我们想要过滤列表怎么办?我们可以始终检索整个集合,然后在 Python 中使用循环进行过滤,但那将非常低效。相反,我们将依赖数据库为我们进行过滤,并简单地指定应返回哪些条目的条件。在以下示例中,我们将指定要按标题等于'First entry'进行过滤的条目。

In []: Entry.query.filter(Entry.title == 'First entry').all()
Out[]: [<Entry u'First entry'>]

如果这对您来说似乎有些神奇,那是因为它确实如此!SQLAlchemy 使用操作符重载将诸如<Model>.<column> == <some value>的表达式转换为称为BinaryExpression的抽象对象。当您准备执行查询时,这些数据结构然后被转换为 SQL。

注意

BinaryExpression只是一个表示逻辑比较的对象,并且是通过重写通常在 Python 中比较值时调用的标准方法而生成的。

为了检索单个条目,您有两个选项:.first().one()。它们的区别和相似之处总结在以下表中:

匹配行的数量 first()行为 one()行为
1 返回对象 返回对象
0 返回None 引发sqlalchemy.orm.exc.NoResultFound
2+ 返回第一个对象(基于显式排序或数据库选择的排序) 引发sqlalchemy.orm.exc.MultipleResultsFound

让我们尝试与之前相同的查询,但是,而不是调用.all(),我们将调用.first()来检索单个Entry实例:

In []: Entry.query.filter(Entry.title == 'First entry').first()
Out[]: <Entry u'First entry'>

请注意,以前的.all()返回包含对象的列表,而.first()只返回对象本身。

特殊查找

在前面的示例中,我们测试了相等性,但还有许多其他类型的查找可能。在下表中,我们列出了一些您可能会发现有用的查找。完整列表可以在 SQLAlchemy 文档中找到。

示例 意义
Entry.title == 'The title' 标题为“The title”的条目,区分大小写。
Entry.title != 'The title' 标题不是“The title”的条目。
Entry.created_timestamp < datetime.date(2014, 1, 25) 2014 年 1 月 25 日之前创建的条目。要使用小于或等于,使用<=。
Entry.created_timestamp > datetime.date(2014, 1, 25) 2014 年 1 月 25 日之后创建的条目。要使用大于或等于,使用>=。
Entry.body.contains('Python') 正文包含单词“Python”的条目,区分大小写。
Entry.title.endswith('Python') 标题以字符串“Python”结尾的条目,区分大小写。请注意,这也将匹配以单词“CPython”结尾的标题,例如。
Entry.title.startswith('Python') 标题以字符串“Python”开头的条目,区分大小写。请注意,这也将匹配标题如“Pythonistas”。
Entry.body.ilike('%python%') 正文包含单词“python”的条目,文本中任何位置,不区分大小写。百分号“%”是通配符。
Entry.title.in_(['Title one', 'Title two']) 标题在给定列表中的条目,要么是'Title one'要么是'Title two'。

组合表达式

前面表格中列出的表达式可以使用位运算符组合,以生成任意复杂的表达式。假设我们想要检索所有博客条目中标题包含PythonFlask的条目。为了实现这一点,我们将创建两个contains表达式,然后使用 Python 的位OR运算符进行组合,这是一个管道|字符,不像其他许多使用双管||字符的语言:

Entry.query.filter(Entry.title.contains('Python') | Entry.title.contains('Flask'))

使用位运算符,我们可以得到一些非常复杂的表达式。试着弄清楚以下示例在询问什么:

Entry.query.filter(
    (Entry.title.contains('Python') | Entry.title.contains('Flask')) &
    (Entry.created_timestamp > (datetime.date.today() - datetime.timedelta(days=30)))
)

您可能已经猜到,此查询返回所有标题包含PythonFlask的条目,并且在过去 30 天内创建。我们使用 Python 的位ORAND运算符来组合子表达式。对于您生成的任何查询,可以通过打印查询来查看生成的 SQL,如下所示:

In []: query = Entry.query.filter(
 (Entry.title.contains('Python') | Entry.title.contains('Flask')) &
 (Entry.created_timestamp > (datetime.date.today() - datetime.timedelta(days=30)))
)
In []: print str(query)

SELECT entry.id AS entry_id, ...
FROM entry 
WHERE (
 (entry.title LIKE '%%' || :title_1 || '%%') OR (entry.title LIKE '%%' || :title_2 || '%%')
) AND entry.created_timestamp > :created_timestamp_1

否定

还有一点要讨论,那就是否定。如果我们想要获取所有标题中不包含PythonFlask的博客条目列表,我们该怎么做呢?SQLAlchemy 提供了两种方法来创建这些类型的表达式,一种是使用 Python 的一元否定运算符(~),另一种是调用db.not_()。以下是如何使用 SQLAlchemy 构建此查询的方法:

使用一元否定:

In []: Entry.query.filter(~(Entry.title.contains('Python') | Entry.title.contains('Flask')))

使用db.not_()

In []: Entry.query.filter(db.not_(Entry.title.contains('Python') | Entry.title.contains('Flask')))

运算符优先级

并非所有操作都被 Python 解释器视为相等。这就像在数学课上学习的那样,我们学到类似2 + 3 * 4的表达式等于14而不是20,因为乘法运算首先发生。在 Python 中,位运算符的优先级都高于诸如相等性测试之类的东西,这意味着在构建查询表达式时,您必须注意括号。让我们看一些示例 Python 表达式,并查看相应的查询:

表达式 结果
(Entry.title == 'Python' | Entry.title == 'Flask') 错误!SQLAlchemy 会抛出错误,因为首先要评估的实际上是'Python' | Entry.title!
(Entry.title == 'Python') | (Entry.title == 'Flask') 正确。返回标题为“Python”或“Flask”的条目。
~Entry.title == 'Python' 错误!SQLAlchemy 会将其转换为有效的 SQL 查询,但结果将没有意义。
~(Entry.title == 'Python') 正确。返回标题不等于“Python”的条目。

如果您发现自己在操作符优先级方面有困难,最好在使用==!=<<=>>=的任何比较周围加上括号。

构建标记系统

标签是一个轻量级的分类系统,非常适合博客。标签允许您将多个类别应用于博客文章,并允许多篇文章在其类别之外相互关联。在我的博客上,我使用标签来组织帖子,这样对于想阅读我关于 Flask 的帖子的人,只需在“Flask”标签下查找即可找到所有相关的帖子。根据我们在第一章中讨论的规范,创建您的第一个 Flask 应用程序,每个博客条目可以有多少个标签都可以,因此关于 Flask 的帖子可能会被标记为 Flask 和 Python。同样,每个标签(例如 Python)可以与多个条目相关联。在数据库术语中,这称为多对多关系。

为了对此进行建模,我们必须首先创建一个模型来存储标签。这个模型将存储我们使用的标签名称,因此在我们添加了一些标签之后,表可能看起来像下面这样:

id tag
1 Python
2 Flask
3 Django
4 random-thoughts

让我们打开models.py并为Tag模型添加一个定义。在文件末尾添加以下类,位于Entry类下方:

class Tag(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(64))
    slug = db.Column(db.String(64), unique=True)

    def __init__(self, *args, **kwargs):
        super(Tag, self).__init__(*args, **kwargs)
        self.slug = slugify(self.name)

    def __repr__(self):
        return '<Tag %s>' % self.name

您以前见过所有这些。我们添加了一个主键,这将由数据库管理,并添加了一个列来存储标签的名称。name列被标记为唯一,因此每个标签在这个表中只会被一行表示,无论它出现在多少个博客条目中。

现在我们既有博客条目模型,也有标签模型,我们需要一个第三个模型来存储两者之间的关系。当我们希望表示博客条目被标记为特定标签时,我们将在这个表中存储一个引用。以下是数据库表级别上正在发生的事情的图示:

构建标记系统

由于我们永远不会直接访问这个中间表(SQLAlchemy 会透明地处理它),我们不会为它创建一个模型,而是简单地指定一个表来存储映射。打开models.py并添加以下突出显示的代码:

import datetime, re

from app import db

def slugify(s):
    return re.sub('[^\w]+', '-', s).lower()

entry_tags = db.Table('entry_tags',
 db.Column('tag_id', db.Integer, db.ForeignKey('tag.id')),
 db.Column('entry_id', db.Integer, db.ForeignKey('entry.id'))
)

class Entry(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(100))
    slug = db.Column(db.String(100), unique=True)
    body = db.Column(db.Text)
    created_timestamp = db.Column(db.DateTime, default=datetime.datetime.now)
    modified_timestamp = db.Column(
        db.DateTime,
        default=datetime.datetime.now,
        onupdate=datetime.datetime.now)

 tags = db.relationship('Tag', secondary=entry_tags,
 backref=db.backref('entries', lazy='dynamic'))

    def __init__(self, *args, **kwargs):
        super(Entry, self).__init__(*args, **kwargs)
        self.generate_slug()

    def generate_slug(self):
        self.slug = ''
        if self.title:
            self.slug = slugify(self.title)

    def __repr__(self):
        return '<Entry %s>' % self.title

class Tag(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(64))
    slug = db.Column(db.String(64), unique=True)

    def __init__(self, *args, **kwargs):
        super(Tag, self).__init__(*args, **kwargs)
        self.slug = slugify(self.name)

    def __repr__(self):
        return '<Tag %s>' % self.name

通过创建entry_tags表,我们已经建立了EntryTag模型之间的链接。SQLAlchemy 提供了一个高级 API 来处理这种关系,名为db.relationship函数。这个函数在Entry模型上创建了一个新属性,允许我们轻松地读取和写入给定博客条目的标签。这两行代码中有很多内容,让我们仔细看一下:

tags = db.relationship('Tag', secondary=entry_tags,
    backref=db.backref('entries', lazy='dynamic'))

我们将Entry类的标签属性设置为db.relationship函数的返回值。前两个参数'Tag'secondary=entry_tags指示 SQLAlchemy 我们将通过entry_tags表查询Tag模型。第三个参数创建了一个反向引用,允许我们从Tag模型返回到相关的博客条目列表。通过指定lazy='dynamic',我们指示 SQLAlchemy,我们不希望它为我们加载所有相关的条目,而是想要一个查询对象。

向条目添加和删除标签

让我们使用 IPython shell 来看看这是如何工作的。关闭当前的 shell 并重新运行scripts/create_db.py脚本。由于我们添加了两个新表,这一步是必要的。现在重新打开 IPython:

(blog) $ python scripts/create_db.py
(blog) $ ipython
In []: from models import *
In []: Tag.query.all()
Out[]: []

目前数据库中没有标签,所以让我们创建一些标签:

In []: python = Tag(name='python')
In []: flask = Tag(name='flask')
In []: db.session.add_all([python, flask])
In []: db.session.commit()

现在让我们加载一些示例条目。在我的数据库中有四个:

In []: Entry.query.all()
Out[]:
[<Entry Py
thon entry>,
 <Entry Flask entry>,
 <Entry More flask>,
 <Entry Django entry>]
In []: python_entry, flask_entry, more_flask, django_entry = _

注意

在 IPython 中,您可以使用下划线(_)来引用上一行的返回值。

要向条目添加标签,只需将它们分配给条目的tags属性。就是这么简单!

In []: python_entry.tags = [python]
In []: flask_entry.tags = [python, flask]
In []: db.session.commit()

我们可以像处理普通的 Python 列表一样处理条目的标签列表,因此通常的.append().remove()方法也可以使用:

In []: kittens = Tag(name='kittens')
In []: python_entry.tags.append(kittens)
In []: db.session.commit()
In []: python_entry.tags
Out[]: [<Tag python>, <Tag kittens>]
In []: python_entry.tags.remove(kittens)
In []: db.session.commit()
In []: python_entry.tags
Out[]: [<Tag python>]

使用 backrefs

创建Entry模型上的tags属性时,您会回忆起我们传入了backref参数。让我们使用 IPython 来看看后向引用是如何使用的。

In []: python  # The python variable is just a tag.
Out[]: <Tag python>
In []: python.entries
Out[]: <sqlalchemy.orm.dynamic.AppenderBaseQuery at 0x332ff90>
In []: python.entries.all()
Out[]: [<Entry Flask entry>, <Entry Python entry>]

Entry.tags引用不同,后向引用被指定为lazy='dynamic'。这意味着,与给出标签列表的entry.tags不同,我们每次访问tag.entries时都不会收到条目列表。为什么呢?通常,当结果集大于几个项目时,将backref参数视为查询更有用,可以进行过滤、排序等操作。例如,如果我们想显示最新的标记为python的条目会怎样?

In []: python.entries.order_by(Entry.created_timestamp.desc()).first()
Out[]: <Entry Flask entry>

注意

SQLAlchemy 文档包含了可以用于 lazy 参数的各种值的优秀概述。您可以在docs.sqlalchemy.org/en/rel_0_9/orm/relationships.html#sqlalchemy.orm.relationship.params.lazy上找到它们。

对模式进行更改

本章最后要讨论的主题是如何对现有的模型定义进行修改。根据项目规范,我们希望能够保存博客条目的草稿。现在我们没有办法知道一个条目是否是草稿,所以我们需要添加一个列来存储条目的状态。不幸的是,虽然db.create_all()用于创建表非常完美,但它不会自动修改现有的表;为了做到这一点,我们需要使用迁移。

将 Flask-Migrate 添加到我们的项目中

我们将使用 Flask-Migrate 来帮助我们在更改模式时自动更新数据库。在博客虚拟环境中,使用pip安装 Flask-Migrate:

(blog) $ pip install flask-migrate

注意

SQLAlchemy 的作者有一个名为 alembic 的项目;Flask-Migrate 使用它并直接将其与 Flask 集成,使事情变得更容易。

接下来,我们将向我们的应用程序添加一个Migrate助手。我们还将为我们的应用程序创建一个脚本管理器。脚本管理器允许我们在应用程序的上下文中直接从命令行执行特殊命令。我们将使用脚本管理器来执行migrate命令。打开app.py并进行以下添加:

from flask import Flask
from flask.ext.migrate import Migrate, MigrateCommand
from flask.ext.script import Manager
from flask.ext.sqlalchemy import SQLAlchemy

from config import Configuration

app = Flask(__name__)
app.config.from_object(Configuration)
db = SQLAlchemy(app)
migrate = Migrate(app, db)

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

为了使用管理器,我们将在app.py旁边添加一个名为manage.py的新文件。将以下代码添加到manage.py中:

from app import manager
from main import *

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

这看起来与main.py非常相似,关键区别在于,我们不是调用app.run(),而是调用manager.run()

注意

Django 有一个类似的,尽管是自动生成的manage.py文件,起着类似的功能。

创建初始迁移

在我们开始更改模式之前,我们需要创建其当前状态的记录。为此,请从博客的app目录内运行以下命令。第一个命令将在app文件夹内创建一个迁移目录,用于跟踪我们对模式所做的更改。第二个命令db migrate将创建我们当前模式的快照,以便将来的更改可以与之进行比较。

(blog) $ python manage.py db init

 Creating directory /home/charles/projects/blog/app/migrations ... done
 ...
(blog) $ python manage.py db migrate
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
 Generating /home/charles/projects/blog/app/migrations/versions/535133f91f00_.py ... done

最后,我们将运行db upgrade来运行迁移,以指示迁移系统一切都是最新的:

(blog) $ python manage.py db upgrade
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.migration] Running upgrade None -> 535133f91f00, empty message

添加状态列

现在我们已经有了当前模式的快照,我们可以开始进行更改。我们将添加一个名为status的新列,该列将存储与特定状态对应的整数值。尽管目前只有两种状态(PUBLICDRAFT),但使用整数而不是布尔值使我们有可能在将来轻松添加更多状态。打开models.py并对Entry模型进行以下添加:

class Entry(db.Model):
 STATUS_PUBLIC = 0
 STATUS_DRAFT = 1

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(100))
    slug = db.Column(db.String(100), unique=True)
    body = db.Column(db.Text)
 status = db.Column(db.SmallInteger, default=STATUS_PUBLIC)
    created_timestamp = db.Column(db.DateTime, default=datetime.datetime.now)
    ...

从命令行,我们将再次运行db migrate来生成迁移脚本。您可以从命令的输出中看到它找到了我们的新列!

(blog) $ python manage.py db migrate
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added column 'entry.status'
 Generating /home/charl
es/projects/blog/app/migrations/versions/2c8e81936cad_.py ... done

因为我们在数据库中有博客条目,所以我们需要对自动生成的迁移进行小修改,以确保现有条目的状态被初始化为正确的值。为此,打开迁移文件(我的是migrations/versions/2c8e81936cad_.py)并更改以下行:

op.add_column('entry', sa.Column('status', sa.SmallInteger(), nullable=True))

nullable=True替换为server_default='0'告诉迁移脚本不要将列默认设置为 null,而是使用0

op.add_column('entry', sa.Column('status', sa.SmallInteger(), server_default='0'))

最后,运行db upgrade来运行迁移并创建状态列。

(blog) $ python manage.py db upgrade
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.migration] Running upgrade 535133f91f00 -> 2c8e81936cad, empty message

恭喜,您的Entry模型现在有了一个状态字段!

总结

到目前为止,您应该熟悉使用 SQLAlchemy 来处理关系数据库。我们介绍了使用关系数据库和 ORM 的好处,配置了一个 Flask 应用程序来连接到关系数据库,并创建了 SQLAlchemy 模型。所有这些都使我们能够在数据之间创建关系并执行查询。最重要的是,我们还使用了迁移工具来处理未来的数据库模式更改。

在第三章中,模板和视图,我们将搁置交互式解释器,开始创建视图以在 Web 浏览器中显示博客条目。我们将利用我们所有的 SQLAlchemy 知识创建有趣的博客条目列表,以及一个简单的搜索功能。我们将构建一组模板,使博客网站在视觉上更具吸引力,并学习如何使用 Jinja2 模板语言来消除重复的 HTML 编码。这将是一个有趣的章节!

第三章:模板和视图

这一章也可以被称为Flask 章节,因为我们将涵盖框架中最具代表性的两个组件:Jinja2 模板语言和 URL 路由框架。到目前为止,我们一直在为博客应用奠定基础,但实际上我们几乎没有涉及到 Flask 的开发。在这一章中,我们将深入了解 Flask,并看到我们的应用最终开始成形。我们将把单调的数据库模型转换为动态呈现的 HTML 页面,使用模板。我们将设计一个 URL 方案,反映我们希望组织博客条目的方式。随着我们在本章的进展,我们的博客应用将开始看起来像一个真正的网站。

在本章中,我们将:

  • 学习如何使用 Jinja2 呈现 HTML 模板

  • 学习如何使用 Jinja2 模板语言提供的循环、控制结构和过滤器

  • 使用模板继承来消除重复的编码

  • 为我们的博客应用创建一个清晰的 URL 方案,并设置从 URL 到视图的路由

  • 使用 Jinja2 模板呈现博客条目列表

  • 为网站添加全文搜索

介绍 Jinja2

Jinja2 是一个快速、灵活和安全的模板引擎。它允许您将网站定义为小块,这些小块被拼凑在一起形成完整的页面。例如,在我们的博客中,我们将为标题、侧边栏、页脚以及用于呈现博客文章的模板创建块。这种方法是 DRY(不要重复自己),这意味着每个块中包含的标记不应该被复制或粘贴到其他地方。由于站点的每个部分的 HTML 只存在于一个地方,因此更改和修复错误变得更容易。Jinja2 还允许您在模板中嵌入显示逻辑。例如,我们可能希望向已登录的用户显示注销按钮,但向匿名浏览的用户显示登录表单。正如您将看到的,使用一些模板逻辑来实现这些类型的事情非常容易。

从一开始,Flask 就是以 Jinja2 为核心构建的,因此在 Flask 应用中使用模板非常容易。由于 Jinja2 是 Flask 框架的要求,它已经安装在我们的虚拟环境中,所以我们可以立即开始使用。

在博客项目的app目录中创建一个名为templates的新文件夹。在模板文件夹中创建一个名为homepage.html的单个文件,并添加以下 HTML 代码:

<!doctype html>
<html>
  <head>
    <title>Blog</title>
  </head>
  <body>
    <h1>Welcome to my blog</h1>
  </body>
</html>

现在在博客项目的app目录中打开views.py。我们将修改我们的homepage视图以呈现新的homepage.html模板。为此,我们将使用 Flask 的render_template()函数,将我们的模板名称作为第一个参数传递进去。呈现模板是一个非常常见的操作,所以 Flask 尽可能地简化了这部分内容:

from flask import render_template

from app import app

@app.route('/')
def homepage():
    return render_template('homepage.html')

使用我们在上一章中创建的manage.py助手,启动开发服务器并导航到http://127.0.0.1:5000/以查看呈现的模板,如下面的屏幕截图所示:

(blog) $ python manage.py runserver
* Running on http://127.0.0.1:5000/
* Restarting with reloader

介绍 Jinja2

基本的模板操作

前面的例子可能看起来并不那么令人印象深刻,因为我们所做的不过是提供一个简单的 HTML 文档。为了使事情变得有趣,我们需要给我们的模板提供上下文。让我们修改我们的主页,显示一个简单的问候语来说明这一点。打开views.py并进行以下修改:

from flask import render_template, request

from app import app

@app.route('/')
def homepage():
    name = request.args.get('name')
 if not name:
 name = '<unknown>'
    return render_template('homepage.html', name=name)

在视图代码中,我们将name传递到模板上下文中。下一步是在实际模板中对name做一些操作。在这个例子中,我们将简单地打印name的值。打开homepage.html并进行以下添加:

<!doctype html>
<html>
  <head>
    <title>Blog</title>
  </head>
  <body>
    <h1>Welcome to my blog</h1>
    <p>Your name is {{ name }}.</p>
  </body>
</html>

启动开发服务器并导航到根 URL。你应该看到类似下面图片的东西:

基本的模板操作

传递给render_template函数的任何关键字参数都可以在模板上下文中使用。在 Jinja2 的模板语言中,双大括号类似于print语句。我们使用{{ name }}操作来输出name的值,该值设置为<unknown>

提示

注重安全的读者可能已经注意到,当我们在浏览器中查看我们的模板时,括号被转义了。通常,括号被浏览器视为 HTML 标记,但是如您所见,Jinja2 已经自动转义了括号,用&lt;&gt;替换了它们。

尝试导航到诸如http://127.0.0.1:5000/?name=Charlie之类的 URL。无论您指定什么值,Jinja2 都会自动为我们呈现,如下图所示

基本模板操作

假设有人恶意访问您的网站并想要制造一些麻烦。注意到查询字符串中的值直接传递到模板中,这个人决定尝试注入一个脚本标记来玩一些恶作剧。幸运的是,Jinja2 在将值插入渲染页面之前会自动转义这些值。

基本模板操作

循环、控制结构和模板编程

Jinja2 支持一种微型编程语言,可用于在上下文中对数据执行操作。如果我们只能将值打印到上下文中,那么实际上就没有太多令人兴奋的事情了。当我们将上下文数据与循环和控制结构等内容结合在一起时,事情就变得有趣起来了。

让我们再次修改我们的主页视图。这次我们将从request.args中接受一个数字,以及一个名称,并显示 0 到该数字之间的所有偶数。好处是我们几乎可以在模板中完成所有这些工作。对views.py进行以下更改:

from flask import render_template, request

from app import app

@app.route('/')
def homepage():
    name = request.args.get('name')
 number = request.args.get('number')
 return render_template('homepage.html', name=name, number=number)

现在打开hompage.html模板并添加以下代码。如果看起来奇怪,不用担心。我们将逐行讲解。

<!doctype html>
<html>
  <head>
    <title>Blog</title>
  </head>
  <body>
    <h1>Welcome to my blog</h1>
    {% if number %}
 <p>Your number is {{ number|int }}</p>
 <ul>
 {% for i in range(number|int) %}
 {% if i is divisibleby 2 %}
 <li>{{ i }}</li>
 {% endif %}
 {% endfor %}
 </ul>
 {% else %}
 <p>No number specified.</p>
 {% endif %}

    <p>Your name is {{ name|default('<unknown>', True) }}.</p>
  </body>
</html>

启动 runserver 并通过查询字符串传递一些值进行实验。还要注意当传递非数字值或负值时会发生什么。

循环、控制结构和模板编程

让我们逐行讲解我们的新模板代码,从{% if number %}语句开始。与使用双大括号的打印标记不同,逻辑标记使用{%%}。我们只是检查上下文中是否传递了一个数字。如果数字是None或空字符串,则此测试将失败,就像在 Python 中一样。

下一行打印了我们数字的整数表示,并使用了一个新的语法|int。竖线符号(|)在 Jinja2 中用于表示对过滤器的调用。过滤器对位于竖线符号左侧的值执行某种操作,并返回一个新值。在这种情况下,我们使用了内置的int过滤器,将字符串转换为整数,在无法确定数字时默认为0。Jinja2 内置了许多过滤器;我们将在本章后面讨论它们。

{% for %}语句用于创建一个for循环,看起来非常接近 Python。我们使用 Jinja2 的range辅助函数生成一个数字序列[0,number)。请注意,我们再次通过int过滤器在调用range时将number上下文值传递给range。还要注意,我们将一个值赋给一个新的上下文变量i。在循环体内,我们可以像使用任何其他上下文变量一样使用i

提示

当然,就像在普通的 Python 中一样,我们也可以在 for 循环上使用{% else %}语句,用于在没有要执行的循环时运行一些代码。

现在我们正在循环遍历数字,我们需要检查i是否为偶数,如果是,则打印出来。Jinja2 提供了几种方法可以做到这一点,但我选择展示了一种名为tests的 Jinja2 特性的使用。与过滤器和控制结构一样,Jinja2 还提供了许多有用的工具来测试上下文值的属性。测试与{% if %}语句一起使用,并通过关键字is表示。因此,我们有{% if i is divisibleby 2 %},这非常容易阅读。如果if语句评估为True,那么我们将使用双大括号打印i的值:{{ i }}

提示

Jinja2 提供了许多有用的测试;要了解更多,请查阅项目文档jinja.pocoo.org/docs/templates/#tests

由于 Jinja2 不知道重要的空格,我们需要明确关闭所有逻辑标记。这就是为什么您看到了一个{% endif %}标记,表示divisibleby 2检查的关闭,以及一个{% endfor %}标记,表示for i in range循环的关闭。在for循环之后,我们现在处于最外层的if语句中,该语句测试是否将数字传递到上下文中。如果没有数字存在,我们希望向用户显示一条消息,因此在调用{% endif %}之前,我们将使用{% else %}标记来显示此消息。

最后,我们已将打印向用户问候语的行更改为{{ name|default('<unknown>', True) }}。在视图代码中,我们删除了将其设置为默认值<unknown>的逻辑。相反,我们将该逻辑移到了模板中。在这里,我们看到了default过滤器(由|字符表示),但与int不同的是,我们传递了多个参数。在 Jinja2 中,过滤器可以接受多个参数。按照惯例,第一个参数出现在管道符号的左侧,因为过滤器经常操作单个值。如果有多个参数,则这些参数在过滤器名称之后的括号中指定。在default过滤器的情况下,我们已指定在未指定名称时使用的值。

Jinja2 内置过滤器

在前面的示例中,我们看到了如何使用int过滤器将上下文值强制转换为整数。除了int之外,Jinja2 还提供了大量有用的内置过滤器。出于空间原因(列表非常长),我只包含了我经验中最常用的过滤器,但整个列表可以在网上找到jinja.pocoo.org/docs/templates/#list-of-builtin-filters

提示

在以下示例中,参数列表中的第一个参数将出现在管道符号的左侧。因此,即使我写了abs(number),使用的过滤器将是number|abs。当过滤器接受多个参数时,剩余的参数将在过滤器名称后的括号中显示。

过滤器和参数 描述和返回值
abs(number) 返回数字的绝对值。

| default(value, default_value='', boolean=False) | 如果value未定义(即上下文中不存在该名称),则使用提供的default_value。如果您只想测试value是否评估为布尔值True(即不是空字符串,数字零,None 等),则将第三个参数传递为True

{{ not_in_context&#124;default:"The value was not in the context" }}

{{ ''&#124;default('An empty string.', True) }}

|

| dictsort(value, case_sensitive=False, by='key') | 按键对字典进行排序,产生(key, value)对。但您也可以按值排序。

<p>Alphabetically by name.</p>
{% for name, age in people&#124;dictsort %}
    {{ name }} is {{ age }} years old.
{% endfor %}

<p>Youngest to oldest.</p>
{% for name, age in people&#124;dictsort(by='value') %}
    {{ name }} is {{ age }} years old.
{% endfor %}

|

int(value, default=0) value转换为整数。如果无法转换该值,则使用指定的默认值。
length(object) 返回集合中的项目数。
reverse(sequence) 反转序列。

| safe(value) | 输出未转义的值。当您有信任的 HTML 希望打印时,此过滤器非常有用。例如,如果 value = "<b>"

{{ value }} --> outputs &lt;b&gt;

{{ value&#124;safe }} --> outputs <b>

|

sort(value, reverse=False, case_sensitive=False, attribute=None) 对可迭代的值进行排序。如果指定了 reverse,则项目将以相反顺序排序。如果使用了 attribute 参数,该属性将被视为排序的值。
striptags(value) 删除任何 HTML 标签,用于清理和输出不受信任的用户输入。
truncate(value, length=255, killwords=False, end='...') 返回字符串的截断副本。长度参数指定要保留多少个字符。如果killwordsFalse,则一个单词可能会被切成一半;如果为True,则 Jinja2 将在前一个单词边界截断。如果值超过长度并且需要被截断,将自动附加end中的值。
urlize(value, trim_url_limit=None, nofollow=False, target=None) 将纯文本中的 URL 转换为可点击的链接。

提示

过滤器可以链接在一起,所以{{ number|int|abs }}首先将数字变量转换为整数,然后返回其绝对值。

为博客创建一个基础模板

Jinja2 的继承和包含功能使得定义一个基础模板成为站点上每个页面的架构基础非常容易。基础模板包含一些基本结构,如<html><head><body>标签,以及 body 的基本结构。它还可以用于包含样式表或脚本,这些样式表或脚本将在每个页面上提供。最重要的是,基础模板负责定义可覆盖的块,我们将在其中放置特定于页面的内容,如页面标题和正文内容。

为了快速启动,我们将使用 Twitter 的 Bootstrap 库(版本 3)。这将使我们能够专注于模板的结构,并且只需进行最少的额外工作就能拥有一个看起来不错的网站。当然,如果您愿意,也可以使用自己的 CSS,但示例代码将使用特定于 bootstrap 的结构。

templates目录中创建一个名为base.html的新文件,并添加以下内容:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>{% block title %}{% endblock %} | My Blog</title>
    <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.0/css/bootstrap.min.css">
    <style type="text/css">
      body { padding-top: 60px; }
    </style>
    {% block extra_styles %}{% endblock %}

    <script src="img/jquery-1.10.2.min.js"></script>
    <script src="img/bootstrap.min.js"></script>
    {% block extra_scripts %}{% endblock %}
  </head>

  <body class="{% block body_class %}{% endblock %}">
    <div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
      <div class="container">
        <div class="navbar-header">
          <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
            <span class="sr-only">Toggle navigation</span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
          </button>
          <a class="navbar-brand" href="#">{% block branding %}My Blog{% endblock %}</a>
        </div>
        <div class="collapse navbar-collapse">
          <ul class="nav navbar-nav">
            <li><a href="/">Home</a></li>
            {% block extra_nav %}{% endblock %}
          </ul>
        </div>
      </div>
    </div>
    <div class="container">
      <div class="row">
        <div class="col-md-9">
          <h1>{% block content_title %}{% endblock %}</h1>
          {% block content %}
          {% endblock %}
        </div>
        <div class="col-md-3">
          {% block sidebar %}
          <ul class="well nav nav-stacked">
            <li><a href="#">Sidebar item</a></li>
          </ul>
          {% endblock %}
        </div>
      </div>
      <div class="row">
        <hr />
        <footer>
          <p>&copy; your name</p>
        </footer>
      </div>
    </div>
  </body>
</html>

在标记中夹杂着一个新的 Jinja2 标签blockblock标签用于指示页面的可覆盖区域。

您可能已经注意到我们正在从公开可用的 URL 中提供 jQuery 和 Bootstrap。在下一章中,我们将讨论如何提供存储在本地磁盘上的静态文件。现在我们可以修改我们的主页模板,并利用新的基础模板。我们可以通过扩展基础模板并覆盖某些块来实现这一点。这与大多数语言中的类继承非常相似。只要继承页面的部分被很好地分成块,我们就可以只覆盖需要更改的部分。让我们打开homepage.html,并用以下内容替换当前内容的一部分:

{% extends "base.html" %}

{% block content_title %}Welcome to my blog{% endblock %}

{% block content %}
  {% if number %}
    <p>Your number is {{ number|int }}</p>
    <ul>
      {% for i in range(number|int) %}
        {% if i is divisibleby 2 %}
          <li>{{ i }}</li>
        {% endif %}
      {% endfor %}
    </ul>
  {% else %}
    <p>No number specified.</p>
  {% endif %}

  <p>Your name is {{ name|default('<unknown>', True) }}.</p>
{% endblock %}

通过扩展原始页面,我们已经删除了所有 HTML 样板和大量复杂性,只关注于使这个页面,我们的主页视图,独特的部分。启动服务器并导航到http://127.0.0.1:5000/,您会看到我们的主页已经改变了。

为博客创建基础模板

恭喜!您现在已经学会了 Jinja2 最常用的一些功能。还有许多更高级的功能我们没有在时间允许的情况下涵盖,我建议阅读项目的文档,以了解 Jinja2 的全部可能性。文档可以在jinja.pocoo.org/docs/找到。

我们仍然需要构建模板来显示我们的博客条目。但在继续构建模板之前,我们首先必须创建一些视图函数,这些函数将生成博客条目的列表。然后我们将条目传递到上下文中,就像我们在主页中所做的那样。

创建 URL 方案

URL 是给人看的,因此它们应该易于记忆。当 URL 方案准确反映网站的隐含结构时,良好的 URL 方案易于记忆。我们的目标是创建一个 URL 方案,使我们网站上的访问者能够轻松找到他们感兴趣的主题的博客条目。

参考我们在第一章中创建的规范,创建您的第一个 Flask 应用程序,我们知道我们希望我们的博客条目按标签和日期进行组织。按标签和日期组织的条目必然是所有条目的子集,因此给我们提供了这样的结构:

URL 目的
/entries/ 这显示了我们所有的博客条目,按最近的顺序排列
/entries/tags/ 这包含用于组织我们的博客条目的所有标签
/entries/tags/python/ 这包含所有标记为python的条目
/entries/learning-the-flask-framework/ 这是显示博客条目标题为学习 Flask 框架的正文内容的详细页面

由于单个博客条目可能与多个标签相关联,我们如何决定将其用作规范 URL?如果我写了一篇名为学习 Flask 框架的博客条目,我可以将其嵌套在/entries//entries/tags/python//entries/tags/flask/等下。这将违反有关良好 URL 的规则之一,即唯一资源应该有一个且仅有一个 URL。因此,我将主张将单个博客条目放在层次结构的顶部:

/entries/learning-the-flask-framework/

通常,具有大量时间敏感内容的新闻网站和博客将使用发布日期嵌套单个内容片段。这可以防止当两篇文章可能具有相同的标题但是在不同时间编写时发生冲突。当每天产生大量内容时,这种方案通常更有意义:

/entries/2014/jan/18/learning-the-flask-framework/

尽管我们在本章中不会涵盖这种类型的 URL 方案,但代码可以在www.packtpub.com/support上找到。

定义 URL 路由

让我们将之前描述的结构转换为 Flask 将理解的一些 URL 路由。在博客项目的app目录中创建一个名为entries的新目录。在entries目录内,创建两个文件,__init__.pyblueprint.py如下:

(blog) $ mkdir entries
(blog) $ touch entries/{__init__,blueprint}.py

Blueprints提供了一个很好的 API,用于封装一组相关的路由和模板。在较小的应用程序中,通常所有内容都会在应用程序对象上注册(即app.route)。当应用程序具有不同的组件时,如我们的应用程序,可以使用 blueprints 来分离各种移动部分。由于/entries/ URL 将完全用于我们的博客条目,我们将创建一个 blueprint,然后定义视图来处理我们之前描述的路由。打开blueprint.py并添加以下代码:

from flask import Blueprint

from models import Entry, Tag

entries = Blueprint('entries', __name__, template_folder='templates')

@entries.route('/')
def index():
    return 'Entries index'

@entries.route('/tags/')
def tag_index():
    pass

@entries.route('/tags/<slug>/')
def tag_detail(slug):
    pass

@entries.route('/<slug>/')
def detail(slug):
    pass

这些 URL 路由是我们将很快填充的占位符,但我想向您展示如何将一组 URL 模式清晰简单地转换为一组路由和视图。

为了访问这些新视图,我们需要使用我们的主要 Flask app对象注册我们的 blueprint。我们还将指示我们的应用程序,我们希望我们的条目的 URL 位于前缀/entries。打开main.py并进行以下添加:

from app import app, db
import models
import views

from entries.blueprint import entries
app.register_blueprint(entries, url_prefix='/entries')

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

如果您想测试一下,请启动调试服务器(python manage.py runserver)并导航到http://127.0.0.1:5000/entries/。您应该会看到以下消息:

定义 URL 路由

构建索引视图

index视图是我们/entries/层次结构中最顶层的 URL,因此将包含所有的条目。随着时间的推移,我们可能会有数十甚至数百个博客条目,因此我们希望对这个列表进行分页,以免压倒我们的访问者(或我们的服务器!)。因为我们经常需要显示对象列表,让我们创建一个助手模块,以便轻松地显示对象的分页列表。在app目录中,创建一个名为helpers.py的新模块,并添加以下代码:

from flask import render_template, request

def object_list(template_name, query, paginate_by=20, **context):
    page = request.args.get('page')
    if page and page.isdigit():
        page = int(page)
    else:
        page = 1
    object_list = query.paginate(page, paginate_by)
    return render_template(template_name, object_list=object_list, **context)

现在,我们将打开entries/blueprint.py并修改index视图以返回分页列表条目:

from flask import Blueprint

from helpers import object_list
from models import Entry, Tag

entries = Blueprint('entries', __name__, template_folder='templates')

@entries.route('/')
def index():
    entries = Entry.query.order_by(Entry.created_timestamp.desc())
 return object_list('entries/index.html', entries)

我们正在导入object_list辅助函数,并将其传递给模板的名称和表示我们希望显示的条目的查询。随着我们构建这些视图的其余部分,您将看到诸如object_list这样的小辅助函数如何使 Flask 开发变得非常容易。

最后一部分是entries/index.html模板。在entries目录中,创建一个名为templates的目录,和一个名为entries的子目录。创建index.html,使得从app目录到entries/templates/entries/index.html的完整路径,并添加以下代码:

{% extends "base.html" %}

{% block title %}Entries{% endblock %}

{% block content_title %}Entries{% endblock %}

{% block content %}
  {% include "includes/list.html" %}
{% endblock %}

这个模板非常简单,所有的工作都将在includes/list.html中进行。{% include %}标签是新的,对于可重用的模板片段非常有用。创建文件includes/list.html并添加以下代码:

{% for entry in object_list.items %}
  <p><a href="{{ url_for('entries.detail', slug=entry.slug) }}">{{ entry.title }}</a></p>
{% endfor %}

url_for函数非常有用。url_for()允许我们提供视图函数的名称或任何参数,然后生成 URL。由于我们希望引用的 URL 是 entries blueprint 的detail视图,视图的名称是entries.detail。详细视图接受一个参数,即条目标题的 slug。

在构建详细视图之前,重新打开基本模板,并在导航部分添加一个链接到条目:

<ul class="nav navbar-nav">
  <li><a href="{{ url_for('homepage') }}">Home</a></li>
  <li><a href="{{ url_for('entries.index') }}">Blog</a></li>
  {% block extra_nav %}{% endblock %}
</ul>

下面的屏幕截图显示了更新后的导航标题,以及博客条目的列表:

构建索引视图

构建详细视图

让我们创建一个简单的视图,用于呈现单个博客条目的内容。条目的 slug 将作为 URL 的一部分传递进来。我们将尝试将其与现有的Entry匹配,如果没有匹配项,则返回 404 响应。更新 entries blueprint 中的detail视图的以下代码:

from flask import render_template
@entries.route('/<slug>/')
def detail(slug):
 entry = Entry.query.filter(Entry.slug == slug).first_or_404()
 return render_template('entries/detail.html', entry=entry)

entries模板目录中创建一个名为detail.html的模板,并添加以下代码。我们将在主内容区域显示条目的标题和正文,但在侧边栏中,我们将显示一个标签列表和条目创建日期:

{% extends "base.html" %}

{% block title %}{{ entry.title }}{% endblock %}

{% block content_title %}{{ entry.title }}{% endblock %}

{% block sidebar %}
  <ul class="well nav nav-list">
    <li><h4>Tags</h4></li>
    {% for tag in entry.tags %}
      <li><a href="{{ url_for('entries.tag_detail', slug=tag.slug) }}">{{ tag.name }}</a></li>
    {% endfor %}
  </ul>

  <p>Published {{ entry.created_timestamp.strftime('%m/%d/%Y') }}</p>
{% endblock %}

{% block content %}
  {{ entry.body }}
{% endblock %}

现在应该可以在索引页面上查看条目,并转到详细视图的链接。正如你可能猜到的,我们需要解决的下一个问题是标签详细页面。

构建详细视图

列出与给定标签匹配的条目

列出与给定标签匹配的条目将结合两个先前视图的逻辑。首先,我们需要使用 URL 中提供的tag slug 查找Tag,然后我们将显示一个object_list,其中包含使用指定标签标记的Entry对象。在tag_detail视图中,添加以下代码:

@entries.route('/tags/<slug>/')
def tag_detail(slug):
 tag = Tag.query.filter(Tag.slug == slug).first_or_404()
 entries = tag.entries.order_by(Entry.created_timestamp.desc())
 return object_list('entries/tag_detail.html', entries, tag=tag)

entries查询将获取与标签相关的所有条目,然后按最近的顺序返回它们。我们还将标签传递到上下文中,以便在模板中显示它。创建tag_detail.html模板并添加以下代码。由于我们将显示一个条目列表,我们将重用我们的list.html包含:

{% extends "base.html" %}

{% block title %}{{ tag.name }} entries{% endblock %}

{% block content_title %}{{ tag.name }} entries{% endblock %}

{% block content %}
  {% include "includes/list.html" %}
{% endblock %}

在下面的屏幕截图中,我已经导航到/entries/tags/python/。这个页面只包含已经被标记为Python的条目:

列出与给定标签匹配的条目

列出所有标签

最后缺失的部分是显示所有标签列表的视图。这个视图将与index条目非常相似,只是我们将查询Tag模型而不是Entry对象。更新以下代码到tag_index视图:

@entries.route('/tags/')
def tag_index():
 tags = Tag.query.order_by(Tag.name)
 return object_list('entries/tag_index.html', tags)

在模板中,我们将每个标签显示为指向相应标签详情页面的链接。创建文件entries/tag_index.html并添加以下代码:

{% extends "base.html" %}

{% block title %}Tags{% endblock %}

{% block content_title %}Tags{% endblock %}

{% block content %}
  <ul>
    {% for tag in object_list.items %}
      <li><a href="{{ url_for('entries.tag_detail', slug=tag.slug) }}">{{ tag.name }}</a></li>
    {% endfor %}
  </ul>
{% endblock %}

如果你愿意,你可以在基础模板的导航中添加一个到标签列表的链接。

全文搜索

为了让用户能够找到包含特定单词或短语的帖子,我们将在包含博客条目列表的页面上添加简单的全文搜索。为了实现这一点,我们将进行一些重构。我们将在所有包含博客条目列表的页面的侧边栏中添加一个搜索表单。虽然我们可以将相同的代码复制粘贴到entries/index.htmlentries/tag_detail.html中,但我们将创建另一个包含搜索小部件的基础模板。创建一个名为entries/base_entries.html的新模板,并添加以下代码:

{% extends "base.html" %}

{% block sidebar %}
  <form class="form-inline well" method="get" role="form">
    <div class="input-group">
      <input class="form-control input-xs" name="q" placeholder="Search..." value="{{ request.args.get('q', '') }}" />
      <span class="input-group-btn">
        <button class="btn btn-default" type="submit">Go</button>
      </span>
    </div>
  </form>
{% endblock %}

{% block content %}
  {% include "includes/list.html" %}
{% endblock %}

提示

尽管我们不会明确地将request传递到上下文中,Flask 会使其可访问。你可以在 Flask 文档的flask.pocoo.org/docs/templating/#standard-context中找到标准上下文变量的列表。

现在我们将更新entries/index.htmlentries/tag_detail.html以利用这个新的基础模板。由于content块包含条目列表,我们可以从这两个模板中删除它:

{% extends "entries/base_entries.html" %}

{% block title %}Entries{% endblock %}

{% block content_title %}Entries{% endblock %}

这是在更改基础模板并删除上下文块后的entries/index.html的样子。对entries/tag_detail.html做同样的操作。

{% extends "entries/base_entries.html" %}
{% block title %}Tags{% endblock %}
{% block content_title %}Tags{% endblock %}

现在我们需要更新我们的视图代码来实际执行搜索。为此,我们将在蓝图中创建一个名为entry_list的新辅助函数。这个辅助函数将类似于object_list辅助函数,但会执行额外的逻辑来根据我们的搜索查询过滤结果。将entry_list函数添加到blueprint.py中。注意它如何检查请求查询字符串是否包含名为q的参数。如果q存在,我们将只返回标题或正文中包含搜索短语的条目:

from flask import request
def entry_list(template, query, **context):
    search = request.args.get('q')
    if search:
        query = query.filter(
            (Entry.body.contains(search)) |
            (Entry.title.contains(search)))
    return object_list(template, query, **context)

为了利用这个功能,修改indextag_detail视图,调用entry_list而不是object_list。更新后的index视图如下:

@entries.route('/')
def index():
    entries = Entry.query.order_by(Entry.created_timestamp.desc())
    return entry_list('entries/index.html', entries)

恭喜!现在你可以导航到条目列表并使用搜索表单进行搜索。

全文搜索

添加分页链接

正如我们之前讨论的,我们希望对条目的长列表进行分页,以便用户不会被极长的列表所压倒。我们实际上已经在object_list函数中完成了所有工作;唯一剩下的任务是添加链接,让用户可以从一个条目页面跳转到下一个页面。

因为分页链接是我们将在多个地方使用的一个功能,我们将在应用程序的模板目录中创建分页include(而不是条目模板目录)。在app/templates/中创建一个名为includes的新目录,并创建一个名为page_links.html的文件。由于object_list返回一个PaginatedQuery对象,我们可以在模板中利用这个对象来确定我们所在的页面以及总共有多少页。为了使分页链接看起来漂亮,我们将使用 Bootstrap 提供的 CSS 类。将以下内容添加到page_links.html中:

<ul class="pagination">
  <li{% if not object_list.has_prev %} class="disabled"{% endif %}>
    {% if not object_list.has_prev %}
      <a href="./?page={{ object_list.prev_num }}">&laquo;</a>
    {% else %}
      <a href="#">&laquo;</a>
    {% endif %}
  </li>
  {% for page in object_list.iter_pages() %}
    <li>
      {% if page %}
        <a {% if page == object_list.page %}class="active" {% endif %}href="./?page={{ page }}">{{ page }}</a>
      {% else %}
        <a class="disabled">...</a>
      {% endif %}
    </li>
  {% endfor %}
  <li{% if not object_list.has_next %} class="disabled"{% endif %}>
    {% if object_list.has_next %}
      <a href="./?page={{ object_list.next_num }}">&raquo;</a>
    {% else %}
      <a href="#">&raquo;</a>
    {% endif %}
  </li>
</ul>

现在,无论我们在哪里显示一个对象列表,让我们在页面底部包含page_links.html模板。目前,我们需要更新的模板只有entries/base_entries.htmlentries/tag_index.htmlbase_entries.htmlcontent块如下:

{% block content %}
  {% include "includes/list.html" %}
  {% include "includes/page_links.html" %}
{% endblock %}

添加分页链接

增强博客应用

在继续下一章之前,我建议花一些时间来实验我们在本章中创建的视图和模板。以下是一些您可以考虑的想法:

  • 在条目详细视图上对标签列表进行排序(提示:使用标签的name属性上的sort过滤器)。

  • 从主页模板中删除示例代码,并添加您自己的内容。

  • 您可能已经注意到,我们正在显示所有条目,而不考虑它们的状态。修改entry_list函数和条目detail视图,只显示状态为STATUS_PUBLICEntry对象。

  • 尝试不同的 Bootstrap 主题- bootswatch.com有许多免费的主题可供选择。

  • 高级:允许指定多个标签。例如,/entries/tags/flask+python/只会显示标记有flaskpython的条目。

总结

在本章中,我们涵盖了大量信息,到目前为止,您应该熟悉创建视图和模板的过程。我们学会了如何呈现 Jinja2 模板以及如何将数据从视图传递到模板上下文中。我们还学会了如何在模板中修改上下文数据,使用 Jinja2 标签和过滤器。在本章的后半部分,我们为网站设计了 URL 结构,并将其转换为 Flask 视图。我们为网站添加了一个简单的全文搜索功能,并通过为条目和标签列表添加分页链接来结束。

在下一章中,我们将学习如何通过网站使用表单创建和编辑博客条目。我们将学习如何处理和验证用户输入,然后将更改保存到数据库中。我们还将添加一个上传照片的功能,以便在博客条目中嵌入图像。

第四章:表单和验证

在本章中,我们将学习如何使用表单直接通过网站修改博客上的内容。这将是一个有趣的章节,因为我们将添加各种新的与网站交互的方式。我们将创建用于处理 Entry 模型的表单,学习如何接收和验证用户数据,并最终更新数据库中的值。表单处理和验证将由流行的 WTForms 库处理。我们将继续构建视图和模板来支持这些新的表单,并在此过程中学习一些新的 Jinja2 技巧。

在本章中,我们将:

  • 安装 WTForms 并创建一个用于处理 Entry 模型的表单

  • 编写视图来验证和处理表单数据,并将更改持久化到数据库中

  • 创建模板来显示表单和验证错误

  • 使用 Jinja2 宏来封装复杂的模板逻辑

  • 向用户显示闪存消息

  • 创建一个图片上传器,并学习如何安全处理文件上传

  • 学习如何存储和提供静态资产,如 JavaScript、样式表和图像上传

开始使用 WTForms

WTForms是 Flask 社区中处理表单和验证的流行选择。它使用一种声明性的方法来构建表单(类似于我们定义 SQLAlchemy 模型的方式),并支持各种不同的字段类型和验证器。

注意

在撰写本书时,WTForms 2.0 仍然是一个开发版本,但应该很快就会成为官方版本。因此,我们将在本书中使用版本 2.0。

让我们开始通过将 WTForms 安装到我们的博客项目virtualenv中:

(blog) $ pip install "wtforms>=2.0"
Successfully installed wtforms
Cleaning up...

我们可以通过打开一个 shell 并检查项目版本来验证安装是否成功:

(blog) $ ./manage.py shell
In [1]: import wtforms

In [2]: wtforms.__version__
Out[2]: '2.0dev'

我的版本显示了开发版本,因为 2.0 尚未正式发布。

为 Entry 模型定义一个表单

我们的目标是能够直接通过我们的网站创建和编辑博客条目,因此我们需要回答的第一个问题是——我们将如何输入我们的新条目的数据?答案当然是使用表单。表单是 HTML 标准的一部分,它允许我们使用自由格式的文本输入、大型多行文本框、下拉选择、复选框、单选按钮等。当用户提交表单时,表单会指定一个 URL 来接收表单数据。然后该 URL 可以处理数据,然后以任何喜欢的方式做出响应。

对于博客条目,让我们保持简单,只有三个字段:

  • 标题,显示为简单的文本输入

  • 正文,显示为大型自由格式文本框

  • 状态,将显示为下拉选择

entries目录中,创建一个名为forms.py的新 Python 文件。我们将定义一个简单的表单类,其中包含这些字段。打开forms.py并添加以下代码:

import wtforms

from models import Entry

class EntryForm(wtforms.Form):
    title = wtforms.StringField('Title')
    body = wtforms.TextAreaField('Body')
    status = wtforms.SelectField(
        'Entry status',
        choices=(
            (Entry.STATUS_PUBLIC, 'Public'),
            (Entry.STATUS_DRAFT, 'Draft')),
        coerce=int)

这应该看起来与我们的模型定义非常相似。请注意,我们正在使用模型中列的名称作为表单字段的名称:这将允许 WTForms 自动在 Entry 模型字段和表单字段之间复制数据。

前两个字段,标题正文,都指定了一个参数:在渲染表单时将显示的标签。状态字段包含一个标签以及两个额外的参数:choicescoercechoices参数由一个 2 元组的列表组成,其中第一个值是我们感兴趣存储的实际值,第二个值是用户友好的表示。第二个参数,coerce,将把表单中的值转换为整数(默认情况下,它将被视为字符串,这是我们不想要的)。

一个带有视图的表单

为了开始使用这个表单,我们需要创建一个视图,该视图将显示表单并在提交时接受数据。为此,让我们打开entries蓝图模块,并定义一个新的 URL 路由来处理条目创建。在blueprint.py文件的顶部,我们需要从forms模块导入EntryForm类:

from app import db
from helpers import object_list
from models import Entry, Tag
from entries.forms import EntryForm

然后,在detail视图的定义之上,我们将添加一个名为create的新视图,该视图将通过导航到/entries/create/来访问。我们必须将其放在detail视图之上的原因是因为 Flask 将按照定义的顺序搜索 URL 路由。由于/entries/create/看起来非常像一个条目详细信息 URL(想象条目的标题是create),如果首先定义了详细信息路由,Flask 将在那里停止,永远不会到达创建路由。

在我们的创建视图中,我们将简单地实例化表单并将其传递到模板上下文中。添加以下视图定义:

@entries.route('/create/')
def create():
    form = EntryForm()
    return render_template('entries/create.html', form=form)

在我们添加代码将新条目保存到数据库之前,让我们构建一个模板,看看我们的表单是什么样子。然后我们将回过头来添加代码来验证表单数据并创建新条目。

create.html 模板

让我们为我们的新表单构建一个基本模板。在其他条目模板旁边创建一个名为create.html的新模板。相对于应用程序目录,该文件的路径应为entries/templates/entries/create.html。我们将扩展基本模板并覆盖内容块以显示我们的表单。由于我们使用的是 bootstrap,我们将使用特殊的 CSS 类来使我们的表单看起来漂亮。添加以下 HTML 代码:

{% extends "base.html" %}

{% block title %}Create new entry{% endblock %}

{% block content_title %}Create new entry{% endblock %}

{% block content %}
  <form action="{{ url_for('entries.create') }}" class="form form-horizontal" method="post">
    {% for field in form %}
      <div class="form-group">
        {{ field.label(class='col-sm-3 control-label') }}
        <div class="col-sm-9">
          {{ field(class='form-control') }}
        </div>
      </div>
    {% endfor %}
    <div class="form-group">
      <div class="col-sm-offset-3 col-sm-9">
        <button type="submit" class="btn btn-default">Create</button>
        <a class="btn" href="{{ url_for('entries.index') }}">Cancel</a>
      </div>
    </div>
  </form>
{% endblock %}

通过迭代我们传入上下文的表单,我们可以渲染每个单独的字段。要渲染字段,我们首先通过简单调用field.label()并传入所需的 CSS 类来渲染字段的标签。同样,要渲染字段,我们调用field(),再次传入 CSS 类。还要注意的是,除了submit按钮,我们还添加了一个Cancel链接,该链接将返回用户到条目列表。

启动开发服务器并导航到http://127.0.0.1:5000/entries/create/以查看以下表单:

create.html 模板

尝试提交表单。当您点击创建按钮时,您应该会看到以下错误消息:

create.html 模板

您看到此消息的原因是因为默认情况下,Flask 视图只会响应 HTTP GET请求。当我们提交表单时,浏览器会发送POST请求,而我们的视图目前不接受。让我们返回create视图并添加代码来正确处理POST请求。

提示

每当表单对数据进行更改(创建、编辑或删除某些内容)时,该表单应指定POST方法。其他表单,例如我们的搜索表单,不进行任何更改,应使用GET方法。此外,当使用GET方法提交表单时,表单数据将作为查询字符串的一部分提交。

处理表单提交

在修改视图之前,让我们向我们的EntryForm添加一个辅助方法,我们将使用该方法将数据从表单复制到我们的Entry对象中。打开forms.py并进行以下添加:

class EntryForm(wtforms.Form):
    ...
    def save_entry(self, entry):
 self.populate_obj(entry)
 entry.generate_slug()
 return entry

这个辅助方法将用表单数据填充我们传入的entry,根据标题重新生成条目的 slug,然后返回entry对象。

现在表单已配置为填充我们的Entry模型,我们可以修改视图以接受和处理POST请求。我们将使用两个新的 Flask 辅助函数,因此修改blueprint.py顶部的导入,添加redirecturl_for

from flask import Blueprint, redirect, render_template, request, url_for

添加导入后,更新blueprint.pycreate视图的以下更改:

from app import db
@entries.route('/create/', methods=['GET', 'POST'])
def create():
    if request.method == 'POST':
        form = EntryForm(request.form)
        if form.validate():
            entry = form.save_entry(Entry())
            db.session.add(entry)
            db.session.commit()
            return redirect(url_for('entries.detail', slug=entry.slug))
    else:
        form = EntryForm()

    return render_template('entries/create.html', form=form)

这是相当多的新代码,让我们仔细看看发生了什么。首先,我们在路由装饰器中添加了一个参数,指示此视图接受GETPOST请求。这将消除当我们提交表单时出现的方法不允许错误。

在视图的主体中,我们现在正在检查request方法,并根据这一点做两件事中的一件。让我们首先看看'else'子句。当我们收到GET请求时,比如当有人打开他们的浏览器并导航到/entries/create/页面时,代码分支将执行。当这种情况发生时,我们只想显示包含表单的 HTML 页面,因此我们将实例化一个表单并将其传递到模板上下文中。

如果这是一个POST请求,当有人提交表单时会发生,我们想要实例化EntryForm并传入原始表单数据。Flask 将原始的 POST 数据存储在特殊属性request.form中,这是一个类似字典的对象。WTForms 知道如何解释原始表单数据并将其映射到我们定义的字段。

在用原始表单数据实例化我们的表单之后,我们需要检查并确保表单有效,通过调用form.validate()。如果表单由于某种原因未能验证,我们将简单地将无效的表单传递到上下文并呈现模板。稍后您将看到我们如何在用户的表单提交出现问题时向用户显示错误消息。

如果表单验证通过,我们最终可以继续保存条目。为此,我们将调用我们的save_entry辅助方法,传入一个新的entry实例。WTForms 将使用表单数据填充Entry对象,然后将其返回给我们,在那里我们将其添加到数据库会话中,提交并重定向。重定向助手将发出 HTTP 302 重定向,将用户的浏览器从/entries/create/发送到新创建的博客文章的详细页面。

打开你的浏览器,试一试。

处理表单提交

验证输入并显示错误消息

我们的表单存在一个明显的问题:现在没有任何东西可以阻止我们意外地提交一个空的博客条目。为了确保在保存时有标题和内容,我们需要使用一个名为验证器的 WTForm 对象。验证器是应用于表单数据的规则,WTForms 附带了许多有用的验证器。一些常用的验证器列在下面:

  • DataRequired:此字段不能为空

  • Length(min=?, max=?):验证输入的数据的长度是否超过最小值,或者是否不超过最大值

  • NumberRange(min=?, max=?):验证输入的数字是否在给定范围内

  • Email:验证数据是否为有效的电子邮件地址

  • URL:验证输入的数据是否为有效的 URL

  • AnyOf(values=?):验证输入的数据是否等于提供的值之一

  • NoneOf(values=?):验证输入的数据是否不等于提供的任何值

对于博客条目表单,我们将只使用DataRequired验证器来确保条目不能在没有标题或正文内容的情况下创建。让我们打开forms.py并将验证器添加到我们的表单定义中。总的来说,我们的表单模块应该如下所示:

import wtforms
from wtforms.validators import DataRequired

from models import Entry

class EntryForm(wtforms.Form):
    title = wtforms.StringField(
        'Title',
        validators=[DataRequired()])
    body = wtforms.TextAreaField(
        'Body',
        validators=[DataRequired()])
    status = wtforms.SelectField(
        'Entry status',
        choices=(
            (Entry.STATUS_PUBLIC, 'Public'),
            (Entry.STATUS_DRAFT, 'Draft')),
        coerce=int)

    def save_entry(self, entry):
        self.populate_obj(entry)
        entry.generate_slug()
        return entry

启动开发服务器,现在尝试提交一个空表单。正如你所期望的那样,由于对form.validate()的调用返回False,它将无法保存。不幸的是,前端没有任何指示我们的表单为什么没有保存。幸运的是,WTForms 将使验证错误在模板中可用,我们所需要做的就是修改我们的模板来显示它们。

为了显示验证错误,我们将使用几个 bootstrap CSS 类和结构,但最终结果将非常好看,如下面的截图所示:

验证输入并显示错误消息

create.html模板中的字段显示代码进行以下更改:

{% for field in form %}
  <div class="form-group{% if field.errors %} has-error has-feedback{% endif %}">
    {{ field.label(class='col-sm-3 control-label') }}
    <div class="col-sm-9">
      {{ field(class='form-control') }}
      {% if field.errors %}
        <span class="glyphicon glyphicon-warning-sign form-control-feedback"></span>
      {% endif %}
      {% for error in field.errors %}<span class="help-block">{{ error }}</span>{% endfor %}
    </div>
  </div>
{% endfor %}

我们通过查看field.errors属性来检查字段是否有任何错误。如果有任何错误,那么我们会做以下事情:

  • form-group div 添加 CSS 类

  • 添加一个特殊的图标表示有错误发生

  • 在表单字段下方显示每个错误的<span>。由于field.errors是一个列表,可能包含多个验证错误,我们将使用 for 循环来遍历这些错误。

现在,您可以使用表单创建有效的博客条目,该表单还会执行一些验证,以确保您不会提交空白表单。在下一节中,我们将描述如何重复使用相同的表单来编辑现有条目。

编辑现有条目

信不信由你,我们实际上可以使用相同的表单来编辑现有条目。我们只需要对视图和模板逻辑进行一些微小的更改,所以让我们开始吧。

为了编辑条目,我们将需要一个视图,因此我们将需要一个 URL。因为视图需要知道我们正在编辑哪个条目,所以将其作为 URL 结构的一部分传达是很重要的,因此我们将在/entries/<slug>/edit/设置edit视图。打开entries/blueprint.py,在详细视图下方,添加以下代码以获取edit视图。请注意与create视图的相似之处:

@entries.route('/<slug>/edit/', methods=['GET', 'POST'])
def edit(slug):
    entry = Entry.query.filter(Entry.slug == slug).first_or_404()
    if request.method == 'POST':
        form = EntryForm(request.form, obj=entry)
        if form.validate():
            entry = form.save_entry(entry)
            db.session.add(entry)
            db.session.commit()
            return redirect(url_for('entries.detail', slug=entry.slug))
    else:
        form = EntryForm(obj=entry)

    return render_template('entries/edit.html', entry=entry, form=form)

就像我们在create视图中所做的那样,我们检查request方法,并根据它,我们将验证和处理表单,或者只是实例化它并将其传递给模板。

最大的区别在于我们如何实例化EntryForm。我们向它传递了一个额外的参数,obj=entry。当 WTForms 接收到一个obj参数时,它将尝试使用从obj中获取的值(在本例中是我们的博客条目)预填充表单字段。

我们还将在模板上下文中传递一个额外的值,即我们正在编辑的条目。我们这样做是为了能够向用户显示条目的标题;这样,我们可以使表单的取消按钮链接回条目详细视图。

编辑.html 模板

正如您可能猜到的,edit.html模板几乎与create.html相同。由于字段渲染逻辑的复杂性,复制并粘贴所有代码似乎是一个坏主意。如果我们决定更改表单字段的显示方式,我们将发现自己需要修改多个文件,这应该始终是一个很大的警告信号。

为了避免这种情况,我们将使用一个强大的 Jinja2 功能,称为宏,来渲染我们的字段。字段渲染代码将在宏中定义,然后,无论我们想要渲染一个字段的地方,我们只需调用我们的宏。这样可以很容易地更改我们的字段样式。

提示

宏是 Jinja2 的一个功能,允许您将模板的一部分视为函数,因此可以使用不同的参数多次调用它,并生成基本相似的 HTML。您可以在 Jinja 文档网站上查看更多内容:jinja.pocoo.org/docs/dev/templates/

由于这个宏对于我们可能希望显示的任何表单字段都是有用的,我们将把它放在我们应用程序的模板目录中。在应用程序的模板目录中,创建一个名为macros的新目录,并添加一个字段form_field.html。相对于应用程序目录,该文件的路径是templates/macros/form_field.html。添加以下代码:

{% macro form_field(field) %}
  <div class="form-group{% if field.errors %} has-error has-feedback{% endif %}">
    {{ field.label(class='col-sm-3 control-label') }}
    <div class="col-sm-9">
      {{ field(class='form-control', **kwargs) }}
      {% if field.errors %}<span class="glyphicon glyphicon-warning-sign form-control-feedback"></span>{% endif %}
      {% for error in field.errors %}<span class="help-block">{{ error }}</span>{% endfor %}
    </div>
  </div>
{% endmacro %}

在大部分情况下,我们只是从create模板中复制并粘贴了字段渲染代码,但有一些区别我想指出:

  • 模板以macro模板标签开头,定义了macro的名称和它接受的任何参数。

  • 当我们渲染字段时,我们传入**kwargs。WTForms 字段可以接受任意关键字参数,然后将其转换为 HTML 标记上的属性。虽然我们目前不打算使用这个功能,但我们将在后面的章节中使用它。

  • 我们使用endmacro标记表示宏的结束。

现在让我们更新create.html以使用新的宏。为了使用这个宏,我们必须首先import它。然后我们可以用一个简单的宏调用替换所有的字段标记。通过这些更改,create.html模板应该是这样的:

{% extends "base.html" %}
{% from "macros/form_field.html" import form_field %}

{% block title %}Create new entry{% endblock %}

{% block content_title %}Create new entry{% endblock %}

{% block content %}
  <form action="{{ url_for('entries.create') }}" class="form form-horizontal" method="post">
    {% for field in form %}
      {{ form_field(field) }}
    {% endfor %}
    <div class="form-group">
      <div class="col-sm-offset-3 col-sm-9">
        <button type="submit" class="btn btn-default">Create</button>
        <a class="btn" href="{{ url_for('entries.index') }}">Cancel</a>
      </div>
    </div>
  </form>
{% endblock %}

搞定这些之后,我们可以继续创建我们的edit.html模板。它看起来几乎和create模板一样,只是我们将在app/entries/templates/entries目录中显示文本,以指示用户他们正在编辑一个现有条目:

{% extends "base.html" %}
{% from "macros/form_field.html" import form_field %}

{% block title %}Edit {{ entry.title }}{% endblock %}

{% block content_title %}Edit {{ entry.title }}{% endblock %}

{% block content %}
  <form action="{{ url_for('entries.edit', slug=entry.slug) }}" class="form form-horizontal" method="post">
    {% for field in form %}
      {{ form_field(field) }}
    {% endfor %}
    <div class="form-group">
      <div class="col-sm-offset-3 col-sm-9">
        <button type="submit" class="btn btn-default">Save</button>
        <a class="btn" href="{{ url_for('entries.detail', slug=entry.slug) }}">Cancel</a>
      </div>
    </div>
  </form>
{% endblock %}

为了结束这一部分,在条目详细页面上,让我们在侧边栏中添加一个链接,可以带我们到Edit页面。在detail.html的侧边栏中添加以下链接:

<a href="{{ url_for('entries.edit', slug=entry.slug) }}">Edit</a>

删除条目

为了完成这一部分,我们将添加一个用于删除条目的视图。我们将设计这个视图,当用户去删除一个条目时,他们会被带到一个确认页面。只有通过提交确认表单(一个POST请求),他们才能真正删除条目。因为这个表单不需要任何字段,我们不需要一个特殊的 WTForms 类,可以直接使用 HTML 创建它。

create.htmledit.html模板旁边创建一个名为delete.html的模板,并添加以下 HTML:

{% extends "base.html" %}

{% block title %}{{ entry.title }}{% endblock %}

{% block content_title %}{{ entry.title }}{% endblock %}

{% block content %}
  <form action="{{ url_for('entries.delete', slug=entry.slug) }}" method="post">
    <fieldset>
      <legend>Delete this entry?</legend>
      <button class="btn btn-danger" type="submit">Delete</button>
      <a class="btn" href="{{ url_for('entries.detail', slug=entry.slug) }}">Cancel</a>
    </fieldset>
  </form>
{% endblock %}

现在我们需要定义entries.delete视图。与edit视图一样,删除条目的 URL 需要条目 slug 作为 URL 结构的一部分。因此,我们将使用/entries/<slug>/delete/

当表单提交时,我们可以简单地从数据库中删除条目,但根据我的经验,我通常会后悔永久删除内容。我们不会真正从数据库中删除条目,而是给它一个_DELETED状态;我们将把它的状态改为STATUS_DELETED。然后我们将修改我们的视图,以便具有这种状态的条目永远不会出现在网站的任何部分。在所有方面,条目都消失了,但是,如果我们将来需要它,我们可以从数据库中检索它。在edit视图下面添加以下视图代码:

@entries.route('/<slug>/delete/', methods=['GET', 'POST'])
def delete(slug):
    entry = Entry.query.filter(Entry.slug == slug).first_or_404()
    if request.method == 'POST':
        entry.status = Entry.STATUS_DELETED
        db.session.add(entry)
        db.session.commit()
        return redirect(url_for('entries.index'))

    return render_template('entries/delete.html', entry=entry)

我们还需要在 model.py 中的 Entries 模型中添加 STATUS_DELETED:

class Entry(db.Model):
    STATUS_PUBLIC = 0
    STATUS_DRAFT = 1
    STATUS_DELETED = 2

与编辑链接一样,花点时间在详细视图侧边栏中添加一个delete链接。

清理

让我们花点时间重构我们的蓝图。由于我们不想在网站上显示已删除的条目,我们需要确保通过状态筛选我们的条目。此外,看着detaileditdelete视图,我看到了三个我们复制并粘贴查询条目的代码的实例。让我们也将其移动到一个辅助函数中。

首先,让我们更新entry_list辅助函数,以筛选出公共或草稿条目。

提示

在下一章中,我们将为网站添加登录功能。一旦我们有了这个功能,我们将添加逻辑,只向创建它们的用户显示草稿条目。

def entry_list(template, query, **context):
    valid_statuses = (Entry.STATUS_PUBLIC, Entry.STATUS_DRAFT)
    query = query.filter(Entry.status.in_(valid_statuses))
    if request.args.get('q'):
        search = request.args['q']
        query = query.filter(
            (Entry.body.contains(search)) |
            (Entry.title.contains(search)))

    return object_list(template, query, **context)

现在我们可以确信,无论我们在哪里显示条目列表,都不会显示已删除的条目。

现在让我们添加一个新的辅助函数来通过其 slug 检索Entry。如果找不到条目,我们将返回 404。在entry_list下面添加以下代码:

def get_entry_or_404(slug):
  valid_statuses = (Entry.STATUS_PUBLIC, Entry.STATUS_DRAFT) (Entry.query
          .filter(
              (Entry.slug == slug) &
              (Entry.status.in_(valid_statuses)))
          .first_or_404())

get_entry_or_404替换detaileditdelete视图中的Entry.query.filter()调用。以下是更新后的 detail 视图:

@entries.route('/<slug>/')
def detail(slug):
    entry = get_entry_or_404(slug)
    return render_template('entries/detail.html', entry=entry)

使用闪存消息

当用户在网站上执行操作时,通常会在随后的页面加载时显示一次性消息,指示他们的操作已成功。这些称为闪存消息,Flask 带有一个辅助函数来显示它们。为了开始使用闪存消息,我们需要在config模块中添加一个秘钥。秘钥是必要的,因为闪存消息存储在会话中,而会话又存储为加密的 cookie。为了安全地加密这些数据,Flask 需要一个秘钥。

打开config.py并添加一个秘钥。可以是短语、随机字符,任何你喜欢的东西:

class Configuration(object):
    APPLICATION_DIR = current_directory
    DEBUG = True
    SECRET_KEY = 'flask is fun!'  # Create a unique key for your app.
    SQLALCHEMY_DATABASE_URI = 'sqlite:///%s/blog.db' % APPLICATION_DIR

现在,无论我们的用户在哪个页面上执行操作,我们都希望向他们显示一个消息,指示他们的操作成功。 这意味着我们将在createeditdelete视图中添加一个消息。 打开条目蓝图并将闪存函数添加到模块顶部的 flask 导入列表中:

from flask import Blueprint, flash, redirect, render_template, request, url_for

然后,在每个适当的视图中,让我们调用flash并显示一个有用的消息。 在重定向之前应该发生调用:

def create():
        ...
            db.session.commit()
            flash('Entry "%s" created successfully.' % entry.title, 'success')
            return redirect(url_for('entries.detail', slug=entry.slug))
        ...

def edit(slug):
        ...
        db.session.commit()
        flash('Entry "%s" has been saved.' % entry.title, 'success')
        return redirect(url_for('entries.detail', slug=entry.slug))
        ...

def delete(slug):
        ...
        db.session.commit()
        flash('Entry "%s" has been deleted.' % entry.title, 'success')
        return redirect(url_for('entries.index'))
        ...

在模板中显示闪存消息

因为我们并不总是知道在需要显示闪存消息时我们将在哪个页面上,所以将显示逻辑添加到基本模板是一种标准做法。 Flask 提供了一个 Jinja2 函数get_flashed_messages,它将返回一个待显示的消息列表。

打开base.html并添加以下代码。 我已经将我的代码放在content_title块和content块之间:

<h1>{% block content_title %}{% endblock %}</h1>
{% for category, message in get_flashed_messages(with_categories=true) %}
 <div class="alert alert-dismissable alert-{{ category }}">
 <button type="button" class="close" data-dismiss="alert">&times;</button>
 {{ message }}
 </div>
{% endfor %}
{% block content %}{% endblock %}

让我们试试看! 启动开发服务器并尝试添加一个新条目。 保存后,您应该被重定向到新条目,并看到一个有用的消息,如下面的屏幕截图所示:

在模板中显示闪存消息

保存和修改帖子上的标签

我们已经讨论了如何保存和修改条目上的标签。 管理标签的最常见方法之一是使用逗号分隔的文本输入,因此我们可以将标签列为PythonFlaskWeb-development。 使用 WTForms 似乎非常简单,因为我们只需使用StringField。 然而,由于我们正在处理数据库关系,这意味着我们需要在Tag模型和逗号分隔的字符串之间进行一些处理。

虽然我们可以通过许多方式来实现这一点,但我们将实现一个自定义字段类TagField,它将封装在逗号分隔的标签名称和Tag模型实例之间进行转换的所有逻辑。

提示

另一个选项是在Entry模型上创建一个property。 属性看起来像一个普通的对象属性,但实际上是 getter 和(有时)setter 方法的组合。 由于 WTForms 可以自动处理我们的模型属性,这意味着,如果我们在 getter 和 setter 中实现我们的转换逻辑,WTForms 将正常工作。

让我们首先定义我们的标签字段类。 我们需要重写两个重要的方法:

  • _value(): 将Tag实例列表转换为逗号分隔的标签名称列表

  • process_formdata(valuelist): 接受逗号分隔的标签列表并将其转换为Tag实例的列表

以下是TagField的实现。 请注意,我们在处理用户输入时要特别小心,以避免在Tag表中创建重复行。 我们还使用 Python 的set()数据类型来消除用户输入中可能的重复项。 将以下类添加到forms.py中的EntryForm上方:

from models import Tag
class TagField(wtforms.StringField):
    def _value(self):
        if self.data:
            # Display tags as a comma-separated list.
            return ', '.join([tag.name for tag in self.data])
        return ''

    def get_tags_from_string(self, tag_string):
        raw_tags = tag_string.split(',')

        # Filter out any empty tag names.
        tag_names = [name.strip() for name in raw_tags if name.strip()]

        # Query the database and retrieve any tags we have already saved.
        existing_tags = Tag.query.filter(Tag.name.in_(tag_names))

        # Determine which tag names are new.
        new_names = set(tag_names) - set([tag.name for tag in existing_tags])

        # Create a list of unsaved Tag instances for the new tags.
        new_tags = [Tag(name=name) for name in new_names]

        # Return all the existing tags + all the new, unsaved tags.
        return list(existing_tags) + new_tags

    def process_formdata(self, valuelist):
        if valuelist:
            self.data = self.get_tags_from_string(valuelist[0])
        else:
            self.data = []

现在,我们只需要将字段添加到EntryForm中。 在status字段下面添加以下字段。 请注意description关键字参数的使用:

class EntryForm(wtforms.Form):
    ...
    tags = TagField(
        'Tags',
        description='Separate multiple tags with commas.')

为了显示这个有用的description文本,让我们对form_field宏进行快速修改:

{% macro form_field(field) %}
  <div class="form-group{% if field.errors %} has-error has-feedback{% endif %}">
    {{ field.label(class='col-sm-3 control-label') }}
    <div class="col-sm-9">
      {{ field(class='form-control', **kwargs) }}
      {% if field.errors %}<span class="glyphicon glyphicon-warning-sign form-control-feedback"></span>{% endif %}
      {% if field.description %}<span class="help-block">{{ field.description|safe }}</span>{% endif %}
      {% for error in field.errors %}<span class="help-block">{{ error }}</span>{% endfor %}
    </div>
  </div>
{% endmacro %}

启动开发服务器,并尝试保存一些标签。 您的表单应该看起来像下面的屏幕截图:

保存和修改帖子上的标签

图像上传

我们将通过为网站添加一个图片上传功能来完成表单处理章节。 这个功能将是一个简单的视图,接受一个图像文件并将其存储在服务器上的上传目录中。 这将使我们能够轻松在博客条目中显示图像。

第一步是创建一个处理图像上传的表单。 除了EntryForm,让我们添加一个名为ImageForm的新表单。 这个表单将非常简单,包含一个文件输入。 我们将使用自定义验证器来确保上传的文件是有效的图像。 将以下代码添加到forms.py中:

class ImageForm(wtforms.Form):
    file = wtforms.FileField('Image file')

在我们添加一个视图来保存表单之前,我们需要知道我们将在哪里保存文件。通常,应用程序的资源(如图像、JavaScript 和样式表)都是从一个名为static的单个目录中提供的。通常的做法是在 web 服务器中覆盖此目录的路径,以便它可以在不经过 Python 中介的情况下传输此文件,从而使访问速度更快。我们利用static目录来存储我们的图像上传。在博客项目的app目录中,让我们创建一个名为static的新目录和一个子目录images

(blog) $ cd ~/projects/blog/blog/app
(blog) $ mkdir -p static/images

现在让我们向配置文件中添加一个新值,这样我们就可以轻松地引用磁盘上图像的路径。这样可以简化我们的代码,以后如果我们选择更改此位置,也会更加方便。打开config.py并添加以下值:

class Configuration(object):
    ...
    STATIC_DIR = os.path.join(APPLICATION_DIR, 'static')
    IMAGES_DIR = os.path.join(STATIC_DIR, 'images')

处理文件上传

我们现在准备创建一个用于处理图像上传的视图。逻辑将与我们的其他表单处理视图非常相似,唯一的区别是,在验证表单后,我们将把上传的文件保存到磁盘上。由于这些图像是用于我们博客条目的,我将视图添加到 entries blueprint 中,可在/entries/image-upload/访问。

我们需要导入我们的新表单以及其他辅助工具。打开blueprint.py并在模块顶部添加以下导入:

import os

from flask import Blueprint, flash, redirect, render_template, request, url_for
from werkzeug import secure_filename

from app import app, db
from helpers import object_list
from models import Entry, Tag
from entries.forms import EntryForm, ImageForm

在视图列表的顶部,让我们添加新的image-upload视图。重要的是它出现在detail视图之前,否则 Flask 会错误地将/image-upload/视为博客条目的 slug。添加以下视图定义:

@entries.route('/image-upload/', methods=['GET', 'POST'])
def image_upload():
    if request.method == 'POST':
        form = ImageForm(request.form)
        if form.validate():
            image_file = request.files['file']
            filename = os.path.join(app.config['IMAGES_DIR'],
                                    secure_filename(image_file.filename))
            image_file.save(filename)
            flash('Saved %s' % os.path.basename(filename), 'success')
            return redirect(url_for('entries.index'))
    else:
        form = ImageForm()

    return render_template('entries/image_upload.html', form=form)

这里的大部分代码可能看起来很熟悉,值得注意的例外是使用request.filessecure_filename。当文件上传时,Flask 会将其存储在request.files中,这是一个特殊的字典,以表单字段的名称为键。我们使用secure_filename进行一些路径连接,以防止恶意文件名,并生成到static/images目录的正确路径,然后将上传的文件保存到磁盘上。就是这么简单。

图片上传模板

让我们为我们的图片上传表单创建一个简单的模板。在 entries 模板目录中创建一个名为image_upload.html的文件,并添加以下代码:

{% extends "base.html" %}
{% from "macros/form_field.html" import form_field %}

{% block title %}Upload an image{% endblock %}

{% block content_title %}Upload an image{% endblock %}

{% block content %}
  <form action="{{ url_for('entries.image_upload') }}" enctype="multipart/form-data" method="post">
    {% for field in form %}
      {{ form_field(field) }}
    {% endfor %}
    <div class="form-group">
      <div class="col-sm-offset-3 col-sm-9">
        <button type="submit" class="btn btn-default">Upload</button>
        <a class="btn" href="{{ url_for('entries.index') }}">Cancel</a>
      </div>
    </div>
  </form>
{% endblock %}

为了让 Flask 处理我们上传的文件,我们必须在定义<form>元素时指定enctype="multipart/form-data"。这是一个非常常见的错误,所以我会再次重复:每当您接受文件上传时,您的表单元素必须指定enctype="multipart/form-data"

继续尝试图片上传。您应该在应用程序中的static/images/directory中看到您上传的文件。您还可以通过浏览器导航到http://127.0.0.1:5000/static/images/the-file-name.jpg来查看图像。

提供静态文件

Flask 将自动从我们的/static/目录中提供文件。当我们在第十章部署我们的网站时,部署您的应用程序,我们将使用Nginx web 服务器来提供静态资产,但是对于本地开发,Flask 使事情变得非常简单。

除了我们的图像上传,让我们还从/static/提供我们网站的 JavaScript 和样式表。下载 jQuery 和 Bootstrap,并将 JavaScript 文件(jquery-<version>.min.jsboostrap.min.js)放在static/js中。将压缩的 bootstrap CSS 文件(bootstrap.min.css)放在static/css中。Bootstrap 还带有一些用于图标的特殊字体。将 bootstrap 字体目录也复制到 static 目录中。现在,您的应用程序的 static 目录中应该有四个目录:cssfontsimagesjs,每个目录中都包含相关文件:

(blog) $ cd static/ && find . -type f
./fonts/glyphicons-halflings-regular.woff
./fonts/glyphicons-halflings-regular.ttf
./fonts/glyphicons-halflings-regular.eot
./fonts/glyphicons-halflings-regular.svg
./images/2012-07-17_16.18.18.jpg
./js/jquery-1.10.2.min.js
./js/bootstrap.min.js
./css/bootstrap.min.css

为了将我们的基本模板指向这些文件的本地版本,我们将使用url_for助手来生成正确的 URL。打开base.html,删除旧的样式表和 JavaScript 标签,并用本地版本替换它们:

<head>
  <meta charset="utf-8">
  <title>{% block title %}{% endblock %} | My Blog</title>

  <link rel="stylesheet" href="{{="{{ url_for('static', filename='css/bootstrap.min.css') }}">
  <style type="text/css">
    body { padding-top: 60px; }
  </style>
  {% block extra_styles %}{% endblock %}

  <script src="img/jquery-1.10.2.min.js') }}"></script>
  <script src="img/bootstrap.min.js') }}"></script>
  {% block extra_scripts %}{% endblock %}
</head>

如果您愿意,可以在static/css目录中创建一个site.css文件,并将<style>标签替换为指向site.css的链接。

摘要

在本章中,我们添加了各种与网站交互的新方法。现在可以直接通过网站创建和修改内容。我们讨论了如何使用 WTForms 创建面向对象的表单,包括从视图处理和验证表单数据,以及将表单数据写入数据库。我们还创建了模板来显示表单和验证错误,并使用 Jinja2 宏来删除重复的代码,使代码更加模块化。然后,我们能够向用户显示单次使用的闪存消息,当他们执行操作时。最后,我们还解释了如何使用 WTForms 和 Flask 处理文件上传,并提供静态资产,如 JavaScript、样式表和图像上传。

在跳转到下一章之前,花一些时间尝试一下我们在网站中添加的新功能。以下是一些可以改进本章内容的方法:

  • 在页眉中添加一个链接到图像上传表单。

  • 在图像上传视图中,验证文件的扩展名是否是已识别的图像扩展名(.png、.jpg、.gif)。

  • 添加一个只读的 StringField 来显示条目的 slug。

  • 我们的标签索引视图将显示与零个条目关联的标签(如果我们添加了一个标签,然后从条目中删除它,这可能是这种情况)。改进查询,只列出具有一个或多个关联条目的标签。提示:Tag.query.join(entry_tags).distinct()

  • 在标签索引中显示与标签关联的条目数量。高级:在单个查询中完成。

  • 高级:创建一个图像模型和用于创建、编辑和删除图像的视图。

在下一章中,我们将为我们的网站添加身份验证,以便只有受信任的用户才能创建和修改内容。我们将构建一个模型来代表博客作者,添加登录/注销表单,并防止未经身份验证的用户访问网站的某些区域。

第五章:用户身份验证

在本章中,我们将向我们的网站添加用户身份验证。能够区分一个用户和另一个用户使我们能够开发一整套新功能。例如,我们将看到如何限制对创建、编辑和删除视图的访问,防止匿名用户篡改网站内容。我们还可以向用户显示他们的草稿帖子,但对其他人隐藏。本章将涵盖向网站添加身份验证层的实际方面,并以讨论如何使用会话跟踪匿名用户结束。

在本章中,我们将:

  • 创建一个数据库模型来表示用户

  • 安装 Flask-Login 并将 LoginManager 助手添加到我们的站点

  • 学习如何使用加密哈希函数安全存储和验证密码

  • 构建用于登录和退出网站的表单和视图

  • 查看如何在视图和模板中引用已登录用户

  • 限制对已登录用户的视图访问

  • 向 Entry 模型添加作者外键

  • 使用 Flask 会话对象跟踪网站的任何访问者

创建用户模型

构建我们的身份验证系统的第一步将是创建一个表示单个用户帐户的数据库模型。我们将存储用户的登录凭据,以及一些额外的信息,如用户的显示名称和他们的帐户创建时间戳。我们的模型将具有以下字段:

  • email(唯一):存储用户的电子邮件地址,并将其用于身份验证

  • password_hash: 不是将每个用户的密码作为明文串联起来,而是使用单向加密哈希函数对密码进行哈希处理

  • name: 用户的名称,这样我们就可以在他们的博客条目旁边显示它

  • slug: 用户名称的 URL 友好表示,也是唯一的

  • active: 布尔标志,指示此帐户是否处于活动状态。只有活动用户才能登录网站

  • created_timestamp: 用户帐户创建的时间

提示

如果您认为还有其他字段可能有用,请随意向此列表添加自己的内容。

现在我们有了字段列表,让我们创建model类。打开models.py,在Tag模型下面,添加以下代码:

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(64), unique=True)
    password_hash = db.Column(db.String(255))
    name = db.Column(db.String(64))
    slug = db.Column(db.String(64), unique=True)
    active = db.Column(db.Boolean, default=True)
    created_timestamp = db.Column(db.DateTime, default=datetime.datetime.now)

    def __init__(self, *args, **kwargs):
        super(User, self).__init__(*args, **kwargs)
        self.generate_slug()

    def generate_slug(self):
        if self.name:
            self.slug = slugify(self.name)

正如您在第二章中所记得的,使用 SQLAlchemy 的关系数据库,我们需要创建一个迁移,以便将这个表添加到我们的数据库中。从命令行,我们将使用manage.py助手来审查我们的模型并生成迁移脚本:

(blog) $ python manage.py db migrate
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'user'
 Generating /home/charles/projects/blog/app/migrations/versions/40ce2670e7e2_.py ... done

生成迁移后,我们现在可以运行db upgrade来进行模式更改:

(blog) $ python manage.py db upgrade
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.migration] Running upgrade 2ceb72931f66 -> 40ce2670e7e2, empty message

现在我们有了用户,下一步将允许他们登录网站。

安装 Flask-Login

Flask-Login 是一个轻量级的扩展,用于处理用户登录和退出网站。根据项目的文档,Flask-Login 将执行以下操作:

  • 登录和退出网站的用户

  • 将视图限制为已登录用户

  • 管理 cookie 和“记住我”功能

  • 帮助保护用户会话 cookie 免受盗窃

另一方面,Flask-Login 不会做以下事情:

  • 对用户帐户的存储做出任何决定

  • 管理用户名、密码、OpenID 或任何其他形式的凭据

  • 处理分层权限或任何超出已登录或已注销的内容

  • 帐户注册、激活或密码提醒

从这些列表中得出的结论是,Flask-Login 最好被认为是一个会话管理器。它只是管理用户会话,并让我们知道哪个用户正在发出请求,以及该用户是否已登录。

让我们开始吧。使用pip安装 Flask-Login:

(blog) $ pip install Flask-Login
Downloading/unpacking Flask-Login
...
Successfully installed Flask-Login
Cleaning up...

为了开始在我们的应用程序中使用这个扩展,我们将创建一个LoginManager类的实例,这是由 Flask-Login 提供的。除了创建LoginManager对象之外,我们还将添加一个信号处理程序,该处理程序将在每个请求之前运行。这个信号处理程序将检索当前登录的用户并将其存储在一个名为g的特殊对象上。在 Flask 中,g对象可以用来存储每个请求的任意值。

将以下代码添加到app.py。导入放在模块的顶部,其余部分放在末尾:

from flask import Flask, g
from flask.ext.login import LoginManager, current_user

# Add to the end of the module.
login_manager = LoginManager(app)
login_manager.login_view = "login"

@app.before_request
def _before_request():
    g.user = current_user

现在我们已经创建了我们的login_manager并添加了一个信号处理程序来加载当前用户,我们需要告诉 Flask-Login 如何确定哪个用户已登录。Flask-Login 确定这一点的方式是将当前用户的 ID 存储在会话中。我们的用户加载器将接受存储在会话中的 ID 并从数据库返回一个User对象。

打开models.py并添加以下代码行:

from app import login_manager

@login_manager.user_loader
def _user_loader(user_id):
    return User.query.get(int(user_id))

现在 Flask-Login 知道如何将用户 ID 转换为 User 对象,并且该用户将作为g.user对我们可用。

实现 Flask-Login 接口

为了让 Flask-Login 与我们的User模型一起工作,我们需要实现一些特殊方法,这些方法构成了 Flask-Login 接口。通过实现这些方法,Flask-Login 将能够接受一个User对象并确定他们是否可以登录网站。

打开models.py并向User类添加以下方法:

class User(db.Model):
    # ... column definitions, etc ...

    # Flask-Login interface..
    def get_id(self):
        return unicode(self.id)

    def is_authenticated(self):
        return True

    def is_active(self):
        return self.active

    def is_anonymous(self):
        return False

第一个方法get_id()指示 Flask-Login 如何确定用户的 ID,然后将其存储在会话中。它是我们用户加载器函数的反向,它给我们一个 ID 并要求我们返回一个User对象。其余的方法告诉 Flask-Login,数据库中的User对象不是匿名的,并且只有在active属性设置为True时才允许登录。请记住,Flask-Login 对我们的User模型或数据库一无所知,因此我们必须非常明确地告诉它。

现在我们已经配置了 Flask-Login,让我们添加一些代码,以便我们可以创建一些用户。

创建用户对象

创建新用户就像创建条目或标签一样,只有一个例外:我们需要安全地对用户的密码进行哈希处理。您永远不应该以明文形式存储密码,并且由于黑客的技术日益复杂,最好使用强大的加密哈希函数。我们将使用Flask-Bcrypt扩展来对我们的密码进行哈希处理和检查,因此让我们使用pip安装这个扩展:

(blog) $ pip install flask-bcrypt
...
Successfully installed Flask-Bcrypt
Cleaning up...

打开app.py并添加以下代码来注册扩展到我们的应用程序:

from flask.ext.bcrypt import Bcrypt

bcrypt = Bcrypt(app)

现在让我们为User对象添加一些方法,以便创建和检查密码变得简单:

from app import bcrypt

class User(db.Model):
    # ... column definitions, other methods ...

    @staticmethod
    def make_password(plaintext):
        return bcrypt.generate_password_hash(plaintext)

    def check_password(self, raw_password):
        return bcrypt.check_password_hash(self.password_hash, raw_password)

    @classmethod
    def create(cls, email, password, **kwargs):
        return User(
            email=email,
            password_hash=User.make_password(password),
            **kwargs)

    @staticmethod
    def authenticate(email, password):
        user = User.query.filter(User.email == email).first()
        if user and user.check_password(password):
            return user
        return False

make_password方法接受明文密码并返回哈希版本,而check_password方法接受明文密码并确定它是否与数据库中存储的哈希版本匹配。然而,我们不会直接使用这些方法。相反,我们将创建两个更高级的方法,createauthenticatecreate方法将创建一个新用户,在保存之前自动对密码进行哈希处理,而authenticate方法将根据用户名和密码检索用户。

通过创建一个新用户来尝试这些方法。打开一个 shell,并使用以下代码作为示例,为自己创建一个用户:

In [1]: from models import User, db

In [2]: user = User.create("charlie@gmail.com", password="secret",
name="Charlie")

In [3]: print user.password
$2a$12$q.rRa.6Y2IEF1omVIzkPieWfsNJzpWN6nNofBxuMQDKn.As/8dzoG

In [4]: db.session.add(user)

In [5]: db.session.commit()

In [6]:  User.authenticate("charlie@gmail.com", "secret")
Out[6]:  <User u"Charlie">

In [7]: User.authenticate("charlie@gmail.com", "incorrect")
Out[7]: False

现在我们有了一种安全地存储和验证用户凭据的方法,我们可以开始构建登录和注销视图了。

登录和注销视图

用户将使用他们的电子邮件和密码登录我们的博客网站;因此,在我们开始构建实际的登录视图之前,让我们从LoginForm开始。这个表单将接受用户名密码,并且还会呈现一个复选框来指示网站是否应该记住我。在app目录中创建一个forms.py模块,并添加以下代码:

import wtforms
from wtforms import validators
from models import User

class LoginForm(wtforms.Form):
    email = wtforms.StringField("Email",
        validators=[validators.DataRequired()])
    password = wtforms.PasswordField("Password",
        validators=[validators.DataRequired()])
    remember_me = wtforms.BooleanField("Remember me?",
        default=True)

提示

请注意,WTForms 还提供了一个电子邮件验证器。但是,正如该验证器的文档所告诉我们的那样,它非常原始,可能无法捕获所有边缘情况,因为完整的电子邮件验证实际上是非常困难的。

为了在正常的 WTForms 验证过程中验证用户的凭据,我们将重写表单的validate()方法。如果找不到电子邮件或密码不匹配,我们将在电子邮件字段下方显示错误。将以下方法添加到LoginForm类:

def validate(self):
    if not super(LoginForm, self).validate():
        return False

    self.user = User.authenticate(self.email.data, self.password.data)
    if not self.user:
        self.email.errors.append("Invalid email or password.")
        return False

    return True

现在我们的表单已经准备好了,让我们创建登录视图。我们将实例化LoginForm并在POST时对其进行验证。此外,当用户成功验证时,我们将重定向他们到一个新页面。

当用户登录时,将其重定向回用户先前浏览的页面是一个很好的做法。为了实现这一点,我们将在查询字符串值next中存储用户先前所在页面的 URL。如果在该值中找到了 URL,我们可以将用户重定向到那里。如果未找到 URL,则用户将默认被重定向到主页。

app目录中打开views.py并添加以下代码:

from flask import flash, redirect, render_template, request, url_for
from flask.ext.login import login_user

from app import app
from app import login_manager
from forms import LoginForm

@app.route("/")
def homepage():
    return render_template("homepage.html")

@app.route("/login/", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        form = LoginForm(request.form)
        if form.validate():
            login_user(form.user, remember=form.remember_me.data)
            flash("Successfully logged in as %s." % form.user.email, "success")
            return redirect(request.args.get("next") or url_for("homepage"))
    else:
        form = LoginForm()
    return render_template("login.html", form=form)

魔法发生在我们成功验证表单(因此验证了用户身份)后的POST上。我们调用login_user,这是 Flask-Login 提供的一个辅助函数,用于设置正确的会话值。然后我们设置一个闪存消息并将用户送上路。

登录模板

login.html模板很简单,除了一个技巧,一个例外。在表单的 action 属性中,我们指定了url_for('login'),但我们还传递了一个额外的值next。这允许我们在用户登录时保留所需的下一个 URL。将以下代码添加到templates/login.html

{% extends "base.html" %}
{% from "macros/form_field.html" import form_field %}
{% block title %}Log in{% endblock %}
{% block content_title %}Log in{% endblock %}
{% block content %}
<form action="{{ url_for('login', next=request.args.get('next','')) }}" class="form form-horizontal" method="post">
{{ form_field(form.email) }}
{{ form_field(form.password) }}
<div class="form-group">
    <div class="col-sm-offset-3 col-sm-9">
        <div class="checkbox">
            <label>{{ form.remember_me() }} Remember me</label>
        </div>
    </div>
</div>
<div class="form-group">
    <div class="col-sm-offset-3 col-sm-9">
        <button type="submit" class="btn btn-default">Log in</button>
        <a class="btn" href="{{ url_for('homepage') }}">Cancel</a>
    </div>
</div>
</form>
{% endblock %}

当您访问登录页面时,您的表单将如下截图所示:

登录模板

注销

最后让我们添加一个视图,用于将用户从网站中注销。有趣的是,此视图不需要模板,因为用户将简单地通过视图,在其会话注销后被重定向。将以下import语句和注销视图代码添加到views.py

# Modify the import at the top of the module.
from flask.ext.login import login_user, logout_user  # Add logout_user

@app.route("/logout/")
def logout():
    logout_user()
    flash('You have been logged out.', 'success')
    return redirect(request.args.get('next') or url_for('homepage'))

再次说明,我们接受next URL 作为查询字符串的一部分,默认为主页,如果未指定 URL。

访问当前用户

让我们在导航栏中创建登录和注销视图的链接。为此,我们需要检查当前用户是否已经通过身份验证。如果是,我们将显示一个指向注销视图的链接;否则,我们将显示一个登录链接。

正如您可能还记得本章早些时候所说的,我们添加了一个信号处理程序,将当前用户存储为 Flask g对象的属性。我们可以在模板中访问这个对象,所以我们只需要在模板中检查g.user是否已经通过身份验证。

打开base.html并对导航栏进行以下添加:

<ul class="nav navbar-nav">
    <li><a href="{{ url_for('homepage') }}">Home</a></li>
    <li><a href="{{ url_for('entries.index') }}">Blog</a></li>
    {% if g.user.is_authenticated %}
    <li><a href="{{ url_for('logout', next=request.path) }}">Log
out</a></li>
    {% else %}
    <li><a href="{{ url_for('login', next=request.path) }}">Log
in</a></li>
    {% endif %}
  {% block extra_nav %}{% endblock %}
</ul>

注意我们如何调用is_authenticated()方法,这是我们在User模型上实现的。Flask-Login 为我们提供了一个特殊的AnonymousUserMixin,如果当前没有用户登录,将使用它。

还要注意的是,除了视图名称,我们还指定了next=request.path。这与我们的登录和注销视图配合使用,以便在单击登录或注销后将用户重定向到其当前页面。

限制对视图的访问

目前,我们所有的博客视图都是不受保护的,任何人都可以访问它们。为了防止恶意用户破坏我们的条目,让我们为实际修改数据的视图添加一些保护。Flask-Login 提供了一个特殊的装饰器login_required,我们将使用它来保护应该需要经过身份验证的视图。

让我们浏览条目蓝图并保护所有修改数据的视图。首先在blueprint.py模块的顶部添加以下导入:

from flask.ext.login import login_required

login_required是一个装饰器,就像app.route一样,所以我们只需包装我们希望保护的视图。例如,这是如何保护image_upload视图的方法:

@entries.route('/image-upload/', methods=['GET', 'POST'])
@login_required
def image_upload():
    ...

浏览模块,并在以下视图中添加login_required装饰器,注意要在路由装饰器下面添加:

  • image_upload

  • create

  • edit

  • 删除

当匿名用户尝试访问这些视图时,他们将被重定向到login视图。作为额外的奖励,Flask-Login 将在重定向到login视图时自动处理指定下一个参数,因此用户将返回到他们试图访问的页面。

存储条目的作者

正如您可能还记得我们在第一章中创建的规范,创建您的第一个 Flask 应用程序,我们的博客网站将支持多个作者。当创建条目时,我们将把当前用户存储在条目的作者列中。为了存储编写给定EntryUser,我们将在用户和条目之间创建一个一对多的关系,以便一个用户可以有多个条目:

存储条目的作者

为了创建一对多的关系,我们将在Entry模型中添加一个指向User表中用户的列。这个列将被命名为author_id,因为它引用了一个User,我们将把它设为外键。打开models.py并对Entry模型进行以下修改:

class Entry(db.Model):
    modified_timestamp = ...
    author_id = db.Column(db.Integer, db.ForeignKey("user.id"))

    tags = ...

由于我们添加了一个新的列,我们需要再次创建一个迁移。从命令行运行db migratedb upgrade

(blog) $ python manage.py db migrate
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added column 'entry.author_id'
 Generating /home/charles/projects/blog/app/migrations/versions/33011181124e_.py ... done

(blog) $ python manage.py db upgrade
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.migration] Running upgrade 40ce2670e7e2 -> 33011181124e, empty message

就像我们对标签所做的那样,最后一步将是在用户模型上创建一个反向引用,这将允许我们访问特定用户关联的Entry行。因为用户可能有很多条目,我们希望对其执行额外的过滤操作,我们将把反向引用暴露为一个查询,就像我们为标签条目所做的那样。

User类中,在created_timestamp列下面添加以下代码行:

entries = db.relationship('Entry', backref='author', lazy='dynamic')

现在我们有能力将User作为博客条目的作者存储起来,下一步将是在创建条目时填充这个列。

注意

如果数据库中有任何博客条目,我们还需要确保它们被分配给一个作者。从交互式 shell 中,让我们手动更新所有现有条目上的作者字段:

In [8]: Entry.query.update({"author_id": user.id})
Out[8]: 6

这个查询将返回更新的行数,在这种情况下是数据库中的条目数。要保存这些更改,再次调用commit()

In [9]: db.session.commit()

设置博客条目的作者

现在我们有一个适合存储Entry作者的列,并且能够访问当前登录的用户,我们可以通过在创建条目时设置条目的作者来利用这些信息。在每个请求之前,我们的信号处理程序将把当前用户添加到 Flask g对象上,由于create视图受login_required装饰器保护,我们知道g.user将是来自数据库的User

因为我们正在使用g 对象来访问用户,所以我们需要导入它,所以在条目蓝图的顶部添加以下导入语句:

from flask import g

在条目蓝图中,我们现在需要修改Entry对象的实例化,手动设置作者属性。对create视图进行以下更改:

if form.validate():
 entry = form.save_entry(Entry(author=g.user))
    db.session.add(entry)

当您要创建一个条目时,您现在将被保存在数据库中作为该条目的作者。试一试吧。

保护编辑和删除视图

如果多个用户能够登录到我们的网站,没有什么可以阻止恶意用户编辑甚至删除另一个用户的条目。这些视图受login_required装饰器保护,但我们需要添加一些额外的代码来确保只有作者可以编辑或删除他们自己的条目。

为了清晰地实现此保护,我们将再次重构条目蓝图中的辅助函数。对条目蓝图进行以下修改:

def get_entry_or_404(slug, author=None):
    query = Entry.query.filter(Entry.slug == slug)
    if author:
        query = query.filter(Entry.author == author)
    else:
        query = filter_status_by_user(query)
    return query.first_or_404()

我们引入了一个新的辅助函数filter_status_by_user。此函数将确保匿名用户无法看到草稿条目。在get_entry_or_404下方的条目蓝图中添加以下函数:

def filter_status_by_user(query):
    if not g.user.is_authenticated:
        return query.filter(Entry.status == Entry.STATUS_PUBLIC)
    else:
        return query.filter(
            Entry.status.in_((Entry.STATUS_PUBLIC,
Entry.STATUS_DRAFT)))

为了限制对editdelete视图的访问,我们现在只需要将当前用户作为作者参数传递。对编辑和删除视图进行以下修改:

entry = get_entry_or_404(slug, author=None)

如果您尝试访问您未创建的条目的editdelete视图,您将收到404响应。

最后,让我们修改条目详细模板,以便除了条目的作者之外,所有用户都无法看到编辑删除链接。在您的entries应用程序中编辑模板entries/detail.html,您的代码可能如下所示:

{% if g.user == entry.author %}
  <li><h4>Actions</h4></li>
  <li><a href="{{ url_for('entries.edit', slug=entry.slug)
}}">Edit</a></li>
<li><a href="{{ url_for('entries.delete', slug=entry.slug)
}}">Delete</a></li>
{% endif %}

显示用户的草稿

我们的条目列表仍然存在一个小问题:草稿条目显示在普通条目旁边。我们不希望向任何人显示未完成的条目,但同时对于用户来说,看到自己的草稿将是有帮助的。因此,我们将修改条目列表和详细信息,只向条目的作者显示公共条目。

我们将再次修改条目蓝图中的辅助函数。我们将首先修改filter_status_by_user函数,以允许已登录用户查看自己的草稿(但不是其他人的):

def filter_status_by_user(query):
    if not g.user.is_authenticated:
        query = query.filter(Entry.status == Entry.STATUS_PUBLIC)
    else:
        # Allow user to view their own drafts.
 query = query.filter(
 (Entry.status == Entry.STATUS_PUBLIC) |
 ((Entry.author == g.user) &
 (Entry.status != Entry.STATUS_DELETED)))
 return query

新的查询可以解析为:“给我所有公共条目,或者我是作者的未删除条目。”

由于get_entry_or_404已经使用了filter_status_by_user辅助函数,因此detaileditdelete视图已经准备就绪。我们只需要处理使用entry_list辅助函数的各种列表视图。让我们更新entry_list辅助函数以使用新的filter_status_by_user辅助函数:

    query = filter_status_by_user(query)

    valid_statuses = (Entry.STATUS_PUBLIC, Entry.STATUS_DRAFT)
    query = query.filter(Entry.status.in_(valid_statuses))
    if request.args.get("q"):
        search = request.args["q"]
        query = query.filter(
            (Entry.body.contains(search)) |
            (Entry.title.contains(search)))
    return object_list(template, query, **context)

就是这样!我希望这展示了一些辅助函数在正确的位置上是如何真正简化开发者生活的。在继续进行最后一节之前,我建议创建一个或两个用户,并尝试新功能。

如果您计划在您的博客上支持多个作者,您还可以添加一个作者索引页面(类似于标签索引),以及列出与特定作者相关联的条目的作者详细页面(user.entries)。

会话

当您通过本章工作时,您可能会想知道 Flask-Login(以及 Flask)是如何能够在请求之间确定哪个用户已登录的。Flask-Login 通过将用户的 ID 存储在称为会话的特殊对象中来实现这一点。会话利用 cookie 来安全地存储信息。当用户向您的 Flask 应用程序发出请求时,他们的 cookie 将随请求一起发送,Flask 能够检查 cookie 数据并将其加载到会话对象中。同样,您的视图可以添加或修改存储在会话中的信息,从而在此过程中更新用户的 cookie。

Flask 会话对象的美妙之处在于它可以用于站点的任何访问者,无论他们是否已登录。会话可以像普通的 Python 字典一样处理。以下代码显示了您如何使用会话跟踪用户访问的最后一个页面:

from flask import request, session

@app.before_request
def _last_page_visited():
    if "current_page" in session:
        session["last_page"] = session["current_page"]
    session["current_page"] = request.path

默认情况下,Flask 会话只持续到浏览器关闭。如果您希望会话持久存在,即使在重新启动之间也是如此,只需设置session.permanent = True

提示

g对象一样,session对象可以直接从模板中访问。

作为练习,尝试为您的网站实现一个简单的主题选择器。创建一个视图,允许用户选择颜色主题,并将其存储在会话中。然后,在模板中,根据用户选择的主题应用额外的 CSS 规则。

总结

在本章中,我们为博客应用程序添加了用户身份验证。我们创建了一个User模型,安全地将用户的登录凭据存储在数据库中,然后构建了用于登录和退出站点的视图。我们添加了一个信号处理程序,在每个请求之前运行并检索当前用户,然后学习如何在视图和模板中使用这些信息。在本章的后半部分,我们将User模型与 Entry 模型集成,从而在过程中使我们的博客更加安全。本章以对 Flask 会话的简要讨论结束。

在下一章中,我们将构建一个管理仪表板,允许超级用户执行诸如创建新用户和修改站点内容等操作。我们还将收集和显示各种站点指标,如页面浏览量,以帮助可视化哪些内容驱动了最多的流量。

第六章:构建管理仪表板

在本章中,我们将为我们的网站构建一个管理仪表板。我们的管理仪表板将使特定的、选择的用户能够管理整个网站上的所有内容。实质上,管理站点将是数据库的图形前端,支持在应用程序表中创建、编辑和删除行的操作。优秀的 Flask-Admin 扩展几乎提供了所有这些功能,但我们将超越默认值,扩展和定制管理页面。

在本章中,我们将:

  • 安装 Flask-Admin 并将其添加到我们的网站

  • 添加用于处理EntryTagUser模型的视图

  • 添加管理网站静态资产的视图

  • 将管理与 Flask-Login 框架集成

  • 创建一个列来标识用户是否为管理员

  • 为管理仪表板创建一个自定义索引页面

安装 Flask-Admin

Flask-Admin 为 Flask 应用程序提供了一个现成的管理界面。Flask-Admin 还与 SQLAlchemy 很好地集成,以提供用于管理应用程序模型的视图。

下面的图像是对本章结束时Entry管理员将会是什么样子的一个 sneak preview:

安装 Flask-Admin

虽然这种功能需要相对较少的代码,但我们仍然有很多内容要涵盖,所以让我们开始吧。首先使用pipFlask-Admin安装到virtualenv中。在撰写本文时,Flask-Admin 的当前版本是 1.0.7。

(blog) $ pip install Flask-Admin
Downloading/unpacking Flask-Admin
...
Successfully installed Flask-Admin
Cleaning up...

如果您希望测试它是否安装正确,可以输入以下代码:

(blog) $ python manage.py shell
In [1]: from flask.ext import admin
In [2]: print admin.__version__
1.0.7

将 Flask-Admin 添加到我们的应用程序

与我们应用程序中的其他扩展不同,我们将在其自己的模块中设置管理扩展。我们将编写几个特定于管理的类,因此将它们放在自己的模块中是有意义的。在app目录中创建一个名为admin.py的新模块,并添加以下代码:

from flask.ext.admin import Admin
from app import app

admin = Admin(app, 'Blog Admin')

因为我们的admin模块依赖于app模块,为了避免循环导入,我们需要确保在app之后加载admin。打开main.py模块并添加以下内容:

from flask import request, session

from app import app, db
import admin  # This line is new, placed after the app import.
import models
import views

现在,您应该能够启动开发服务器并导航到/admin/以查看一个简单的管理员仪表板-默认的仪表板,如下图所示:

将 Flask-Admin 添加到我们的应用程序

随着您在本章中的进展,我们将把这个无聊和普通的管理界面变成一个丰富而强大的仪表板,用于管理您的博客。

通过管理公开模型

Flask-Admin 带有一个contrib包,其中包含专门设计用于与 SQLAlchemy 模型一起工作的特殊视图类。这些类提供开箱即用的创建、读取、更新和删除功能。

打开admin.py并更新以下代码:

from flask.ext.admin import Admin
from flask.ext.admin.contrib.sqla import ModelView

from app import app, db
from models import Entry, Tag, User

admin = Admin(app, 'Blog Admin')
admin.add_view(ModelView(Entry, db.session))
admin.add_view(ModelView(Tag, db.session))
admin.add_view(ModelView(User, db.session))

请注意我们如何调用admin.add_view()并传递ModelView类的实例,以及db会话,以便它可以访问数据库。Flask-Admin 通过提供一个中央端点来工作,我们开发人员可以向其中添加我们自己的视图。

启动开发服务器并尝试再次打开您的管理站点。它应该看起来像下面的截图:

通过管理公开模型

尝试通过在导航栏中选择其链接来点击我们模型的视图之一。点击Entry链接以干净的表格格式显示数据库中的所有条目。甚至有链接可以创建、编辑或删除条目,如下一个截图所示:

通过管理公开模型

Flask-Admin 提供的默认值很好,但是如果您开始探索界面,您会开始注意到一些微妙的东西可以改进或清理。例如,可能不需要将 Entry 的正文文本包括在列中。同样,状态列显示状态为整数,但我们更希望看到与该整数相关联的名称。我们还可以单击每个Entry行中的铅笔图标。这将带您到默认的编辑表单视图,您可以使用它来修改该条目。

所有看起来都像下面的截图:

通过管理员公开模型

如前面的截图所示,Flask-Admin 在处理我们的外键到键和多对多字段(作者和标签)方面做得非常出色。它还相当不错地选择了要为给定字段使用哪个 HTML 小部件,如下所示:

  • 标签可以使用漂亮的多选小部件添加和删除

  • 作者可以使用下拉菜单选择

  • 条目正文方便地显示为文本区域

不幸的是,这个表单存在一些明显的问题,如下所示:

  • 字段的排序似乎是任意的。

  • Slug字段显示为可编辑文本输入,因为这是由数据库模型管理的。相反,此字段应该从 Entry 的标题自动生成。

  • 状态字段是一个自由格式的文本输入字段,但应该是一个下拉菜单,其中包含人类可读的状态标签,而不是数字。

  • 创建时间戳修改时间戳字段看起来是可编辑的,但应该自动生成。

在接下来的部分中,我们将看到如何自定义Admin类和ModelView类,以便管理员真正为我们的应用程序工作。

自定义列表视图

让我们暂时把表单放在一边,专注于清理列表。为此,我们将创建一个 Flask-Admin 的子类ModelViewModelView类提供了许多扩展点和属性,用于控制列表显示的外观和感觉。

我们将首先通过手动指定我们希望显示的属性来清理列表列。此外,由于我们将在单独的列中显示作者,我们将要求 Flask-Admin 从数据库中高效地获取它。打开admin.py并更新以下代码:

from flask.ext.admin import Admin
from flask.ext.admin.contrib.sqla import ModelView

from app import app, db
from models import Entry, Tag, User

class EntryModelView(ModelView):
    column_list = [
        'title', 'status', 'author', 'tease', 'tag_list', 'created_timestamp',
    ]
    column_select_related_list = ['author']  # Efficiently SELECT the author.

admin = Admin(app, 'Blog Admin')
admin.add_view(EntryModelView(Entry, db.session))
admin.add_view(ModelView(Tag, db.session))
admin.add_view(ModelView(User, db.session))

您可能会注意到teasetag_list实际上不是我们Entry模型中的列名。Flask-Admin 允许您使用任何属性作为列值。我们还指定要用于创建对其他模型的引用的列。打开models.py模块,并向Entry模型添加以下属性:

@property
def tag_list(self):
    return ', '.join(tag.name for tag in self.tags)

@property
def tease(self):
    return self.body[:100]

现在,当您访问Entry管理员时,您应该看到一个干净、可读的表格,如下图所示:

自定义列表视图

让我们也修复状态列的显示。这些数字很难记住 - 最好显示人类可读的值。Flask-Admin 带有枚举字段(如状态)的辅助程序。我们只需要提供要显示值的状态值的映射,Flask-Admin 就会完成剩下的工作。在EntryModelView中进行以下添加:

class EntryModelView(ModelView):
    _status_choices = [(choice, label) for choice, label in [
 (Entry.STATUS_PUBLIC, 'Public'),
 (Entry.STATUS_DRAFT, 'Draft'),
 (Entry.STATUS_DELETED, 'Deleted'),
 ]]

 column_choices = {
 'status': _status_choices,
 }
    column_list = [
        'title', 'status', 'author', 'tease', 'tag_list', 'created_timestamp',
    ]
    column_select_related_list = ['author']

我们的Entry列表视图看起来好多了。现在让我们对User列表视图进行一些改进。同样,我们将对ModelView进行子类化,并指定要覆盖的属性。在admin.py中在EntryModelView下面添加以下类:

class UserModelView(ModelView):
    column_list = ['email', 'name', 'active', 'created_timestamp']

# Be sure to use the UserModelView class when registering the User:
admin.add_view(UserModelView(User, db.session))

以下截图显示了我们对User列表视图的更改:

自定义列表视图

向列表视图添加搜索和过滤

除了显示我们的模型实例列表外,Flask-Admin 还具有强大的搜索和过滤功能。假设我们有大量条目,并且想要找到包含特定关键字(如 Python)的条目。如果我们能够在列表视图中输入我们的搜索,并且 Flask-Admin 只列出标题或正文中包含单词'Python'的条目,那将是有益的。

正如您所期望的那样,这是非常容易实现的。打开admin.py并添加以下行:

class EntryModelView(ModelView):
    _status_choices = [(choice, label) for choice, label in [
        (Entry.STATUS_PUBLIC, 'Public'),
        (Entry.STATUS_DRAFT, 'Draft'),
        (Entry.STATUS_DELETED, 'Deleted'),
    ]]

    column_choices = {
        'status': _status_choices,
    }
    column_list = [
        'title', 'status', 'author', 'tease', 'tag_list', 'created_timestamp',
    ]
    column_searchable_list = ['title', 'body']
    column_select_related_list = ['author']

当您重新加载Entry列表视图时,您将看到一个新的文本框,允许您搜索titlebody字段,如下面的屏幕截图所示:

向列表视图添加搜索和过滤

尽管全文搜索可能非常有用,但对于状态创建时间戳等非文本字段,拥有更强大的过滤能力会更好。再次,Flask-Admin 提供了易于使用、易于配置的过滤选项,来拯救我们。

让我们通过向Entry列表添加几个过滤器来看看过滤器是如何工作的。我们将再次修改EntryModelView如下:

class EntryModelView(ModelView):
    _status_choices = [(choice, label) for choice, label in [
        (Entry.STATUS_PUBLIC, 'Public'),
        (Entry.STATUS_DRAFT, 'Draft'),
        (Entry.STATUS_DELETED, 'Deleted'),
    ]]

    column_choices = {
        'status': _status_choices,
    }
    column_filters = [
 'status', User.name, User.email, 'created_timestamp'
 ]
    column_list = [
        'title', 'status', 'author', 'tease', 'tag_list', 'created_timestamp',
    ]
    column_searchable_list = ['title', 'body']
    column_select_related_list = ['author']

column_filters属性包含Entry模型上的列名称,以及来自User相关模型的字段:

column_filters = [
    'status', User.name, User.email, 'created_timestamp'
]

当您访问Entry列表视图时,您现在将看到一个名为添加过滤器的新下拉菜单。尝试各种数据类型。请注意,当您尝试在状态列上进行过滤时,Flask-Admin 会自动使用PublicDraftDeleted标签。还要注意,当您在创建时间戳上进行过滤时,Flask-Admin 会呈现一个漂亮的日期/时间选择器小部件。在下面的屏幕截图中,我设置了各种过滤器:

向列表视图添加搜索和过滤

此时,Entry列表视图非常实用。作为练习,为User ModelView设置column_filterscolumn_searchable_list属性。

自定义管理模型表单

我们将通过展示如何自定义表单类来结束模型视图的讨论。您会记得,默认表单由 Flask-Admin 提供的有一些限制。在本节中,我们将展示如何自定义用于创建和编辑模型实例的表单字段的显示。

我们的目标是删除多余的字段,并为状态字段使用更合适的小部件,实现以下屏幕截图中所示的效果:

自定义管理模型表单

为了实现这一点,我们首先手动指定我们希望在表单上显示的字段列表。这是通过在EntryModelView 类上指定form_columns属性来完成的:

class EntryModelView(ModelView):
    ...
    form_columns = ['title', 'body', 'status', 'author', 'tags']

此外,我们希望status字段成为一个下拉小部件,使用各种状态的可读标签。由于我们已经定义了状态选择,我们将指示 Flask-Admin 使用 WTForms SelectField覆盖status字段,并传入有效选择的列表:

from wtforms.fields import SelectField  # At top of module.

class EntryModelView(ModelView):
    ...
    form_args = {
        'status': {'choices': _status_choices, 'coerce': int},
    }
    form_columns = ['title', 'body', 'status', 'author', 'tags']
    form_overrides = {'status': SelectField}

默认情况下,用户字段将显示为一个带有简单类型的下拉菜单。不过,想象一下,如果此列表包含数千个用户!这将导致一个非常大的查询和一个慢的渲染时间,因为需要创建所有的<option>元素。

当包含外键的表单呈现到非常大的表时,Flask-Admin 允许我们使用 Ajax 来获取所需的行。将以下属性添加到EntryModelView,现在您的用户将通过 Ajax 高效加载:

form_ajax_refs = {
    'author': {
        'fields': (User.name, User.email),
    },
}

这个指令告诉 Flask-Admin,当我们查找作者时,它应该允许我们在作者的姓名或电子邮件上进行搜索。以下屏幕截图显示了它的外观:

自定义管理模型表单

我们现在有一个非常漂亮的Entry表单。

增强用户表单

因为密码在数据库中以哈希形式存储,直接显示或编辑它们的价值很小。然而,在User表单上,我们将使输入新密码来替换旧密码成为可能。就像我们在Entry表单上对status字段所做的那样,我们将指定一个表单字段覆盖。然后,在模型更改处理程序中,我们将在保存时更新用户的密码。

UserModelView模块进行以下添加:

from wtforms.fields import PasswordField  # At top of module.

class UserModelView(ModelView):
    column_filters = ('email', 'name', 'active')
    column_list = ['email', 'name', 'active', 'created_timestamp']
    column_searchable_list = ['email', 'name']

    form_columns = ['email', 'password', 'name', 'active']
    form_extra_fields = {
 'password': PasswordField('New password'),
 }

    def on_model_change(self, form, model, is_created):
 if form.password.data:
 model.password_hash = User.make_password(form.password.data)
 return super(UserModelView, self).on_model_change(
 form, model, is_created)

以下截图显示了新的User表单的样子。如果您希望更改用户的密码,只需在新密码字段中输入新密码即可。

增强用户表单

生成 slug

仍然有一个方面需要解决。当创建新的EntryUserTag对象时,Flask-Admin 将无法正确生成它们的slug。这是由于 Flask-Admin 在保存时实例化新模型实例的方式。为了解决这个问题,我们将创建一些ModelView的子类,以确保为EntryUserTag对象正确生成slug

打开admin.py文件,并在模块顶部添加以下类:

class BaseModelView(ModelView):
    pass

class SlugModelView(BaseModelView):
    def on_model_change(self, form, model, is_created):
        model.generate_slug()
        return super(SlugModelView, self).on_model_change(
            form, model, is_created)

这些更改指示 Flask-Admin,每当模型更改时,应重新生成 slug。

为了开始使用这个功能,更新EntryModelViewUserModelView模块以扩展SlugModelView类。对于Tag模型,直接使用SlugModelView类进行注册即可。

总结一下,您的代码应该如下所示:

from flask.ext.admin import Admin
from flask.ext.admin.contrib.sqla import ModelView
from wtforms.fields import SelectField

from app import app, db
from models import Entry, Tag, User, entry_tags

class BaseModelView(ModelView):
    pass

class SlugModelView(BaseModelView):
    def on_model_change(self, form, model, is_created):
        model.generate_slug()
        return super(SlugModelView, self).on_model_change(
            form, model, is_created)

class EntryModelView(SlugModelView):
    _status_choices = [(choice, label) for choice, label in [
        (Entry.STATUS_PUBLIC, 'Public'),
        (Entry.STATUS_DRAFT, 'Draft'),
        (Entry.STATUS_DELETED, 'Deleted'),
    ]]

    column_choices = {
        'status': _status_choices,
    }
    column_filters = ['status', User.name, User.email, 'created_timestamp']
    column_list = [
        'title', 'status', 'author', 'tease', 'tag_list', 'created_timestamp',
    ]
    column_searchable_list = ['title', 'body']
    column_select_related_list = ['author']

    form_ajax_refs = {
        'author': {
            'fields': (User.name, User.email),
        },
    }
    form_args = {
        'status': {'choices': _status_choices, 'coerce': int},
    }
    form_columns = ['title', 'body', 'status', 'author', 'tags']
    form_overrides = {'status': SelectField}

class UserModelView(SlugModelView):
    column_filters = ('email', 'name', 'active')
    column_list = ['email', 'name', 'active', 'created_timestamp']
    column_searchable_list = ['email', 'name']

    form_columns = ['email', 'password', 'name', 'active']
    form_extra_fields = {
        'password': PasswordField('New password'),
    }

    def on_model_change(self, form, model, is_created):
        if form.password.data:
            model.password_hash = User.make_password(form.password.data)
        return super(UserModelView, self).on_model_change(
            form, model, is_created)

admin = Admin(app, 'Blog Admin')
admin.add_view(EntryModelView(Entry, db.session))
admin.add_view(SlugModelView(Tag, db.session))
admin.add_view(UserModelView(User, db.session))

这些更改确保正确生成 slug,无论是保存现有对象还是创建新对象。

通过管理员管理静态资产

Flask-Admin 提供了一个方便的界面,用于管理静态资产(或磁盘上的其他文件),作为管理员仪表板的扩展。让我们向我们的网站添加一个FileAdmin,它将允许我们上传或修改应用程序的static目录中的文件。

打开admin.py文件,并在文件顶部导入以下模块:

from flask.ext.admin.contrib.fileadmin import FileAdmin

然后,在各种ModelView实现下,添加以下突出显示的代码行:

class BlogFileAdmin(FileAdmin):
 pass

admin = Admin(app, 'Blog Admin')
admin.add_view(EntryModelView(Entry, db.session))
admin.add_view(SlugModelView(Tag, db.session))
admin.add_view(UserModelView(User, db.session))
admin.add_view(
 BlogFileAdmin(app.config['STATIC_DIR'], '/static/', name='Static Files'))

在浏览器中打开管理员,您应该会看到一个名为静态文件的新选项卡。单击此链接将带您进入一个熟悉的文件浏览器,如下截图所示:

通过管理员管理静态资产

提示

如果您在管理文件时遇到问题,请确保为static目录及其子目录设置了正确的权限。

保护管理员网站

当您测试新的管理员网站时,您可能已经注意到它没有进行任何身份验证。为了保护我们的管理员网站免受匿名用户(甚至某些已登录用户)的侵害,我们将向User模型添加一个新列,以指示用户可以访问管理员网站。然后,我们将使用 Flask-Admin 提供的钩子来确保请求用户具有权限。

第一步是向我们的User模型添加一个新列。将admin列添加到User模型中,如下所示:

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(64), unique=True)
    password_hash = db.Column(db.String(255))
    name = db.Column(db.String(64))
    slug = db.Column(db.String(64), unique=True)
    active = db.Column(db.Boolean, default=True)
 admin = db.Column(db.Boolean, default=False)
    created_timestamp = db.Column(db.DateTime, default=datetime.datetime.now)

现在我们将使用 Flask-Migrate 扩展生成模式迁移:

(blog) $ python manage.py db migrate
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added column 'user.admin'
 Generating /home/charles/projects/blog/app/migrations/versions/33011181124e_.py ... done

(blog) $ python manage.py db upgrade
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.migration] Running upgrade 40ce2670e7e2 -> 33011181124e, empty message

让我们还向User模型添加一个方法,用于告诉我们给定的用户是否是管理员。将以下方法添加到User模型中:

class User(db.Model):
    # ...

    def is_admin(self):
        return self.admin

这可能看起来很傻,但如果您希望更改应用程序确定用户是否为管理员的语义,这是很好的代码规范。

在继续下一节之前,您可能希望修改UserModelView类,将admin列包括在column_listcolumn_filtersform_columns中。

创建身份验证和授权混合

由于我们在管理员视图中创建了几个视图,我们需要一种可重复使用的表达我们身份验证逻辑的方法。我们将通过组合实现此重用。您已经在视图装饰器(@login_required)的形式中看到了组合-装饰器只是组合多个函数的一种方式。Flask-Admin 有点不同,它使用 Python 类来表示单个视图。我们将使用一种友好于类的组合方法,称为mixins,而不是函数装饰器。

mixin 是提供方法覆盖的类。在 Flask-Admin 的情况下,我们希望覆盖的方法是is_accessible方法。在这个方法内部,我们将检查当前用户是否已经验证。

为了访问当前用户,我们必须在admin模块的顶部导入特殊的g对象:

from flask import g, url_for

在导入语句下面,添加以下类:

class AdminAuthentication(object):
    def is_accessible(self):
        return g.user.is_authenticated and g.user.is_admin()

最后,我们将通过 Python 的多重继承将其与其他几个类混合在一起。对BaseModelView 类进行以下更改:

class BaseModelView(AdminAuthentication, ModelView):
    pass

还有BlogFileAdmin 类

class BlogFileAdmin(AdminAuthentication, FileAdmin):
    pass

如果尝试访问/admin/entry/等管理员视图 URL 而不符合is_accessible条件,Flask-Admin 将返回 HTTP 403 Forbidden 响应,如下截图所示:

创建身份验证和授权 mixin

注意

由于我们没有对Tag管理员模型进行更改,因此仍然可以访问。我们将由您来解决如何保护它。

设置自定义首页

我们的管理员着陆页(/admin/)非常无聊。实际上,除了导航栏之外,它根本没有任何内容。Flask-Admin 允许我们指定自定义索引视图,我们将使用它来显示一个简单的问候语。

为了添加自定义索引视图,我们需要导入几个新的帮助程序。将以下突出显示的导入添加到admin模块的顶部:

from flask.ext.admin import Admin, AdminIndexView, expose

from flask import redirect请求提供@expose装饰器,就像 Flask 本身使用@route一样。由于这个视图是索引,我们将要暴露的 URL 是/。以下代码将创建一个简单的索引视图,用于呈现模板。请注意,在初始化Admin对象时,我们将索引视图指定为参数:

class IndexView(AdminIndexView):
    @expose('/')
    def index(self):
        return self.render('admin/index.html')

admin = Admin(app, 'Blog Admin', index_view=IndexView())

最后还缺少一件事:身份验证。由于用户通常会直接访问/admin/来访问管理员,因此检查索引视图中当前用户是否经过身份验证将非常方便。我们可以通过以下方式来检查:当前用户是否经过身份验证。

class IndexView(AdminIndexView):
    @expose('/')
    def index(self):
        if not (g.user.is_authenticated and g.user.is_admin()):
 return redirect(url_for('login', next=request.path))
        return self.render('admin/index.html')

Flask-Admin 模板

Flask-Admin 提供了一个简单的主模板,您可以扩展它以创建统一的管理员站点外观。Flask-Admin 主模板包括以下区块:

区块名称 描述
head_meta 头部页面元数据
title 页面标题
head_css 头部的 CSS 链接
head 文档头部的任意内容
page_body 页面布局
brand 菜单栏中的标志
main_menu 主菜单
menu_links 导航栏
access_control 菜单栏右侧的区域,可用于添加登录/注销按钮
messages 警报和各种消息
body 主内容区域
tail 内容下方的空白区域

对于这个示例,body块对我们来说最有趣。在应用程序的templates目录中,创建一个名为admin的新子目录,其中包含一个名为index.html的空文件。

让我们自定义管理员着陆页,以在服务器上显示当前日期和时间。我们将扩展 Flask-Admin 提供的master模板,仅覆盖body块。在模板中创建admin目录,并将以下代码添加到templates/admin/index.html

{% extends "admin/master.html" %}

{% block body %}
  <h3>Hello, {{ g.user.name }}</h3>
{% endblock %}

以下是我们新着陆页的截图:

Flask-Admin 模板

这只是一个例子,用来说明扩展和定制管理面板是多么简单。尝试使用各种模板块,看看是否可以在导航栏中添加一个注销按钮。

阅读更多

Flask-Admin 是一个多才多艺、高度可配置的 Flask 扩展。虽然我们介绍了 Flask-Admin 的一些常用功能,但是要讨论的功能实在太多,无法在一个章节中全部涵盖。因此,我强烈建议您访问该项目的文档,如果您想继续学习。文档可以在flask-admin.readthedocs.org/上找到。

总结

在本章中,我们学习了如何使用 Flask-Admin 扩展为我们的应用程序创建管理面板。我们学习了如何将我们的 SQLAlchemy 模型公开为可编辑对象的列表,以及如何定制表格和表单的外观。我们添加了一个文件浏览器,以帮助管理应用程序的静态资产。我们还将管理面板与我们的身份验证系统集成。

在下一章中,我们将学习如何向我们的应用程序添加 API,以便可以通过编程方式访问它。

第七章:AJAX 和 RESTful API

在本章中,我们将使用 Flask-Restless 为博客应用创建一个 RESTful API。RESTful API 是以编程方式访问您的博客的一种方式,通过提供代表您的博客的高度结构化的数据。Flask-Restless 非常适用于我们的 SQLAlchemy 模型,并且还处理复杂的任务,如序列化和结果过滤。我们将使用我们的 REST API 为博客条目构建一个基于 AJAX 的评论功能。在本章结束时,您将能够为您的 SQLAlchemy 模型创建易于配置的 API,并在您的 Flask 应用中进行 AJAX 请求的创建和响应。

在本章中,我们将:

  • 创建一个模型来存储博客条目上的评论

  • 安装 Flask-Restless

  • 为评论模型创建一个 RESTful API

  • 构建一个用于使用 Ajax 与我们的 API 进行通信的前端

创建评论模型

在我们开始创建 API 之前,我们需要为我们希望共享的资源创建一个数据库模型。我们正在构建的 API 将用于使用 AJAX 创建和检索评论,因此我们的模型将包含存储未经身份验证用户在我们条目中的评论的所有相关字段。

对于我们的目的,以下字段应该足够:

  • name,发表评论的人的姓名

  • email,评论者的电子邮件地址,我们将仅使用它来显示他们在Gravatar上的图片

  • URL,评论者博客的 URL

  • ip_address,评论者的 IP 地址

  • body,实际评论

  • status,其中之一是PublicSpamDeleted

  • created_timestamp,评论创建的时间戳

  • entry_id,评论相关的博客条目的 ID

让我们通过在我们的应用程序的models.py模块中创建Comment模型定义来开始编码:

class Comment(db.Model):
    STATUS_PENDING_MODERATION = 0
    STATUS_PUBLIC = 1
    STATUS_SPAM = 8
    STATUS_DELETED = 9

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(64))
    email = db.Column(db.String(64))
    url = db.Column(db.String(100))
    ip_address = db.Column(db.String(64))
    body = db.Column(db.Text)
    status = db.Column(db.SmallInteger, default=STATUS_PUBLIC)
    created_timestamp = db.Column(db.DateTime, default=datetime.datetime.now)
    entry_id = db.Column(db.Integer, db.ForeignKey('entry.id'))

    def __repr__(self):
        return '<Comment from %r>' % (self.name,)

在添加Comment模型定义之后,我们需要设置CommentEntry模型之间的 SQLAlchemy 关系。您会记得,我们在设置UserEntry之间的关系时曾经做过一次,通过 entries 关系。我们将通过在Entry模型中添加一个 comments 属性来为Comment做这个。

tags关系下面,添加以下代码到Entry模型定义中:

class Entry(db.Model):
    # ...
    tags = db.relationship('Tag', secondary=entry_tags,
        backref=db.backref('entries', lazy='dynamic'))
    comments = db.relationship('Comment', backref='entry', lazy='dynamic')

我们已经指定了关系为lazy='dynamic',正如您从第五章验证用户中所记得的那样,这意味着在任何给定的Entry实例上,comments属性将是一个可过滤的查询。

创建模式迁移

为了开始使用我们的新模型,我们需要更新我们的数据库模式。使用manage.py助手,为Comment模型创建一个模式迁移:

(blog) $ python manage.py db migrate
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'comment'
 Generating /home/charles/projects/blog/app/migrations/versions/490b6bc5f73c_.py ... done

然后通过运行upgrade来应用迁移:

(blog) $ python manage.py db upgrade
INFO  [alembic.migration] Context impl SQLiteImpl.
INFO  [alembic.migration] Will assume non-transactional DDL.
INFO  [alembic.migration] Running upgrade 594ebac9ef0c -> 490b6bc5f73c, empty message

Comment模型现在已经准备好使用了!在这一点上,如果我们使用常规的 Flask 视图来实现评论,我们可能会创建一个评论蓝图并开始编写一个视图来处理评论的创建。然而,我们将使用 REST API 公开评论,并直接从前端使用 AJAX 创建它们。

安装 Flask-Restless

有了我们的模型,我们现在准备安装 Flask-Restless,这是一个第三方 Flask 扩展,可以简单地为您的 SQLAlchemy 模型构建 RESTful API。确保您已经激活了博客应用的虚拟环境后,使用pip安装 Flask-Restless:

(blog) $ pip install Flask-Restless

您可以通过打开交互式解释器并获取已安装的版本来验证扩展是否已安装。不要忘记,您的确切版本号可能会有所不同。

(blog) $ ./manage.py shell

In [1]: import flask_restless

In [2]: flask_restless.__version__
Out[2]: '0.13.0'

现在我们已经安装了 Flask-Restless,让我们配置它以使其与我们的应用程序一起工作。

设置 Flask-Restless

像其他 Flask 扩展一样,我们将从app.py模块开始,通过配置一个将管理我们新 API 的对象。在 Flask-Restless 中,这个对象称为APIManager,它将允许我们为我们的 SQLAlchemy 模型创建 RESTful 端点。将以下行添加到app.py

# Place this import at the top of the module alongside the other extensions.
from flask.ext.restless import APIManager

# Place this line below the initialization of the app and db objects.
api = APIManager(app, flask_sqlalchemy_db=db)

因为 API 将依赖于我们的 Flask API 对象和我们的Comment模型,所以我们需要确保我们不创建任何循环模块依赖关系。我们可以通过在应用程序目录的根目录下创建一个新模块“api.py”来避免引入循环导入。

让我们从最基本的开始,看看 Flask-Restless 提供了什么。在api.py中添加以下代码:

from app import api
from models import Comment

api.create_api(Comment, methods=['GET', 'POST'])

api.py中的代码调用了我们的APIManager对象上的create_api()方法。这个方法将用额外的 URL 路由和视图代码填充我们的应用程序,这些代码一起构成了一个 RESTful API。方法参数指示我们只允许GETPOST请求(意味着评论可以被读取或创建,但不能被编辑或删除)。

最后的操作是在main.py中导入新的 API 模块,这是我们应用程序的入口点。我们导入模块纯粹是为了它的副作用,注册 URL 路由。在main.py中添加以下代码:

from app import app, db
import admin
import api
import models
import views

...

发出 API 请求

在一个终端中,启动开发服务器。在另一个终端中,让我们看看当我们向我们的 API 端点发出GET请求时会发生什么(注意没有尾随的斜杠):

$ curl 127.0.0.1:5000/api/comment
{
 "num_results": 0,
 "objects": [],
 "page": 1,
 "total_pages": 0
}

数据库中没有评论,所以没有对象被序列化和返回给我们。然而,有一些有趣的元数据告诉我们数据库中有多少对象,我们在哪一页,以及有多少总页的评论存在。

让我们通过向我们的 API POST 一些 JSON 数据来创建一个新的评论(我将假设你的数据库中的第一个条目的 id 为1)。我们将使用curl提交一个包含新评论的 JSON 编码表示的POST请求:

$ curl -X POST -H "Content-Type: application/json" -d '{
 "name": "Charlie",
 "email": "charlie@email.com",
 "url": "http://charlesleifer.com",
 "ip_address": "127.0.0.1",
 "body": "Test comment!",
 "entry_id": 1}' http://127.0.0.1:5000/api/comment

假设没有拼写错误,API 将以以下数据回应,确认新的Comment的创建:

{
  "body": "Test comment!",
  "created_timestamp": "2014-04-22T19:48:33.724118",
  "email": "charlie@email.com",
  "entry": {
    "author_id": 1,
    "body": "This is an entry about Python, my favorite programming language.",
    "created_timestamp": "2014-03-06T19:50:09",
    "id": 1,
    "modified_timestamp": "2014-03-06T19:50:09",
    "slug": "python-entry",
    "status": 0,
    "title": "Python Entry"
  },
  "entry_id": 1,
  "id": 1,
  "ip_address": "127.0.0.1",
  "name": "Charlie",
  "status": 0,
  "url": "http://charlesleifer.com"
}

正如你所看到的,我们 POST 的所有数据都包含在响应中,除了其余的字段数据,比如新评论的 id 和时间戳。令人惊讶的是,甚至相应中已经序列化并包含了相应的Entry对象。

现在我们在数据库中有了一个评论,让我们尝试向我们的 API 发出另一个GET请求:

$ curl 127.0.0.1:5000/api/comment
{
 "num_results": 1,
 "objects": [
 {
 "body": "Test comment!",
 "created_timestamp": "2014-04-22T19:48:33.724118",
 "email": "charlie@email.com",
 "entry": {
 "author_id": 1,
 "body": "This is an entry about Python, my favorite programming language.",
 "created_timestamp": "2014-03-06T19:50:09",
 "id": 1,
 "modified_timestamp": "2014-03-06T19:50:09",
 "slug": "python-entry",
 "status": 0,
 "title": "Python Entry"
 },
 "entry_id": 1,
 "id": 1,
 "ip_address": "127.0.0.1",
 "name": "Charlie",
 "status": 0,
 "url": "http://charlesleifer.com"
 }
 ],
 "page": 1,
 "total_pages": 1
}

第一个对象包含了当我们进行POST请求时返回给我们的完全相同的数据。此外,周围的元数据已经改变,以反映数据库中现在有一个评论的事实。

使用 AJAX 创建评论

为了允许用户发表评论,我们首先需要一种捕获他们输入的方法,我们将通过使用wtforms创建一个Form类来实现这一点。这个表单应该允许用户输入他们的姓名、电子邮件地址、一个可选的 URL 和他们的评论。

在条目蓝图的表单模块中,添加以下表单定义:

class CommentForm(wtforms.Form):
    name = wtforms.StringField('Name', validators=[validators.DataRequired()])
    email = wtforms.StringField('Email', validators=[
        validators.DataRequired(),
        validators.Email()])
    url = wtforms.StringField('URL', validators=[
        validators.Optional(),
        validators.URL()])
    body = wtforms.TextAreaField('Comment', validators=[
        validators.DataRequired(),
        validators.Length(min=10, max=3000)])
    entry_id = wtforms.HiddenField(validators=[
        validators.DataRequired()])

    def validate(self):
        if not super(CommentForm, self).validate():
            return False

        # Ensure that entry_id maps to a public Entry.
        entry = Entry.query.filter(
            (Entry.status == Entry.STATUS_PUBLIC) &
            (Entry.id == self.entry_id.data)).first()
        if not entry:
            return False

        return True

你可能会想为什么我们要指定验证器,因为 API 将处理 POST 的数据。我们这样做是因为 Flask-Restless 不提供验证,但它提供了一个我们可以执行验证的钩子。这样,我们就可以在我们的 REST API 中利用 WTForms 验证。

为了在条目详细页面使用表单,我们需要在渲染详细模板时将表单传递到上下文中。打开条目蓝图并导入新的CommentForm

from entries.forms import EntryForm, ImageForm, CommentForm

然后修改“详细”视图,将一个表单实例传递到上下文中。我们将使用请求的条目的值预填充entry_id隐藏字段:

@entries.route('/<slug>/')
def detail(slug):
    entry = get_entry_or_404(slug)
    form = CommentForm(data={'entry_id': entry.id})
    return render_template('entries/detail.html', entry=entry, form=form)

现在表单已经在详细模板上下文中,剩下的就是渲染表单。在entries/templates/entries/includes/中创建一个空模板,命名为comment_form.html,并添加以下代码:

{% from "macros/form_field.html" import form_field %}
<form action="/api/comment" class="form form-horizontal" id="comment-form" method="post">
  {{ form_field(form.name) }}
  {{ form_field(form.email) }}
  {{ form_field(form.url) }}
  {{ form_field(form.body) }}
  {{ form.entry_id() }}
  <div class="form-group">
    <div class="col-sm-offset-3 col-sm-9">
      <button type="submit" class="btn btn-default">Submit</button>
    </div>
  </div>
</form>

值得注意的是,我们没有使用form_field宏来处理entry_id字段。这是因为我们不希望评论表单显示一个对用户不可见的字段的标签。相反,我们将用这个值初始化表单。

最后,我们需要在detail.html模板中包含评论表单。在条目正文下面,添加以下标记:

{% block content %}
  {{ entry.body }}

  <h4 id="comment-form">Submit a comment</h4>
 {% include "entries/includes/comment_form.html" %}
{% endblock %}

使用开发服务器,尝试导航到任何条目的详细页面。你应该会看到一个评论表单:

使用 AJAX 创建评论

AJAX 表单提交

为了简化进行 AJAX 请求,我们将使用 jQuery 库。如果你愿意,可以随意替换为其他 JavaScript 库,但是由于 jQuery 如此普遍(并且与 Bootstrap 兼容),我们将在本节中使用它。如果你一直在跟着代码进行开发,那么 jQuery 应该已经包含在所有页面中。现在我们需要创建一个 JavaScript 文件来处理评论提交。

statics/js/中创建一个名为comments.js的新文件,并添加以下 JavaScript 代码:

Comments = window.Comments || {};

(function(exports, $) { /* Template string for rendering success or error messages. */
  var alertMarkup = (
    '<div class="alert alert-{class} alert-dismissable">' +
    '<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>' +
    '<strong>{title}</strong> {body}</div>');

  /* Create an alert element. */
  function makeAlert(alertClass, title, body) {
    var alertCopy = (alertMarkup
                     .replace('{class}', alertClass)
                     .replace('{title}', title)
                     .replace('{body}', body));
    return $(alertCopy);
  }

  /* Retrieve the values from the form fields and return as an object. */
  function getFormData(form) {
    return {
      'name': form.find('input#name').val(),
      'email': form.find('input#email').val(),
      'url': form.find('input#url').val(),
      'body': form.find('textarea#body').val(),
      'entry_id': form.find('input[name=entry_id]').val()
    }
  }

  function bindHandler() {
    /* When the comment form is submitted, serialize the form data as JSON
             and POST it to the API. */
    $('form#comment-form').on('submit', function() {
      var form = $(this);
      var formData = getFormData(form);
      var request = $.ajax({
        url: form.attr('action'),
        type: 'POST',
        data: JSON.stringify(formData),
        contentType: 'application/json; charset=utf-8',
        dataType: 'json'
      });
      request.success(function(data) {
        alertDiv = makeAlert('success', 'Success', 'your comment was posted.');
        form.before(alertDiv);
        form[0].reset();
      });
      request.fail(function() {
        alertDiv = makeAlert('danger', 'Error', 'your comment was not posted.');
        form.before(alertDiv);
      });
      return false;
    });
  }

  exports.bindHandler = bindHandler;
})(Comments, jQuery);

comments.js代码处理将表单数据序列化为 JSON 后,提交到 REST API。它还处理 API 响应,并显示成功或错误消息。

detail.html模板中,我们只需要包含我们的脚本并绑定提交处理程序。在详细模板中添加以下块覆盖:

{% block extra_scripts %}
  <script type="text/javascript" src="img/comments.js') }}"></script>
  <script type="text/javascript">
    $(function() {
      Comments.bindHandler();
    });
  </script>
{% endblock %}

试着提交一两条评论。

在 API 中验证数据

不幸的是,我们的 API 没有对传入数据进行任何类型的验证。为了验证POST数据,我们需要使用 Flask-Restless 提供的一个钩子。Flask-Restless 将这些钩子称为请求预处理器和后处理器。

让我们看看如何使用 POST 预处理器对评论数据进行一些验证。首先打开api.py并进行以下更改:

from flask.ext.restless import ProcessingException

from app import api
from entries.forms import CommentForm
from models import Comment

def post_preprocessor(data, **kwargs):
    form = CommentForm(data=data)
    if form.validate():
        return form.data
    else:
        raise ProcessingException(
            description='Invalid form submission.',
            code=400)

api.create_api(
    Comment,
    methods=['GET', 'POST'],
    preprocessors={
        'POST': [post_preprocessor],
    })

我们的 API 现在将使用来自CommentForm的验证逻辑来验证提交的评论。我们通过为POST方法指定一个预处理器来实现这一点。我们已经实现了post_preprocessor作为POST预处理器,它接受反序列化的POST数据作为参数。然后我们可以将这些数据传递给我们的CommentForm并调用它的validate()方法。如果验证失败,我们将引发一个ProcessingException,向 Flask-Restless 发出信号,表明这些数据无法处理,并返回一个400 Bad Request 响应。

在下面的截图中,我没有提供必需的评论字段。当我尝试提交评论时,我收到了一个错误消息:

在 API 中验证数据

预处理器和后处理器

我们刚刚看了一个使用 Flask-Restless 的POST方法预处理器的示例。在下表中,你可以看到其他可用的钩子:

方法名称 描述 预处理器参数 后处理器参数
GET_SINGLE 通过主键检索单个对象 instance_id,对象的主键 result,对象的字典表示
GET_MANY 检索多个对象 search_params,用于过滤结果集的搜索参数字典 result,对象的search_params表示
PUT_SINGLE 通过主键更新单个对象 instance_id数据,用于更新对象的数据字典 result,更新后对象的字典表示
PUT_MANY 更新多个对象 search_params,用于确定要更新哪些对象的搜索参数字典。data,用于更新对象的数据字典。 query,表示要更新的对象的 SQLAlchemy 查询。data``search_params
POST 创建新实例 data,用于填充新对象的数据字典 result,新对象的字典表示
DELETE 通过主键删除实例 instance_id,要删除的对象的主键 was_deleted,一个布尔值,指示对象是否已被删除

使用 AJAX 加载评论

现在我们能够使用 AJAX 创建经过验证的评论,让我们使用 API 来检索评论列表,并在博客条目下方显示它们。为此,我们将从 API 中读取值,并动态创建 DOM 元素来显示评论。您可能还记得我们之前检查的 API 响应中返回了相当多的私人信息,包括每条评论相关联的整个序列化表示的Entry。对于我们的目的来说,这些信息是多余的,而且还会浪费带宽。

让我们首先对评论端点进行一些额外的配置,以限制我们返回的Comment字段。在api.py中,对api.create_api()的调用进行以下添加:

api.create_api(
    Comment,
    include_columns=['id', 'name', 'url', 'body', 'created_timestamp'],
    methods=['GET', 'POST'],
    preprocessors={
        'POST': [post_preprocessor],
    })

现在请求评论列表会给我们一个更易管理的响应,不会泄露实现细节或私人数据:

$ curl http://127.0.0.1:5000/api/comment
{
 "num_results": 1,
 "objects": [
 {
 "body": "Test comment!",
 "created_timestamp": "2014-04-22T19:48:33.724118",
 "name": "Charlie",
 "url": "http://charlesleifer.com"
 }
 ],
 "page": 1,
 "total_pages": 1
}

一个很好的功能是在用户的评论旁边显示一个头像。Gravatar 是一个免费的头像服务,允许用户将他们的电子邮件地址与图像关联起来。我们将使用评论者的电子邮件地址来显示他们关联的头像(如果存在)。如果用户没有创建头像,将显示一个抽象图案。

让我们在Comment模型上添加一个方法来生成用户 Gravatar 图像的 URL。打开models.py并向Comment添加以下方法:

def gravatar(self, size=75):
    return 'http://www.gravatar.com/avatar.php?%s' % urllib.urlencode({
        'gravatar_id': hashlib.md5(self.email).hexdigest(),
        'size': str(size)})

您还需要确保在模型模块的顶部导入hashliburllib

如果我们尝试在列的列表中包括 Gravatar,Flask-Restless 会引发异常,因为gravatar实际上是一个方法。幸运的是,Flask-Restless 提供了一种在序列化对象时包含方法调用结果的方法。在api.py中,对create_api()的调用进行以下添加:

api.create_api(
    Comment,
    include_columns=['id', 'name', 'url', 'body', 'created_timestamp'],
    include_methods=['gravatar'],
    methods=['GET', 'POST'],#, 'DELETE'],
    preprocessors={
        'POST': [post_preprocessor],
    })

继续尝试获取评论列表。现在你应该看到 Gravatar URL 包含在序列化响应中。

检索评论列表

现在我们需要返回到我们的 JavaScript 文件,并添加代码来检索评论列表。我们将通过向 API 传递搜索过滤器来实现这一点,API 将仅检索与请求的博客条目相关联的评论。搜索查询被表示为一系列过滤器,每个过滤器指定以下内容:

  • 列的名称

  • 操作(例如,等于)

  • 要搜索的值

打开comments.js并在以下行之后添加以下代码:

(function(exports, $) {:
function displayNoComments() {
  noComments = $('<h3>', {
    'text': 'No comments have been posted yet.'});
  $('h4#comment-form').before(noComments);
}

/* Template string for rendering a comment. */
var commentTemplate = (
  '<div class="media">' +
    '<a class="pull-left" href="{url}">' +
      '<img class="media-object" src="img/{gravatar}" />' +
    '</a>' +
    '<div class="media-body">' +
    '<h4 class="media-heading">{created_timestamp}</h4>{body}' +
  '</div></div>'
);

function renderComment(comment) {
  var createdDate = new Date(comment.created_timestamp).toDateString();
  return (commentTemplate
          .replace('{url}', comment.url)
          .replace('{gravatar}', comment.gravatar)
          .replace('{created_timestamp}', createdDate)
          .replace('{body}', comment.body));
}

function displayComments(comments) {
  $.each(comments, function(idx, comment) {
    var commentMarkup = renderComment(comment);
    $('h4#comment-form').before($(commentMarkup));
  });
}

function load(entryId) {
  var filters = [{
    'name': 'entry_id',
    'op': 'eq',
    'val': entryId}];
  var serializedQuery = JSON.stringify({'filters': filters});

  $.get('/api/comment', {'q': serializedQuery}, function(data) {
    if (data['num_results'] === 0) {
      displayNoComments();
    } else {
      displayComments(data['objects']);
    }
  });
}

然后,在文件底部附近,导出load函数以及bindHandler导出,如下所示:

exports.load = load;
exports.bindHandler = bindHandler;

我们添加的新 JavaScript 代码会向 API 发出 AJAX 请求,以获取与给定条目相关联的评论。如果没有评论存在,将显示一条消息,指示尚未发表评论。否则,条目将作为列表呈现在Entry正文下方。

最后的任务是在页面呈现时在详细模板中调用Comments.load()。打开detail.html并添加以下突出显示的代码:

<script type="text/javascript">
  $(function() {
    Comments.load({{ entry.id }});
    Comments.bindHandler();
  });
</script>

在发表了一些评论之后,评论列表看起来如下图所示:

检索评论列表

作为练习,看看你是否能够编写代码来呈现用户发表的任何新评论。您会记得,当成功创建评论时,新数据将作为 JSON 对象返回。

阅读更多

Flask-Restless 支持许多配置选项,由于篇幅原因,本章未能涵盖。搜索过滤器是一个非常强大的工具,我们只是触及了可能性的表面。此外,预处理和后处理钩子可以用于实现许多有趣的功能,例如以下功能:

  • 可以在预处理器中实现的身份验证

  • GET_MANY的默认过滤器,可以用于限制评论列表,例如只显示公开的评论

  • 向序列化响应添加自定义或计算值

  • 修改传入的POST值以在模型实例上设置默认值

如果 REST API 是您的应用程序中的关键组件,我强烈建议花时间阅读 Flask-Restless 文档。文档可以在网上找到:flask-restless.readthedocs.org/en/latest/

总结

在本章中,我们使用 Flask-Restless 扩展为我们的应用程序添加了一个简单的 REST API。然后,我们使用 JavaScript 和 Ajax 将我们的前端与 API 集成,允许用户查看和发布新评论,而无需编写一行视图代码。

在下一章中,我们将致力于创建可测试的应用程序,并找到改进我们代码的方法。这也将使我们能够验证我们编写的代码是否按照我们的意愿进行操作;不多,也不少。自动化这一过程将使您更有信心,并确保 RESTful API 按预期工作。

第八章:测试 Flask 应用

在本章中,我们将学习如何编写覆盖博客应用程序所有部分的单元测试。我们将利用 Flask 的测试客户端来模拟实时请求,并了解 Mock 库如何简化测试复杂交互,比如调用数据库等第三方服务。

在本章中,我们将学习以下主题:

  • Python 的单元测试模块和测试编写的一般指导

  • 友好的测试配置

  • 如何使用 Flask 测试客户端模拟请求和会话

  • 如何使用 Mock 库测试复杂交互

  • 记录异常和错误邮件

单元测试

单元测试是一个让我们对代码、bug 修复和未来功能有信心的过程。单元测试的理念很简单;你编写与你的功能代码相辅相成的代码。

举个例子,假设我们设计了一个需要正确计算一些数学的程序;你怎么知道它成功了?为什么不拿出一个计算器,你知道计算机是什么吗?一个大计算器。此外,计算机在乏味的重复任务上确实非常擅长,那么为什么不编写一个单元测试来为你计算出答案呢?对代码的所有部分重复这种模式,将这些测试捆绑在一起,你就对自己编写的代码完全有信心了。

注意

有人说测试是代码“味道”的标志,你的代码如此复杂,以至于需要测试来证明它的工作。这意味着代码应该更简单。然而,这真的取决于你的情况,你需要自己做出判断。在我们开始简化代码之前,单元测试是一个很好的起点。

单元测试的巧妙之处在于测试与功能代码相辅相成。这些方法证明了测试的有效性,而测试证明了方法的有效性。它减少了代码出现重大功能错误的可能性,减少了将来重新编写代码的头痛,并允许你专注于你想要处理的新功能的细枝末节。

提示

单元测试的理念是验证代码的小部分,或者说是测试简单的功能部分。这将构建成应用程序的整体。很容易写出大量测试代码,测试的是代码的功能而不是代码本身。如果你的测试看起来很大,通常表明你的主要代码应该被分解成更小的方法。

Python 的单元测试模块

幸运的是,几乎总是如此,Python 有一个内置的单元测试模块。就像 Flask 一样,很容易放置一个简单的单元测试模块。在你的主要博客应用程序中,创建一个名为tests的新目录,并在该目录中创建一个名为test.py的新文件。现在,使用你喜欢的文本编辑器,输入以下代码:

import unittest

class ExampleTest(unittest.TestCase):
  def setUp(self):
    pass

  def tearDown(self):
    pass

  def test_some_functionality(self):
    pass

  def test_some_other_functionality(self):
    pass

if __name__ == "__main__":
  unittest.main()

前面的片段演示了我们将编写的所有单元测试模块的基本框架。它简单地利用内置的 Python 模块unittest,然后创建一个包装特定测试集的类。在这个例子中,测试是以单词test开头的方法。单元测试模块将这些方法识别为每次调用unittest.main时应该运行的方法。此外,TestCase类(ExampleTest类在这里继承自它)具有一些特殊方法,单元测试将始终尝试使用。其中之一是setUp,这是在运行每个测试方法之前运行的方法。当您想要在隔离环境中运行每个测试,但是,例如,要在数据库中建立连接时,这可能特别有用。

另一个特殊的方法是tearDown。每次运行测试方法时都会运行此方法。同样,当我们想要维护数据库时,这对于每个测试都在隔离环境中运行非常有用。

显然,这个代码示例如果运行将不会做任何事情。要使其处于可用状态,并且遵循测试驱动开发TDD)的原则,我们首先需要编写一个测试,验证我们即将编写的代码是否正确,然后编写满足该测试的代码。

一个简单的数学测试

在这个示例中,我们将编写一个测试,验证一个方法将接受两个数字作为参数,从第二个参数中减去一个,然后将它们相乘。看一下以下示例:

参数 1 参数 2 答案
1 1 1 * (1-1) = 0
1 2 1 * (2-1) = 1
2 3 2 * (3-1) = 4

在你的test.py文件中,你可以创建一个在ExampleTest类中表示前面表格的方法,如下所示:

  def test_minus_one_multiplication(self):
    self.assertEqual(my_multiplication(1,1), 0)
    self.assertEqual(my_multiplication(1,2), 1)
    self.assertEqual(my_multiplication(2,3), 4)
    self.assertNotEqual(my_multiplication(2,2), 3)

前面的代码创建了一个新的方法,使用 Python 的unittest模块来断言问题的答案。assertEqual函数将my_multiplication方法返回的响应作为第一个参数,并将其与第二个参数进行比较。如果通过了,它将什么也不做,等待下一个断言进行测试。但如果不匹配,它将抛出一个错误,并且你的测试方法将停止执行,告诉你出现了错误。

在前面的代码示例中,还有一个assertNotEqual方法。它的工作方式与assertEqual类似,但是检查值是否不匹配。还有一个好主意是检查你的方法何时可能失败。如果你只检查了方法将起作用的情况,那么你只完成了一半的工作,并且可能会在边缘情况下遇到问题。Python 的unittest模块提供了各种各样的断言方法,这将是有用的去探索。

现在我们可以编写将给出这些结果的方法。为简单起见,我们将在同一个文件中编写该方法。在文件中,创建以下方法:

def my_multiplication(value1, value2):
  return value1 * value2 – 1

保存文件并使用以下命令运行它:

python test.py

一个简单的数学测试

哎呀!它失败了。为什么?嗯,回顾my_multiplication方法发现我们漏掉了一些括号。让我们回去纠正一下:

def my_multiplication(value1, value2):
  return value1 * (value2 – 1)

现在让我们再次运行它:

一个简单的数学测试

成功了!现在我们有了一个正确的方法;将来,我们将知道它是否被更改过,以及在以后需要如何更改。现在来用这个新技能与 Flask 一起使用。

Flask 和单元测试

你可能会想:“单元测试对于代码的小部分看起来很棒,但是如何为整个 Flask 应用程序进行测试呢?”嗯,正如之前提到的一种方法是确保所有的方法尽可能离散——也就是说,确保你的方法尽可能少地完成它们的功能,并避免方法之间的重复。如果你的方法不是离散的,现在是整理它们的好时机。

另一件有用的事情是,Flask 已经准备好进行单元测试。任何现有应用程序都有可能至少可以应用一些单元测试。特别是,任何 API 区域,例如无法验证的区域,都可以通过利用 Flask 中已有的代表 HTTP 请求的方法来进行极其容易的测试。以下是一个简单的示例:

import unittest
from flask import request
from main import app

class AppTest(unittest.TestCase):
  def setUp(self):
    self.app = app.test_client()

  def test_homepage_works(self):
    response = self.app.get("/")
    self.assertEqual(response.status_code, 200)

if __name__ == "__main__":
  unittest.main()

这段代码应该看起来非常熟悉。它只是重新编写了前面的示例,以验证主页是否正常工作。Flask 公开的test_client方法允许通过代表 HTTP 调用的方法简单访问应用程序,就像test方法的第一行所示。test方法本身并不检查页面的内容,而只是检查页面是否成功加载。这可能听起来微不足道,但知道主页是否正常工作是很有用的。结果呢?你可以在这里看到:

Flask 和单元测试

提示

需要注意的一件事是,我们不需要测试 Flask 本身,必须避免测试它,以免为自己创造太多工作。

测试一个页面

关于运行先前的测试的一件事需要注意的是,它们非常简单。实际上没有浏览器会以这种方式行事。浏览器会执行诸如存储用于登录的 cookie、请求 JavaScript、图像和 CSS 文件等静态文件,以及请求特定格式的数据等操作。不知何故,我们需要模拟这种功能,并测试结果是否正确。

提示

这是单元测试开始变成功能测试的部分。虽然这本身并没有什么错,但值得注意的是,较小的测试更好。

幸运的是,Flask 通过使用先前的app.get方法来为您完成所有这些工作,但是您可以使用一些技巧来使事情变得更容易。例如,向TestCase类添加登录和退出功能将使事情变得简单得多:

    LOGIN_URL = "/login/"
    LOGOUT_URL = "/logout/"

    def login (self, email, password):
        return self.app.post(self.LOGIN_URL, data={
            "email": email,
            "password": password
        }, follow_redirects=True)

前面的代码是未来测试用例的框架。每当我们有一个需要登录和退出的测试用例时,只需将此Mixin添加到继承列表中,它就会自动可用:

class ExampleFlaskTest(unittest.TestCase, FlaskLoginMixin):
  def setUp(self):
    self.app = app.test_client()

  def test_login(self):
    response = self.login("admin", "password")
    self.assertEqual(response.status_code, 200)
    self.assertTrue("Success" in response.data)

  def test_failed_login(self):
    response = self.login("admin", "PASSWORD")
        self.assertEqual(response.status_code, 200)
        self.assertTrue("Invalid" in response.data)

  def test_logout(self):
    response = self.logout()
    self.assertEqual(response.status_code, 200)
    self.assertTrue("logged out" in response.data)

我们刚刚解释的测试用例使用了FlaskLoginMixin,这是一组方法,可以帮助检查登录和退出是否正常工作。这是通过检查响应页面是否发送了正确的消息,并且页面内容中是否有正确的警告来实现的。我们的测试还可以进一步扩展,以检查用户是否可以访问他们不应该访问的页面。Flask 会为您处理会话和 cookie,所以只需使用以下代码片段即可:

class ExampleFlaskTest(unittest.TestCase, FlaskLoginMixin):
  def setUp(self):
    self.app = app.test_client()

  def test_admin_can_get_to_admin_page(self):
    self.login("admin", "password")
    response = self.app.get("/admin/")
    self.assertEqual(response.status_code, 200)
    self.assertTrue("Hello" in response.data)

  def test_non_logged_in_user_can_get_to_admin_page(self):
    response = self.app.get("/admin/")
    self.assertEqual(response.status_code, 302)
    self.assertTrue("redirected" in response.data)

  def test_normal_user_cannot_get_to_admin_page(self):
    self.login("user", "password")
    response = self.app.get("/admin/")
    self.assertEqual(response.status_code, 302)
    self.assertTrue("redirected" in response.data)

  def test_logging_out_prevents_access_to_admin_page(self):
    self.login("admin", "password")
    self.logout()
    response = self.app.get("/admin/")
    self.assertEqual(response.status_code, 302)
    self.assertTrue("redirected" in response.data)

前面的代码片段显示了如何测试某些页面是否受到正确保护。这是一个非常有用的测试。它还验证了,当管理员注销时,他们将无法再访问他们在登录时可以访问的页面。方法名称是自解释的,因此如果这些测试失败,很明显可以知道正在测试什么。

测试 API

测试 API 甚至更容易,因为它是程序干预。使用第七章中设置的先前评论 API,AJAX 和 RESTful API,我们可以很容易地插入和检索一些评论,并验证它是否正常工作。为了测试这一点,我们需要import json 库来处理我们的基于JSON的 API:

class ExampleFlaskAPITest(unittest.TestCase, FlaskLoginMixin):
  def setUp(self):
    self.app = app.test_client()
    self.comment_data = {
      "name": "admin",
      "email": "admin@example.com",
      "url": "http://localhost",
      "ip_address": "127.0.0.1",
      "body": "test comment!",
      "entry_id": 1
    }

  def test_adding_comment(self):
    self.login("admin", "password")
      data=json.dumps(self.comment_data), content_type="application/json")
    self.assertEqual(response.status_code, 200)
    self.assertTrue("body" in response.data)
    self.assertEqual(json.loads(response.data)['body'], self.comment_data["body"])

  def test_getting_comment(self):
            result = self.app.post("/api/comment",
            data=json.dumps(self.comment_data), content_type="application/json")
        response = self.app.get("/api/comment")
        self.assertEqual(response.status_code, 200)
        self.assertTrue(json.loads(result.data) in json.loads(response.data)['objects'])

前面的代码示例显示了创建一个评论字典对象。这用于验证输入的值与输出的值是否相同。因此,这些方法测试将评论数据发布到/api/comment端点,验证服务器返回的数据是否正确。test_getting_comment方法再次检查是否将评论发布到服务器,但更关心所请求的结果,通过验证发送的数据是否与输出的数据相同。

测试友好的配置

在团队中编写测试或在生产环境中编写测试时遇到的第一个障碍之一是,我们如何确保测试在不干扰生产甚至开发数据库的情况下运行。您肯定不希望尝试修复错误或试验新功能,然后发现它所依赖的数据已经发生了变化。有时,只需要在本地数据库的副本上运行一个快速测试,而不受任何其他人的干扰,Flask 应用程序知道如何使用它。

Flask 内置的一个功能是根据环境变量加载配置文件。

app.config.from_envvar('FLASK_APP_BLOG_CONFIG_FILE')

前面的方法调用通知您的 Flask 应用程序应该加载在环境变量FLASK_APP_BLOG_CONFIG_FILE中指定的文件中的配置。这必须是要加载的文件的绝对路径。因此,当您运行测试时,应该在这里引用一个特定于运行测试的文件。

由于我们已经为我们的环境设置了一个配置文件,并且正在创建一个测试配置文件,一个有用的技巧是利用现有的配置并覆盖重要的部分。首先要做的是创建一个带有 init.py 文件的 config 目录。然后可以将我们的 testing.py 配置文件添加到该目录中,并覆盖 config.py 配置文件的一些方面。例如,你的新测试配置文件可能如下所示:

TESTING=True
DATABASE="sqlite://

上面的代码添加了 TESTING 属性,可以用来确定你的应用程序当前是否正在进行测试,并将 DATABASE 值更改为更适合测试的数据库,一个内存中的 SQLite 数据库,不必在测试结束后清除。

然后这些值可以像 Flask 中的任何其他配置一样使用,并且在运行测试时,可以指定环境变量指向该文件。如果我们想要自动更新测试的环境变量,我们可以在test文件夹中的test.py文件中更新 Python 的内置 OS 环境变量对象:

import os
os.environ['FLASK_APP_BLOG_CONFIG_FILE'] = os.path.join(os.getcwd(), "config", "testing.py")

模拟对象

模拟是测试人员工具箱中非常有用的一部分。模拟允许自定义对象被一个对象覆盖,该对象可以用来验证方法对其参数是否执行正确的操作。有时,这可能需要重新构想和重构你的应用程序,以便以可测试的方式工作,但是概念很简单。我们创建一个模拟对象,将其运行通过方法,然后对该对象运行测试。它特别适用于数据库和 ORM 模型,比如SQLAlchemy

有很多模拟框架可用,但是在本书中,我们将使用Mockito

pip install mockito

这是最简单的之一:

>>> from mockito import *
>>> mock_object = mock()
>>> mock_object.example()
>>> verify(mock_object).example()
True

上面的代码从Mockito库导入函数,创建一个可以用于模拟的mock对象,对其运行一个方法,并验证该方法已经运行。显然,如果你希望被测试的方法在没有错误的情况下正常运行,你需要在调用模拟对象上的方法时返回一个有效的值。

>>> duck = mock()
>>> when(duck).quack().thenReturn("quack")
>>> duck.quack()
"quack"

在上面的例子中,我们创建了一个模拟的duck对象,赋予它quack的能力,然后证明它可以quack

注意

在 Python 这样的动态类型语言中,当你拥有的对象可能不是你期望的对象时,使用鸭子类型是一种常见的做法。正如这句话所说“如果它走起来像鸭子,叫起来像鸭子,那它一定是鸭子”。这在创建模拟对象时非常有用,因为很容易使用一个假的模拟对象而不让你的方法注意到切换。

当 Flask 使用其装饰器在你的方法运行之前运行方法,并且你需要覆盖它,例如,替换数据库初始化程序时,就会出现困难。这里可以使用的技术是让装饰器运行一个对模块全局可用的方法,比如创建一个连接到数据库的方法。

假设你的app.py看起来像下面这样:

from flask import Flask, g

app = Flask("example")

def get_db():
  return {}

@app.before_request
def setup_db():
  g.db = get_db()

@app.route("/")
def homepage():
  return g.db.get("foo")

上面的代码设置了一个非常简单的应用程序,创建了一个 Python 字典对象作为一个虚假的数据库。现在要覆盖为我们自己的数据库如下:

from mockito import *
import unittest
import app

class FlaskExampleTest(unittest.TestCase):
  def setUp(self):
    self.app = app.app.test_client()
    self.db = mock()
    def get_fake_db():
      return self.db
    app.get_db =  get_fake_db

  def test_before_request_override(self):
    when(self.db).get("foo").thenReturn("123")
    response = self.app.get("/")
    self.assertEqual(response.status_code, 200)
    self.assertEqual(response.data, "123")

if __name__ == "__main__":
  unittest.main()

上面的代码使用 Mockito 库创建一个虚假的数据库对象。它还创建了一个方法,覆盖了 app 模块中创建数据库连接的方法,这里是一个简单的字典对象。你会注意到,当使用 Mockito 时,你也可以指定方法的参数。现在当测试运行时,它会向数据库插入一个值,以便页面返回;然后进行测试。

记录和错误报告

记录和错误报告对于一个生产就绪的网络应用来说是内在的。即使你的应用程序崩溃,记录仍然会记录所有问题,而错误报告可以直接通知我们特定的问题,即使网站仍在运行。

在任何人报告错误之前发现错误可能是非常令人满意的。这也使得您能够在用户开始向您抱怨之前推出修复。然而,为了做到这一点,您需要知道这些错误是什么,它们是在什么时候发生的,以及是什么导致了它们。

幸运的是,现在您应该非常熟悉,Python 和 Flask 已经掌握了这一点。

日志记录

Flask 自带一个内置的记录器——Python 内置记录器的一个已定义实例。你现在应该对它非常熟悉了。默认情况下,每次访问页面时都会显示记录器消息。

日志记录

前面的屏幕截图显然显示了终端的输出。我们可以在这里看到有人在特定日期从localhost127.0.0.1)访问了根页面,使用了GET请求,以及其他一些目录。服务器响应了一个“200成功”消息和两个“404未找到错误”消息。虽然在开发时拥有这个终端输出是有用的,但如果您的应用程序在生产环境中运行时崩溃,这并不一定很有用。我们需要从写入的文件中查看发生了什么。

记录到文件

有各种各样依赖于操作系统的将这样的日志写入文件的方法。然而,如前所述,Python 已经内置了这个功能,Flask 只是遵循 Python 的计划,这是非常简单的。将以下内容添加到app.py文件中:

from logging.handlers import RotatingFileHandler
file_handler = RotatingFileHandler('blog.log')
app.logger.addHandler(file_handler)

需要注意的一点是,记录器使用不同的处理程序来完成其功能。我们在这里使用的处理程序是RotatingFileHandler。这个处理程序不仅会将文件写入磁盘(在这种情况下是blog.log),还会确保我们的文件不会变得太大并填满磁盘,潜在地导致网站崩溃。

自定义日志消息

在尝试调试难以追踪的问题时,一个非常有用的事情是我们可以向我们的博客应用程序添加更多的日志记录。这可以通过 Flask 内置的日志对象来实现,如下所示:

@app.route("/")
def homepage():
  app.logger.info("Homepage has been accessed.")

前面的示例演示了如何创建自定义日志消息。然而,这样的消息实际上会相当大幅地减慢我们的应用程序,因为它会在每次访问主页时将该消息写入文件或控制台。幸运的是,Flask 也理解日志级别的概念,我们可以指定在不同环境中应记录哪些消息。例如,在生产环境中记录信息消息是没有用的,而用户登录失败则值得记录。

app.logger.warning("'{user}' failed to login successfully.".format(user=user))

前面的命令只是记录了一个警告,即用户未能成功登录,使用了 Python 的字符串格式化方法。只要 Python 中的错误日志记录足够低,这条消息就会被显示。

级别

日志级别的原则是:日志的重要性越高,级别越高,根据您的日志级别,记录的可能性就越小。例如,要能够记录警告(以及以上级别,如ERROR),我们需要将日志级别调整为WARNING。我们可以在配置文件中进行这样的调整。编辑config文件夹中的config.py文件,添加以下内容:

import logging
LOG_LEVEL=logging.WARNING
Now in your app.py add the line:
app.logger.setLevel(config['LOG_LEVEL'])

前面的代码片段只是使用内置的 Python 记录器告诉 Flask 如何处理日志。当然,您可以根据您的环境设置不同的日志级别。例如,在config文件夹中的testing.py文件中,我们应该使用以下内容:

LOG_LEVEL=logging.ERROR

至于测试的目的,我们不需要警告。同样,我们应该为任何生产配置文件做同样的处理;对于任何开发配置文件,使用样式。

错误报告

在机器上记录错误是很好的,但如果错误直接发送到您的收件箱,您可以立即收到通知,那就更好了。幸运的是,像所有这些东西一样,Python 有一种内置的方法可以做到这一点,Flask 可以利用它。这只是另一个处理程序,比如RotatingFileHandler

from logging.handlers import SMTPHandler
email_handler = SMTPHandler("127.0.0.1", "admin@localhost", app.config['ADMIN_EMAILS'], "{appname} error".format(appname=app.name))
app.logger.addHandler(email_handler)

前面的代码创建了一个SMTPHandler,其中配置了邮件服务器的位置和发送地址,从配置文件中获取要发送邮件的电子邮件地址列表,并为邮件设置了主题,以便我们可以确定错误的来源。

阅读更多

单元测试是一个广阔而复杂的领域。Flask 在其他编写有效测试的技术方面有一些很好的文档:flask.pocoo.org/docs/0.10/testing/

当然,Python 有自己的单元测试文档:docs.python.org/2/library/unittest.html

Flask 使用 Python 的日志模块进行日志记录。这又遵循了 C 库结构的日志记录级别。更多细节可以在这里找到:docs.python.org/2/library/logging.html

总结

在本章中,我们已经学会了如何为我们的博客应用创建一些测试,以验证它是否正确加载页面,以及登录是否正确进行。我们还设置了将日志记录到文件,并在发生错误时发送电子邮件。

在下一章中,我们将学习如何通过扩展来改进我们的博客,这些扩展可以在我们的部分付出最小的努力的情况下添加额外的功能。

第九章:优秀的扩展

在本章中,我们将学习如何通过一些流行的第三方扩展增强我们的 Flask 安装。扩展允许我们以非常少的工作量添加额外的安全性或功能,并可以很好地完善您的博客应用程序。我们将研究跨站点请求伪造CSRF)保护您的表单,Atom 订阅源以便其他人可以找到您的博客更新,为您使用的代码添加语法高亮,减少渲染模板时的负载的缓存,以及异步任务,以便您的应用程序在进行密集操作时不会变得无响应。

在本章中,我们将学习以下内容:

  • 使用 Flask-SeaSurf 进行 CSRF 保护

  • 使用 werkzeug.contrib 生成 Atom 订阅源

  • 使用 Pygments 进行语法高亮

  • 使用 Flask-Cache 和 Redis 进行缓存

  • 使用 Celery 进行异步任务执行

SeaSurf 和表单的 CSRF 保护

CSRF 保护通过证明 POST 提交来自您的站点,而不是来自另一个站点上精心制作的恶意利用您博客上的 POST 端点的网络表单,为您的站点增加了安全性。这些恶意请求甚至可以绕过身份验证,如果您的浏览器仍然认为您已登录。

我们避免这种情况的方法是为站点上的任何表单添加一个特殊的隐藏字段,其中包含由服务器生成的值。当提交表单时,可以检查特殊字段中的值是否与服务器生成的值匹配,如果匹配,我们可以继续提交表单。如果值不匹配或不存在,则表单来自无效来源。

注意

CSRF 保护实际上证明了包含 CSRF 字段的模板用于生成表单。这可以减轻来自其他站点的最基本的 CSRF 攻击,但不能确定表单提交只来自我们的服务器。例如,脚本仍然可以屏幕抓取页面的内容。

现在,自己构建 CSRF 保护并不难,而且通常用于生成我们的表单的 WTForms 已经内置了这个功能。但是,让我们来看看 SeaSurf:

pip install flask-seasurf

安装 SeaSurf 并使用 WTForms 后,将其集成到我们的应用程序中现在变得非常容易。打开您的app.py文件并添加以下内容:

from flask.ext.seasurf import SeaSurf
csrf = SeaSurf(app)

这只是为您的应用程序启用了 SeaSurf。现在,要在您的表单中启用 CSRF,请打开forms.py并创建以下 Mixin:

from flask.ext.wtf import HiddenField
import g

from app import app

class CSRFMixin(object):
  @staticmethod
  @app.before_request
  def add_csrf():
    self._csrf_token = HiddenField(default=g._csrf_token)

上述代码创建了一个简单的 CSRF Mixin,可以选择在所有表单中使用。装饰器确保在请求之前运行该方法,以便向您的表单添加具有随机生成的 CSRF 令牌值的HiddenField字段。要在您的表单中使用此 Mixin,在这种情况下是您的登录表单,更新类如下:

class LoginForm(Form, CSRFMixin):

就是这样。我们需要对所有要保护的表单进行这些更改,通常是所有表单。

创建 Atom 订阅源

任何博客都非常有用的一个功能是让读者能够及时了解最新内容。这通常是通过 RSS 阅读器客户端来实现的,它会轮询您的 RSS 订阅源。虽然 RSS 被广泛使用,但更好、更成熟的订阅格式是可用的,称为 Atom。

这两个文件都可以由客户端请求,并且是标准和简单的 XML 数据结构。幸运的是,Flask 内置了 Atom 订阅源生成器;或者更具体地说,Flask 使用的 WSGI 接口中内置了一个贡献的模块,称为 Werkzeug。

让它运行起来很简单,我们只需要从数据库中获取最近发布的帖子。最好为此创建一个新的 Blueprint;但是,您也可以在main.py中完成。我们只需要利用一些额外的模块:

from urlparse import urljoin
from flask import request, url_for
from werkzeug.contrib.atom import AtomFeed
from models import Entry

并创建一个新的路由:

@app.route('/latest.atom')
def recent_feed():
    feed = AtomFeed(
        'Latest Blog Posts',
        feed_url=request.url,
         url=request.url_root,
         author=request.url_root
     )
    entries = EntrY.query.filter(Entry.status == Entry.STATUS_PUBLIC).order_by(EntrY.created_timestamp.desc()).limit(15).all()
    for entry in entries:
        feed.add(
            entry.title,
            entry.body,
            content_type='html',
            url=urljoin(request.url_root, url_for("entries.detail", slug=entry.slug) ),
            updated=entry.modified_timestamp,
            published=entry.created_timestamp
        )
    return feed.get_response()

现在运行您的 Flask 应用程序,Atom 订阅源将可以从http://127.0.0.1:5000/latest.atom访问

使用 Pygments 进行语法高亮

通常,作为编码人员,我们希望能够在网页上显示代码,虽然不使用语法高亮显示阅读代码是一种技能,但一些颜色可以使阅读体验更加愉快。

与 Python 一样,已经有一个模块可以为您完成这项工作,当然,您可以通过以下命令轻松安装它:

pip install Pygments

注意

Pygments 仅适用于已知的代码部分。因此,如果您想显示代码片段,我们可以这样做。但是,如果您想突出显示代码的内联部分,我们要么遵循 Markdown 的下一节,要么需要使用一些在线 Javascript,例如highlight.js

要创建代码片段,我们需要首先创建一个新的蓝图。让我们创建一个名为snippets的目录,然后创建一个__init__.py文件,接着创建一个名为blueprint.py的文件,其中包含以下代码:

from flask import Blueprint, request, render_template, redirect, url_for
from helpers import object_list
from app import db, app

from models import Snippet
from forms import SnippetForm

from pygments import highlight
from pygments.lexers import PythonLexer
from pygments.formatters import HtmlFormatter

snippets = Blueprint('snippets', __name__, template_folder='templates')

@app.template_filter('pygments')
def pygments_filter(code):
    return highlight(code, PythonLexer(), HtmlFormatter())

@snippets.route('/')
def index():
    snippets = Snippet.query.order_by(Snippet.created_timestamp.desc())
    return object_list('entries/index.html', snippets)

@snippets.route('/<slug>/')
def detail(slug):
    snippet = Snippet.query.filter(Snippet .slug == slug).first_or_404()
    return render_template('snippets/detail.html', entry=snippet)

@snippets.route('/create/', methods=['GET', 'POST'])
def create():
    if request.method == 'POST':
        form = SnippetForm(request.form)
        if form.validate():
            snippet = form.save_entry(Snippet())
            db.session.add(snippet)
            db.session.commit()
            return redirect(url_for('snippets.detail', slug=snippet.slug))
    else:
        form = SnippetForm()

    return render_template('snippets/create.html', form=form)

@snippets.route('/<slug>/edit/', methods=['GET', 'POST'])
def edit(slug):
    snippet = Snippet.query.filter(Snippet.slug == slug).first_or_404()
    if request.method == 'POST':
        form = SnippetForm(request.form, obj=snippet)
        if form.validate():
            snippet = form.save_entry(snippet)
            db.session.add(snippet)
            db.session.commit()
            return redirect(url_for('snippets.detail', slug=entry.slug))
    else:
        form = EntryForm(obj=entry)

    return render_template('entries/edit.html', entry=snippet, form=form)

在前面的示例中,我们设置了 Pygments 模板过滤器,允许将一串代码转换为 HTML 代码。我们还巧妙地利用了完全适合我们需求的条目模板。我们使用我们自己的detail.html,因为那里是 Pygments 发生魔法的地方。我们需要在 snippets 目录中创建一个 templates 目录,然后在 templates 中创建一个名为 snippets 的目录,这是我们存储 detail.html 的地方。因此,现在我们的目录结构看起来像 app/snippets/templates/snipperts/detail.html 现在让我们设置该文件,如下所示:

{% extends "base.html" %}

{% block title %}{{ entry.title }} - Snippets{% endblock %}

{% block content_title %}Snippet{% endblock %}

{% block content %}
    {{ entry.body | pygments | safe}}
{% endblock %}

这基本上与我们在书中早期使用的detail.html相同,只是现在我们通过我们在应用程序中创建的 Pygments 过滤器传递它。由于我们早期使用的模板过滤器生成原始 HTML,我们还需要将其输出标记为安全。

我们还需要更新博客的 CSS 文件,因为 Pygments 使用 CSS 选择器来突出显示单词,而不是在页面上浪费地编写输出。它还允许我们根据需要修改颜色。要找出我们的 CSS 应该是什么样子,打开 Python shell 并运行以下命令:

>>> from pygments.formatters import HtmlFormatter
>>> print HtmlFormatter().get_style_defs('.highlight')

前面的命令现在将打印出 Pygments 建议的示例 CSS,我们可以将其复制粘贴到static目录中的.css文件中。

这段代码的其余部分与之前的 Entry 对象没有太大不同。它只是允许您创建、更新和查看代码片段。您会注意到我们在这里使用了一个SnippetForm,我们稍后会定义。

还要创建一个models.py,其中包含以下内容:

class Snippet(db.Model):
    STATUS_PUBLIC = 0
    STATUS_DRAFT = 1

    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(100))
    slug = db.Column(db.String(100), unique=True)
    body = db.Column(db.Text)
    status = db.Column(db.SmallInteger, default=STATUS_PUBLIC)
    created_timestamp = db.Column(db.DateTime, default=datetime.datetime.now)
    modified_timestamp = db.Column(
        db.DateTime,
        default=datetime.datetime.now,
        onupdate=datetime.datetime.now)

    def __init__(self, *args, **kwargs):
        super(Snippet, self).__init__(*args, **kwargs)  # Call parent constructor.
        self.generate_slug()

    def generate_slug(self):
        self.slug = ''
        if self.title:
            self.slug = slugify(self.title)

    def __repr__(self):
        return '<Snippet: %s>' % self.title

现在我们必须重新运行create_db.py脚本以创建新表。

我们还需要创建一个新的表单,以便可以创建代码片段。在forms.py中添加以下代码:

from models import Snippet

class SnippetForm(wtforms.Form):
    title = wtforms.StringField('Title', validators=[DataRequired()])
    body = wtforms.TextAreaField('Body', validators=[DataRequired()])
    status = wtforms.SelectField(
        'Entry status',
        choices=(
            (Snippet.STATUS_PUBLIC, 'Public'),
            (Snippet.STATUS_DRAFT, 'Draft')),
        coerce=int)

    def save_entry(self, entry):
        self.populate_obj(entry)
        entry.generate_slug()
        return entry

最后,我们需要确保通过编辑main.py文件使用此蓝图并添加以下内容:

from snippets.blueprint import snippets
app.register_blueprint(snippets, url_prefix='/snippets')

一旦我们在这里添加了一些代码,使用Snippet模型,生成的代码将如下图所示呈现:

使用 Pygments 进行语法高亮

使用 Markdown 进行简单编辑

Markdown 是一种现在广泛使用的网络标记语言。它允许您以特殊格式编写纯文本,可以通过程序转换为 HTML。在从移动设备编辑文本时,这可能特别有用,例如,突出显示文本使其加粗比在 PC 上更加困难。您可以在daringfireball.net/projects/markdown/上查看如何使用 Markdown 语法。

注意

Markdown 的一个有趣之处在于,您仍然可以同时使用 HTML 和 Markdown。

当然,在 Python 中快速简单地运行这个是很容易的。我们按照以下步骤安装它:

sudo pip install Flask-Markdown

然后我们可以将其应用到我们的蓝图或应用程序中,如下所示:

from flaskext.markdown import Markdown
Markdown(app)

这将在我们的模板中创建一个名为markdown的新过滤器,并且在渲染模板时可以使用它:

{{ entry.body | markdown }}

现在,您只需要在 Markdown 中编写并保存您的博客条目内容。

如前所述,您可能还希望美化代码块;Markdown 内置了这个功能,因此我们需要扩展先前的示例如下:

from flaskext.markdown import Markdown
Markdown(app, extensions=['codehilite'])

现在可以使用 Pygments 来渲染 Markdown 代码块。但是,由于 Pygments 使用 CSS 为代码添加颜色,我们需要从 Pygments 生成我们的 CSS。但是,这次使用的父块具有一个名为codehilite的类(之前称为 highlight),因此我们需要进行调整。在 Python shell 中,键入以下内容:

>>> from pygments.formatters import HtmlFormatter
>>> print HtmlFormatter().get_style_defs('.codehilite')

现在将输出添加到static目录中的.css文件中。因此,使用包含的 CSS,您的 Markdown 条目现在可能如下所示:

使用 Markdown 进行简单编辑

还有许多其他内置的 Markdown 扩展可以使用;您可以查看它们,只需在初始化 Markdown 对象时使用它们的名称作为字符串。

使用 Flask-Cache 和 Redis 进行缓存

有时(我知道很难想象),我们会为我们的网站付出很多努力,添加功能,这通常意味着我们最终不得不为一个简单的静态博客条目执行大量数据库调用或复杂的模板渲染。现在数据库调用不应该很慢,大量模板渲染也不应该引人注目,但是,如果将其扩展到大量用户(希望您是在预期的),这可能会成为一个问题。

因此,如果网站大部分是静态的,为什么不将响应存储在单个高速内存数据存储中呢?无需进行昂贵的数据库调用或复杂的模板渲染;对于相同的输入或路径,获取相同的内容,而且更快。

正如现在已经成为一种口头禅,我们已经可以在 Python 中做到这一点,而且就像以下这样简单:

sudo pip install Flask-Cache

要使其运行,请将其添加到您的应用程序或蓝图中:

from flask.ext.cache import Cache

app = Flask(__name__)
cache = Cache(app, config={'CACHE_TYPE': 'redis'})

当然,您还需要安装 Redis,这在 Debian 和 Ubuntu 系统上非常简单:

sudo apt-get install redis-server

不幸的是,Redis 尚未在 Red Hat 和 CentOS 的打包系统中提供。但是,您可以从他们的网站上下载并编译 Redis

redis.io/download

默认情况下,Redis 是不安全的;只要我们不将其暴露给我们的网络,这应该没问题,而且对于 Flask-Cache,我们不需要进行任何其他配置。但是,如果您希望对其进行锁定,请查看 Redis 的 Flask-Cache 配置。

现在我们可以在视图中使用缓存(以及任何方法)。这就像在路由上使用装饰器一样简单。因此,打开一个视图并添加以下内容:

@app.route("/")
@cache.cached(timeout=600) # 10 minutes
def homepage():
…

您将在这里看到,缓存的装饰器在路由内部,并且我们有一个 10 分钟的超时值,以秒为单位。这意味着,无论您的主页的渲染有多繁重,或者它可能进行多少数据库调用,响应都将在该时间段内直接从内存中获取。

显然,缓存有其时间和地点,并且可能是一门艺术。如果每个用户都有一个自定义的主页,那么缓存将是无用的。但是,我们可以缓存模板的部分内容,因此诸如<head>中的所有<link>元素这样的常见区域很少会更改,但是url_for('static', ...)过滤器不必每次重新生成。例如,看下面的代码:

{% cache 1800 %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/blog.min.css') }}">
{% endcache %}

前面的代码部分表示链接元素应该缓存 30 分钟,以秒为单位。您可能还希望对脚本的引用进行相同的操作。我们也可以用它来加载最新博客文章的列表,例如。

通过创建安全、稳定的站点版本来创建静态内容

对于低动态内容的高流量网站的一种技术是创建一个简单的静态副本。这对博客非常有效,因为内容通常是静态的,并且每天最多更新几次。但是,您仍然需要为实际上没有变化的内容执行大量数据库调用和模板渲染。

当然,有一个 Flask 扩展程序可以解决这个问题:Frozen-Flask。Frozen-Flask 识别 Flask 应用程序中的 URL,并生成应该在那里的内容。

因此,对于生成的页面,它会生成 HTML,对于 JavaScript 和图像等静态内容,它会将它们提取到一个基本目录中,这是您网站的静态副本,并且可以由您的 Web 服务器作为静态内容提供。

这样做的另一个好处是,网站的活动版本更加安全,因为无法使用 Flask 应用程序或 Web 服务器更改它。

当然,这也有一些缺点。如果您的网站上有动态内容,例如评论,就不再可能以常规方式存储和呈现它们。此外,如果您的网站上有多个作者,您需要一种共享数据库内容的方式,以便它们不会生成网站的单独副本。解决方案将在本节末尾提出。但首先,让我们按照以下方式安装 Frozen-Flask:

pip install Frozen-Flask

接下来,我们需要创建一个名为freeze.py的文件。这是一个简单的脚本,可以自动设置 Frozen-Flask:

from flask_frozen import Freezer
from main import app

freezer = Freezer(app)

if __name__ == '__main__':
    freezer.freeze()

以上代码使用了 Frozen-Flask 的所有默认设置,并在以下方式运行:

python freeze.py

将创建(或覆盖)包含博客静态副本的build目录。

Frozen-Flask 非常智能,将自动查找所有链接,只要它们是从根主页按层次引用的;对于博客文章,这样做效果很好。但是,如果条目从主页中删除,并且它们通过另一个 URL 上的存档页面访问,您可能需要向 Frozen-Flask 提供指针以找到它们的位置。例如,将以下内容添加到freeze.py 文件中:

import models

@freezer.register_generator
def archive():
    for post in models.Entry.all():
        yield {'detail': product.id}

Frozen-Flask 很聪明,并使用 Flask 提供的url_for方法来创建静态文件。这意味着url_for 方法可用的任何内容都可以被 Frozen-Flask 使用,如果无法通过正常路由找到。

在静态站点上发表评论

因此,您可能已经猜到,通过创建静态站点,您会失去一些博客基本原理——这是鼓励交流和辩论的一个领域。幸运的是,有一个简单的解决方案。

博客评论托管服务,如 Disqus 和 Discourse,工作方式类似于论坛,唯一的区别是每个博客帖子都创建了一个主题。您可以免费使用它们的服务来进行讨论,或者使用 Discourse 在自己的平台上免费运行他们的服务器,因为它是完全开源的。

同步多个编辑器

Frozen-Flask 的另一个问题是,对于分布在网络上的多个作者,您如何管理存储帖子的数据库?每个人都需要相同的最新数据库副本;否则,当您生成站点的静态副本时,它将无法创建所有内容。

如果您都在同一个环境中工作,一个解决方案是在网络内的服务器上运行博客的工作副本,并且在发布时,它将使用集中式数据库来创建博客的已发布版本。

然而,如果您都在不同的地方工作,集中式数据库不是理想的解决方案或无法保护,另一个解决方案是使用基于文件系统的数据库引擎,如 SQLite。然后,当对数据库进行更新时,可以通过电子邮件、Dropbox、Skype 等方式将该文件传播给其他人。然后,他们可以从本地运行 Frozen-Flask 创建可发布内容的最新副本。

使用 Celery 进行异步任务

Celery 是一个允许您在 Python 中运行异步任务的库。这在 Python 中特别有帮助,因为 Python 是单线程运行的,您可能会发现自己有一个长时间运行的任务,您希望要么启动并丢弃;要么您可能希望向您网站的用户提供有关所述任务进度的反馈。

一个这样的例子是电子邮件。用户可能会请求发送电子邮件,例如重置密码请求,您不希望他们在生成和发送电子邮件时等待页面加载。我们可以将其设置为启动和丢弃操作,并让用户知道该请求正在处理中。

Celery 能够摆脱 Python 的单线程环境的方式是,我们必须单独运行一个 Celery 代理实例;这会创建 Celery 所谓的执行实际工作的工作进程。然后,您的 Flask 应用程序和工作进程通过消息代理进行通信。

显然,我们需要安装 Celery,我相信您现在可以猜到您需要的命令是以下命令:

pip install celery

现在我们需要一个消息代理服务器。有很多选择;查看 Celery 的网站以获取支持的选择,但是,由于我们已经在 Flask-Cache 设置中设置了 Redis,让我们使用它。

现在我们需要告诉 Celery 如何使用 Redis 服务器。打开 Flask 应用程序配置文件并添加以下行:

CELERY_BROKER_URL = 'redis://localhost:6379/0'

此配置告诉您的 Celery 实例在哪里找到它需要与 Celery 代理通信的消息代理。现在我们需要在我们的应用程序中初始化 Celery 实例。在main.py 文件中添加以下内容:

from celery import Celery

celery = Celery(app.name, broker=app.config['CELERY_BROKER_URL'])

这将使用来自 Flask 配置文件的配置创建一个Celery实例,因此我们还可以从 Celery 代理访问celery对象并共享相同的设置。

现在我们需要为 Celery 工作进程做一些事情。在这一点上,我们将利用 Flask-Mail 库:

pip install Flask-Mail

我们还需要一些配置才能运行。将以下参数添加到您的 Flask 配置文件中:

MAIL_SERVER = "example.com"
MAIL_PORT = 25
MAIL_USERNAME = "email_username"
MAIL_PASSWORD = "email_password"

此配置告诉 Flask-Mail 您的电子邮件服务器在哪里。很可能默认设置对您来说已经足够好,或者您可能需要更多选项。查看 Flask-Mail 配置以获取更多选项。

现在让我们创建一个名为tasks.py的新文件,并创建一些要运行的任务,如下所示:

from flask_mail import Mail, Message
from main import app, celery

mail = Mail(app)

@celery.task
def send_password_verification(email, verification_code):
  msg = Message(
    "Your password reset verification code is: {0}".format(verification_code),
                  sender="from@example.com",
                  recipients=[email]
  )
  mail.send(msg)

这是一个非常简单的消息生成;我们只是生成一封电子邮件,内容是新密码是什么,电子邮件来自哪里(我们的邮件服务器),电子邮件发送给谁,以及假设是用户账户的电子邮件地址,然后发送;然后通过已设置的邮件实例发送消息。

现在我们需要让我们的 Flask 应用程序利用新的异步能力。让我们创建一个视图,监听被 POST 到它的电子邮件地址。这可以在与帐户或主应用程序有关的任何蓝图中进行。

import tasks

@app.route("/reset-password", methods=['POST'])
def reset_password():
  user_email = request.form.get('email')
  user = db.User.query.filter(email=user_email).first()
  if user:
    new_password = db.User.make_password("imawally")
    user.update({"password_hash": new_password})
    user.commit()
    tasks.send_password_verification.delay(user.email, new_password)
    flash("Verification e-mail sent")
  else:
    flash("User not found.")
  redirect(url_for('homepage'))

前面的视图接受来自浏览器的 POST 消息,其中包含声称忘记密码的用户的电子邮件。我们首先通过他们的电子邮件地址查找用户,以查看用户是否确实存在于我们的数据库中。显然,在不存在的帐户上重置密码是没有意义的。当然,如果他们不存在,用户将收到相应的消息。

但是,如果用户帐户确实存在,首先要做的是为他们生成一个新密码。我们在这里使用了一个硬编码的示例密码。然后更新数据库中的密码,以便用户在收到电子邮件时可以使用它进行登录。一切都搞定后,我们就可以在之前创建的任务上运行.delay,并使用我们想要使用的参数。这会指示 Celery 在准备好时运行底层方法。

注意

请注意,这不是进行密码重置的最佳解决方案。这只是为了说明您可能希望以简洁的方式执行此操作。密码重置是一个令人惊讶地复杂的领域,有很多事情可以做来提高此功能的安全性和隐私性,例如检查 CSRF 值,限制调用方法的次数,并使用随机生成的 URL 供用户重置密码,而不是通过电子邮件发送的硬编码解决方案。

最后,当我们运行 Flask 应用程序时,我们需要运行 Celery 代理;否则,几乎不会发生任何事情。不要忘记,这个代理是启动所有异步工作者的进程。我们可以做的最简单的事情就是从 Flask 应用程序目录中运行以下命令:

celeryd -A main worker

这很简单地启动了 Celery 代理,并告诉它查找main应用程序中的 celery 配置,以便它可以找到配置和应该运行的任务。

现在我们可以启动我们的 Flask 应用程序并发送一些电子邮件。

使用 Flask-script 创建命令行指令

使用 Flask 非常有用的一件事是创建一个命令行界面,这样当其他人使用您的软件时,他们可以轻松地使用您提供的方法,比如设置数据库、创建管理用户或更新 CSRF 密钥。

我们已经有一个类似的脚本,并且可以在这种方式中使用的脚本是第二章中的create_db.py脚本,使用 SQLAlchemy 的关系数据库。为此,再次有一个 Flask 扩展。只需运行以下命令:

pip install Flask-Script

现在,Flask-Script 的有趣之处在于,命令的工作方式与 Flask 中的路由和视图非常相似。让我们看一个例子:

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

manager = Manager(app)
@manager.command
def hello():
    print "Hello World"

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

您可以在这里看到,Flask-Script 将自己称为 Manager,但管理器也将自己挂钩到 Flask 应用程序中。这意味着您可以通过使用app引用来对 Flask 应用程序执行任何操作。

因此,如果我们将create_db.py应用程序转换为 Flask-Script 应用程序,我们应该创建一个文件来完成这项工作。让我们称之为manage.py,并从文件create_db.py中插入:

from main import db

@manager.command
def create_db():
    db.create_all()

所有这些只是设置一个装饰器,以便manage.py带有参数create_db将运行create_db.py中的方法。

现在我们可以从以下命令行运行:

python manage.py create_db

参考

总结

在本章中,我们做了各种各样的事情。您已经看到如何创建自己的 Markdown 渲染器,以便编辑更容易,并将命令移动到 Flask 中,使其更易管理。我们创建了 Atom feeds,这样我们的读者可以在发布新内容时找到它,并创建了异步任务,这样我们就不会在等待页面加载时锁定用户的浏览器。

在我们的最后一章中,我们将学习如何将我们的简单应用程序转变为一个完全部署的博客,具有所有讨论的功能,已经得到保护,并且可以使用。

第十章:部署您的应用程序

在本章中,我们将学习如何以安全和自动化的可重复方式部署我们的 Flask 应用程序。我们将看到如何配置常用的WSGIWeb 服务器网关接口)能力服务器,如 Apache、Nginx,以及 Python Web 服务器 Gunicorn。然后,我们将看到如何使用 SSL 保护部分或整个站点,最后将我们的应用程序包装在配置管理工具中,以自动化我们的部署。

在本章中,我们将学习以下主题:

  • 配置常用的 WSGI 服务器

  • 高效地提供静态文件

  • 使用 SSL 保护您的网站

  • 使用 Ansible 自动化部署

使用 WSGI 服务器运行 Flask

重要的是要注意,Flask 本身并不是一个 Web 服务器。Web 服务器是面向互联网的工具,经过多年的开发和修补,并且可以同时运行多个服务。

在互联网上仅运行 Flask 作为 Web 服务器可能会很好,这要归功于 Werkzeug WSGI 层。然而,Flask 在页面路由和渲染系统上的真正重点是开发。作为 Web 服务器运行 Flask 可能会产生意想不到的影响。理想情况下,Flask 将位于 Web 服务器后面,并在服务器识别到对您的应用程序的请求时被调用。为此,Web 服务器和 Flask 需要能够使用相同的语言进行通信。

幸运的是,Flask 构建在 Werkzeug 堆栈之上,该堆栈旨在使用 WSGI 协议。WSGI 是一个常见的协议,被诸如 Apache 的 httpd 和 Nginx 之类的 Web 服务器使用。它可以用来管理 Flask 应用程序的负载,并以 Python 可以理解的方式传达关于请求来源和请求头的重要信息。

然而,要让 Werkzeug 使用 WSGI 协议与您的 Web 服务器通信,我们必须使用一个网关。这将接收来自您的 Web 服务器和 Python 应用程序的请求,并在它们之间进行转换。大多数 Web 服务器都会使用 WSGI,尽管有些需要一个模块,有些需要一个单独的网关,如 uWSGI。

首先要做的一件事是为 WSGI 网关创建一个 WSGI 文件以进行通信。这只是一个具有已知结构的 Python 文件,以便 WSGI 网关可以访问它。我们需要在与您的博客应用程序的其余部分相同的目录中创建一个名为wsgi.py的文件,它将包含:

from app import app as application

Flask 默认是与 WSGI 兼容的,因此我们只需要以正确的方式声明对象,以便 WSGI 网关理解。现在,Web 服务器需要配置以找到此文件。

Apache 的 httpd

Apache 的 httpd 目前可能是互联网上使用最广泛的 Web 服务器。该程序的名称实际上是 httpd,并由 Apache 软件基金会维护。然而,大多数人都将其称为Apache,因此我们也将称其为Apache

要确保在基于 Debian 和 Ubuntu 的系统上安装了 Apache 和 WSGI 模块,请运行以下命令:

sudo apt-get install apache2 libapache2-mod-wsgi

但是,在基于 Red Hat 和 Fedora 的系统上运行以下命令:

sudo yum install httpd mod_wsgi

要设置 Apache 配置,我们必须创建一个指定新 VirtualHost 的配置文件。您必须找到系统上存放这些文件的目录。在基于 Debian 的系统(如 Ubuntu)中,这将在/etc/apache2/sites-available中;在基于 Red Hat/Fedora 的系统中,我们需要在/etc/apache2/conf.d目录中创建一个名为blog.conf的文件。

在该配置文件中,使用以下代码更新内容:

<VirtualHost *:80>

    WSGIScriptAlias / <path to app>/wsgi.py

    <Directory <path to app>/>
        Order deny,allow
        Allow from all
    </Directory>

</VirtualHost>

此配置指示 Apache,对于对端口80上主机的每个请求,都要尝试从wsgi.py脚本加载。目录部分告诉 Apache 如何处理对该目录的请求,并且默认情况下,最好拒绝任何访问 Web 服务器的人对源目录中的文件的访问。请注意,在这种情况下,<path to app>是存储wsgi.py文件的目录的完整绝对路径。

现在我们需要为 Apache 的 httpd 服务器启用 WSGI 模块。这样 Apache 就知道在指定 WSGI 配置时要使用它。在基于 Debian 和 Ubuntu 的系统中,我们只需运行此命令:

sudo a2enmod wsgi

然而,在 Red Hat 和 CentOS 系统上,情况会复杂一些。我们需要创建或修改文件/etc/httpd/conf.d/wsgi.conf,并包含以下行:

LoadModule wsgi_module modules/mod_wsgi.so

现在我们需要通过运行以下命令在基于 Debian 和 Ubuntu 的系统上启用我们的新站点:

sudo a2ensite blog

这指示 Apache 在/etc/apache2/sites-available/etc/apache2/sites-enabled之间创建符号链接,Apache 实际上从中获取其配置。现在我们需要重新启动 Apache。在您的特定环境或分发中,可以以许多方式执行此操作。最简单的方法可能只是运行以下命令:

sudo service apache2 restart

所以我们需要做的就是通过浏览器连接到 Web 服务器,访问http://localhost/

在 Debian 和 Ubuntu 系统的/var/log/apache2/error.log和基于 Red Hat 和 CentOS 的系统的/var/log/httpd/error_log中检查是否有任何问题。

请注意,一些 Linux 发行版默认配置必须禁用。这可能可以通过在 Debian 和 Ubuntu 系统中输入以下命令来禁用:

sudo a2dissite default

然而,在基于 Red Hat 和 CentOS 的系统中,我们需要删除/etc/httpd/conf.d/welcome.conf文件:

sudo rm /etc/httpd/conf.d/welcome.conf

当然,我们需要再次重启 Debian 和 Ubuntu 系统的服务器:

sudo service apache2 restart

在基于 Red Hat 和 CentOS 的系统中:

sudo service httpd restart

Apache 还有一个重新加载选项,而不是重新启动。这告诉服务器再次查看配置文件并与其一起工作。这通常比重新启动更快,并且可以保持现有连接打开。而重新启动会退出服务器并重新启动,带走打开的连接。重新启动的好处是更明确,对于设置目的更一致。

提供静态文件

在使用 Flask 时,通过 Web 服务器,非常重要的一步是通过为站点的静态内容创建一个快捷方式来减少应用程序的负载。这将把相对琐碎的任务交给 Web 服务器,使得处理过程更快速、更响应。这也是一件简单的事情。

编辑您的blog.conf文件,在<VirtualHost *:80>标签内添加以下行:

Alias /static <path to app>/static

在这里,<path to app>是静态目录存在的完整绝对路径。然后按照以下步骤重新加载 Debian 和 Ubuntu 系统的 Apache 配置:

sudo service apache2 restart

对于基于 Red Hat 和 CentOS 的系统如下:

sudo service httpd restart

这将告诉 Apache 在浏览器请求/static时在何处查找文件。您可以通过查看 Apache 日志文件来看到这一点,在 Debian 和 Ubuntu 系统中为/var/log/apache2/access.log,在基于 Red Hat 和 CentOS 的系统中为/var/log/httpd/access.log

Nginx

Nginx 正迅速成为取代 Apache 的 httpd 的事实标准 Web 服务器。它被证明更快,更轻量级,尽管配置有所不同,但更容易理解。

尽管 Nginx 已经支持 WSGI 有一段时间了,但即使是更新的 Linux 发行版也可能没有更新到它,因此我们必须使用一个称为 uWSGI 的接口层来访问 Python web 应用程序。uWSGI 是一个用 Python 编写的 WSGI 网关,可以通过套接字在 WSGI 和您的 Web 服务器之间进行翻译。我们需要安装 Nginx 和 uWSGI。在基于 Debian 和 Ubuntu 的系统中运行以下命令:

sudo apt-get install nginx

在基于 Red Hat 或 Fedora 的系统中,以下

sudo yum install nginx

现在由于 uWSGI 是一个 Python 模块,我们可以使用 pip 安装它:

sudo pip install uwsgi

要在基于 Debian 和 Ubuntu 的系统中配置 Nginx,需要在 /etc/nginx/sites-available 中创建一个名为 blog.conf 的文件,或者在基于 Red Hat 或 Fedora 的系统中,在 /etc/nginx/conf.d 中创建文件,并添加以下内容:

server {
    listen      80;
    server_name _;

    location / { try_files $uri @blogapp; }
    location @blogapp {
        include uwsgi_params;
        uwsgi_pass unix:/var/run/blog.wsgi.sock;
    }
}

这个配置与 Apache 配置非常相似,尽管是以 Nginx 形式表达的。它在端口 80 上接受连接,并且对于任何服务器名称,它都会尝试访问 blog.wsgi.sock,这是一个用于与 uWSGI 通信的 Unix 套接字文件。您会注意到 @blogapp 被用作指向位置的快捷方式引用。

只有在基于 Debian 和 Ubuntu 的系统中,我们现在需要通过从可用站点创建符号链接到已启用站点来启用新站点:

sudo ln -s /etc/nginx/sites-available/blog.conf /etc/nginx/sites-enabled

然后我们需要告诉 uWSGI 在哪里找到套接字文件,以便它可以与 Nginx 通信。为此,我们需要在 blog app 目录中创建一个名为 uwsgi.ini 的 uWSGI 配置文件,其中包含以下内容:

[uwsgi]
base = <path to app>
app = app
module = app
socket = /var/run/blog.wsgi.sock

您将需要将 <path to app> 更改为您的 app.py 文件存在的路径。还要注意套接字是如何设置在与 Nginx 站点配置文件中指定的相同路径中的。

注意

您可能会注意到 INI 文件的格式和结构非常类似于 Windows 的 INI 文件。

我们可以通过运行以下命令来验证此配置是否有效:

uwsgi –ini uwsgi.ini

现在 Nginx 知道如何与网关通信,但还没有使用站点配置文件;我们需要重新启动它。在您特定的环境中可以通过多种方式执行此操作。最简单的方法可能就是运行以下命令:

sudo service nginx restart

所以我们需要做的就是通过浏览器连接到 Web 服务器,访问 http://localhost/

请注意,一些 Linux 发行版附带了必须禁用的默认配置。在基于 Debian 和 Ubuntu 的系统以及基于 Red Hat 和 CentOS 的系统中,通常可以通过删除 /etc/nginx/conf.d/default.conf 文件来完成此操作。

sudo rm /etc/nginx/conf.d/default.conf

并重新启动 nginx 服务:

sudo service nginx restart

注意

Nginx 还有一个重新加载选项,而不是重新启动。这告诉服务器再次查看配置文件并与其一起工作。这通常比重新启动更快,并且可以保持现有的连接打开。而重新启动会退出服务器并重新启动,带走打开的连接。重新启动的好处在于它更加明确,并且对于设置目的更加一致。

提供静态文件

在使用 Flask 通过 Web 服务器时,非常重要的一步是通过为站点上的静态内容创建一个快捷方式,以减轻应用程序的负载。这将使 Web 服务器从相对琐碎的任务中解脱出来,使得向最终浏览器提供基本文件的过程更快速、更响应。这也是一个简单的任务。

编辑您的 blog.conf 文件,在 server { 标签内添加以下行:

location /static {
    root <path to app>/static;
}

其中 <path to app> 是静态目录存在的完整绝对路径。重新加载 Nginx 配置:

sudo service nginx restart

这将告诉 Nginx 在浏览器请求 /static 时在哪里查找文件。您可以通过查看 Nginx 日志文件 /var/log/nginx/access.log 来看到这一点。

Gunicorn

Gunicorn 是一个用 Python 编写的 Web 服务器。它已经理解了 WSGI,Flask 也是如此,因此让 Gunicorn 运行起来就像输入以下代码一样简单:

pip install gunicorn
gunicorn app:app

其中app:app是您的应用程序,模块名称是我们在其中使用的(与 uWSGI 配置基本相同)。除此之外还有更多选项,但例如,从中工作并设置端口和绑定是有用的:

gunicorn --bind 127.0.0.1:8000 app:app

--bind标志告诉 Gunicorn 要连接到哪个接口以及在哪个端口。如果我们只需要在内部使用 Web 应用程序,这是有用的。

另一个有用的标志是--daemon标志,它告诉 Gunicorn 在后台运行并与您的 shell 分离。这意味着我们不再直接控制该进程,但它正在运行,并且可以通过设置的绑定接口和端口进行访问。

使用 SSL 保护您的网站

在一个日益残酷的互联网上,通过证明其真实性来提高网站的安全性是很重要的。改善网站安全性的常用工具是使用 SSL,甚至更好的是 TLS。

SSL 和 TLS 证书允许您的服务器通过受信任的第三方基于您的浏览器连接的域名进行验证。这意味着,作为网站用户,我们可以确保我们正在交谈的网站在传输过程中没有被更改,是我们正在交谈的正确服务器,并且在服务器和我们的浏览器之间发送的数据不能被嗅探。当我们想要验证用户发送给我们的信息是否有效和受保护时,这显然变得重要,而我们的用户希望知道我们的数据在传输过程中受到保护。

获取您的证书

首先要做的是生成您的 SSL 证书请求。这与第三方一起使用,该第三方签署请求以验证您的服务器与任何浏览器。有几种方法可以做到这一点,取决于您的系统,但最简单的方法是运行以下命令:

openssl req -nodes -newkey rsa:2048 -sha256 -keyout private.key -out public.csr

现在将询问您有关您所属组织的一些问题,但重要的是通用名称。这是您的服务器将被访问的域名(不带https://):

Country Name (2 letter code) [AU]: GB
State or Province Name (full name) [Some-State]: London
Locality Name (eg, city) []: London
Organization Name (eg, company) [Internet Widgits Pty Ltd]: Example Company
Organizational Unit Name (eg, section) []: IT
Common Name (eg, YOUR name) []: blog.example.com
Email Address []:
A challenge password []:
An optional company name []:

在这里,您可以看到我们使用blog.example.com作为我们示例域名,我们的博客应用将在该域名下访问。您必须在这里使用您自己的域名。电子邮件地址和密码并不是非常重要的,可以留空,但您应该填写“组织名称”字段,因为这将是您的 SSL 证书被识别为的名称。如果您不是一家公司,只需使用您自己的名字。

该命令为我们生成了两个文件;一个是private.key文件,这是我们的服务器用来与浏览器签署通信的文件,另一个是public.csr,这是发送给处理服务器和浏览器之间验证的第三方服务的证书请求文件。

注意

公钥/私钥加密是一个广泛但深入研究的主题。鉴于 Heartbleed 攻击,如果您希望保护服务器,了解这个是值得的。

下一步是使用第三方签署您的public.csr请求。有许多服务可以为您执行此操作,有些免费,有些略有成本;例如Let's Encrypt等一些服务可以完全免费地自动化整个过程。它们都提供基本相同的服务,但它们可能不会全部内置到所有浏览器中,并且为不同成本的不同程度的支持提供不同程度的支持。

这些服务将与您进行验证过程,要求您的public.csr证书请求,并为您的主机名返回一个已签名的.crt证书文件。

注意

请注意,将您的.crt.key文件命名为其中申请证书的站点主机名可能会对您有所帮助。在我们的情况下,这将是blog.example.com.crt

您的新.crt文件和现有的.key文件可以放在服务器的任何位置。但是,通常.crt文件放在/etc/ssl/certs中,而.key文件放在/etc/ssl/private中。

所有正确的文件都放在正确的位置后,我们需要重新打开用于我们的博客服务的现有 Apache 配置。最好运行一个正常的 HTTP 和 HTTPS 服务。但是,由于我们已经努力设置了 HTTPS 服务,强制执行它以重定向我们的用户是有意义的。这可以通过一个称为 HSTS 的新规范来实现,但并非所有的 Web 服务器构建都支持这一点,所以我们将使用重定向。

提示

您可以通过向操作系统的主机文件添加一个条目来在本地机器上运行带有 SSL 证书的测试域。只是不要忘记在完成后将其删除。

Apache httpd

首先要更改的是VirtualHost行上的端口,从默认的 HTTP 端口80更改为默认的 HTTPS 端口443

<VirtualHost *:443>

我们还应该指定服务器的主机名正在使用的 SSL 证书;因此,在 VirtualHost 部分添加一个ServerName参数。这将确保证书不会在错误的域中使用。

ServerName blog.example.com

您必须用您将要使用的主机名替换blog.example.com

我们还需要设置 SSL 配置,以告诉 Apache 如何响应:

SSLEngine on
SSLProtocol -all +TLSv1 +SSLv2
SSLCertificateFile /etc/ssl/certs/blog.example.com.crt
SSLCertificateKeyFile /etc/ssl/private/blog.example.com.key
SSLVerifyClient None

这里的情况是,Apache 中的 SSL 模块被启用,为该站点指定了公共证书和私钥文件,并且不需要客户端证书。禁用默认的 SSL 协议并启用 TLS 非常重要,因为 TLS 被认为比 SSL 更安全。但是,仍然启用 SSLv2 以支持旧版浏览器。

现在我们需要测试它。让我们重新启动 Apache:

sudo service apache2 restart

尝试使用浏览器连接到 Web 服务器,不要忘记您现在正在使用https://

现在它正在工作,最后一步是将普通的 HTTP 重定向到 HTTPS。在配置文件中,再次添加以下内容:

<VirtualHost *:80>
  ServerName blog.example.com
  RewriteEngine On
  RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI}
</VirtualHost>

我们为端口80创建一个新的VirtualHost,并指定它是为ServerName blog.example.com主机名而设的。然后我们使用 Apache 中的Rewrite模块简单地将浏览器重定向到相同的 URL,但是在开头使用 HTTPS。

再次重启 Apache:

sudo service apache2 restart

现在在网站上用浏览器测试这个配置;验证您被重定向到 HTTPS,无论您访问哪个页面。

Nginx

Nginx 的配置非常简单。与 Apache 配置非常相似,我们需要更改 Nginx 将监听我们站点的端口。由于 HTTPS 在端口443上运行,这里的区别在于告诉 Nginx 期望 SSL 连接。在配置中,我们必须更新以下行:

listen   443 ssl;

现在要将 SSL 配置添加到配置的服务器元素中,输入以下内容:

server_name blog.example.com;
ssl_certificate /etc/ssl/certs/blog.example.com.crt;
ssl_certificate_key /etc/ssl/private/blog.example.com.key;
ssl_protocols TLSv1 SSLv2;

这告诉 Nginx 将此配置应用于对blog.example.com主机名的请求(不要忘记用您自己的替换它),因为我们不希望为不适用的域发送 SSL 证书。我们还指定了公共证书文件位置和文件系统上的私有 SSL 密钥文件位置。最后,我们指定了要使用的 SSL 协议,这意味着启用 TLS(被认为比 SSL 更安全)。但是 SSLv2 仍然启用以支持旧版浏览器。

现在来测试它。让我们重新启动 Nginx 服务:

sudo service nginx restart

尝试使用浏览器连接到 Web 服务器,不要忘记您现在正在使用https://

一旦我们证明它正在工作,最后一步是将普通的 HTTP 重定向到 HTTPS。再次在配置文件中添加以下内容:

server {
    listen 80;
    server_name blog.example.com;
    rewrite ^ https://$server_name$request_uri? permanent;
}

这与以前的普通 HTTP 配置基本相同;只是我们使用rewrite命令告诉 Nginx 捕获所有 URL,并向访问 HTTP 端口的浏览器发送重定向命令,以转到 HTTPS,使用他们在 HTTP 上尝试使用的确切路径。

最后一次,重新启动 Nginx:

sudo service nginx restart

最后,在您被重定向到 HTTPS 的网站上测试您的浏览器,无论您访问哪个页面。

Gunicorn

从 0.17 版本开始,Gunicorn 也添加了 SSL 支持。要从命令行启用 SSL,我们需要一些标志:

gunicorn --bind 0.0.0.0:443 --certfile /etc/ssl/certs/blog.example.com.crt --keyfile /etc/ssl/private/blog.example.com.key --ssl-version 2 --ciphers TLSv1  app:app

这与 Nginx 和 Apache SSL 配置的工作方式非常相似。它指定要绑定的端口,以及在这种情况下的所有接口。然后,它将 Gunicorn 指向公共证书和私钥文件,并选择在旧版浏览器中使用 SSLv2 和(通常被认为更安全的)TLS 密码协议。

通过在浏览器中输入主机名和 HTTPS 来测试这个。

现在准备好了,让我们将端口80重定向到端口443。这在 Gunicorn 中相当复杂,因为它没有内置的重定向功能。一个解决方案是创建一个非常简单的 Flask 应用程序,在 Gunicorn 上的端口80启动,并重定向到端口443。这将是一个新的应用程序,带有一个新的app.py文件,其内容如下:

from flask import Flask,request, redirect
import urlparse

app = Flask(__name__)

@app.route('/')
@app.route('/<path:path>')
def https_redirect(path='/'):
    url = urlparse.urlunparse((
        'https',
        request.headers.get('Host'),
        path,
        '','',''
    ))

    return redirect(url, code=301)
if __name__ == '__main__':
    app.run()

这是一个非常简单的 Flask 应用程序,可以在任何地方使用,将浏览器重定向到等效的 URL,但在前面加上 HTTPS。它通过使用标准的 Python urlparse库,使用浏览器发送到服务器的标头中的请求主机名,以及路由中的通用路径变量来构建 URL。然后,它使用 Flask 的redirect方法告诉浏览器它真正需要去哪里。

注意

请注意,空字符串对于 urlunparse 函数很重要,因为它期望一个完整的 URL 元组,就像由 urlparse 生成的那样。

您现在可能已经知道如何在 Gunicorn 中运行这个,尽管如此,要使用的命令如下:

gunicorn --bind 0.0.0.0:80 app:app

现在使用浏览器连接到旧的 HTTP 主机,您应该被重定向到 HTTPS 版本。

使用 Ansible 自动化部署

Ansible 是一个配置管理工具。它允许我们以可重复和可管理的方式自动化部署我们的应用程序,而无需每次考虑如何部署我们的应用程序。

Ansible 可以在本地和通过 SSH 工作。您可以使用 Ansible 的一个聪明之处是让 Ansible 配置自身。根据您自己的配置,然后可以告诉它部署它需要的其他机器。

然而,我们只需要专注于使用 Apache、WSGI 和 Flask 构建我们自己的本地 Flask 实例。

首先要做的是在我们要部署 Flask 应用的机器上安装 Ansible。由于 Ansible 是用 Python 编写的,我们可以通过使用pip来实现这一点:

sudo pip install ansible

现在我们有了一个配置管理器,既然配置管理器是用来设置服务器的,让我们建立一个 playbook,Ansible 可以用来构建整个机器。

在一个新项目或目录中,创建一个名为blog.yml的文件。我们正在创建一个 Ansible 称为 Playbook 的文件;它是一个按顺序运行的命令列表,并构建我们在 Apache 下运行的博客。为简单起见,在这个文件中假定您使用的是一个 Ubuntu 衍生操作系统:

---

- hosts: webservers
  user: ubuntu
  sudo: True

  vars:
    app_src: ../blog
    app_dest: /srv/blog

  tasks:
    - name: install necessary packages
      action: apt pkg=$item state=installed
      with_items:
        - apache2
        - libapache2-mod-wsgi
        - python-setuptools
    - name: Enable wsgi module for Apache
      action: command a2enmod wsgi
    - name: Blog app configuration for Apache
      action: template src=templates/blog dest=/etc/apache/sites-available/blog
    - name: Copy blog app in
      action: copy src=${app_src} dest=${app_dest}
    - name: Enable site
 action: command a2ensite blog
    - name: Reload Apache
      action: service name=apache2 state=reloaded

Ansible Playbook 是一个 YAML 文件,包含几个部分;主要部分描述了“play”。hosts值描述了后续设置应该应用于哪组机器。user描述了 play 应该以什么用户身份运行;对于您来说,这应该是 Ansible 可以运行以安装您的应用程序的用户。sudo设置告诉 Ansible 以sudo权限运行此 play,而不是以 root 身份运行。

vars部分描述了 playbook 中常见的变量。这些设置很容易找到,因为它们位于顶部,但也可以在 playbook 配置中以${example_variable}的格式稍后使用,如果example_variable在这里的vars部分中定义。这里最重要的变量是app_src变量,它告诉 Ansible 在将应用程序复制到正确位置时在哪里找到我们的应用程序。在这个例子中,我们假设它在一个名为blog的目录中,但对于您来说,它可能位于文件系统的其他位置,您可能需要更新此变量。

最后一个最重要的部分是tasks部分。这告诉 Ansible 在更新它控制的机器时要运行什么。如果您熟悉 Ubuntu,这些任务应该有些熟悉。例如,action: apt告诉 apt 确保with_items列表中指定的所有软件包都已安装。您将注意到$item变量与pkg参数。$item变量由 Ansible 自动填充,因为它在with_items命令和apt命令上进行迭代,apt命令使用pkg参数来验证软件包是否已安装。

随后的任务使用命令行命令a2enmod wsgi启用 WSGI 模块,这是 Debian 系统中启用模块的简写,通过填充模板设置我们博客站点的 Apache 配置。幸运的是,Ansible 用于模板的语言是 Jinja,您很可能已经熟悉。我们的模板文件的内容应该与此blog.yml相关,在一个名为templates的目录中,一个名为blog的文件。内容应该如下所示:

NameVirtualHost *:80

<VirtualHost *:80>
    WSGIScriptAlias / {{ app_dest }}/wsgi.py

    <Directory {{ app_dest }}/>
        Order deny,allow
        Allow from all
    </Directory>
</VirtualHost>

这应该很熟悉,这是 Apache 部分示例的直接剽窃;但是,我们已经利用了 Ansible 变量来填充博客应用程序的位置。这意味着,如果我们想将应用程序安装到另一个位置,只需更新app_dest变量即可。

最后,在 Playbook 任务中,它将我们非常重要的博客应用程序复制到机器上,使用 Debian 简写在 Apache 中启用站点,并重新加载 Apache,以便可以使用该站点。

所以剩下的就是在那台机器上运行 Ansible,并让它为您构建系统。

ansible-playbook blog.yml --connection=local

这告诉 Ansible 运行我们之前创建的 Playbook 文件blog.yml,并在local连接类型上使用它,这意味着应用于本地机器。

提示

Ansible 提示

值得注意的是,这可能不是在大型分布式环境中使用 Ansible 的最佳方式。首先,您可能希望将其应用于远程机器,或者将 Apache 配置、Apache WSGI 配置、Flask 应用程序配置和博客配置分开成 Ansible 称为角色的单独文件;这将使它们可重用。

另一个有用的提示是指定使用的配置文件并在 Apache 中设置静态目录。阅读 Ansible 文档,了解更多有关改进部署的方法的想法:

docs.ansible.com/

阅读更多

有关如何在 Apache 和 WSGI 中更有效地保护您的 Flask 部署,通过创建只能运行 Flask 应用程序的无 shell 用户,详细信息请参见www.subdimension.co.uk/2012/04/24/Deploying_Flask_to_Apache.html

此指南还提供了更多针对 CentOS 系统的示例,以及通过 Ansible 在 Lighttpd 和 Gunicorn 上部署的所有示例www.zufallsheld.de/2014/11/19/deploying-lighttpd-your-flask-apps-gunicorn-and-supervisor-with-ansible-on-centos/

摘要

在本章中,我们已经看到了许多运行 Flask 应用程序的方法,包括在多个 Web 服务器中保护隐私和安全,并提供静态文件以减少 Flask 应用程序的负载。我们还为 Ansible 制作了一个配置文件,以实现可重复的应用程序部署,因此,如果需要重新构建机器,这将是一个简单的任务。

posted @ 2024-05-20 16:51  绝不原创的飞龙  阅读(17)  评论(0编辑  收藏  举报