Flask-蓝图-全-

Flask 蓝图(全)

原文:zh.annas-archive.org/md5/53AA49F14B72D97DBF009B5C4214AEF0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

情景很熟悉:你是一名网页开发者,已经使用过几种编程语言、框架和环境,决定学习足够的 Python 来制作一些玩具网页应用程序。也许你已经使用过一些 Python 网页框架来构建一个或两个应用程序,并且想探索一些你一直听说过的替代选项。

这通常是人们了解 Flask 的方式。

作为一个微框架,Flask 旨在帮助你,然后不再干涉你。与大多数其他通用网页框架采取非常不同的方法,Flask 由一个非常小的核心组成,处理和规范化 HTTP 和 WSGI 规范(通过 Werkzeug),并提供一个非常好的模板语言(通过 Jinja2)。Flask 的美妙之处在于其固有的可扩展性:由于它从一开始就被设计为做得很少,因此也很容易扩展。这样做的一个愉快的结果是,你不必受制于特定的数据库抽象层、身份验证协议或缓存机制。

学习一个新的框架不仅仅是学习提供给你的基本功能和对象:同样重要的是学习如何调整框架以帮助你构建应用程序的特定要求。

本书将演示如何使用 Python 网页微框架开发一系列网页应用程序项目,并利用扩展和外部 Python 库/API 来扩展各种更大更复杂的网页应用程序的开发。

本书内容

第一章,“从正确的角度开始-使用 Virtualenv”,开始了我们对 Python 网页应用程序开发的深入探讨,介绍了使用和管理虚拟环境来隔离应用程序依赖关系的基础知识。我们将研究安装和分发可重用的 Python 代码包的设置工具、pip、库和实用程序,以及 virtualenv,这是一个用于创建项目的基于 Python 软件要求的隔离环境的工具。我们还将讨论这些工具无法做到的事情,并研究 virtualenvwrapper 抽象,以增强 virtualenv 提供的功能。

第二章,“从小到大-扩展 Flask 应用程序结构”,探讨了你可能考虑为 Flask 应用程序考虑的各种基线布局和配置。随着我们从最简单的单文件应用程序结构逐渐进展到更复杂的多包蓝图架构,我们概述了每种方法的利弊。

第三章,“Snap-代码片段共享应用程序”,构建了我们的第一个简单的 Flask 应用程序,重点是学习最流行的关系数据库抽象之一,SQLAlchemy,以及一些最流行的 Flask 扩展:Flask-Login 用于处理经过身份验证的用户登录会话,Flask-Bcrypt 确保帐户密码以安全方式存储,Flask-WTF 用于创建和处理基于表单的输入数据。

第四章,“Socializer-可测试的时间轴”,为社交网页应用程序构建了一个非常简单的数据模型,主要关注使用 pytest,Python 测试框架和工具进行单元和功能测试。我们还将探讨应用程序工厂模式的使用,该模式允许我们为简化测试目的实例化我们应用程序的不同版本。此外,详细描述了 Blinker 库提供的常常被省略(和遗忘)的信号的使用和创建。

第五章,Shutterbug,Photo Stream API,围绕基于 JSON 的 API 构建了一个应用程序的框架,这是当今任何现代 Web 应用程序的要求。我们使用了许多基于 API 的 Flask 扩展之一,Flask-RESTful,用于原型设计 API,我们还深入研究了无状态系统的简单身份验证机制,并在此过程中编写了一些测试。我们还短暂地进入了 Werkzeug 的世界,这是 Flask 构建的 WSGI 工具包,用于构建自定义 WSGI 中间件,允许无缝处理基于 URI 的版本号,以适应我们新生 API 的需求。

第六章,Hublot – Flask CLI Tools,涵盖了大多数 Web 应用程序框架讨论中经常省略的一个主题:命令行工具。解释了 Flask-Script 的使用,并创建了几个基于 CLI 的工具,以与我们应用程序的数据模型进行交互。此外,我们将构建我们自己的自定义 Flask 扩展,用于包装现有的 Python 库,以从 GitHub API 获取存储库和问题信息。

第七章,Dinnerly – Recipe Sharing,介绍了 OAuth 授权流程的概念,这是许多大型 Web 应用程序(如 Twitter、Facebook 和 GitHub)实施的,以允许第三方应用程序代表帐户所有者行事,而不会损害基本帐户安全凭据。为食谱共享应用程序构建了一个简单的数据模型,允许所谓的社交登录以及将数据从我们的应用程序跨发布到用户连接的服务的 feeds 或 streams。最后,我们将介绍使用 Alembic 的数据库迁移的概念,它允许您以可靠的方式将 SQLAlchemy 模型元数据与基础关系数据库表的模式同步。

本书需要什么

要完成本书中大多数示例的操作,您只需要您喜欢的文本编辑器或 IDE,访问互联网(以安装各种 Flask 扩展,更不用说 Flask 本身了),一个关系数据库(SQLite、MySQL 或 PostgreSQL 之一),一个浏览器,以及对命令行的一些熟悉。我们已经注意到了在每一章中完成示例所需的额外软件包或库。

本书适合谁

本书是为希望深入了解 Web 应用程序开发世界的新 Python 开发人员,或者对学习 Flask 及其背后的基于扩展的生态系统感兴趣的经验丰富的 Python Web 应用程序专业人士而创建的。要充分利用每一章,您应该对 Python 编程语言有扎实的了解,对关系数据库系统有基本的了解,并且熟练掌握命令行。

约定

本书中,您将找到许多不同类型信息的文本样式。以下是一些样式的示例,以及它们的含义解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"这将创建一个空的app1环境并激活它。您应该在 shell 提示符中看到一个(app1)标签。"

代码块设置如下:

[default]
  <div>{{ form.password.label }}: {{ form.password }}</div>
  {% if form.password.errors %}
  <ul class="errors">{% for error in form.password.errors %}<li>{{ error }}</li>{% endfor %}</ul>
  {% endif %}

  <div><input type="submit" value="Sign up!"></div>
</form>

{% endblock %}

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

    from application.users.views import users
    app.register_blueprint(users, url_prefix='/users')

 from application.posts.views import posts
 app.register_blueprint(posts, url_prefix='/posts')

        # …

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

$ source ~/envs/testing/bin/activate
(testing)$ pip uninstall numpy

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这样的方式出现在文本中:"然后它断言返回的 HTML 中出现了注册!按钮文本"。

注意

警告或重要说明显示在这样的框中。

提示

提示和技巧显示如下。

第一章:正确开始——使用 Virtualenv

现代软件开发中的一个巨大困难是依赖管理。一般来说,软件项目的依赖关系包括所需的库或组件,以使项目能够正确运行。对于 Flask 应用程序(更一般地说,对于 Python 应用程序),大多数依赖关系由特别组织和注释的源文件组成。创建后,这些源文件包可以包含在其他项目中,依此类推。对于一些人来说,这种依赖链可能变得难以管理,当链中的任何库发生细微变化时,可能会导致一系列不兼容性,从而使进一步的开发陷入停滞。在 Python 世界中,正如您可能已经知道的那样,可重用的源文件的基本单元是 Python 模块(包含定义和语句的文件)。一旦您在本地文件系统上创建了一个模块,并确保它在系统的 PYTHONPATH 中,将其包含在新创建的项目中就像指定导入一样简单,如下所示:

import the_custom_module

其中the_custom_module.py是一个存在于执行程序系统的$PYTHONPATH中的文件。

注意:

$PYTHONPATH可以包括对压缩存档(.zip文件夹)的路径,除了正常的文件路径。

当然,故事并不会在这里结束。虽然最初在本地文件系统中散布模块可能很方便,但当您想要与他人共享一些您编写的代码时会发生什么?通常,这将涉及通过电子邮件/Dropbox 发送相关文件,然而,这显然是一个非常繁琐且容易出错的解决方案。幸运的是,这是一个已经被考虑过并且已经在缓解常见问题方面取得了一些进展的问题。其中最重要的进展之一是本章的主题,以及如何利用以下创建可重用的、隔离的代码包的技术来简化 Flask 应用程序的开发:

  • 使用 pip 和 setuptools 进行 Python 打包

  • 使用 virtualenv 封装虚拟环境

各种 Python 打包范例/库提出的解决方案远非完美;与热情的 Python 开发者争论的一种肯定方式是宣称打包问题已经解决!我们在这方面还有很长的路要走,但通过改进 setuptools 和其他用于构建、维护和分发可重用 Python 代码的库,我们正在逐步取得进展。

在本章中,当我们提到一个包时,我们实际上要谈论的是一个分发——一个从远程源安装的软件包——而不是一个使用the__init__.py约定来划分包含我们想要导入的模块的文件夹结构的集合。

Setuptools 和 pip

当开发人员希望使他们的代码更广泛可用时,首要步骤之一将是创建一个与 setuptools 兼容的包。

现代 Python 版本的大多数发行版将已经安装了 setuptools。如果您的系统上没有安装它,那么获取它相对简单,官方文档中还提供了额外的说明:

wget https://bootstrap.pypa.io/ez_setup.py -O - | python

安装了 setuptools 之后,创建兼容包的基本要求是在项目的根目录创建一个setup.py文件。该文件的主要内容应该是调用setup()函数,并带有一些强制(和许多可选)参数,如下所示:

from setuptools import setup

setup(
 name="My Great Project",
 version="0.0.1",
 author="Jane Doe",
 author_email="jane@example.com",
 description= "A brief summary of the project.",
 license="BSD",
 keywords="example tutorial flask",
 url="http://example.com/my-great-project",
 packages=['foobar','tests'],
 long_description="A much longer project description.",
 classifiers=[
 "Development Status :: 3 - Alpha",
 "Topic :: Utilities",
 "License :: OSI Approved :: BSD License",
 ],
)

提示

下载示例代码

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

一旦软件包被创建,大多数开发人员将选择使用 setuptools 本身提供的内置工具将他们新创建的软件包上传到 PyPI——几乎所有 Python 软件包的官方来源。虽然使用特定的公共 PyPI 存储库并不是必需的(甚至可以设置自己的个人软件包索引),但大多数 Python 开发人员都希望在这里找到他们的软件包。

这将引出拼图中另一个至关重要的部分——pip Python 软件包安装程序。如果您已安装 Python 2.7.9 或更高版本,则pip将已经存在。某些发行版可能已经为您预安装了它,或者它可能存在于系统级软件包中。对于类似 Debian 的 Linux 发行版,可以通过以下命令安装它:

apt-get install python-pip

同样,其他基于 Linux 的发行版将有他们自己推荐的软件包管理器。如果您更愿意获取源代码并手动安装,只需获取文件并使用 Python 解释器运行即可:

$ curl -o get-pip.py https://bootstrap.pypa.io/get-pip.py
$ python get-pip.py

Pip 是一个用于安装 Python 软件包的工具(本身也是一个 Python 软件包)。虽然它不是唯一的选择,但pip是迄今为止使用最广泛的。

注意

pip的前身是easy_install,在 Python 社区中已经大部分被后者取代。easy_install模块存在一些相当严重的问题,比如允许部分安装、无法卸载软件包而需要用户手动删除相关的.egg文件,以及包含有用的成功和错误消息的控制台输出,允许开发人员在出现问题时确定最佳操作方式。

可以在命令行中调用 pip 来在本地文件系统上安装科学计算软件包,比如说:

$ pip install numpy

上述命令将查询默认的 PyPI 索引,寻找名为numpy的软件包,并将最新版本下载到系统的特定位置,通常是/usr/local/lib/pythonX.Y/site-packagesXYpip指向的 Python 版本的主/次版本)。此操作可能需要 root 权限,因此需要sudo或类似的操作来完成。

虚拟环境的许多好处之一是,它们通常避免了对已安装软件包进行系统级更改时可能出现的权限提升要求。

一旦此操作成功完成,您现在可以将numpy软件包导入新模块,并使用它提供的所有功能:

import numpy

x = numpy.array([1, 2, 3])
sum = numpy.sum(x)
print sum  # prints 6

一旦我们安装了这个软件包(或者其他任何软件包),就没有什么可以阻止我们以通常的方式获取其他软件包。此外,我们可以通过将它们的名称作为install命令的附加参数来一次安装多个软件包:

$ pip install scipy pandas # etc.

避免依赖地狱,Python 的方式

新开发人员可能会想要安装他们遇到的每个有趣的软件包。这样做的话,他们可能会意识到这很快就会变成一个卡夫卡式的情况,先前安装的软件包可能会停止工作,新安装的软件包可能会表现得不可预测,如果它们成功安装的话。前述方法的问题,正如你们中的一些人可能已经猜到的那样,就是冲突的软件包依赖关系。例如,假设我们安装了软件包A;它依赖于软件包Q的版本 1 和软件包R的版本 1。软件包B依赖于软件包R的版本 2(其中版本 1 和 2 不兼容)。Pip 将愉快地为您安装软件包B,这将升级软件包R到版本 2。这将使软件包A在最好的情况下完全无法使用,或者在最坏的情况下,使其以未记录和不可预测的方式行为。

Python 生态系统已经提出了一个解决从俗称为依赖地狱中产生的基本问题的解决方案。虽然远非完美,但它允许开发人员规避在 Web 应用程序开发中可能出现的许多最简单的软件包版本依赖冲突。

virtualenv工具(Python 3.3 中的默认模块venv)是必不可少的,以确保最大限度地减少陷入依赖地狱的机会。以下引用来自virtualenv官方文档的介绍部分:

它创建一个具有自己安装目录的环境,不与其他 virtualenv 环境共享库(也可以选择不访问全局安装的库)。

更简洁地说,virtualenv允许您为每个 Python 应用程序(或任何 Python 代码)创建隔离的环境。

注意

virtualenv工具不会帮助您管理 Python 基于 C 的扩展的依赖关系。例如,如果您从pip安装lxml软件包,它将需要您拥有正确的libxml2libxslt系统库和头文件(它将链接到)。virtualenv工具将无法帮助您隔离这些系统级库。

使用 virtualenv

首先,我们需要确保在本地系统中安装了virtualenv工具。这只是从 PyPI 存储库中获取它的简单事情:

$ pip install virtualenv

注意

出于明显的原因,应该在可能已经存在的任何虚拟环境之外安装这个软件包。

创建新的虚拟环境

创建新的虚拟环境很简单。以下命令将在指定路径创建一个新文件夹,其中包含必要的结构和脚本,包括默认 Python 二进制文件的完整副本:

$ virtualenv <path/to/env/directory>

如果我们想创建一个位于~/envs/testing的环境,我们首先要确保父目录存在,然后调用以下命令:

$ mkdir -p ~/envs
$ virtualenv ~/envs/testing

在 Python 3.3+中,一个大部分与 API 兼容的virtualenv工具被添加到默认语言包中。模块的名称是venv,然而,允许您创建虚拟环境的脚本的名称是pyvenv,可以以与先前讨论的virtualenv工具类似的方式调用:

$ mkdir -p ~/envs
$ pyvenv ~/envs/testing

激活和停用虚拟环境

创建虚拟环境不会自动激活它。环境创建后,我们需要激活它,以便对 Python 环境进行任何修改(例如安装软件包)将发生在隔离的环境中,而不是我们系统的全局环境中。默认情况下,激活虚拟环境将更改当前活动用户的提示字符串($PS1),以便显示所引用的虚拟环境的名称:

$ source ~/envs/testing/bin/activate
(testing) $ # Command prompt modified to display current virtualenv

Python 3.3+的命令是相同的:

$ source ~/envs/testing/bin/activate
(testing) $ # Command prompt modified to display current virtualenv

当您运行上述命令时,将发生以下一系列步骤:

  1. 停用任何已激活的环境。

  2. 使用virtualenv bin/目录的位置在您的$PATH变量之前添加,例如~/envs/testing/bin:$PATH

  3. 如果存在,则取消设置$PYTHONHOME

  4. 修改您的交互式 shell 提示,以包括当前活动的virtualenv的名称。

由于$PATH环境变量的操作,通过激活环境的 shell 调用的 Python 和pip二进制文件(以及通过pip安装的其他二进制文件)将包含在~/envs/testing/bin中。

向现有环境添加包

我们可以通过简单激活它,然后以以下方式调用pip来轻松向虚拟环境添加包:

$ source ~/envs/testing/bin/activate
(testing)$ pip install numpy

这将把numpy包安装到测试环境中,只有测试环境。您的全局系统包不受影响,以及任何其他现有环境。

从现有环境中卸载包

卸载pip包也很简单:

$ source ~/envs/testing/bin/activate
(testing)$ pip uninstall numpy

这将仅从测试环境中删除numpy包。

这是 Python 软件包管理存在相对重要的一个地方:卸载一个包不会卸载它的依赖项。例如,如果安装包A并安装依赖包BC,则以后卸载包A将不会卸载BC

简化常见操作-使用 virtualenvwrapper 工具

我经常使用的一个工具是virtualenvwrapper,它是一组非常智能的默认值和命令别名,使得使用虚拟环境更直观。现在让我们将其安装到我们的全局系统中:

$ pip install virtualenvwrapper

注意

这也将安装virtualenv包,以防它尚未存在。

接下来,您需要将以下行添加到您的 shell 启动文件的末尾。这很可能是~/.bashrc,但是如果您已将默认 shell 更改为其他内容,例如zsh,那么它可能会有所不同(例如~/.zshrc):

export WORKON_HOME=$HOME/.virtualenvs
source /usr/local/bin/virtualenvwrapper.sh

在上述代码块的第一行指示使用virtualenvwrapper创建的新虚拟环境应存储在$HOME/.virtualenvs中。您可以根据需要修改此设置,但我通常将其保留为一个很好的默认值。我发现将所有虚拟环境放在我的主目录中的同一个隐藏文件夹中可以减少个别项目中的混乱,并使误将整个虚拟环境添加到版本控制变得更加困难。

注意

将整个虚拟环境添加到版本控制可能看起来像一个好主意,但事情从来不像看起来那么简单。一旦运行稍微(或完全)不同的操作系统的人决定下载您的项目,其中包括可能包含针对您自己的架构编译的C模块的包的完整virtualenv文件夹,他们将很难使事情正常工作。

相反,pip 支持并且许多开发人员使用的常见模式是在虚拟环境中冻结已安装包的当前状态,并将其保存到requirements.txt文件中:

(testing) $ pip freeze > requirements.txt

然后,该文件可以添加到版本控制系统VCS)中。由于该文件的目的是声明应用程序所需的依赖关系,而不是提供它们或指示如何构建它们,因此您的项目的用户可以自由选择以任何方式获取所需的包。通常,他们会通过pip安装它们,pip可以很好地处理要求文件:

(testing) $ pip install –r  requirements.txt

第二行在当前 shell 环境中添加了一些方便的别名,以创建、激活、切换和删除环境:

  • mkvirtualenv test:这将创建一个名为 test 的环境并自动激活它。

  • mktmpenv test:这将创建一个名为 test 的临时环境并自动激活它。一旦调用停用脚本,此环境将被销毁。

  • workon app:这将切换到 app 环境(已经创建)。

  • workonalias lsvirtualenv):当您不指定环境时,这将打印所有可用的现有环境。

  • deactivate:如果有的话,这将禁用当前活动的环境。

  • rmvirtualenv app:这将完全删除 app 环境。

我们将使用以下命令创建一个环境来安装我们的应用程序包:

$ mkvirtualenv app1

这将创建一个空的app1环境并激活它。您应该在 shell 提示符中看到一个(app1)标签。

注意

如果您使用的是 Bash 或 ZSH 之外的 shell,此环境标签可能会出现也可能不会出现。这样的工作方式是,用于激活虚拟环境的脚本还会修改当前的提示字符串(PS1环境变量),以便指示当前活动的virtualenv。因此,如果您使用非常特殊或非标准的 shell 配置,则有可能无法正常工作。

摘要

在本章中,我们看到了任何非平凡的 Python 应用程序都面临的最基本的问题之一:库依赖管理。值得庆幸的是,Python 生态系统已经开发了被广泛采用的virtualenv工具,用于解决开发人员可能遇到的最常见的依赖问题子集。

此外,我们还介绍了一个工具virtualenvwrapper,它抽象了一些使用virtualenv执行的最常见操作。虽然我们列出了这个软件包提供的一些功能,但virtualenvwrapper可以做的事情更加广泛。我们只是在这里介绍了基础知识,但如果您整天都在使用 Python 虚拟环境,深入了解这个工具能做什么是不可或缺的。

第二章:从小到大-扩展 Flask 应用程序结构

Flask 是一个很棒的框架,适合想要编写一个非常快速的单文件应用程序以原型化 API 或构建一个非常简单的网站的人。然而,不那么明显的是,Flask 在更大、更模块化的应用程序结构中的灵活性和能力,这在单模块布局变得更加繁琐而不再方便时是必不可少的。本章我们将涵盖的主要要点如下:

  • 如何将基于模块的 Flask 应用程序转换为基于包的布局

  • 如何在基于包的应用程序结构上实现 Flask 蓝图

  • 如何确保我们的结果应用程序可以使用内置的 Werkzeug 开发服务器运行

你的第一个 Flask 应用程序结构

在官方网站上找到的典型的 Flask 入门应用程序是简单的典范,这是你很可能以前就遇到过的:

# app.py 
from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
 return "Hello World!"

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

首先通过从pip安装Flask包(当然是在虚拟环境中),然后在 Python 解释器下执行脚本来运行前面的应用程序:

$ pip install Flask
$ python app.py

这将启动 Werkzeug 开发 Web 服务器,默认情况下会在http://localhost:5000上为Flask通过pip获取时安装的应用程序提供服务。

人们启动新的Flask应用程序的典型方式是向我们在前一节中展示的非常简单的模块添加各种端点:

from flask import Flask, request
app = Flask(__name__)

@app.route("/")
def hello():
 return "Hello World!"

@app.route("/contact")
def contact():
 return "You can contact me at 555-5555, or "
 " email me at test@example.com"

@app.route('/login', methods=['GET', 'POST'])
def login():
 if request.method == 'POST':
 # Logic for handling login
 pass
 else:
 # Display login form
 pass

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

虽然直观,但是一旦应用程序的复杂性增加,这种方法的缺点就变得明显起来:

  • 模块中的函数定义数量几乎与我们想要路由到的 URL 数量成线性增长。虽然这不是一个固有的缺点,但开发人员应该更喜欢将功能拆分成更容易理解的小包。

  • 路由所需的模板和静态文件积累在同一子文件夹位置,因此使它们的组织更加复杂和容易出错。

  • 某些操作(例如日志记录)在按包配置而不是在一个庞大的模块中配置时会变得更简单。

从模块到包

可以对基于模块的 Flask 应用程序应用的最简单的结构变化是将其转换为典型的 Python 包,并特别考虑静态和模板文件夹。

application
└──application
    ├──__init__.py
    ├──static
    │  ├──app.js
    │  └──styles.css
    └──templates
         ├──index.html
         └──layout.html

在这里,我们创建了一个顶级应用程序包,将app.py模块以及statictemplate文件夹放入其中,并将其重命名为__init__.py

注意

__init__.py文件是一个文件夹被视为有效的 Python 包所必需的。

此时应该处理的一个细节是用于运行开发服务器的代码。如果你还记得,单模块应用程序包含以下条件语句:

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

这使我们能够直接用 Python 解释器执行模块文件,如下所示:

$ python app.py
* Running on http://localhost:5000/

出于各种原因,这不再是一个可行的选择。然而,我们仍然希望以简单的方式运行开发服务器。为此,我们将创建一个run.py文件,作为内部application包文件夹的同级:

├──application
│  ├──__init__.py
│  ├──static
│  │  ├──app.js
│  │  └──styles.css
│  └──templates
│  ├──index.html
│  └──layout.html
└──run.py

run.py文件中,我们将添加以下片段:

from application import app
app.run()

这使我们能够通过 CLI 调用以下命令以通常的方式运行开发服务器:

$ python run.py

注意

通常情况下,在__init__.py包中包含修改状态的代码(例如创建 Flask 应用程序对象)被认为是一种不好的做法。我们现在只是为了说明目的而这样做。

我们的 Flask 应用程序对象的run方法可以接受一些可选参数。以下是最有用的几个:

  • host:要绑定的主机 IP。默认为任何端口,用0.0.0.0表示。

  • port:应用程序将绑定到的端口。默认为5000

  • debug:如果设置为True,Werkzeug 开发服务器在检测到代码更改时将重新加载,并在发生未处理的异常时在 HTML 页面中提供一个交互式调试器。

在我们在前一节中概述的新应用程序结构中,很容易看到功能,比如路由处理程序定义,可以从__init__.py中拆分成类似views.py模块的东西。同样,我们的数据模型可以被分解成一个models.py模块,如下所示:

application
├──application
│  ├──__init__.py
│  ├──models.py
│  ├──static
│  │  ├──app.js
│  │  └──styles.css
│  ├──templates
│  │  ├──index.html
│  │  └──layout.html
│  └──views.py
└──run.py

我们只需要在__init__.py中导入这些模块,以确保在运行应用程序时它们被加载:

from flask import Flask
app = Flask(__name__)

import application.models
import application.views

注意

请注意,我们需要在实例化应用程序对象后导入视图,否则将创建循环导入。一旦我们开始使用蓝图开发应用程序,我们通常会尽量避免循环导入,确保一个蓝图不从另一个蓝图中导入。

同样,我们必须在views.py模块中导入 Flask 应用程序对象,以便我们可以使用@app.route装饰器来定义我们的路由处理程序:

from application import app

@app.route("/")
def hello():
 return "Hello World!"

@app.route("/contact")
def contact():
 return "You can contact me at 555-5555, or "
 " email me at test@example.com"

@app.route('/login', methods=['GET', 'POST'])
def login():
 if request.method == 'POST':
 # Logic for handling login
 pass
 else:
 # Display login form
 pass

如预期的那样,应用程序仍然可以像以前一样使用内置的 Werkzeug 应用程序服务器从命令行界面CLI)运行;唯一改变的是我们文件的组织。我们获得的优势(以额外文件的代价和可能出现循环导入的可能性)是功能分离和组织:我们的视图处理程序可以根据其感兴趣的领域在单个或多个模块中分组,我们的数据层和实用函数可以存在于应用程序结构的其他位置。

从包到蓝图

我们刚刚探讨的基于包的应用程序结构可能适用于大量的应用程序。然而,Flask 为我们提供了一种抽象级别即蓝图,它在视图层面上规范和强制实施了关注点的分离。

注意

不要将 Flask 中的蓝图概念与同名的 Packt 图书系列的概念混淆!

一个变得过于笨重的 Flask 应用程序可以被分解成一组离散的蓝图——每个蓝图都有自己的 URI 映射和视图函数、静态资源(例如 JavaScript 和 CSS 文件)、Jinja 模板,甚至 Flask 扩展。在许多方面,蓝图与 Flask 应用程序本身非常相似。但是,蓝图不是独立的 Flask 应用程序,不能作为独立的应用程序运行,如官方 Flask 文档中所述:

在 Flask 中,蓝图不是可插拔应用程序,因为它实际上不是一个应用程序——它是一组可以在应用程序上注册的操作,甚至可以多次注册。—官方 Flask 文档,flask.pocoo.org/docs/0.10/blueprints/

因此,应用程序中的所有蓝图将共享相同的主应用程序对象和配置,并且它们必须在 URI 分发之前向主 Flask 对象注册。

我们的第一个蓝图

以前基于包的应用程序布局可以通过首先添加一个新的包来包含我们的蓝图来扩展为基于蓝图的架构,我们将简单地称之为users

├──application
│  ├──__init__.py
│  └──users
│  ├──__init__.py
│  └──views.py
└──run.py

users包的内容包括必需的__init__.py和另一个模块views.py。我们(现在只是简单的)users蓝图的视图函数将放在views.py模块中:

from flask import Blueprint

users = Blueprint('users', __name__)

@users.route('/me')
def me():
 return "This is my page.", 200

注意

我们本可以将这段代码放在users/__init__.py文件中,而不是将其分离成自己的views.py模块;但这样做的话,我们将会在包初始化中放置一个产生副作用的代码(即,实例化用户蓝图对象),这通常是不被赞同的。将其分离成一个不同的模块会带来一些额外的复杂性,但将会在以后避免一些麻烦。

在这个新模块中,我们从 Flask 中导入了Blueprint类,并用它来实例化了一个users蓝图对象。Blueprint类有两个必需的参数,nameimport_name,我们提供的是users__name__,这是所有 Python 模块和脚本都可以使用的全局魔术属性。前者可以是我们所需的所有注册蓝图中的任何唯一标识符,后者应该是实例化蓝图对象的模块的名称。

一旦我们完成了这一步,我们必须修改我们在application/__init__.py中的应用程序初始化,以便将蓝图绑定到 Flask 应用程序对象:

from flask import Flask
from application.users.views import users

app = Flask(__name__)
app.register_blueprint(users, url_prefix='/users')

在将蓝图对象注册到应用程序实例时,可以指定几个可选参数。其中一个参数是url_prefix,它将自动为所讨论的蓝图中定义的所有路由添加给定字符串的前缀。这使得封装所有视图和路由变得非常简单,这些视图和路由用于处理以/users/* URI 段开头的任何端点的请求,这是我们在本书中经常使用的一种模式。

完成后,我们可以通过我们的run.py脚本以通常的方式使用内置的 Werkzeug 应用程序服务器来运行我们的应用程序:

$ python run.py

打开我们选择的浏览器并导航到http://localhost:5000/users/me会产生以下渲染结果:

我们的第一个蓝图

总结

在本章中,我们从最常见的简单 Flask 应用程序架构开始,并探讨了一些扩展它的方法,以实现更模块化的方法。我们首先从基于模块的布局转向基于包的布局,然后升级到使用 Flask 蓝图,为我们在接下来的章节中使用的基本应用程序结构铺平了道路。

在下一章中,我们将利用在这里获得的知识,通过使用蓝图模式和几个众所周知的 Flask 扩展来创建我们的第一个功能性 Flask 应用程序。

第三章:Snap - 代码片段共享应用程序

在本章中,我们将构建我们的第一个完全功能的、基于数据库的应用程序。这个应用程序,代号 Snap,将允许用户使用用户名和密码创建帐户。用户将被允许登录、注销、添加和列出所谓的半私密snaps文本,这些文本可以与其他人分享。

本章中,您应该熟悉以下至少一种关系数据库系统:PostgreSQL、MySQL 或 SQLite。此外,对 SQLAlchemy Python 库的一些了解将是一个优势,它充当这些(以及其他几个)数据库的抽象层和对象关系映射器。如果您对 SQLAlchemy 的使用不熟悉,不用担心。我们将对该库进行简要介绍,以帮助新开发人员迅速上手,并为经验丰富的开发人员提供复习。

从现在开始,在本书中,SQLite 数据库将是我们选择的关系数据库。我们列出的其他数据库系统都是基于客户端/服务器的,具有多种配置选项,可能需要根据安装的系统进行调整,而 SQLite 的默认操作模式是独立、无服务器和零配置。

我们建议您使用 SQLite 来处理这个项目和接下来的章节中的项目,但 SQLAlchemy 支持的任何主要关系数据库都可以。

入门

为了确保我们正确开始,让我们创建一个项目存在的文件夹和一个虚拟环境来封装我们将需要的任何依赖项:

$ mkdir -p ~/src/snap && cd ~/src/snap
$ mkvirtualenv snap -i flask

这将在给定路径创建一个名为snap的文件夹,并带我们到这个新创建的文件夹。然后它将在这个环境中创建 snap 虚拟环境并安装 Flask。

注意

请记住,mkvirtualenv工具将创建虚拟环境,这将是从pip安装软件包的默认位置集,但mkvirtualenv命令不会为您创建项目文件夹。这就是为什么我们将首先运行一个命令来创建项目文件夹,然后再创建虚拟环境。虚拟环境通过激活环境后执行的$PATH操作完全独立于文件系统中项目文件的位置。

然后,我们将使用基本的基于蓝图的项目布局创建一个空的用户蓝图。所有文件的内容几乎与我们在上一章末尾描述的内容相同,布局应该如下所示:

application
├── __init__.py
├── run.py
└── users
    ├── __init__.py
    ├── models.py
    └── views.py

Flask-SQLAlchemy

一旦上述文件和文件夹被创建,我们需要安装下一个重要的一组依赖项:SQLAlchemy 和使与该库交互更类似于 Flask 的 Flask 扩展,Flask-SQLAlchemy:

$ pip install flask-sqlalchemy

这将安装 Flask 扩展到 SQLAlchemy 以及后者的基本分发和其他几个必要的依赖项,以防它们尚未存在。

现在,如果我们使用的是除 SQLite 之外的关系数据库系统,这就是我们将在其中创建数据库实体的时刻,比如在 PostgreSQL 中,以及创建适当的用户和权限,以便我们的应用程序可以创建表并修改这些表的内容。然而,SQLite 不需要任何这些。相反,它假设任何可以访问数据库文件系统位置的用户也应该有权限修改该数据库的内容。

在本章的后面,我们将看到如何通过 SQLAlchemy 自动创建 SQLite 数据库文件。然而,为了完整起见,这里是如何在文件系统的当前文件夹中创建一个空数据库:

$ sqlite3 snap.db  # hit control-D to escape out of the interactive SQL console if necessary.

注意

如前所述,我们将使用 SQLite 作为示例应用程序的数据库,并且给出的指示将假定正在使用 SQLite;二进制文件的确切名称可能在您的系统上有所不同。如果使用的不是 SQLite,您可以替换等效的命令来创建和管理您选择的数据库。

现在,我们可以开始对 Flask-SQLAlchemy 扩展进行基本配置。

配置 Flask-SQLAlchemy

首先,我们必须在application/__init__.py文件中将 Flask-SQLAlchemy 扩展注册到application对象中:

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

app = Flask(__name__)

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///../snap.db'
db = SQLAlchemy(app)

app.config['SQLALCHEMY_DATABASE_URI']的值是我们之前创建的snap.db SQLite数据库的转义相对路径。一旦这个简单的配置就位,我们就能够通过db.create_all()方法自动创建 SQLite 数据库,这可以在交互式 Python shell 中调用:

$  python
>>>from application import db
>>>db.create_all()

这是一个幂等操作,这意味着即使数据库已经存在,也不会发生任何变化。然而,如果本地数据库文件不存在,它将被创建。这也适用于添加新的数据模型:运行db.create_all()将它们的定义添加到数据库,确保相关表已被创建并且可访问。然而,它并不考虑已经存在于数据库中的现有模型/表定义的修改。为此,您需要使用相关工具(例如 sqlite CLI,或者迁移工具如 Alembic,我们将在后面的章节中讨论)来修改相应的表定义,以匹配您模型中已更新的定义。

SQLAlchemy 基础知识

SQLAlchemy 首先是一个与 Python 中的关系数据库进行交互的工具包。

虽然它提供了令人难以置信的多种功能,包括各种数据库引擎的 SQL 连接处理和连接池、处理自定义数据类型的能力以及全面的 SQL 表达式 API,但大多数开发人员熟悉的功能是对象关系映射器。这个映射器允许开发人员将 Python 对象定义与他们选择的数据库中的 SQL 表连接起来,从而使他们能够灵活地控制自己应用程序中的领域模型,并且只需要最小的耦合到数据库产品和引擎特定的 SQL 特性。

虽然在本章讨论对象关系映射器的有用性(或缺乏有用性)超出了范围,但对于那些不熟悉 SQLAlchemy 的人,我们将提供使用这个工具带来的好处清单,如下所示:

  • 您的领域模型是为了与最受尊敬、经过测试和部署的 Python 包之一——SQLAlchemy 进行交互而编写的。

  • 由于有关使用 SQLAlchemy 的广泛文档、教程、书籍和文章,将新开发人员引入项目变得更加容易。

  • 查询的验证是在模块导入时使用 SQLAlchemy 表达式语言完成的,而不是针对数据库执行每个查询字符串以确定是否存在语法错误。表达式语言是用 Python 编写的,因此可以使用您通常的一套工具和 IDE 进行验证。

  • 由于实现了设计模式,如工作单元、身份映射和各种延迟加载特性,开发人员通常可以避免执行比必要更多的数据库/网络往返。考虑到典型 Web 应用程序中请求/响应周期的大部分很容易归因于各种类型的网络延迟,最小化典型响应中的数据库查询数量在多个方面都是性能上的胜利。

  • 虽然许多成功的高性能应用程序可以完全建立在 ORM 上,但 SQLAlchemy 并不强制要求这样做。如果出于某种原因,更倾向于编写原始的 SQL 查询字符串或直接使用 SQLAlchemy 表达语言,那么您可以这样做,并仍然从 SQLAlchemy 本身的连接池和 Python DBAPI 抽象功能中受益。

既然我们已经给出了几个理由,说明为什么应该使用这个数据库查询和领域数据抽象层,让我们看看如何定义一个基本的数据模型。

声明式映射和 Flask-SQLAlchemy

SQLAlchemy 实现了一种称为数据映射器的设计模式。基本上,这个数据映射器的工作是在代码中桥接数据模型的定义和操作(在我们的情况下,Python 类定义)以及数据库中这个数据模型的表示。映射器应该知道代码相关的操作(例如,对象构造、属性修改等)如何与我们选择的数据库中的 SQL 特定语句相关联,确保在我们映射的 Python 对象上执行的操作与它们关联的数据库表正确同步。

我们可以通过两种方式将 SQLAlchemy 集成到我们的应用程序中:通过使用提供表、Python 对象和数据映射一致集成的声明式映射,或者通过手动指定这些关系。此外,还可以使用所谓的 SQLAlchemy“核心”,它摒弃了基于数据域的方法,而是基于 SQL 表达语言构造,这些构造包含在 SQLAlchemy 中。

在本章(以及将来的章节)中,我们将使用声明式方法。

要使用声明式映射功能,我们需要确保我们定义的任何模型类都将继承自 Flask-SQLAlchemy 提供给我们的声明基类Model类(一旦我们初始化了扩展):

from application import db

class User(db.Model):
 # model attributes
 pass

这个Model类本质上是sqlalchemy.ext.declarative.declarative_base类的一个实例(带有一些额外的默认值和有用的功能),它为对象提供了一个元类,该元类将处理适当的映射构造。

一旦我们在适当的位置定义了我们的模型类定义,我们将通过使用Column对象实例来定义通过类级属性映射的相关 SQL 表的详细信息。Column 调用的第一个参数是我们想要对属性施加的类型约束(对应于数据库支持的特定模式数据类型),以及类型支持的任何可选参数,例如字段的大小。还可以提供其他参数来指示对生成的表字段定义的约束:

class User(db.Model):

 id = db.Column(db.Integer, primary_key=True)
 email = db.Column(db.String(255), unique=True)
 username = db.Column(db.String(40), unique=True)

注意

如前所述,仅仅定义属性并不会自动转换为数据库中的新表和列。为此,我们需要调用db.create_all()来初始化表和列的定义。

我们可以轻松地创建此模型的实例,并为我们在类定义中声明的属性分配一些值:

$ (snap) python
>>>from application.users.models import User
>>>new_user = User(email="me@example.com", username="me")
>>>new_user.email
'me@example.com'
>>>new_user.username
'me'

注意

您可能已经注意到,我们的用户模型没有定义__init__方法,但当实例化上面的示例时,我们能够将emailusername参数传递给对象构造函数。这是 SQLAlchemy 声明基类的一个特性,它会自动将命名参数在对象构造时分配给它们的对象属性对应项。因此,通常不需要为数据模型定义一个具体的构造方法。

模型对象的实例化并不意味着它已经持久化到数据库中。为此,我们需要通知 SQLAlchemy 会话,我们希望添加一个新对象以进行跟踪,并将其提交到数据库中:

>>>from application import db
>>>db.session.add(new_user)
>>>db.session.commit()

一旦对象被提交,id属性将获得底层数据库引擎分配给它的主键值:

>>>print(new_user.id)
1

如果我们想修改属性的值,例如,更改特定用户的电子邮件地址,我们只需要分配新值,然后提交更改:

>>>new_user.email = 'new@example.com'
>>>db.session.add(new_user)
>>>db.session.commit()
>>>print(new_user.email)
u'new@example.com'

此时,您可能已经注意到在任何以前的操作中都没有编写过一行 SQL,并且可能有点担心您创建的对象中嵌入的信息没有持久保存到数据库中。对数据库的粗略检查应该让您放心:

$ sqlite3 snap.db
SQLite version 3.8.5 2014-08-15 22:37:57
Enter ".help" for usage hints.
sqlite> .tables
user
sqlite> .schema user
CREATE TABLE user (
 id INTEGER NOT NULL,
 email VARCHAR(255),
 username VARCHAR(40),
 PRIMARY KEY (id),
 UNIQUE (email),
 UNIQUE (username)
);
sqlite> select * from user;
1|new@example.com|me

注意

请记住,SQLite 二进制文件的确切名称可能会因您选择的操作系统而异。此外,如果您选择了除 SQLite 之外的数据库引擎来跟随这些示例,相关的命令和结果可能会大相径庭。

就是这样:SQLAlchemy 成功地在幕后管理了相关的 SQL INSERT 和 UPDATE 语句,让我们可以使用本机 Python 对象,并在准备将数据持久保存到数据库时通知会话。

当然,我们不仅限于定义类属性。在许多情况下,声明模型上的实例方法可能会证明很有用,以便我们可以执行更复杂的数据操作。例如,想象一下,我们需要获取给定用户的主键 ID,并确定它是偶数还是奇数。方法声明将如你所期望的那样:

class User(db.Model):

 id = db.Column(db.Integer, primary_key=True)
 email = db.Column(db.String(255), unique=True)
 username = db.Column(db.String(40), unique=True)

def is_odd_id(self):
 return (self.id % 2 != 0)

实例方法调用可以像往常一样执行,但在将对象提交到会话之前,主键值将为 none:

$ (snap)  python
Python 2.7.10 (default, Jul 13 2015, 23:27:37)
[GCC 4.2.1 Compatible Apple LLVM 6.1.0 (clang-602.0.53)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>fromapplication.users.models import User
>>>test = User(email='method@example.com', username='method_test')
>>>from application import db
>>>db.session.add(test)
>>>db.session.commit()
>>> test.id
2
>>>test.is_odd_id()
False

当然,在大多数 Web 应用程序的上下文中,前面的实现是微不足道且有些毫无意义的。然而,定义模型实例方法以编码业务逻辑的能力非常方便,我们将在本章后面看到 Flask-Login 扩展中的一些内容。

快照数据模型

现在我们已经探索了 SQLAlchemy 声明基础和 Flask-SQLAlchemy 扩展的基础知识,使用了一个简化的模型,我们的下一步是完善一个用户数据模型,这是几乎任何 Web 应用程序的基石。我们将在用户蓝图中创建这个模型,在一个新的users/models.py模块中利用我们对 SQLAlchemy 模型的知识,为用户passwordcreated_on字段添加字段,以存储记录创建的时间。此外,我们将定义一些实例方法:

import datetime
from application import db

class User(db.Model):

 # The primary key for each user record.
 id = db.Column(db.Integer, primary_key=True)

 # The unique email for each user record.
 email = db.Column(db.String(255), unique=True)

 # The unique username for each record.
 username = db.Column(db.String(40), unique=True)

 # The hashed password for the user
 password = db.Column(db.String(60))

#  The date/time that the user account was created on.
 created_on = db.Column(db.DateTime, 
 default=datetime.datetime.utcnow)

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

 def is_authenticated(self):
 """All our registered users are authenticated."""
 return True

 def is_active(self):
 """All our users are active."""
 return True

 def is_anonymous(self):
 """We don)::f):lf):"""users are authenticated."""
 return False

 def get_id(self):
 """Get the user ID as a Unicode string."""
 return unicode(self.id)

is_authenticatedis_activeis_anonymousget_id方法目前可能看起来是任意的,但它们是下一步所需的,即安装和设置 Flask-Login 扩展,以帮助我们管理用户身份验证系统。

Flask-Login 和 Flask-Bcrypt 用于身份验证

我们已经多次使用其他库进行了安装扩展,我们将在当前项目的虚拟环境中安装这些扩展:

$ (snap) pip install flask-login flask-bcrypt

第一个是一个特定于 Flask 的库,用于规范几乎每个 Web 应用程序都需要的标准用户登录过程,后者将允许我们确保我们在数据库中存储的用户密码使用行业标准算法进行哈希处理。

安装后,我们需要以通常的方式实例化和配置扩展。为此,我们将添加到application/__init__.py模块中:

from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.login import LoginManager
from flask.ext.bcrypt import Bcrypt

app = Flask(__name__)

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///../snap.db'
db = SQLAlchemy(app)

login_manager = LoginManager()
login_manager.init_app(app)
flask_bcrypt = Bcrypt(app)

from application.users import models as user_models
from application.users.views import users

为了正确运行,Flask-Login 扩展还必须知道如何仅通过用户的 ID 从数据库中加载用户。我们必须装饰一个函数来完成这个任务,并为简单起见,我们将它插入到application/__init__.py模块的最后:

from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.login LoginManager
from flask.ext.bcrypt import Bcrypt

app = Flask(__name__)

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///../snap.db'
db = SQLAlchemy(app)

login_manager = LoginManager()
login_manager.init_app(app)
flask_bcrypt = Bcrypt(app)

from application.users import models as user_models
from application.users.views import users

@login_manager.user_loader
def load_user(user_id):
 return application.user_models.query.get(int(user_id))

现在我们已经设置了模型和所需的方法/函数,以便 Flask-Login 可以正确运行,我们的下一步将是允许用户像几乎任何 Web 应用程序一样登录使用表单。

Flask-WTF - 表单验证和呈现

Flask-WTF(https://flask-wtf.readthedocs.org/en/latest/)扩展包装了 WTForms 库,这是一个非常灵活的管理和验证表单的工具,并且可以在 Flask 应用程序中方便地使用。让我们现在安装它,然后我们将定义我们的第一个表单来处理用户登录:

$ pip install flask-wtf

接下来,我们将在我们的users/views.py模块中定义我们的第一个表单:

from flask import Blueprint

from flask.ext.wtf import Form
from wtforms import StringField, PasswordField
from wtforms.validators import DataRequired, Length

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

classLoginForm(Form):
 """
 Represents the basic Login form elements & validators.
 """

 username = StringField('username', validators=[DataRequired()])
 password = PasswordField('password', validators=[DataRequired(),
 Length(min=6)])

在这里,我们定义了LoginForm,它是Form的子类,具有usernamepassword的类属性。这些属性的值分别是StringFieldPasswordField,每个都有自己的验证器集,指示这两个字段的表单数据都需要非空,并且密码字段本身应至少为六个字符长才能被视为有效。

我们的LoginForm类将以两种不同的方式被使用,如下所示:

  • 它将在我们的login.html模板中呈现所需的表单字段

  • 它将验证我们需要完成用户成功登录所需的 POST 表单数据

为了实现第一个,我们需要在application/templates/layout.html中定义我们的 HTML 布局,使用 Jinja2 模板语言。请注意使用current_user对象代理,它通过 Flask-Login 扩展在所有 Jinja 模板中提供,这使我们能够确定正在浏览的人是否已经认证,如果是,则应该向这个人呈现略有不同的页面内容:

<!doctype html>
<html>
  <head>
    <title>Snaps</title>
  </head>

  <body>
    <h1>Snaps</h1>

    {% for message in get_flashed_messages() %}
    <div class="flash">{{ message }}</div>
    {% endfor %}

    {% if not current_user.is_authenticated() %}
    <a href="{{ url_for('users.login') }}">login</a>
    {% else %}
    <a href="{{ url_for('users.logout') }}">logout</a>
    {% endif %}

    <div class="content">
    {% block content %}{% endblock %}
    </div>
  </body>
</html>

现在我们已经有了极其基本的布局,我们需要在application/users/templates/users/login.html中创建我们的login.html页面:

注意

当使用蓝图时,application/users/templates/users/index.html的相对复杂路径是必需的,因为默认模板加载程序搜索注册的模板路径的方式,它允许相对简单地在主应用程序模板文件夹中覆盖蓝图模板,但会增加一些额外的文件树复杂性。

{% extends "layout.html" %}

{% block content %}

<form action="{{ url_for('users.login')}}" method="post">
  {{ form.hidden_tag() }}
  {{ form.id }}
  <div>{{ form.username.label }}: {{ form.username }}</div>
  {% if form.username.errors %}
  <ul class="errors">{% for error in form.username.errors %}<li>{{ error }}</li>{% endfor %}</ul>
  {% endif %}

  <div>{{ form.password.label }}: {{ form.password }}</div>
  {% if form.password.errors %}
  <ul class="errors">{% for error in form.password.errors %}<li>{{ error }}</li>{% endfor %}</ul>
  {% endif %}

  <div><input type="submit" value="Login"></div>
</form>

{% endblock %}

前面的代码将扩展我们之前定义的基本应用程序级layout.html,并插入隐藏的表单字段(Flask-WTF 提供的内置 CSRF 保护所需),表单标签,表单输入和提交按钮。我们还将显示 WTForms 返回的内联错误,以防我们提交的数据未通过相关字段的表单验证器。

跨站请求伪造CSRF是一种攻击类型,当恶意网站、电子邮件、博客、即时消息或程序导致用户的网络浏览器在用户当前已认证的受信任站点上执行不需要的操作时发生。OWASP 对 CSRF 的定义

注意

防止跨站请求伪造最常见的方法是在发送给用户的每个 HTML 表单中包含一个令牌,然后可以针对已认证用户的会话中的匹配令牌进行验证。如果令牌无法验证,那么表单数据将被拒绝,因为当前认证用户可能并不是自愿提交相关表单数据。

现在我们已经创建了login.html模板,接下来我们可以在application/users/views.py中挂接一个路由视图处理程序来处理登录和表单逻辑:

from flask import (Blueprint, flash, render_template, url_for, redirect, g)
from flask.ext.login import login_user, logout_user, current_user

from flask.ext.wtf import Form
from wtforms import StringField, PasswordField
from wtforms.validators import DataRequired, Length

from models import User
from application import flask_bcrypt

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

class LoginForm(Form):
 """
 Represents the basic Login form elements & validators.
 """

 username = StringField('username', 
validators=[DataRequired()])
password = PasswordField('password', 
validators=[DataRequired(),Length(min=6)])

@users.route('/login', methods=['GET', 'POST'])
def login():
 """
Basic user login functionality.

 If the user is already logged in, we
redirect the user to the default snaps index page.

 If the user is not already logged in and we have
form data that was submitted via POST request, we
call the validate_on_submit() method of the Flask-WTF
 Form object to ensure that the POST data matches what
we are expecting. If the data validates, we login the
user given the form data that was provided and then
redirect them to the default snaps index page.

 Note: Some of this may be simplified by moving the actual User
loading and password checking into a custom Flask-WTF validator
for the LoginForm, but we avoid that for the moment, here.
 """

current_user.is_authenticated():
 return redirect(url_for('snaps.listing))

 form = LoginForm()
 if form.validate_on_submit():

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

 if not user:
 flash("No such user exists.")
 returnrender_template('users/login.html', form=form)

 if(not flask_bcrypt.check_password_hash(user.password,
 form.password.data)):

 flash("Invalid password.")
 returnrender_template('users/login.html', form=form)

 login_user(user, remember=True)
 flash("Success!  You're logged in.")
 returnredirect(url_for("snaps.listing"))

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

@users.route('/logout', methods=['GET'])
def logout():
 logout_user()
 return redirect(url_for(('snaps.listing'))

哈希用户密码

我们将更新我们的用户模型,以确保密码在更新“密码”字段时由 Flask-Bcrypt 加密。为了实现这一点,我们将使用 SQLAlchemy 的一个功能,它类似于 Python 的@property 装饰器(以及相关的 property.setter 方法),名为混合属性。

注意

混合属性之所以被命名为混合属性,是因为当在类级别或实例级别调用时,它们可以提供完全不同的行为。SQLAlchemy 文档是了解它们在领域建模中可以扮演的各种角色的好地方。

我们将简单地将密码类级属性重命名为_password,以便我们的混合属性方法不会发生冲突。随后,我们添加了封装了密码哈希逻辑的混合属性方法,以在属性分配时使用:

注意

除了混合属性方法之外,我们对分配密码哈希的要求也可以通过使用 SQLAlchemy TypeDecorator 来满足,这允许我们增加现有类型(例如,String 列类型)的附加行为。

import datetime
from application import db, flask_bcrypt
from sqlalchemy.ext.hybrid import hybrid_property

class User(db.Model):

 # …

 # The hashed password for the user
 _password = db.Column('password', db.String(60))

 # …
 @hybrid_property
 def password(self):
 """The bcrypt'ed password of the given user."""

return self._password

 @password.setter
 def password(self, password):
 """Bcrypt the password on assignment."""

 self._password = flask_bcrypt.generate_password_hash(
 password)

 # …

为了生成一个用于测试目的的用户(并验证我们的密码是否在实例构造/属性分配时被哈希),让我们加载 Python 控制台,并使用我们定义的模型和我们创建的 SQLAlchemy 数据库连接自己创建一个用户实例:

提示

如果您还没有,不要忘记使用db.create_all()来初始化数据库。

>>>from application.users.models import User
>>>user = User(username='test', password='mypassword', email='test@example.com')
>>>user.password
'$2a$12$O6oHgytOVz1hrUyoknlgqeG7TiVS7M.ogRPv4YJgAJyVeUIV8ad2i'
>>>from application import db
>>>db.session.add(user)
>>>db.session.commit()

配置应用程序 SECRET_KEY

我们需要的最后一点是定义一个应用程序范围的SECRET_KEY,Flask-WTF 将使用它来签署用于防止 CSRF 攻击的令牌。我们将在application/__init__.py中的应用程序配置中添加此密钥:

from flask import Flask
fromflask.ext.sqlalchemy import SQLAlchemy
fromflask.ext.login import LoginManager
fromflask.ext.bcrypt import Bcrypt

app = Flask(__name__)

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///../snap.db'
app.config['SECRET_KEY'] = "-80:,bPrVzTXp*zXZ0[9T/ZT=1ej08"
# …

注意

当然,您会想要使用您自己的唯一密钥;最简单的方法是通过/dev/urandom来使用您系统内核的随机数设备,对于大多数 Linux 发行版都是可用的。在 Python 中,您可以使用os.urandom方法来获得一个具有n字节熵的随机字符串。

连接蓝图

在我们运行应用程序之前,我们需要使用 Flask 应用程序对象注册我们新创建的用户蓝图。这需要对application/__init__.py进行轻微修改:

from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.login import LoginManager
from flask.ext.bcrypt import Bcrypt

app = Flask(__name__)

# …
from application.users.views import users
app.register_blueprint(users, url_prefix='/users')

# …

让我们运行这个东西

既然我们已经把所有小部件放在一起,让我们运行应用程序并让事情发生。我们将使用一个类似于我们在上一章中使用的run.py文件,它已经适应了我们的应用程序工厂的工作方式:

from application import create_app

app = create_app(config='settings')
app.run(debug=True)

该文件被放置在application文件夹的同级目录下,然后以通常的方式调用:

$ python run.py

访问http://localhost:5000/users/login,您应该会看到我们创建的usernamepassword输入字段。如果您尝试输入无效字段(例如,不存在的用户名),页面将显示相关的错误消息。如果您尝试使用我们在交互提示中创建的用户凭据登录,那么您应该会看到文本:Success! You logged in

快照的数据模型

既然我们已经创建了我们的基本用户模型、视图函数,并连接了我们的身份验证系统,让我们创建一个新的蓝图来存储我们的快照所需的模型,在application/snaps/models.py下。

提示

不要忘记创建application/snaps/__init__.py,否则该文件夹将无法被识别为一个包!

这个模型将与我们的用户模型非常相似,但将包含有关用户和他们的快照之间关系的附加信息。在 SQLAlchemy 中,我们将通过使用ForeignKey对象和relationship方法来描述表中记录之间的关系:

import datetime
import hashlib
from application import db

class Snap(db.Model):

 # The primary key for each snap record.
 id = db.Column(db.Integer, primary_key=True)

 # The name of the file; does not need to be unique.
 name = db.Column(db.String(128))

 # The extension of the file; used for proper syntax 
 # highlighting
 extension = db.Column(db.String(12))

 # The actual content of the snap
 content = db.Column(db.Text())

 # The unique, un-guessable ID of the file
 hash_key = db.Column(db.String(40), unique=True)

 #  The date/time that the snap was created on.
 created_on = db.Column(db.DateTime, 
 default=datetime.datetime.utcnow,index=True)

 # The user this snap belongs to
 user_id = db.Column(db.Integer, db.ForeignKey('user.id'))

 user = db.relationship('User', backref=db.backref(
 'snaps', lazy='dynamic'))

 def __init__(self, user_id, name, content, extension):
 """
 Initialize the snap object with the required attributes.
 """

 self.user_id = user_id
 self.name = name
 self.content = content
 self.extension = extension

self.created_on = datetime.datetime.utcnow()

 # This could be made more secure by combining the 
 # application SECRET_KEYin the hash as a salt.
 self.hash_key = hashlib.sha1(self.content + str(self.created_on)).hexdigest()

 def __repr__(self):
 return '<Snap {!r}>'.format(self.id)

这个模型大部分应该是相对熟悉的;它与我们之前为用户模式构建的模型并没有太大的不同。对于我们的快照,我们将需要一些强制属性,如下所示:

  • user_id:这是创建快照的用户的 ID。由于我们当前的实现将要求用户进行身份验证才能创建快照,所有生成的快照都将与发布它们的用户相关联。这也将使我们在以后轻松扩展系统,以包括用户个人资料、个人快照统计信息和删除快照的能力。

  • created_on:这在构造函数中设置为当前的 UTC 时间戳,并将用于按降序排序以在我们的首页上以列表形式显示它们。

  • hash_key:这个属性也在构造函数中设置,是快照内容与创建时间戳的加密哈希。这给了我们一个不容易猜测的唯一安全 ID,我们可以用它来在以后引用快照。

注意

尽管我们为前面的hash_key描述的条件并不保证该值是唯一的,快照哈希键的唯一性也通过数据库级别的唯一索引约束得到了强制。

  • content:这是快照本身的内容——模型的主要部分。

  • extension:这是快照的文件扩展名,这样我们就可以包含简单的语法高亮。

  • name:这是快照的名称,不需要是唯一的。

  • user:这是一个特殊属性,声明每个快照实例都与一个用户实例相关联,并允许我们访问创建快照的用户的数据。backref选项还指定了反向应该是可能的:也就是说,通过用户实例上的快照属性访问用户创建的所有快照。

使用内容敏感的默认函数更好的默认值

对前面的模型可以进行的一个改进是删除显式的__init__方法。最初定义它的唯一原因是确保可以从内容字段的值构造hash_key字段。虽然在大多数情况下,定义的显式对象构造函数已经足够好了,但 SQLAlchemy 提供了功能,允许我们根据另一个字段的内容设置一个字段的默认值。这被称为上下文敏感的默认函数,可以在application/snaps/models.py模块的顶部声明为这样:

defcontent_hash(context):
 # This could be made more secure by combining the
 # application SECRET_KEY in the hash as a salt.
 content = context.current_parameters['content']
 created_on = context.current_parameters['created_on']
 return hashlib.sha1(content + str(created_on)).hexdigest()

一旦存在这个方法,我们就可以将hash_key列的默认参数定义为我们的content_hash内容敏感的默认值:

# The unique, un-guessable ID of the file
hash_key = db.Column(db.String(40), unique=True, 
 default=content_hash)

快照视图处理程序

接下来,我们将创建所需的视图和模板,以列出和添加快照。为此,我们将在application/snaps/views.py中实例化一个Blueprint对象,并声明我们的路由处理程序:

from flask import Blueprint
from flask.ext.login import login_required

from .models import Snap

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

@snaps.route('/', methods=['GET'])
def listing():
"""List all snaps; most recent first."""

@snaps.route('/add', methods=['GET', 'POST'])
@login_required
def add():
 """Add a new snap."""

请注意,我们已经用@login_required装饰器包装了我们的add()路由处理程序,这将阻止未经身份验证的用户访问此端点的所有定义的 HTTP 动词(在本例中为 GET 和 POST),并返回 401。

注意

与其让服务器返回 HTTP 401 未经授权,不如配置 Flask-Login 将未经身份验证的用户重定向到登录页面,方法是将login_manager.login_view属性设置为登录页面本身的url_for兼容位置,而在我们的情况下将是users.login

现在,让我们创建 WTForm 对象来表示一个快照,并将其放在application/snaps/views.py模块中:

from flask.ext.wtf import Form
from wtforms import StringField
from wtforms.widgets import TextArea
from wtforms.validators import DataRequired

class SnapForm(Form):
 """Form for creating new snaps."""

 name = StringField('name', validators=[DataRequired()])
 extension = StringField('extension', 
 validators=[DataRequired()])
 content = StringField('content', widget=TextArea(),
 validators=[DataRequired()])

提示

虽然这在某种程度上是个人偏好的问题,但使用 WTForms(或任何其他类似的抽象)创建的表单可以放在模型旁边,而不是视图。或者,更进一步地,如果您有许多不同的表单与复杂的数据关系,也许将所有声明的表单放在应用程序的自己的模块中也是明智的。

我们的快照需要一个名称、一个扩展名和快照本身的内容,我们已经在前面的表单声明中封装了这些基本要求。让我们实现我们的add()路由处理程序:

from flask import Blueprint, render_template, url_for, redirect, current_app, flash
from flask.ext.login import login_required, current_user
from sqlalchemy import exc

from .models import Snap
from application import db

# …

@snaps.route('/add', methods=['GET', 'POST'])
@login_required
def add():
 """Add a new snap."""

 form = SnapForm()

 if form.validate_on_submit():
 user_id = current_user.id

 snap = Snap(user_id=user_id, name=form.name.data,
 content=form.content.data, 
 extension=form.extension.data)
 db.session.add(snap)

try:
 db.session.commit()
 except exc.SQLAlchemyError:
 current_app.exception("Could not save new snap!")
 flash("Something went wrong while posting your snap!")

 else:
 return render_template('snaps/add.html', form=form)

 return redirect(url_for('snaps.listing'))

简而言之,我们将验证提交的 POST 数据,以确保它满足我们在SnapForm类声明中指定的验证器,然后继续使用提供的表单数据和当前认证用户的 ID 来实例化一个Snap对象。构建完成后,我们将将此对象添加到当前的 SQLAlchemy 会话中,然后尝试将其提交到数据库。如果发生 SQLAlchemy 异常(所有 SQLAlchemy 异常都继承自salalchemy.exc.SQLALchemyError),我们将记录异常到默认的应用程序日志处理程序,并设置一个闪存消息,以便提醒用户发生了意外情况。

为了完整起见,我们将在这里包括极其简单的application/snaps/templates/snaps/add.html Jinja 模板:

{% extends "layout.html" %}

{% block content %}
<form action="{{ url_for('snaps.add')}}" method="post">

  {{ form.hidden_tag() }}
  {{ form.id }}

  <div class="row">
    <div>{{ form.name.label() }}: {{ form.name }}</div>
    {% if form.name.errors %}
    <ul class="errors">{% for error in form.name.errors %}<li>{{ error }}</li>{% endfor %}</ul>
    {% endif %}

    <div>{{ form.extension.label() }}: {{ form.extension }}</div>
    {% if form.extension.errors %}
    <ul class="errors">{% for error in form.extension.errors %}<li>{{ error }}</li>{% endfor %}</ul>
    {% endif %}
  </div>

  <div class="row">
    <div>{{ form.content.label() }}: {{ form.content }}</div>
    {% if form.content.errors %}
    <ul class="errors">{% for error in form.content.errors %}<li>{{ error }}</li>{% endfor %}</ul>
    {% endif %}
  </div>

  <div><input type="submit" value="Snap"></div>
</form>

{% endblock %}

完成了add()处理程序和相关模板后,现在是时候转向listing()处理程序了,这将偶然成为我们应用程序的登陆页面。列表页面将以相反的时间顺序显示最近发布的 20 个快照:

@snaps.route('/', methods=['GET'])
def listing():
 """List all snaps; most recent first."""
 snaps = Snap.query.order_by(
 Snap.created_on.desc()).limit(20).all()
 return render_template('snaps/index.html', snaps=snaps)

application/snaps/templates/snaps/add.html Jinja 模板呈现了我们从数据库中查询到的快照:

{% extends "layout.html" %}

{% block content %}
<div class="new-snap">
  <p><a href="{{url_for('snaps.add')}}">New Snap</a></p>
</div>

{% for snap in snaps %}
<div class="snap">
  <span class="author">{{snap.user.username}}</span>, published on <span class="date">{{snap.created_on}}</span>
  <pre><code>{{snap.content}}</code></pre>
</div>
{% endfor %}

{% endblock %}

接下来,我们必须确保我们创建的快照蓝图已加载到应用程序中,并通过将其添加到application/__init__.py模块来添加到根/URI 路径:

from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.login import LoginManager
from flask.ext.bcrypt import Bcrypt

# …

from application.users import models as user_models
from application.users.views import users
from application.snaps.views import snaps

app.register_blueprint(users, url_prefix='/users')
app.register_blueprint(snaps, url_prefix='')

@login_manager.user_loader
de fload_user(user_id):
 return user_models.User.query.get(int(user_id))

为了测试我们的新功能,我们需要将新创建的快照模型添加到我们的数据库中。我们可以通过执行我们在本章前面描述的db.create_all()函数来实现这一点。由于我们经常运行这个命令,让我们将其放在与我们的主应用程序包文件同级的脚本中,并将文件命名为database.py

from application import db
db.create_all()

一旦就位,我们可以简单地使用 Python 解释器执行脚本,以在我们的数据库中创建新的快照模型:

$ python database.py

现在,我们的数据库应该已经根据我们的模型定义更新了,让我们确保应用程序按预期运行:

$ python run.py

假设没有错误,您应该能够访问显示的 URL,并使用我们在本章早些时候创建的用户之一的凭据登录。当然,您可以通过交互式 Python 解释器创建一个新用户,然后使用这些凭据来测试应用程序的身份验证功能:

$ python
>>>from application import db
>>>from application.users.models import User
>>>user = User(name='test', email='test@example.com', password='foobar')
>>>db.session.add(user)
>>>db.session.commit(user)

总结

通过阅读本章并构建 Snap 应用程序,我们已经看到了 Flask 如何通过使用扩展来增强,例如 Flask-WTF(用于 Web 表单创建和验证)、Flask-SQLAlchemy(用于与 SQLAlchemy 数据库抽象库的简单集成)、Flask-Bcrypt(用于密码哈希)和 Flask-Login(用于简单用户登录系统的标准实现要求的抽象)。虽然 Flask 本身相对简洁,但可用的扩展生态系统使得构建一个完全成熟的用户认证应用程序可以快速且相对轻松地完成。

我们探讨了上述扩展及其有用性,包括 Flask-WTF 和 Flask-SQLAlchemy,并设计了一个基于蓝图的简单应用程序,集成了上述所有组件。虽然 Snap 应用程序本身非常简单,还有很多功能需要实现,但它非常容易更新和添加其他功能。

在下一章中,我们将构建一个具有更复杂数据模型的应用程序,并包含一些在今天的 Web 应用程序中常见的社交功能。此外,它将被构建和设置为单元和功能测试,这是任何微不足道的应用程序都不应该缺少的功能。

第四章:Socializer-可测试的时间线

在本章中,我们将使用代号“Socializer”构建我们的下一个应用程序。这个应用程序将为您提供一个非常典型的时间线信息流,其变体出现在许多知名的现代网络应用程序中。

这个应用程序将允许经过身份验证的用户关注其他用户,并被其他用户关注,并以时间顺序显示被关注用户发布的内容。除了构建基于时间线的应用程序所需的基本功能之外,我们还将使用优秀的Blinker库来实现其他行为,以进行进程内发布/订阅信号,这将使我们能够将应用程序解耦为更可组合、可重用的部分。

此外,Socializer 将在构建过程中考虑单元测试和功能测试,使我们能够对各种模型和视图进行严格测试,以确保其按照我们的期望进行功能。

开始

就像我们在上一章中所做的那样,让我们为这个应用程序创建一个全新的目录,另外创建一个虚拟环境,并安装我们将要使用的一些基本包:

$ mkdir -p ~/src/socializer && cd ~/src/socializer
$ mkvirtualenv socializer
$ pip install flask flask-sqlalchemy flask-bcrypt flask-login flask-wtf blinker pytest-flask

我们的应用程序布局暂时将与上一章中使用的布局非常相似:

├── application
│   ├── __init__.py
│   └── users
│       ├── __init__.py
│       ├── models.py
│       └── views.py
└── run.py
└── database.py

应用程序工厂

单元测试和功能测试的一个主要好处是能够在各种不同条件和配置下确保应用程序以已知和可预测的方式运行。为此,在我们的测试套件中构建所有 Flask 应用程序对象将是一个巨大的优势。然后,我们可以轻松地为这些对象提供不同的配置,并确保它们表现出我们期望的行为。

值得庆幸的是,这完全可以通过应用程序工厂模式来实现,而 Flask 对此提供了很好的支持。让我们在application/__init__.py模块中添加一个create_app方法:

from flask import Flask

def create_app(config=None):
 app = Flask(__name__)

 if config is not None:
 app.config.from_object(config)

 return app

这个方法的作用相对简单:给定一个可选的config参数,构建一个 Flask 应用程序对象,可选地应用这个自定义配置,最后将新创建的 Flask 应用程序对象返回给调用者。

以前,我们只是在模块本身中实例化一个 Flask 对象,这意味着在导入此包或模块时,应用程序对象将立即可用。然而,这也意味着没有简单的方法来做以下事情:

  • 将应用程序对象的构建延迟到模块导入到本地命名空间之后的某个时间。这一开始可能看起来很琐碎,但对于可以从这种惰性实例化中受益的大型应用程序来说,这是非常有用和强大的。正如我们之前提到的,应尽可能避免产生副作用的包导入。

  • 替换不同的应用程序配置值,例如在运行测试时可能需要的配置值。例如,我们可能希望在运行测试套件时避免向真实用户发送电子邮件通知。

  • 在同一进程中运行多个 Flask 应用程序。虽然本书中没有明确讨论这个概念,但在各种情况下这可能是有用的,比如拥有为公共 API 的不同版本提供服务的单独应用程序实例,或者为不同内容类型(JSON、XML 等)提供服务的单独应用程序对象。关于这个主题的更多信息可以从官方 Flask 在线文档的应用程序调度部分中获取flask.pocoo.org/docs/0.10/patterns/appdispatch/

有了应用程序工厂,我们现在在何时以及如何构建我们的主应用程序对象方面有了更多的灵活性。当然,缺点(或者优点,如果你打算在同一个进程中运行多个应用程序!)是,我们不再可以访问一个准全局的app对象,我们可以导入到我们的模块中,以便注册路由处理程序或访问app对象的日志记录器。

应用程序上下文

Flask 的主要设计目标之一是确保您可以在同一个 Python 进程中运行多个应用程序。那么,一个应用程序如何确保被导入到模块中的app对象是正确的,而不是在同一个进程中运行的其他应用程序的对象?

在支持单进程/多应用程序范式的其他框架中,有时可以通过强制显式依赖注入来实现:需要app对象的代码应明确要求将 app 对象传递给需要它的函数或方法。从架构设计的角度来看,这听起来很棒,但如果第三方库或扩展不遵循相同的设计原则,这很快就会变得繁琐。最好的情况是,您最终将需要编写大量的样板包装函数,最坏的情况是,您最终将不得不诉诸于在模块和类中进行猴子补丁,这将最终导致比您最初预期的麻烦更多的脆弱性和不必要的复杂性。

注意

当然,显式依赖注入样板包装函数本身并没有什么不对。Flask 只是选择了一种不同的方法,过去曾因此受到批评,但已经证明是灵活、可测试和有弹性的。

Flask,不管好坏,都是建立在基于代理对象的替代方法之上的。这些代理对象本质上是容器对象,它们在所有线程之间共享,并且知道如何分派到在幕后绑定到特定线程的真实对象。

注意

一个常见的误解是,在多线程应用程序中,根据 WSGI 规范,每个请求将被分配一个新的线程:这根本不是事实。新请求可能会重用现有但当前未使用的线程,并且这个旧线程可能仍然存在局部作用域的变量,可能会干扰您的新请求处理。

其中一个代理对象current_app被创建并绑定到当前请求。这意味着,我们不再导入一个已经构建好的 Flask 应用程序对象(或者更糟糕的是,在同一个请求中创建额外的应用程序对象),而是用以下内容替换它:

from flask import current_app as app

提示

当然,导入的current_app对象的别名是完全可选的。有时最好将其命名为current_app,以提醒自己它不是真正的应用程序对象,而是一个代理对象。

使用这个代理对象,我们可以规避在实现应用程序工厂模式时,在导入时没有可用的实例化 Flask 应用程序对象的问题。

实例化一个应用程序对象

当然,我们需要在某个时候实际创建一个应用程序对象,以便代理有东西可以代理。通常,我们希望创建对象一次,然后确保调用run方法以启动 Werkzeug 开发服务器。

为此,我们可以修改上一章中的run.py脚本,从我们的工厂实例化 app 对象,并调用新创建的实例的run方法,如下所示:

from application import create_app

app = create_app()
app.run(debug=True)

现在,我们应该能够像以前一样运行这个极其简陋的应用程序:

$ python run.py

提示

还可以调用 Python 解释器,以便为您导入并立即执行模块、包或脚本。这是通过-m标志实现的,我们之前对run.py的调用可以修改为更简洁的版本,如下所示:

$ python –m run

单元和功能测试

实现应用程序工厂以分发 Flask 应用程序实例的主要好处之一是,我们可以更有效地测试应用程序。我们可以为不同的测试用例构建不同的应用程序实例,并确保它们尽可能地相互隔离(或者尽可能地与 Flask/Werkzeug 允许的隔离)。

Python 生态系统中测试库的主要组成部分是 unittest,它包含在标准库中,并包括了 xUnit 框架所期望的许多功能。虽然本书不会详细介绍 unittest,但一个典型的基于类的测试用例将遵循以下基本结构,假设我们仍然使用工厂模式来将应用程序配置与实例化分离:

from myapp import create_app
import unittest

class AppTestCase(unittest.TestCase):

 def setUp(self):
 app = create_app()  # Could also pass custom settings.
 app.config['TESTING'] = True
 self.app = app

 # Whatever DB initialization is required

 def tearDown(self):
 # If anything needs to be cleaned up after a test.
 Pass

 def test_app_configuration(self):
 self.assertTrue(self.app.config['TESTING'])
 # Other relevant assertions

if __name__ == '__main__':
 unittest.main()

使用 unittest 测试格式/样式的优点如下:

  • 不需要外部依赖;unittest 是 Python 标准库的一部分。

  • 入门相对容易。大多数 xUnit 测试框架遵循类似的命名约定来声明测试类和测试方法,并包含几个典型断言的辅助函数,如assertTrueassertEqual等。

然而,它并不是唯一的选择;我们将使用pytest和包装方便功能的相关 Flask 扩展pytest-flask

除了作为一个稍微现代化和简洁的测试框架外,pytest相对于许多其他测试工具提供的另一个主要优势是能够为测试定义固定装置,这在它们自己的文档中描述得非常简洁,如下所示:

  • 固定装置具有明确的名称,并通过声明其在测试函数、模块、类或整个项目中的使用来激活它们

  • 固定装置以模块化的方式实现,因为每个固定装置名称都会触发一个固定装置函数,该函数本身可以使用其他固定装置

  • 固定装置管理从简单单元到复杂功能测试的规模,允许您根据配置和组件选项对固定装置和测试进行参数化,或者在类、模块或整个测试会话范围内重用固定装置

在测试 Flask 应用程序的情况下,这意味着我们可以在fixture中定义对象(例如我们的应用程序对象),然后通过使用与定义的固定装置函数相同名称的参数,将该对象自动注入到测试函数中。

如果上一段文字有点难以理解,那么一个简单的例子就足以澄清问题。让我们创建以下的conftest.py文件,其中将包含任何测试套件范围的固定装置和辅助工具,供其他测试使用:

import pytest
from application import create_app

@pytest.fixture
def app():
 app = create_app()
 return app

我们将在tests/test_application.py中创建我们的第一个测试模块,如下所示:

提示

请注意tests_*前缀对于测试文件名是重要的——它允许pytest自动发现哪些文件包含需要运行的测试函数和断言。如果您的 tests/folder 中的文件名没有上述前缀,那么测试运行器将放弃加载它,并将其视为包含具有测试断言的函数的文件。

import flask

def test_app(app):
 assert isinstance(app, flask.Flask)

请注意

请注意test_app函数签名中的app参数与conftest.py中定义的app固定装置函数的名称相匹配,传递给test_app的值是app固定装置函数的返回值。

我们将使用安装到我们的虚拟环境中的py.test可执行文件来运行测试套件(当我们添加了pytest-flaskpytest库时),在包含conftest.py和我们的 tests/文件夹的目录中运行,输出将指示我们的测试模块已被发现并运行:

$ py.test
=============== test session starts ================
platform darwin -- Python 2.7.8 -- py-1.4.26 -- pytest-2.7.0
rootdir: /path/to/socializer, inifile:
plugins: flask
collected 1 items

tests/test_application.py .

============= 1 passed in 0.02 seconds =============

就是这样!我们已经编写并运行了我们的第一个应用程序测试,尽管不是很有趣。如果你还不太明白发生了什么,不要担心;本章中将进行大量具体的测试,还会有更多的例子。

社交功能-朋友和关注者

许多现代网络应用程序允许用户添加朋友关注其他用户,并且自己也可以被添加朋友或关注。虽然这个概念在文字上可能很简单,但有许多实现和变体,所有这些都针对它们特定的用例进行了优化。

在这种情况下,我们想要实现一个类似新闻订阅的服务,该服务会显示来自选定用户池的信息,并在每个经过身份验证的用户中显示独特的聚合时间线,以下是可能使用的三种方法类别:

  • 写入时的扇出:每个用户的新闻订阅都存储在一个单独的逻辑容器中,旨在使读取非常简单、快速和直接,但代价是去规范化和较低的写入吞吐量。逻辑容器可以是每个用户的数据库表(尽管对于大量用户来说效率非常低),也可以是列式数据库(如 Cassandra)中的列,或者更专门的存储解决方案,如 Redis 列表,可以以原子方式向其中添加元素。

  • 读取时的扇出:当新闻订阅需要额外的定制或处理来确定诸如可见性或相关性之类的事情时,通常最好使用读取时的扇出方法。这允许更精细地控制哪些项目将出现在动态信息中,以及以哪种顺序(假设需要比时间顺序更复杂的东西),但这会增加加载用户特定动态信息的计算时间。通过将最近的项目保存在 RAM 中(这是 Facebook™新闻订阅背后的基本方法,也是 Facebook 在世界上部署最大的 Memcache 的原因),但这会引入几层复杂性和间接性。

  • 天真的规范化:这是方法中最不可扩展的,但实现起来最简单。对于许多小规模应用程序来说,这是最好的起点:一个包含所有用户创建的项目的帖子表(带有对创建该特定项目的用户的外键约束)和一个跟踪哪些用户正在关注谁的关注者表。可以使用各种缓存解决方案来加速请求的部分,但这会增加额外的复杂性,并且只有在必要时才能引入。

对于我们的 Socializer 应用程序,第三种方法,所谓的天真规范化,将是我们实现的方法。其他方法也是有效的,你可以根据自己的目标选择其中任何一条路线,但出于简单和阐述的目的,我们将选择需要最少工作量的方法。

有了这个想法,让我们开始实现所需的基本 SQLAlchemy 模型和关系。首先,让我们使用我们新创建的应用程序工厂来初始化和配置 Flask-SQLAlchemy 扩展,以及使用相同的混合属性方法来哈希我们的用户密码,这是我们在上一章中探讨过的方法。我们的application/__init__.py如下:

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

# Initialize the db extension, but without configuring
# it with an application instance.
db = SQLAlchemy()

# The same for the Bcrypt extension
flask_bcrypt = Bcrypt()

def create_app(config=None):
 app = Flask(__name__)

 if config is not None:
 app.config.from_object(config)

 # Initialize any extensions and bind blueprints to the
 # application instance here.
 db.init_app(app)
 flask_bcrypt.init_app(app)

 return app

由于应用程序工厂的使用,我们将扩展(dbflask_bcrypt)的实例化与它们的配置分开。前者发生在导入时,后者需要在构建 Flask 应用对象时发生。幸运的是,大多数现代的 Flask 扩展都允许发生这种确切的分离,正如我们在前面的片段中所演示的那样。

现在,我们将通过创建application/users/__init__.py来创建我们的用户包,然后我们将创建application/users/models.py,其中包含我们用于 Flask-Login 扩展的标准部分(稍后我们将使用),就像我们在上一章中所做的那样。此外,我们将为我们的关注者表和用户模型上的关联关系添加一个显式的 SQLAlchemy 映射:

import datetime
from application import db, flask_bcrypt
from sqlalchemy.ext.hybrid import hybrid_property

__all__ = ['followers', 'User']

# We use the explicit SQLAlchemy mappers for declaring the
# followers table, since it does not require any of the features
# that the declarative base model brings to the table.
#
# The `follower_id` is the entry that represents a user who
# *follows* a `user_id`.
followers = db.Table(
 'followers',
 db.Column('follower_id', db.Integer, db.ForeignKey('user.id'),
 primary_key=True),
 db.Column('user_id', db.Integer, db.ForeignKey('user.id'),
 primary_key=True))

class User(db.Model):

 # The primary key for each user record.
 id = db.Column(db.Integer, primary_key=True)

 # The unique email for each user record.
 email = db.Column(db.String(255), unique=True)

 # The unique username for each record.
 username = db.Column(db.String(40), unique=True)

 # The hashed password for the user
 _password = db.Column('password', db.String(60))
 #  The date/time that the user account was created on.
 created_on = db.Column(db.DateTime,
 default=datetime.datetime.utcnow)

 followed = db.relationship('User',
 secondary=followers,
 primaryjoin=(id==followers.c.follower_id ),
 secondaryjoin=(id==followers.c.user_id),
 backref=db.backref('followers', lazy='dynamic'),
 lazy='dynamic')

 @hybrid_property
 def password(self):
 """The bcrypt'ed password of the given user."""

 return self._password

 @password.setter
 def password(self, password):
 """Bcrypt the password on assignment."""

 self._password = flask_bcrypt.generate_password_hash(
 password)

 def __repr__(self):
 return '<User %r>' % self.username

 def is_authenticated(self):
 """All our registered users are authenticated."""
 return True

 def is_active(self):
 """All our users are active."""
 return True

 def is_anonymous(self):
 """We don't have anonymous users; always False"""
 return False
 def get_id(self):
 """Get the user ID."""
 return unicode(self.id)

用户模型的followed属性是一个 SQLAlchemy 关系,它通过中间的关注者表将用户表映射到自身。由于社交连接需要隐式的多对多关系,中间表是必要的。仔细看一下followed属性,如下所示的代码:

 followed = db.relationship('User',
 secondary=followers,
 primaryjoin=(id==followers.c.follower_id ),
 secondaryjoin=(id==followers.c.user_id),
 backref=db.backref('followers', lazy='dynamic'),
 lazy='dynamic')

我们可以看到,与本章和以前章节中使用的常规列定义相比,声明有些复杂。然而,relationship函数的每个参数都有一个非常明确的目的,如下列表所示:

  • User:这是目标关系类的基于字符串的名称。这也可以是映射类本身,但是那样你可能会陷入循环导入问题的泥潭。

  • primaryjoin:这个参数的值将被评估,然后用作主表(user)到关联表(follower)的join条件。

  • secondaryjoin:这个参数的值,类似于primaryjoin,在关联表(follower)到子表(user)的join条件中被评估并使用。由于我们的主表和子表是一样的(用户关注其他用户),这个条件几乎与primaryjoin参数中产生的条件相同,只是在关联表中映射的键方面有所不同。

  • backref:这是将插入到实例上的属性的名称,该属性将处理关系的反向方向。这意味着一旦我们有了一个用户实例,我们就可以访问user.followers来获取关注给定用户实例的人的列表,而不是user.followed属性,其中我们明确定义了当前用户正在关注的用户列表。

  • lazy:这是任何基于关系的属性最常被误用的属性。有各种可用的值,包括selectimmediatejoinedsubquerynoloaddynamic。这些确定了相关数据的加载方式或时间。对于我们的应用程序,我们选择使用dynamic的值,它不返回一个可迭代的集合,而是返回一个可以进一步细化和操作的Query对象。例如,我们可以做一些像user.followed.filter(User.username == 'example')这样的事情。虽然在这种特定情况下并不是非常有用,但它提供了巨大的灵活性,有时以生成效率较低的 SQL 查询为代价。

我们将设置各种属性,以确保生成的查询使用正确的列来创建自引用的多对多连接,并且只有在需要时才执行获取关注者列表的查询。关于这些特定模式的更多信息可以在官方的 SQLAlchemy 文档中找到:docs.sqlalchemy.org/en/latest/

现在,我们将为我们的用户模型添加一些方法,以便便于关注/取消关注其他用户。由于 SQLAlchemy 的一些内部技巧,为用户添加和移除关注者可以表达为对本地 Python 列表的操作,如下所示:

def unfollow(self, user):
 """
 Unfollow the given user.

 Return `False` if the user was not already following the user.
 Otherwise, remove the user from the followed list and return
 the current object so that it may then be committed to the 
 session.
 """

 if not self.is_following(user):
 return False

 self.followed.remove(user)
 return self

def follow(self, user):
 """
 Follow the given user.
 Return `False` if the user was already following the user.
 """

 if self.is_following(user):
 return False

 self.followed.append(user)
 return self

def is_following(self, user):
 """
 Returns boolean `True` if the current user is following the
 given `user`, and `False` otherwise.
 """
 followed = self.followed.filter(followers.c.user_id == user.id)
 return followed.count() > 0

注意

实际上,您并不是在原生的 Python 列表上操作,而是在 SQLAlchemy 知道如何跟踪删除和添加的数据结构上操作,然后通过工作单元模式将这些同步到数据库。

接下来,我们将在application/posts/models.py的蓝图模块中创建Post模型。像往常一样,不要忘记创建application/posts/__init__.py文件,以便将文件夹声明为有效的 Python 包,否则在尝试运行应用程序时将出现一些非常令人困惑的导入错误。

目前,这个特定的模型将是一个简单的典范。以下是该项目的用户模型的当前实现:

from application import db
import datetime

__all__ = ['Post']

class Post(db.Model):

 # The unique primary key for each post created.
 id = db.Column(db.Integer, primary_key=True)
 # The free-form text-based content of each post.
 content = db.Column(db.Text())

 #  The date/time that the post was created on.
 created_on = db.Column(db.DateTime(),
 default=datetime.datetime.utcnow, index=True)

 # The user ID that created this post.
 user_id = db.Column(db.Integer(), db.ForeignKey('user.id'))

 def __repr__(self):
 return '<Post %r>' % self.body

一旦我们定义了Post模型,我们现在可以为用户模型添加一个方法,该方法允许我们获取与当前实例链接的用户的新闻源。我们将该方法命名为newsfeed,其实现如下:

def newsfeed(self):
 """
 Return all posts from users followed by the current user,
 in descending chronological order.

 """

 join_condition = followers.c.user_id == Post.user_id
 filter_condition = followers.c.follower_id == self.id
 ordering = Post.created_on.desc()

 return Post.query.join(followers,
 (join_condition)).filter(
 filter_condition).order_by(ordering)

注意

请注意,为了实现上述方法,我们必须将Post模型导入到application/users/models.py模块中。虽然这种特定的情况将正常运行,但必须始终注意可能会有一些难以诊断的潜在循环导入问题。

功能和集成测试

在大多数单元、功能和集成测试的处理中,通常建议在编写相应的代码之前编写测试。虽然这通常被认为是一个良好的实践,出于各种原因(主要是允许您确保正在编写的代码解决了已定义的问题),但为了简单起见,我们等到现在才涉及这个主题。

首先,让我们创建一个新的test_settings.py文件,它与我们现有的settings.py同级。这个新文件将包含我们在运行测试套件时想要使用的应用程序配置常量。最重要的是,它将包含一个指向不是我们应用程序数据库的数据库的 URI,如下所示:

SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/test_app.db'
DEBUG = True
TESTING = True

注意

前面的SQLALCHEMY_DATABASE_URI字符串指向/tmp/test_app.db作为测试数据库的位置。当然,您可以选择与系统范围的tmp目录不同的路径。

我们还将对conftest.py文件进行一些添加,以添加额外的装置,用于初始化测试数据库,并确保我们有一个 SQLAlchemy 数据库会话对象可用于可能需要它的任何测试函数:

import pytest
import os
from application import create_app, db as database

DB_LOCATION = '/tmp/test_app.db'

@pytest.fixture(scope='session')
def app():
 app = create_app(config='test_settings')
 return app

@pytest.fixture(scope='session')
def db(app, request):
 """Session-wide test database."""
 if os.path.exists(DB_LOCATION):
 os.unlink(DB_LOCATION)

 database.app = app
 database.create_all()

 def teardown():
 database.drop_all()
 os.unlink(DB_LOCATION)
 request.addfinalizer(teardown)
 return database

@pytest.fixture(scope='function')
def session(db, request):

 session = db.create_scoped_session()
 db.session = session

 def teardown():
 session.remove()

 request.addfinalizer(teardown)
 return session

注意

会话装置可以通过显式事务进行增强,确保在拆卸时开始并提交事务。这个(简单)实现留给读者作为一个练习。

scope参数指示了创建的装置对象的生命周期。在前面的例子中,我们为会话装置指定了function,这意味着为每个作为参数调用的测试函数创建一个新的装置对象。如果我们使用module作为我们的作用域值,我们将为每个包含该装置的module创建一个新的装置:一个装置将用于模块中的所有测试。这不应与session作用域值混淆,后者表示为整个测试套件运行的整个持续时间创建一个装置对象。会话范围可以在某些情况下非常有用,例如,创建数据库连接是一个非常昂贵的操作。如果我们只需要创建一次数据库连接,那么测试套件的总运行时间可能会大大缩短。

有关py.test装置装饰器的scope参数以及使用内置的request对象添加拆卸终结器回调函数的更多信息,可以查看在线文档:pytest.org/latest/contents.html

我们可以编写一个简单的测试,从我们的声明性用户模型中创建一个新用户,在tests/test_user_model.py中:

from application.users import models

def test_create_user_instance(session):
 """Create and save a user instance."""

 email = 'test@example.com'
 username = 'test_user'
 password = 'foobarbaz'

 user = models.User(email, username, password)
 session.add(user)
 session.commit()

 # We clear out the database after every run of the test suite
 # but the order of tests may affect which ID is assigned.
 # Let's not depend on magic numbers if we can avoid it.
 assert user.id is not None

 assert user.followed.count() == 0
 assert user.newsfeed().count() == 0

在使用py.test运行测试套件后,我们应该看到我们新创建的测试文件出现在列出的输出中,并且我们的测试应该无错误地运行。我们将断言我们新创建的用户应该有一个 ID(由数据库分配),并且不应该关注任何其他用户。因此,我们创建的用户的新闻源也不应该有任何元素。

让我们为用户数据模型的非平凡部分添加一些更多的测试,这将确保我们的关注/关注关系按预期工作:

def test_user_relationships(session):
 """User following relationships."""

 user_1 = models.User(
 email='test1@example.com', username='test1',
 password='foobarbaz')
 user_2 = models.User(
 email='test2@example.com', username='test2',
 password='bingbarboo')

 session.add(user_1)
 session.add(user_2)

 session.commit()

 assert user_1.followed.count() == 0
 assert user_2.followed.count() == 0

 user_1.follow(user_2)

 assert user_1.is_following(user_2) is True
 assert user_2.is_following(user_1) is False
 assert user_1.followed.count() == 1

 user_1.unfollow(user_2)

 assert user_1.is_following(user_2) is False
 assert user_1.followed.count() == 0

使用 Blinker 发布/订阅事件

在任何非平凡应用程序的生命周期中,一个困难是确保代码库中存在正确的模块化水平。

存在各种方法来创建接口、对象和服务,并实现设计模式,帮助我们管理不断增加的复杂性,这是不可避免地为现实世界的应用程序所创建的。一个经常被忽视的方法是 Web 应用程序中的进程内发布-订阅设计模式。

通常,发布-订阅,或者更通俗地称为 pub/sub,是一种消息模式,其中存在两类参与者:发布者订阅者。发布者发送消息,订阅者订阅通过主题(命名通道)或消息内容本身产生的消息的子集。

在大型分布式系统中,pub/sub 通常由一个消息总线或代理来中介,它与所有各种发布者和订阅者通信,并确保发布的消息被路由到感兴趣的订阅者。

然而,为了我们的目的,我们可以使用一些更简单的东西:使用非常简单的Blinker包支持的进程内发布/订阅系统,如果安装了 Flask。

来自 Flask 和扩展的信号

当存在Blinker包时,Flask 允许您订阅发布的各种信号(主题)。此外,Flask 扩展可以实现自己的自定义信号。您可以订阅应用程序中的任意数量的信号,但是信号订阅者接收消息的顺序是未定义的。

Flask 发布的一些更有趣的信号在以下列表中描述:

  • request_started: 这是在请求上下文创建后立即发送的,但在任何请求处理发生之前

  • request_finished: 这是在响应构造后发送的,但在发送回客户端之前立即发送

Flask-SQLAlchemy 扩展本身发布了以下两个信号:

  • models_committed: 这是在任何修改的模型实例提交到数据库后发送的

  • before_models_committed: 这是在模型实例提交到数据库之前发送的

Flask-Login 发布了半打信号,其中许多可以用于模块化认证问题。以下列出了一些有用的信号:

  • user_logged_in: 当用户登录时发送

  • user_logged_out: 当用户注销时发送

  • user_unauthorized: 当未经认证的用户尝试访问需要认证的资源时发送

创建自定义信号

除了订阅由 Flask 和各种 Flask 扩展发布的信号主题之外,还可以(有时非常有用!)创建自己的自定义信号,然后在自己的应用程序中使用。虽然这可能看起来像是一个绕圈子的方法,简单的函数或方法调用就足够了,但是将应用程序的各个部分中的正交关注点分离出来的能力是一个吸引人的建议。

例如,假设你有一个用户模型,其中有一个update_password方法,允许更改给定用户实例的密码为新的值。当密码被更改时,我们希望向用户发送一封邮件,通知他们发生了这个动作。

现在,这个功能的简单实现就是在update_password方法中发送邮件,这本身并不是一个坏主意。然而,想象一下,我们还有另外十几个实例需要发送邮件给用户:当他们被新用户关注时,当他们被用户取消关注时,当他们达到一定的关注者数量时,等等。

然后问题就显而易见了:我们在应用程序的各个部分混合了发送邮件给用户的逻辑和功能,这使得越来越难以理解、调试和重构。

虽然有几种方法可以管理这种复杂性,但当实现发布/订阅模式时,可以明显地看到可能的关注点的明确分离。在我们的 Flask 应用程序中使用自定义信号,我们可以创建一个添加关注者的信号,在动作发生后发布一个事件,任何数量的订阅者都可以监听该特定事件。此外,我们可以组织我们的应用程序,使得类似事件的信号订阅者(例如,发送电子邮件通知)在代码库中的同一位置。

让我们创建一个信号,每当一个用户关注另一个用户时就发布一个事件。首先,我们需要创建我们的Namespace信号容器对象,以便我们可以声明我们的信号主题。让我们在application/__init__.py模块中做这件事:

from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.bcrypt import Bcrypt
from blinker import Namespace

# Initialize the db extension, but without configuring
# it with an application instance.
db = SQLAlchemy()
flask_bcrypt = Bcrypt()

socializer_signals = Namespace()
user_followed = socializer_signals.signal('user-followed')

# …

一旦这个功能就位,我们在User.follow()方法中发出user-followed事件就很简单了,如下所示:

def follow(self, user):
 """
 Follow the given user.

 Return `False` if the user was already following the user.
 """

 if self.is_following(user):
 return False
 self.followed.append(user)

 # Publish the signal event using the current model (self) as sender.
 user_followed.send(self)

 return self

注意

记得在application/users/models.py模块顶部添加from the application import user_followed导入行。

一旦发布了事件,订阅者可能会连接。让我们在application/signal_handlers.py中实现信号处理程序:

__all__ = ['user_followed_email']

import logging

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
def user_followed_email(user, **kwargs):
 logger.debug(
 "Send an email to {user}".format(user=user.username))

from application import user_followed

def connect_handlers():
 user_followed.connect(user_followed_email)

最后,我们需要确保我们的信号处理程序通过将函数导入到application/__init__.py模块来注册:

from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.bcrypt import Bcrypt
from blinker import Namespace

# Initialize the db extension, but without configuring
# it with an application instance.
db = SQLAlchemy()
flask_bcrypt = Bcrypt()

socializer_signals = Namespace()
user_followed = socializer_signals.signal('user-followed')

from signal_handlers import connect_handlers
connect_handlers()

# …
# …

添加此功能后,每当用户关注其他用户时,我们都将在配置的日志输出中打印一条调试消息。实际向用户发送电子邮件的功能留给读者作为练习;一个好的起点是使用Flask-Mail扩展。

异常的优雅处理

无论我们多么努力,有时我们使用和编写的代码会引发异常。

通常,这些异常是在特殊情况下抛出的,但这并不减少我们应该了解应用程序的哪些部分可能引发异常,以及我们是否希望在调用点处理异常,还是简单地让它冒泡到调用堆栈的另一个帧。

对于我们当前的应用程序,有几种异常类型我们希望以一种优雅的方式处理,而不是让整个 Python 进程崩溃,导致一切戛然而止,变得丑陋不堪。

在上一章中,我们简要提到了大多数基于 Flask 和 SQLAlchemy 的应用程序(或几乎任何其他数据库抽象)中需要存在的必要异常处理,但当这些异常确实出现时,处理它们的重要性怎么强调都不为过。考虑到这一点,让我们创建一些视图、表单和模板,让我们作为新用户注册到我们的应用程序,并查看一些异常出现时处理它们的示例。

首先,让我们在application/users/views.py中创建基本的用户视图处理程序:

from flask import Blueprint, render_template, url_for, redirect, flash, g
from flask.ext.login import login_user, logout_user

from flask.ext.wtf import Form
from wtforms import StringField, PasswordField
from wtforms.validators import DataRequired, Length

from models import User
from application import db, flask_bcrypt

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

class Login	Form(Form):
 """
Represents the basic Login form elements & validators.
 """

username = StringField('username',
 validators=[DataRequired()])
password = PasswordField('password',
 validators=[DataRequired(),Length(min=6)])

class CreateUserForm(Form):
 """
 Encapsulate the necessary information required for creating a new user.
 """

 username = StringField('username', validators=[DataRequired(), Length(min=3, max=40)])
 email = StringField('email', validators=[DataRequired(), Length(max=255)])
 password = PasswordField('password', validators=[DataRequired(),
 Length(min=8)])

 @users.route('/signup', methods=['GET', 'POST'])
 def signup():
 """
Basic user creation functionality.

 """

form = CreateUserForm()

if form.validate_on_submit():

 user = User( username=form.username.data,
 email=form.email.data,
 password=form.password.data)

 # add the user to the database
 db.session.add(user)
 db.session.commit()
 # Once we have persisted the user to the database successfully,
 # authenticate that user for the current session
login_user(user, remember=True)
return redirect(url_for('users.index'))

return render_template('users/signup.html', form=form)

@users.route('/', methods=['GET'])
def index():
return "User index page!", 200

@users.route('/login', methods=['GET', 'POST'])
def login():
 """
Basic user login functionality.

 """

if hasattr(g, 'user') and g.user.is_authenticated():
return redirect(url_for('users.index'))

form = LoginForm()

if form.validate_on_submit():

 # We use one() here instead of first()
 user = User.query.filter_by(username=form.username.data).one()
 if not user or not flask_bcrypt.check_password_hash(user.password, form.password.data):

 flash("No such user exists.")
 return render_template('users/login.html', form=form)

 login_user(user, remember=True)
 return redirect(url_for('users.index'))
 return render_template('users/login.html', form=form)

@users.route('/logout', methods=['GET'])
def logout():
logout_user()
return redirect(url_for('users.login'))

你会发现,登录和注销功能与我们在上一章使用 Flask-Login 扩展创建的功能非常相似。因此,我们将简单地包含这些功能和定义的路由(以及相关的 Jinja 模板),而不加评论,并专注于新的注册路由,该路由封装了创建新用户所需的逻辑。此视图利用了新的application/users/templates/users/signup.html视图,该视图仅包含允许用户输入其期望的用户名、电子邮件地址和密码的相关表单控件:

{% extends "layout.html" %}

{% block content %}

<form action="{{ url_for('users.signup')}}" method="post">
  {{ form.hidden_tag() }}
  {{ form.id }}
  <div>{{ form.username.label }}: {{ form.username }}</div>
  {% if form.username.errors %}
  <ul class="errors">{% for error in form.username.errors %}<li>{{ error }}</li>{% endfor %}</ul>
  {% endif %}

  <div>{{ form.email.label }}: {{ form.email }}</div>
  {% if form.email.errors %}
  <ul class="errors">{% for error in form.email.errors %}<li>{{ error }}</li>{% endfor %}</ul>
  {% endif %}

  <div>{{ form.password.label }}: {{ form.password }}</div>
  {% if form.password.errors %}
  <ul class="errors">{% for error in form.password.errors %}<li>{{ error }}</li>{% endfor %}</ul>
  {% endif %}

  <div><input type="submit" value="Sign up!"></div>
</form>

{% endblock %}

一旦我们有了前面的模板,我们将更新我们的应用程序工厂,将用户视图绑定到应用程序对象。我们还将初始化 Flask-Login 扩展,就像我们在上一章所做的那样:

from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.bcrypt import Bcrypt
from blinker import Namespace
from flask.ext.login import LoginManager

# Initialize the db extension, but without configuring
# it with an application instance.
db = SQLAlchemy()
flask_bcrypt = Bcrypt()
login_manager = LoginManager()

socializer_signals = Namespace()
user_followed = socializer_signals.signal('user-followed')

from signal_handlers import *

def create_app(config=None):
app = Flask(__name__)

if config is not None:
 app.config.from_object(config)

 # Initialize any extensions and bind blueprints to the
 # application instance here.
 db.init_app(app)
 flask_bcrypt.init_app(app)
 login_manager.init_app(app)

 from application.users.views import users
 app.register_blueprint(users, url_prefix='/users')

 from application.users import models as user_models
 @login_manager.user_loader
 de fload_user(user_id):
 return user_models.User.query.get(int(user_id))

 return app

别忘了在我们的application/settings.py模块中添加一个SECRET_KEY配置值:

SQLALCHEMY_DATABASE_URI = 'sqlite:///socializer.db'
SECRET_KEY = 'BpRvzXZ800[-t:=z1eZtx9t/,P*'

现在,我们应该能够运行应用程序并访问http://localhost:5000/users/signup,在那里我们将看到一系列用于创建新用户账户的表单输入。在成功创建新用户后,我们将自动使用 Flask-Login 扩展的login_user()方法进行身份验证。

然而,我们尚未考虑到的是,由于与我们的 SQLAlchemy 模型和数据库期望不匹配,用户创建失败的情况。这可能由于多种原因发生:

  • 现有用户已经声明了提交的电子邮件或用户名值,这两者在我们用户模型中都被标记为唯一

  • 某个字段需要数据库指定的额外验证标准,而这些标准未被满足

  • 数据库不可用(例如,由于网络分区)

为了确保这些事件以尽可能优雅的方式处理,我们必须封装可能引发相关异常的代码部分,这些异常表明了这些条件之一。因此,在我们的application/users/views.py模块中的注册路由中,我们将修改将用户持久化到数据库的代码部分:

# place with other imports…
from sqlalchemy import exc

# …

try:
 db.session.add(user)
 db.session.commit()
 except exc.IntegrityError as e:
 # A unique column constraint was violated
 current_app.exception("User unique constraint violated.")
 return render_template('users/signup.html', form=form)
 except exc.SQLAlchemyError:
 current_app.exception("Could not save new user!")
 flash("Something went wrong while creating this user!")
 return render_template('users/signup.html', form=form)

此外,我们将在登录路由中使用 try/except 块包装User.query.filter_by(username=form.username.data).one(),以确保我们处理登录表单中提交的用户名在数据库中根本不存在的情况:

try:
    # We use one() here instead of first()
    user = User.query.filter_by(
           username=form.username.data).one()s
except NoResultFound:
    flash("User {username} does not exist.".format(
        username=form.username.data))
    return render_template('users/login.html', form=form)

# …

功能测试

既然我们已经创建了一些处理用户注册和登录的路由和模板,让我们利用本章早些时候获得的py.test知识来编写一些事后的集成测试,以确保我们的视图按预期行为。首先,让我们在application/tests/test_user_views.py中创建一个新的测试模块,并编写我们的第一个使用客户端固定装置的测试,以便通过内置的 Werkzeug 测试客户端模拟对应用程序的请求。这将确保已构建适当的请求上下文,以便上下文绑定对象(例如,url_forg)可用,如下所示:

def test_get_user_signup_page(client):
 """Ensure signup page is available."""
 response = client.get('/users/signup')
 assert response.status_code == 200
 assert 'Sign up!' in response.data

前面的测试首先向/users/signup路由发出请求,然后断言该路由的 HTTP 响应代码为200(任何成功返回render_template()函数的默认值)。然后它断言注册!按钮文本出现在返回的 HTML 中,这是一个相对安全的保证,即所讨论的页面在没有任何重大错误的情况下被渲染。

接下来,让我们添加一个成功用户注册的测试,如下所示:

from flask import session, get_flashed_messages
from application.users.models import User
from application import flask_bcrypt

def test_signup_new_user(client):
 """Successfully sign up a new user."""
 data = {'username': 'test_username', 'email': 'test@example.com',
 'password': 'my test password'}

 response = client.post('/users/signup', data=data)

 # On successful creation we redirect.
 assert response.status_code == 302

 # Assert that a session was created due to successful login
 assert '_id' in session

 # Ensure that we have no stored flash messages indicating an error
 # occurred.
 assert get_flashed_messages() == []

 user = User.query.filter_by(username=data['username']).one()

 assert user.email == data['email']
 assert user.password
 assert flask_bcrypt.check_password_hash(
 user.password, data['password'])

如果我们立即运行测试套件,它会失败。这是由于 Flask-WTF 引入的一个微妙效果,它期望为任何提交的表单数据提供 CSRF 令牌。以下是我们修复此问题的两种方法:

  • 我们可以在模拟的 POST 数据字典中手动生成 CSRF 令牌;WTForms库提供了实现此功能的方法

  • 我们可以在test_settings.py模块中将WTF_CSRF_ENABLED配置布尔值设置为False,这样测试套件中发生的所有表单验证将不需要 CSRF 令牌即可被视为有效。

第一种方法的优势在于,请求/响应周期中发送的数据将紧密反映生产场景中发生的情况,缺点是我们必须为想要测试的每个表单生成(或程序化抽象)所需的 CSRF 令牌。第二种方法允许我们在测试套件中完全停止关心 CSRF 令牌,这也是一个缺点。本章中,我们将采用第二种方法所述的方式。

在前面的测试中,我们将首先创建一个包含我们希望 POST 到注册端点的模拟表单数据的字典,然后将此数据传递给client.post('/users/signup')方法。在新用户成功注册后,我们应该期望被重定向到不同的页面(我们也可以检查响应中Location头的存在和值),此外,Flask-Login 将创建一个会话 ID 来处理我们的用户会话。此外,对于我们当前的应用程序,成功的注册尝试意味着我们不应该有任何存储以供显示的闪现消息,并且应该有一个新用户记录,其中包含在 POST 中提供的数据,并且该数据应该可用并填充。

虽然大多数开发者非常热衷于测试请求的成功路径,但测试最常见的失败路径同样重要,甚至更为重要。为此,让我们为最典型的失败场景添加以下几个测试,首先是使用无效用户名的情况:

import pytest
import sqlalchemy

def test_signup_invalid_user(client):
 """Try to sign up with invalid data."""

 data = {'username': 'x', 'email': 'short@example.com',
 'password': 'a great password'}

 response = client.post('/users/signup', data=data)

 # With a form error, we still return a 200 to the client since
 # browsers are not always the best at handling proper 4xx response codes.
 assert response.status_code == 200
 assert 'must be between 3 and 40 characters long.' in response.data

注意

记住,我们在application.users.views.CreateUserForm类中定义了用户注册的表单验证规则;用户名必须介于 3 到 40 个字符之间。

def test_signup_invalid_user_missing_fields(client):
 """Try to sign up with missing email."""

 data = {'username': 'no_email', 'password': 'a great password'}
 response = client.post('/users/signup', data=data)

 assert response.status_code == 200
 assert 'This field is required' in response.data

 with pytest.raises(sqlalchemy.orm.exc.NoResultFound):
 User.query.filter_by(username=data['username']).one()

 data = {'username': 'no_password', 'email': 'test@example.com'}
 response = client.post('/users/signup', data=data)

 assert response.status_code == 200
 assert 'This field is required' in response.data

 with pytest.raises(sqlalchemy.orm.exc.NoResultFound):
 User.query.filter_by(username=data['username']).one()

注意

在前面的测试中,我们使用了py.test(及其他测试库)中一个经常被忽视的便利函数,即raises(exc)上下文管理器。这允许我们将一个函数调用包裹起来,在其中我们期望抛出异常,如果预期的异常类型(或派生类型)未被抛出,它本身将导致测试套件中的失败。

你的新闻动态

尽管我们已经构建了大部分支持架构,为我们的 Socializer 应用程序提供功能,但我们仍缺少拼图中更基本的一块:能够按时间顺序查看你关注的人的帖子。

为了使显示帖子所有者的信息更简单一些,让我们在我们的Post模型中添加一个关系定义:

class Post(db.Model):
 # …
 user = db.relationship('User',
 backref=db.backref('posts', lazy='dynamic'))

这将允许我们使用post.user访问与给定帖子关联的任何用户信息,这在显示单个帖子或帖子列表的任何视图中都将非常有用。

让我们在application/users/views.py中为此添加一条路由:

@users.route('/feed', methods=['GET'])
@login_required
def feed():
 """
 List all posts for the authenticated user; most recent first.
 """
 posts = current_user.newsfeed()
 return render_template('users/feed.html', posts=posts)

请注意,前面的代码片段使用了current_user代理(您应该将其导入到模块中),该代理由 Flask-Login 扩展提供。由于 Flask-Login 扩展在代理中存储了经过身份验证的用户对象,因此我们可以像在普通user对象上一样调用其方法和属性。

由于之前的 feed 端点已经运行,我们需要在application/users/templates/users/feed.html中提供支持模板,以便我们实际上可以渲染响应:

{% extends "layout.html" %}

{% block content %}
<div class="new-post">
  <p><a href="{{url_for('posts.add')}}">New Post</a></p>
</div>

{% for post in posts %}
<div class="post">
  <span class="author">{{post.user.username}}</span>, published on <span class="date">{{post.created_on}}</span>
  <pre><code>{{post.content}}</code></pre>
</div>
{% endfor %}

{% endblock %}

我们需要的最后一部分是添加新帖子的视图处理程序。由于我们尚未创建application/posts/views.py模块,让我们来创建它。我们将需要一个Flask-WTForm类来处理/验证新帖子,以及一个路由处理程序来发送和处理所需的字段,所有这些都连接到一个新的蓝图上:

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

from flask.ext.login import login_required, current_user
from flask.ext.wtf import Form
from wtforms import StringField
from wtforms.widgets import TextArea
from wtforms.validators import DataRequired
from sqlalchemy import exc

from models import Post
from application import db

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

class CreatePostForm(Form):
 """Form for creating new posts."""

 content = StringField('content', widget=TextArea(),
 validators=[DataRequired()])

@posts.route('/add', methods=['GET', 'POST'])
@login_required
def add():
 """Add a new post."""

 form = CreatePostForm()
 if form.validate_on_submit():
 user_id = current_user.id

 post = Post(user_id=user_id, content=form.content.data)
 db.session.add(post)

 try:
 db.session.commit()
 except exc.SQLAlchemyError:
 current_app.exception("Could not save new post!")
 flash("Something went wrong while creating your post!")
 else:
 return render_template('posts/add.html', form=form)

 return redirect(url_for('users.feed'))

相应的application/posts/templates/posts/add.html文件正如预期的那样相对简单,并且让人想起上一章中使用的视图模板。这里是:

{% extends "layout.html" %}

{% block content %}
<form action="{{ url_for('posts.add')}}" method="post">

  {{ form.hidden_tag() }}
  {{ form.id }}

  <div class="row">
    <div>{{ form.content.label }}: {{ form.content }}</div>
    {% if form.content.errors %}
    <ul class="errors">{% for error in form.content.errors %}<li>{{ error }}</li>{% endfor %}</ul>
    {% endif %}
  </div>

  <div><input type="submit" value="Post"></div>
</form>

{% endblock %}

最后,我们需要通过在我们的应用程序工厂中将其绑定到我们的应用程序对象,使应用程序意识到这个新创建的帖子蓝图,位于application/__init__.py中:

def create_app(config=None):
    app = Flask(__name__)

    # …
    from application.users.views import users
    app.register_blueprint(users, url_prefix='/users')

 from application.posts.views import posts
 app.register_blueprint(posts, url_prefix='/posts')

        # …

一旦上述代码就位,我们可以通过在/users/signup端点的 Web 界面上创建用户帐户,然后在/posts/add上为用户创建帖子来为这些用户生成一些测试用户和帖子。否则,我们可以创建一个小的 CLI 脚本来为我们执行此操作,我们将在下一章中学习如何实现。我们还可以编写一些测试用例来确保新闻源按预期工作。实际上,我们可以做这三件事!

摘要

我们通过首先介绍应用程序工厂的概念,并描述了这种方法的一些好处和权衡来开始本章。接下来,我们使用我们新创建的应用程序工厂来使用py.test设置我们的第一个测试套件,这需要对我们的应用程序对象的创建方式进行一些修改,以确保我们获得一个适合的实例,配置为测试场景。

然后,我们迅速着手实现了典型 Web 应用程序背后的基本数据模型,其中包含了社交功能,包括关注其他用户以及被其他用户关注的能力。我们简要涉及了所谓新闻源应用程序的几种主要实现模式,并为我们自己的数据模型使用了最简单的版本。

这随后导致我们讨论和探索了发布/订阅设计模式的概念,Flask 和各种 Flask 扩展集成了Blinker包中的一个进程内实现。利用这些新知识,我们创建了自己的发布者和订阅者,使我们能够解决许多现代 Web 应用程序中存在的一些常见横切关注点。

对于我们的下一个项目,我们将从创建过去几章中使用的基于 HTML 的表单和视图切换到另一个非常重要的现代 Web 应用程序部分:提供一个有用的 JSON API 来进行交互。

第五章:Shutterbug,照片流 API

在本章中,我们将构建一个(主要是)基于 JSON 的 API,允许我们查看按时间顺序倒序排列的已添加照片列表——由于 Instagram 和类似的照片分享应用程序,这在近年来变得非常流行。为简单起见,我们将放弃许多这些应用程序通常围绕的社交方面;但是,我们鼓励您将前几章的知识与本章的信息相结合,构建这样的应用程序。

Shutterbug,我们即将开始的最小 API 应用程序,将允许用户通过经过身份验证的基于 JSON 的 API 上传他们选择的照片。

此外,我们将使用 Flask(实际上是 Werkzeug)的较少为人所知的功能之一,创建一个自定义中间件,允许我们拦截传入请求并修改全局应用程序环境,用于非常简单的 API 版本控制。

开始

和前几章一样,让我们为这个应用程序创建一个全新的目录和虚拟环境:

$ mkdir -p ~/src/shutterbug && cd ~/src/shutterbug
$ mkvirtualenv shutterbug
$ pip install flask flask-sqlalchemy pytest-flask flask-bcrypt

创建以下应用程序布局以开始:

├── application/
│   ├── __init__.py
│   └── resources
│       ├── __init__.py
│       └── photos.py
├── conftest.py
├── database.py
├── run.py
├── settings.py
└── tests/

注意

这里呈现的应用程序布局与我们在前几章中使用的典型基于 Blueprint 的结构不同;我们将使用典型 Flask-RESTful 应用程序建议的布局,这也适合 Shutterbug 应用程序的简单性。

应用程序工厂

在本章中,我们将再次使用应用程序工厂模式;让我们将我们的骨架create_app方法添加到application/__init__.py模块中,并包括我们的 Flask-SQLAlchemy 数据库初始化:

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

# Initialize the db extension, but without configuring
# it with an application instance.
db = SQLAlchemy()
flask_bcrypt = Bcrypt()

def create_app(config=None):
    app = Flask(__name__)

    if config is not None:
        app.config.from_object(config)

    db.init_app(app)
    flask_bcrypt.init_app(app)

    return app

让我们包含我们的基本run.py

from application import create_app

app = create_app()
app.run()

这应该使我们能够使用内置的 Werkzeug 应用程序服务器运行应用程序,代码如下:

$ python run.py

插曲——Werkzeug

我们在本书的过程中已经几次谈到了 Werkzeug,但我们并没有真正解释它是什么,为什么我们使用它,或者它为什么有用。要理解 Werkzeug,我们首先需要知道它存在的原因。为此,我们需要了解 Python Web 服务器网关接口规范的起源,通常缩写为 WSGI。

如今,选择 Python Web 应用程序框架相对来说是一个相对简单的偏好问题:大多数开发人员根据以前的经验、必要性(例如,设计为异步请求处理的 Tornado)或其他可量化或不可量化的标准选择框架。

然而,几年前,应用程序框架的选择影响了您可以使用的 Web 服务器。由于当时所有 Python Web 应用程序框架以稍微不同的方式实现了它们自己的 HTTP 请求处理,它们通常只与 Web 服务器的子集兼容。开发人员厌倦了这种有点不方便的现状,提出了通过一个共同规范 WSGI 统一 Web 服务器与 Python 应用程序的交互的提案。

一旦建立了 WSGI 规范,所有主要框架都采用了它。此外,还创建了一些所谓的实用工具;它们的唯一目的是将官方 WSGI 规范与更健壮的中间 API 进行桥接,这有助于开发现代 Web 应用程序。此外,这些实用程序库可以作为更完整和健壮的应用程序框架的基础。

您现在可能已经猜到,Werkzeug 是这些 WSGI 实用程序库之一。当与模板语言 Jinja 和一些方便的默认配置、路由和其他基本 Web 应用程序必需品结合使用时,我们就有了 Flask。

Flask 是我们在本书中主要处理的内容,但是从 Werkzeug 中抽象出来的大部分工作都包含在其中。虽然它很大程度上不被注意到,但是可以直接与它交互,以拦截和修改请求的部分,然后 Flask 有机会处理它。在本章中,当我们为 JSON API 请求实现自定义 Werkzeug 中间件时,我们将探索其中的一些可能性。

使用 Flask-RESTful 创建简单的 API

使用 Flask 的一个巨大乐趣是它提供了看似无限的可扩展性和可组合性。由于它是一个相当薄的层,位于 Werkzeug 和 Jinja 之上,因此在约束方面对开发人员的要求并不多。

由于这种灵活性,我们可以利用 Flask-RESTful 等扩展,使得创建基于 JSON 的 API 变得轻松愉快。首先,让我们安装这个包:

$ pip install flask-restful

接下来,让我们以通常的方式在我们的应用工厂中初始化这个扩展:

from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.bcrypt import Bcrypt
from flask.ext.restful import Api

# ………
api = Api()

def create_app(config=None):
    app = Flask(__name__)

    if config is not None:
        app.config.from_object(config)

    db.init_app(app)
    flask_bcrypt.init_app(app)

 api.init_app(app)

    return app

Flask-RESTful 扩展的主要构建块是资源的概念。资源在本质上是一个带有一些非常有用的默认设置的Flask方法视图,用于内容类型协商。如果直到现在你还没有遇到过 Flask 中MethodView的概念,不要担心!它们非常简单,并且通过允许您在类上定义方法,直接映射到基本的 HTTP 动词:GETPUTPOSTPATCHDELETE,为您提供了一个相对简单的接口来分离 RESTful 资源。Flask-RESTful 资源又扩展了MethodView类,因此允许使用相同的基于动词的路由处理风格。

更具体地说,这意味着 Flask-RESTful API 名词可以以以下方式编写。我们将首先将我们的照片资源视图处理程序添加到application/resources/photos.py中:

class SinglePhoto(Resource):

    def get(self, photo_id):
        """Handling of GET requests."""
        pass

    def delete(self, photo_id):
        """Handling of DELETE requests."""
        pass

class ListPhoto(Resource):

    def get(self):
        """Handling of GET requests."""
        pass

    def post(self):
        """Handling of POST requests."""
        pass

注意

在前面的两个Resource子类中,我们定义了可以处理的 HTTP 动词的一个子集;我们并不需要为所有可能的动词定义处理程序。例如,如果我们的应用程序接收到一个 PATCH 请求到前面的资源中的一个,Flask 会返回 HTTP/1.1 405 Method Not Allowed。

然后,我们将这些视图处理程序导入到我们的应用工厂中,在application/__init__.py中,以便将这两个类绑定到我们的 Flask-RESTful API 对象:

from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.restful import Api
from flask.ext.bcrypt import Bcrypt

# Initialize the db extension, but without configuring
# it with an application instance.
db = SQLAlchemy()
api = Api()
flask_bcrypt = Bcrypt()

def create_app(config=None):
    app = Flask(__name__)

    if config is not None:
        app.config.from_object(config)

    db.init_app(app)
    flask_bcrypt.init_app(app)

 from .resources.photos import SinglePhoto, ListPhoto
 api.add_resource(ListPhoto, '/photos')
 api.add_resource(SinglePhoto, '/photos/<int:photo_id>')

    api.init_app(app)

    return app

注意

请注意,在调用api.init_app(app)之前,我们已经将资源绑定到了 API 对象。如果我们在绑定资源之前初始化,路由将不存在于 Flask 应用程序对象上。

我们可以通过启动交互式 Python 会话并检查 Flask 应用程序的url_map属性来确认我们定义的路由是否映射到应用程序对象。

提示

从应用程序文件夹的父文件夹开始会话,以便正确设置PYTHONPATH

In [1]: from application import create_app
In [2]: app = create_app()
In [3]: app.url_map
Out[3]:
Map([<Rule '/photos' (HEAD, POST, OPTIONS, GET) -> listphoto>,
 <Rule '/photos/<photo_id>' (HEAD, DELETE, OPTIONS, GET) -> singlephoto>,
 <Rule '/static/<filename>' (HEAD, OPTIONS, GET) -> static>])

前面的输出列出了一个 Werkzeug Map对象,其中包含三个Rule对象,每个对象列出了一个 URI,对该 URI 有效的 HTTP 动词,以及一个标准化标识符(视图处理程序可以是函数,也可以是MethodView子类,还有其他几个选项),指示将调用哪个视图处理程序。

注意

Flask 将自动处理所有已定义端点的 HEAD 和 OPTIONS 动词,并为静态文件处理添加一个默认的/static/<filename>路由。如果需要,可以通过在应用程序工厂中对Flask对象初始化设置static_folder参数为None来禁用此默认静态路由:

 app = Flask(__name__, static_folder=None)

让我们对我们的骨架用户视图资源处理程序做同样的事情,我们将在application/resources/users.py中声明:

from flask.ext.restful import Resource

class SingleUser(Resource):

    def get(self, user_id):
        """Handling of GET requests."""
        pass

class CreateUser(Resource):

    def post(self):
        """Handling of POST requests."""
        pass

注意

请注意,我们本可以将post方法处理程序放在SingleUser资源定义中,但相反,我们将其拆分为自己的资源。这并非绝对必要,但会使我们的应用程序更容易跟踪,并且只会花费我们额外的几行代码。

与我们在照片视图中所做的类似,我们将把它们添加到我们的 Flask-RESTful API 对象中的应用工厂中:

def create_app(config=None):

    # …

    from .resources.photos import SinglePhoto, ListPhoto
    from .resources.users import SingleUser, CreateUser

    api.add_resource(ListPhoto, '/photos')
    api.add_resource(SinglePhoto, '/photos/<int:photo_id>')
    api.add_resource(SingleUser, '/users/<int:user_id>')
    api.add_resource(CreateUser, '/users')

    api.init_app(app)
    return app

使用混合属性改进密码处理

我们的User模型将与我们在上一章中使用的模型非常相似,并且将使用类属性getter/setter来处理password属性。这将确保无论我们是在对象创建时设置值还是手动设置已创建对象的属性,都能一致地应用 Bcrypt 密钥派生函数到原始用户密码。

这包括使用 SQLAlchemy 的hybrid_property描述符,它允许我们定义在类级别访问时(例如User.password,我们希望返回用户模型的密码字段的 SQL 表达式)与实例级别访问时(例如User().password,我们希望返回用户对象的实际加密密码字符串而不是 SQL 表达式)行为不同的属性。

我们将把密码类属性定义为_password,这将确保我们避免任何不愉快的属性/方法名称冲突,以便我们可以正确地定义混合的gettersetter方法。

由于我们的应用在数据建模方面相对简单,我们可以在application/models.py中使用单个模块来处理我们的模型:

from application import db, flask_bcrypt
from sqlalchemy.ext.hybrid import hybrid_property

import datetime

class User(db.Model):
    """SQLAlchemy User model."""

    # The primary key for each user record.
    id = db.Column(db.Integer, primary_key=True)

    # The unique email for each user record.
    email = db.Column(db.String(255), unique=True, nullable=False)

    # The unique username for each record.
    username = db.Column(db.String(40), unique=True, nullable=False)

 # The bcrypt'ed user password
 _password = db.Column('password', db.String(60), nullable=False)

    #  The date/time that the user account was created on.
    created_on = db.Column(db.DateTime,
       default=datetime.datetime.utcnow)

    def __repr__(self):
        return '<User %r>' % self.username

 @hybrid_property
 def password(self):
 """The bcrypt'ed password of the given user."""

 return self._password

 @password.setter
 def password(self, password):
 """Bcrypt the password on assignment."""

        self._password = flask_bcrypt.generate_password_hash(password)

在同一个模块中,我们可以声明我们的Photo模型,它将负责维护与图像相关的所有元数据,但不包括图像本身:

class Photo(db.Model):
    """SQLAlchemy Photo model."""

    # The unique primary key for each photo created.
    id = db.Column(db.Integer, primary_key=True)

    # The free-form text-based comment of each photo.
    comment = db.Column(db.Text())

    # Path to photo on local disk
    path = db.Column(db.String(255), nullable=False)

    #  The date/time that the photo was created on.
    created_on = db.Column(db.DateTime(),
        default=datetime.datetime.utcnow, index=True)

    # The user ID that created this photo.
    user_id = db.Column(db.Integer(), db.ForeignKey('user.id'))

    # The attribute reference for accessing photos posted by this user.
    user = db.relationship('User', backref=db.backref('photos',
        lazy='dynamic'))

    def __repr__(self):
        return '<Photo %r>' % self.comment

API 身份验证

对于大多数应用程序和 API,身份验证和授权的概念对于非平凡操作至关重要:

  • 身份验证:这断言所提供的凭据的真实性,并确保它们属于已知实体;简单来说,这意味着确保提供给应用程序的用户名和密码属于有效用户。一旦验证,应用程序就会假定使用这些凭据执行的请求是代表给定用户执行的。

  • 授权:这是经过身份验证的实体在应用程序范围内的可允许操作。在大多数情况下,授权预设了已经进行了预先身份验证步骤。实体可能已经经过身份验证,但没有被授权访问某些资源:如果您在 ATM 机上输入您的卡和 PIN 码(因此进行了身份验证),您可以查看自己的账户,但尝试查看另一个人的账户将会(希望!)导致拒绝,因为您没有被授权访问那些信息。

对于 Shutterbug,我们只关心身份验证。如果我们要添加各种功能,比如能够创建可以访问共享照片池的私人用户组,那么就需要系统化的授权来确定哪些用户可以访问哪些资源的子集。

身份验证协议

许多开发人员可能已经熟悉了几种身份验证协议:通常的标识符/密码组合是现有大多数网络应用程序的标准,而 OAuth 是许多现代 API 的标准(例如 Twitter、Facebook、GitHub 等)。对于我们自己的应用程序,我们将使用非常简单的 HTTP 基本身份验证协议。

虽然 HTTP 基本身份验证并不是最灵活也不是最安全的(实际上它根本不提供任何加密),但对于简单的应用程序、演示和原型 API 来说,实施这种协议是合理的。在 Twitter 早期,这实际上是您可以使用的唯一方法来验证其 API!此外,在通过 HTTPS 传输数据时,我们应该在任何生产级环境中这样做,我们可以确保包含用户标识和密码的明文请求受到加密,以防止任何可能监听的恶意第三方。

HTTP 基本身份验证的实现并不是过于复杂的,但绝对是我们可以转嫁给扩展的东西。让我们继续将 Flask-HTTPAuth 安装到我们的环境中,这包括创建扩展的实例:

$ pip install flask-httpauth

并在我们的application/__init__.py中设置扩展:

from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.restful import Api
from flask.ext.bcrypt import Bcrypt
from flask.ext.httpauth import HTTPBasicAuth

# …

api = Api()
flask_bcrypt = Bcrypt()
auth = HTTPBasicAuth()

def create_app(config=None):
    # …

 import authentication

    api.add_resource(ListPhoto, '/photos')
    api.add_resource(SinglePhoto, '/photos/<int:photo_id>')

    # …

Flask-HTTPAuth 包括各种装饰器来声明处理程序/回调,以执行身份验证过程的各个部分。我们将实现一个可以最大程度控制身份验证方式的处理程序,并将其放在application/authentication.py中的新模块中。除了验证凭据外,我们还将在成功验证时将 SQLAlchemy 用户对象附加到 Flask 上下文本地g,以便我们可以在请求处理和响应生成的其他部分中利用这些数据:

import sqlalchemy
from . import auth, flask_bcrypt
from .models import User
from flask import g

@auth.verify_password
def verify_password(username, password):
    """Verify a username/hashed password tuple."""

    try:
        user = User.query.filter_by(username=username).one()
    except sqlalchemy.orm.exc.NoResultFound:
        # We found no username that matched
        return False

    # Perform password hash comparison in time-constant manner.
    verified = flask_bcrypt.check_password_hash(user.password,
        password)

 if verified is True:
 g.current_user = user

    return verified

auth.verify_password装饰器允许我们指定一个接受用户名和密码的函数,这两者都从发送请求的 Authorization 头中提取出来。然后,我们将使用这些信息来查询具有相同用户名的用户的数据库,并在成功找到一个用户后,我们将确保提供的密码散列到与我们为该用户存储的相同值。如果密码不匹配或用户名不存在,我们将返回 False,Flask-HTTPAuth 将向请求客户端返回 401 未经授权的标头。

现在,要实际使用 HTTP 基本身份验证,我们需要将auth.login_required装饰器添加到需要身份验证的视图处理程序中。我们知道除了创建新用户之外,所有用户操作都需要经过身份验证的请求,所以让我们实现这一点:

from flask.ext.restful import Resource
from application import auth

class SingleUser(Resource):

 method_decorators = [auth.login_required]

    def get(self, user_id):
        """Handling of GET requests."""
        pass

    # …

注意

由于 Resource 对象的方法的 self 参数指的是 Resource 实例而不是方法,我们不能在视图的各个方法上使用常规视图装饰器。相反,我们必须使用method_decorators类属性,它将按顺序应用已声明的函数到已调用的视图方法上,以处理请求。

获取用户

现在我们已经弄清楚了应用程序的身份验证部分,让我们实现 API 端点以创建新用户和获取现有用户数据。我们可以如下完善SingleUser资源类的get()方法:

from flask.ext.restful import abort

# …

def get(self, user_id):
    """Handling of GET requests."""

    if g.current_user.id != user_id:
        # A user may only access their own user data.
        abort(403, message="You have insufficient permissions"
            " to access this resource.")

    # We could simply use the `current_user`,
    # but the SQLAlchemy identity map makes this a virtual
    # no-op and alos allows for future expansion
    # when users may access information of other users
    try:
        user = User.query.filter(User.id == user_id).one()
    except sqlalchemy.orm.exc.NoResultFound:
        abort(404, message="No such user exists!")

    data = dict(
        id=user.id,
        username=user.username,
        email=user.email,
        created_on=user.created_on)

    return data, 200

在前面的方法中发生了很多新的事情,让我们来分解一下。首先,我们将检查请求中指定的user_id(例如,GET /users/1)是否与当前经过身份验证的用户相同:

if g.current_user.id != user_id:
        # A user may only access their own user data.
        abort(403, message="You have insufficient permissions"
            " to access this resource.")

虽然目前这可能看起来有些多余,但它在允许将来更简单地修改授权方案的同时,还扮演了遵循更符合 RESTful 方法的双重角色。在这里,资源是由其 URI 唯一指定的,部分由用户对象的唯一主键标识符构成。

经过授权检查后,我们将通过查询传递为命名 URI 参数的user_id参数,从数据库中提取相关用户:

try:
    user = User.query.filter(User.id == user_id).one()
except sqlalchemy.orm.exc.NoResultFound:
    abort(404, message="No such user exists!")

如果找不到这样的用户,那么我们将使用 HTTP 404 Not Found 中止当前请求,并指定消息以使非 20x 响应的原因更清晰。

最后,我们将构建一个用户数据的字典,作为响应返回。我们显然不希望返回散列密码或其他敏感信息,因此我们将明确指定我们希望在响应中序列化的字段:

data = dict(id=user.id, username=user.username, email=user.email,
            created_on=user.created_on)

    return data, 200

由于 Flask-RESTful,我们不需要显式地将我们的字典转换为 JSON 字符串:响应表示默认为application/json。然而,有一个小问题:Flask-RESTful 使用的默认 JSON 编码器不知道如何将 Python datetime对象转换为它们的 RFC822 字符串表示。这可以通过指定application/json MIME 类型表示处理程序并确保我们使用flask.json编码器而不是 Python 标准库中的默认json模块来解决。

我们可以在application/__init__.py模块中添加以下内容:

from flask import Flask, json, make_response
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.restful import Api
from flask.ext.bcrypt import Bcrypt
from flask.ext.httpauth import HTTPBasicAuth

# …

db = SQLAlchemy()
# …

@api.representation('application/json')
def output_json(data, code, headers=None):
    resp = make_response(json.dumps(data), code)
    resp.headers.extend(headers or {})
    return resp

创建新用户

从 API 中获取现有用户的类比当然是创建新用户。而典型的 Web 应用程序通过填写各种表单字段来完成这一过程,通过我们的 API 创建新用户需要将信息通过 POST 请求提交到服务器进行验证,然后将新用户插入数据库。这些步骤的实现应该放在我们的CreateUser资源的post()方法中:

class CreateUser(Resource):

    def post(self):
        """Create a new user."""

        data = request.json
        user = User(**data)

        db.session.add(user)

        try:
            db.session.commit()
        except sqlalchemy.exc.IntegrityError:
            abort(409, message="User already exists!")

        data = dict(id=user.id, username=user.username, email=user.email, created_on=user.created_on)

        return data, 201, {'Location': url_for( 'singleuser', user_id=user.id, _external=True)}

注意

如果请求的内容类型设置为application/json,则request.json文件将填充 POST 数据。

在前面的方法实现中没有什么太意外的:我们从request.json中获取了 POST 数据,创建了一个User对象(非常不安全!您可以在本章稍后看到更好的替代方法),尝试将其添加到数据库中并捕获异常,如果同一用户名或电子邮件地址的用户已经存在,然后序列化一个 HTTP 201 Created 响应,其中包含新创建用户的 URI 的Location头。

输入验证

虽然 Flask 包含一个相对简单的方式来通过flask.request代理对象访问 POST 的数据,但它不包含任何功能来验证数据是否按我们期望的格式进行格式化。这没关系!Flask 试图尽可能地与数据存储和操作无关,将这些工作留给开发人员。幸运的是,Flask-RESTful 包括reqparse模块,可以用于数据验证,其使用在精神上与用于 CLI 参数解析的流行argparse库非常相似。

我们将在application/resources/users.py模块中设置我们的新用户数据解析器/验证器,并声明我们的字段及其类型以及在 POST 数据中是否为有效请求所需的字段:

from flask.ext.restful import Resource, abort, reqparse, url_for

# …

new_user_parser = reqparse.RequestParser()
new_user_parser.add_argument('username', type=str, required=True)
new_user_parser.add_argument('email', type=str, required=True)
new_user_parser.add_argument('password', type=str, required=True)

现在我们在模块中设置了new_user_parser,我们可以修改CreateUser.post()方法来使用它:

def post(self):
    """Handling of POST requests."""

    data = new_user_parser.parse_args(strict=True)
    user = User(**data)

    db.session.add(user)

    # …

new_user_parser.parse_args(strict=True)的调用将尝试匹配我们之前通过add_argument定义的字段的声明类型和要求,并且在请求中存在任何字段未通过验证或者有额外字段没有明确考虑到的情况下,将内部调用abort()并返回 HTTP 400 错误(感谢strict=True选项)。

使用reqparse来验证 POST 的数据可能比我们之前直接赋值更加繁琐,但是安全性更高。通过直接赋值技术,恶意用户可能会发送任意数据,希望覆盖他们不应该访问的字段。例如,我们的数据库可能包含内部字段subscription_exipires_on datetime,一个恶意用户可能会提交一个包含这个字段值设置为遥远未来的 POST 请求。这绝对是我们想要避免的事情!

API 测试

让我们应用一些我们在之前章节中学到的关于使用pytest进行功能和集成测试的知识。

我们的第一步(在必要的 pip 安装pytest-flask之后)是像我们在之前的章节中所做的那样添加一个conftest.py文件,它是我们application/文件夹的同级文件夹。

import pytest
import os
from application import create_app, db as database

DB_LOCATION = '/tmp/test_shutterbug.db'

@pytest.fixture(scope='session')
def app():
    app = create_app(config='test_settings')
    return app

@pytest.fixture(scope='function')
def db(app, request):
    """Session-wide test database."""
    if os.path.exists(DB_LOCATION):
        os.unlink(DB_LOCATION)

    database.app = app
    database.create_all()

    def teardown():
        database.drop_all()
        os.unlink(DB_LOCATION)

    request.addfinalizer(teardown)
    return database

@pytest.fixture(scope='function')
def session(db, request):

    session = db.create_scoped_session()
    db.session = session

    def teardown():
        session.remove()

    request.addfinalizer(teardown)
    return session

前面的conftest.py文件包含了我们编写 API 测试所需的基本测试装置;这里不应该有任何意外。然后我们将添加我们的test_settings.py文件,它是新创建的conftest.py的同级文件,并填充它与我们想要在测试运行中使用的应用程序配置值:

SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/test_shutterbug.db'
SECRET_KEY = b"\x98\x9e\xbaP'D\x03\xf5\x91u5G\x1f"
DEBUG = True
UPLOAD_FOLDER = '/tmp/'
TESTING = True

一旦这些都就位,我们就可以开始在tests/test_users.py中编写我们的测试函数和断言。我们的第一个测试将确保我们可以通过 API 创建一个新用户,并且新创建的资源的 URI 将在Location标头中返回给我们:

from application.models import User
from flask import json
import base64

def test_create_new_user(db, session, client):
    """Attempt to create a basic user."""

    data = {'username': 'you', 'email': 'you@example.com',
            'password': 'foobar'}

    response = client.post('/users', data=data)
    assert response.status_code == 201
    assert 'Location' in response.headers

    user = User.query.filter(User.username == data['username']).one()

    assert '/users/{}'.format(user.id) in response.headers['Location']

一旦我们确定可以创建用户,下一个逻辑步骤是测试如果客户端尝试使用无效或缺少的参数创建用户,则会返回错误:

def test_create_invalid_user(db, session, client):
    """Try to create a user with invalid/missing information."""

    data = {'email': 'you@example.com'}
    response = client.post('/users', data=data)

    assert response.status_code == 400
    assert 'message' in response.json
    assert 'username' in response.json['message']

作为对我们的 HTTP 基本身份验证实现的健全性检查,让我们还添加一个测试来获取单个用户记录,这需要对请求进行身份验证:

def test_get_single_user_authenticated(db, session, client):
    """Attempt to fetch a user."""

    data = {'username': 'authed', 'email': 'authed@example.com',
            'password': 'foobar'}
    user = User(**data)
    session.add(user)
    session.commit()

    creds = base64.b64encode(
        b'{0}:{1}'.format(
            user.username, data['password'])).decode('utf-8')

    response = client.get('/users/{}'.format(user.id),
        headers={'Authorization': 'Basic ' + creds})

    assert response.status_code == 200
    assert json.loads(response.get_data())['id'] == user.id

未经身份验证的请求获取单个用户记录的相关测试如下:

def test_get_single_user_unauthenticated(db, session, client):
    data = {'username': 'authed', 'email': 'authed@example.com',
            'password': 'foobar'}
    user = User(**data)
    session.add(user)
    session.commit()

    response = client.get('/users/{}'.format(user.id))
    assert response.status_code == 401

我们还可以测试我们非常简单的授权实现是否按预期运行(回想一下,我们只允许经过身份验证的用户查看自己的信息,而不是系统中其他任何用户的信息。)通过创建两个用户并尝试通过经过身份验证的请求访问彼此的数据来进行测试:

def test_get_single_user_unauthorized(db, session, client):

    alice_data = {'username': 'alice', 'email': 'alice@example.com',
            'password': 'foobar'}
    bob_data = {'username': 'bob', 'email': 'bob@example.com',
            'password': 'foobar'}
    alice = User(**alice_data)
    bob = User(**bob_data)

    session.add(alice)
    session.add(bob)

    session.commit()

    alice_creds = base64.b64encode(b'{0}:{1}'.format(
        alice.username, alice_data['password'])).decode('utf-8')

    bob_creds = base64.b64encode(b'{0}:{1}'.format(
        bob.username, bob_data['password'])).decode('utf-8')

    response = client.get('/users/{}'.format(alice.id),
        headers={'Authorization': 'Basic ' + bob_creds})

    assert response.status_code == 403

    response = client.get('/users/{}'.format(bob.id),
        headers={'Authorization': 'Basic ' + alice_creds})

    assert response.status_code == 403

插曲 - Werkzeug 中间件

对于某些任务,我们有时需要在将请求路由到处理程序函数或方法之前修改传入请求数据和/或环境的能力。在许多情况下,实现这一点的最简单方法是使用before_request装饰器注册一个函数;这通常用于在g对象上设置request-global值或创建数据库连接。

虽然这应该足够涵盖大部分最常见的用例,但有时在 Flask 应用程序对象下方(构造请求代理对象时)但在 HTTP 服务器上方更方便。为此,我们有中间件的概念。此外,一个正确编写的中间件将在其他兼容的 WSGI 实现中可移植;除了应用程序特定的怪癖外,没有什么能阻止您在我们当前的 Flask 应用程序中使用最初为 Django 应用程序编写的中间件。

中间件相对简单:它们本质上是任何可调用的东西(类、实例、函数或方法,可以以类似于函数的方式调用),以便返回正确的响应格式,以便链中的其他中间件可以正确调用。

对于我们当前基于 API 的应用程序有用的中间件的一个例子是,它允许我们从请求 URI 中提取可选的版本号,并将此信息存储在环境中,以便在请求处理过程中的各个点使用。例如,对/v0.1a/users/2的请求将被路由到/users/2的处理程序,并且v0.1a将通过request.environ['API_VERSION']在 Flask 应用程序本身中可访问。

application/middlewares.py中的新模块中,我们可以实现如下:

import re

version_pattern = re.compile(r"/v(?P<version>[0-9a-z\-\+\.]+)", re.IGNORECASE)

class VersionedAPIMiddleware(object):
    """

    The line wrapping here is a bit off, but it's not critical.

    """

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

    def __call__(self, environ, start_response):
        path = environ.get('PATH_INFO', '')

        match = version_pattern.match(path)

        if match:
            environ['API_VERSION'] = match.group(1)
            environ['PATH_INFO'] = re.sub(version_pattern, '', path,
                count=1)
        else:
            environ['API_VERSION'] = None

        return self.app(environ, start_response)

我们将在工厂中将此中间件绑定到应用程序对象:

# …

from .middlewares import VersionedAPIMiddleware

# …
def create_app(config=None):
    app = Flask(__name__, static_folder=None)
 app.wsgi_app = VersionedAPIMiddleware(app.wsgi_app)

    # …

    api.init_app(app)
    return app

注意

在添加多个 WSGI 中间件时,它们的顺序有时很重要。在添加可能修改 WSGI 环境的中间件时,请务必记住这一点。

一旦绑定,中间件将在 Flask 接收请求之前插入请求处理,即使我们明确实例化了一个 Flask 应用程序对象。在应用程序中访问API_VERSION值只是简单地查询绑定到请求环境的键:

from flask import request
# …
# …
if request.environ['API_VERSION'] > 2:
    # Handle this differently
else:
    # Handle it normally

API 版本号的解析也可以扩展到检查 HTTP 头(自定义或其他),除了我们在此提供的基于 URL 的版本提取;可以为任一方便性提出论点。

回到 Shutterbug - 上传照片

现在我们有了一个最小但功能齐全的 API 来创建和获取用户,我们需要一个类似的 API 来上传照片。首先,我们将使用与之前相同的资源模式,另外定义一个RequestParser实例来验证有关照片的用户提交数据:

from flask.ext.restful import Resource, reqparse
from flask import current_app, request, g, url_for
from application import auth, db, models
import uuid
import os
import werkzeug

new_photo_parser = reqparse.RequestParser()
new_photo_parser.add_argument('comment', type=str,
    required=False)
new_photo_parser.add_argument('photo',
    type=werkzeug.datastructures.FileStorage,
    required=True, location='files')

class UploadPhoto(Resource):

    method_decorators = [auth.login_required]

    def post(self):
        """Adds a new photo via form-encoded POST data."""

        data = new_photo_parser.parse_args(strict=True)

        # Save our file to the filesystem first
        f = request.files['photo']

        extension = os.path.splitext(f.filename)[1]
        name = werkzeug.utils.secure_filename(
            str(uuid.uuid4()) + extension)
        path = os.path.join(
            current_app.config['UPLOAD_FOLDER'], name)

        f.save(path)

        data['user_id'] = g.current_user.id
        data['path'] = path

        # Get rid of the binary data that was sent; we've already
        # saved this to disk.
        del data['photo']

        # Add a new Photo entry to the database once we have
        # successfully saved the file to the filesystem above.
        photo = models.Photo(**data)
        db.session.add(photo)
        db.session.commit()

        data = dict(id=photo.id,
            path=photo.path, comment=photo.comment,
            created_on=photo.created_on)

        return data, 201, {'Location': url_for('singlephoto',
            photo_id=photo.id, _external=True)}

请注意,在前面的UploadPhoto资源中,我们正在访问request.files以提取通过 POST 发送到端点的二进制数据。然后,我们解析出扩展名,生成一个唯一的随机字符串作为文件名,最后将文件保存到我们在应用程序配置中配置的已知UPLOAD_FOLDER中。

注意

请注意,我们使用werkzeug.utils.secure_filename函数来净化上传图像的扩展名,以确保它不容易受到路径遍历或其他基于文件系统的利用的影响,这在处理用户上传的二进制数据时很常见。

在接受将持久化到文件系统的不受信任数据时,应该执行许多其他验证和净化步骤(例如,确保文件的 MIME 类型与实际上传的扩展名和二进制数据匹配,限制图像的大小/尺寸),但出于简洁起见,我们省略了它们。数据验证技术和最佳实践本身就可以填满一整本书。

我们最终将图像持久化到的本地文件系统路径与可能陪伴照片上传的可选评论一起添加到我们的照片 SQLAlchemy 记录中。然后将整个记录添加到会话中,并提交到数据库,然后在标头中返回新创建的资产的位置的 201 响应。在这里,我们避免处理一些简单的错误条件,以便我们可以专注于所呈现的核心概念,并将它们的实现留给读者作为练习。

在尝试任何新的照片上传功能之前,请确保将资源绑定到我们应用程序工厂中的 API 对象:

def create_app(config=None):
    # …

 from .resources.photos import (SinglePhoto, ListPhoto,
 UploadPhoto)
 # …

    api.add_resource(ListPhoto, '/photos')
 api.add_resource(UploadPhoto, '/photos')
    api.add_resource(SinglePhoto, '/photos/<int:photo_id>')
    api.add_resource(SingleUser, '/users/<int:user_id>')
    api.add_resource(CreateUser, '/users')

    # …

分布式系统中的文件上传

我们已经大大简化了现代 Web 应用程序中文件上传的处理。当然,简单通常有一些缺点。

其中最明显的是,在前面的实现中,我们受限于单个应用服务器。如果存在多个应用服务器,则确保上传的文件在这些多个服务器之间保持同步将成为一个重大的运营问题。虽然有许多解决这个特定问题的解决方案(例如,分布式文件系统协议,如 NFS,将资产上传到远程存储,如 Amazon 的简单存储服务S3)等),但它们都需要额外的思考和考虑来评估它们的利弊以及对应用程序结构的重大更改。

测试照片上传

由于我们正在进行一些测试,让我们通过在tests/test_photos.py中编写一些简单的测试来保持这个过程。首先,让我们尝试使用未经身份验证的请求上传一些二进制数据:

import io
import base64
from application.models import User, Photo

def test_unauthenticated_form_upload_of_simulated_file(session, client):
    """Ensure that we can't upload a file via un-authed form POST."""

    data = dict(
        file=(io.BytesIO(b'A test file.'), 'test.png'))

    response = client.post('/photos', data=data)
    assert response.status_code == 401

然后,让我们通过正确验证的请求来检查明显的成功路径:

def test_authenticated_form_upload_of_simulated_file(session, client):
    """Upload photo via POST data with authenticated user."""

    password = 'foobar'
    user = User(username='you', email='you@example.com',
        password=password)

    session.add(user)

    data = dict(
        photo=(io.BytesIO(b'A test file.'), 'test.png'))

    creds = base64.b64encode(
        b'{0}:{1}'.format(user.username, password)).decode('utf-8')

    response = client.post('/photos', data=data,
        headers={'Authorization': 'Basic ' + creds})

    assert response.status_code == 201
    assert 'Location' in response.headers

    photos = Photo.query.all()
    assert len(photos) == 1

    assert ('/photos/{}'.format(photos[0].id) in
        response.headers['Location'])

最后,让我们确保在提交(可选)评论时,它被持久化到数据库中:

def test_upload_photo_with_comment(session, client):
    """Adds a photo with a comment."""

    password = 'foobar'
    user = User(username='you', email='you@example.com',
    password=password)

    session.add(user)

    data = dict(
        photo=(io.BytesIO(b'A photo with a comment.'),
        'new_photo.png'),
        comment='What an inspiring photo!')

    creds = base64.b64encode(
        b'{0}:{1}'.format(
            user.username, password)).decode('utf-8')

    response = client.post('/photos', data=data,
        headers={'Authorization': 'Basic ' + creds})

    assert response.status_code == 201
    assert 'Location' in response.headers

    photos = Photo.query.all()
    assert len(photos) == 1

    photo = photos[0]
    assert photo.comment == data['comment']

获取用户的照片

除了上传照片的能力之外,Shutterbug 应用程序的核心在于能够以逆向时间顺序获取经过认证用户上传的照片列表。为此,我们将完善application/resources/photos.py中的ListPhoto资源。由于我们希望能够对返回的照片列表进行分页,我们还将创建一个新的RequestParser实例来处理常见的页面/限制查询参数。此外,我们将使用 Flask-RESTful 的编组功能来序列化从 SQLAlchemy 返回的Photo对象,以便将它们转换为 JSON 并发送到请求的客户端。

注意

编组是 Web 应用程序(以及大多数其他类型的应用程序!)经常做的事情,即使你可能从未听说过这个词。简单地说,你将数据转换成更适合传输的格式,比如 Python 字典或列表,然后将其转换为 JSON 格式,并通过 HTTP 传输给发出请求的客户端。

from flask.ext.restful import Resource, reqparse, fields, marshal
photos_parser = reqparse.RequestParser()
photos_parser.add_argument('page', type=int, required=False,
        default=1, location='args')
photos_parser.add_argument('limit', type=int, required=False,
        default=10, location='args')

photo_fields = {
    'path': fields.String,
    'comment': fields.String,
    'created_on': fields.DateTime(dt_format='rfc822'),
}

class ListPhoto(Resource):

    method_decorators = [auth.login_required]

    def get(self):
        """Get reverse chronological list of photos for the
        currently authenticated user."""

        data = photos_parser.parse_args(strict=True)
        offset = (data['page'] - 1) * data['limit']
        photos = g.current_user.photos.order_by(
            models.Photo.created_on.desc()).limit(
            data['limit']).offset(offset)

        return marshal(list(photos), photo_fields), 200

请注意,在前面的ListPhoto.get()处理程序中,我们根据请求参数提供的页面和限制计算了一个偏移值。页面和限制与我们的数据集大小无关,并且易于理解,适用于消费 API 的客户端。SQLAlchemy(以及大多数数据库 API)只理解偏移和限制。转换公式是众所周知的,并适用于任何排序的数据集。

摘要

本章的开始有些不同于之前的章节。我们的目标是创建一个基于 JSON 的 API,而不是一个典型的生成 HTML 并消费提交的 HTML 表单数据的 Web 应用程序。

我们首先稍微偏离一下,解释了 Werkzeug 的存在和用处,然后使用名为 Flask-RESTful 的 Flask 扩展创建了一个基本的 API。接下来,我们确保我们的 API 可以通过要求身份验证来保护,并解释了身份验证和授权之间微妙但根本的区别。

然后,我们看了如何实现 API 的验证规则,以确保客户端可以创建有效的资源(例如新用户、上传照片等)。我们使用py.test框架实现了几个功能和集成级别的单元测试。

我们通过实现最重要的功能——照片上传,完成了本章。我们确保这个功能按预期运行,并实现了照片的逆向时间顺序视图,这对 API 的消费者来说是必要的,以便向用户显示上传的图片。在此过程中,我们讨论了 Werkzeug 中间件的概念,这是一种强大但经常被忽视的方式,可以在 Flask 处理请求之前审查和(可能)修改请求。

在下一章中,我们将探讨使用和创建命令行工具,这将允许我们通过 CLI 接口和管理我们的 Web 应用程序。

第六章:Hublot - Flask CLI 工具

在管理 Web 应用程序时,通常有一些任务是我们希望完成的,而不必创建整个管理 Web 界面;即使这可能相对容易地通过诸如 Flask-Admin 之类的工具来实现。许多开发人员首先转向 shell 脚本语言。Bash 几乎在大多数现代 Linux 操作系统上都是通用的,受到系统管理员的青睐,并且足够强大,可以脚本化可能需要的任何管理任务。

尽管可敬的 Bash 脚本绝对是一个选择,但编写一个基于 Python 的脚本会很好,它可以利用我们为 Web 应用程序精心制作的一些应用程序特定的数据处理。这样做,我们可以避免重复大量精力和努力,这些精力和努力是在创建、测试和部署数据模型和领域逻辑的痛苦过程中投入的,这是任何 Web 应用程序的核心。这就是 Flask-Script 的用武之地。

注意

在撰写本文时,Flask 尚未发布 1.0 版本,其中包括通过 Flask 作者开发的Click库进行集成的 CLI 脚本处理。由于 Flask/Click 集成的 API 在现在和 Flask 1.0 发布之间可能会发生重大变化,因此我们选择通过 Flask-Script 包来实现本章讨论的 CLI 工具,这已经是 Flask 的事实标准解决方案相当长的时间了。但是,通过 Click API 创建管理任务可以考虑用于任何新的 Flask 应用程序-尽管实现方式有很大不同,但基本原则是足够相似的。

除了我们可能需要一个 shell 脚本执行的不经常的任务,例如导出计算数据,向一部分用户发送电子邮件等,还有一些来自我们以前应用程序的任务可以移植到 Flask-Script CLI 命令中:

  • 创建/删除我们当前的数据库模式,从而替换我们以前项目中的database.py

  • 运行我们的 Werkzeug 开发服务器,替换以前项目中的run.py

此外,由于 Flask-Script 是为 Flask 应用程序编写可重用 CLI 脚本的当前事实标准解决方案,许多其他扩展发布 CLI 命令,可以集成到您的现有应用程序中。

在本章中,我们将创建一个应用程序,将从Github API 中提取的数据存储在本地数据库中。

注意

Git 是一种分布式版本控制系统DVCS),在过去几年中变得非常流行,而且理由充分。它已经迅速成为了大量使用各种语言编写的开源项目的首选版本控制系统。

GitHub 是 Git 开源和闭源代码存储库的最知名的托管平台,还配备了一个非常完整的 API,允许根据提供的经过身份验证的凭据,以编程方式访问可用的数据和元数据(评论、拉取请求、问题等)。

为了获取这些数据,我们将创建一个简单的 Flask 扩展来封装基于 REST 的 API 查询,以获取相关数据,然后我们将使用这个扩展来创建一个 CLI 工具(通过 Flask-Script),可以手动运行或连接到基于事件或时间的调度程序,例如 cron。

然而,在我们进行任何操作之前,让我们建立一个非常简单的应用程序框架,以便我们可以开始 Flask-Script 集成。

开始

我们再次使用基本的基于 Blueprint 的应用程序结构,并为这个新的冒险创建一个全新的虚拟环境和目录:

$ mkdir -p ~/src/hublot && cd ~/src/hublot
$ mkvirtualenv hublot
$ pip install flask flask-sqlalchemy flask-script

我们将开始使用的应用程序布局与我们在以前基于 Blueprint 的项目中使用的非常相似,主要区别在于manage.py脚本,它将是我们的 Flask-Script CLI 命令的主要入口点。还要注意缺少run.pydatabase.py,这是我们之前提到的,并且很快会详细解释的。

├── application
│   ├── __init__.py
│   └── repositories
│       ├── __init__.py
│       └── models.py
└── manage.py

与我们之前的工作保持一致,我们继续使用“应用工厂”模式,允许我们在运行时实例化我们的应用,而不是在模块导入时进行,就像我们将要使用的 Flask-SQLAlchemy 扩展一样。

我们的application/__init__.py文件包含以下内容,您应该会非常熟悉:

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

# Initialize the db extension, but without configuring
# it with an application instance.
db = SQLAlchemy()

def create_app(config=None):
    app = Flask(__name__)

    if config is not None:
        app.config.from_object(config)

    # Initialize extensions
    db.init_app(app)

    return app

我们的application/settings.py文件包含了我们对于 Flask-SQLAlchemy 应用程序所需的基本内容:

SQLALCHEMY_DATABASE_URI = 'sqlite:///../hublot.db'

注意

对于这个特定项目,我们将使用 SQLite 作为我们的首选数据库;如果您决定使用不同的数据库,请相应调整 URI。

为了简便起见,我们将引入简化的RepositoryIssue模型,这些模型将包含我们想要收集的数据。这些模型将存在于application/repositories/models.py中:

from application import db
from sqlalchemy.schema import UniqueConstraint
import datetime

class Repository(db.Model):
    """Holds the meta-information about a particular
    Github repository."""

    # The unique primary key for the local repository record.
    id = db.Column(db.Integer, primary_key=True)

    # The name of the repository.
    name = db.Column(db.String(length=255), nullable=False)

    # The github org/user that owns the repository.
    owner = db.Column(db.String(length=255), nullable=False)

    # The description (if any) of the repository.
    description = db.Column(db.Text())

    #  The date/time that the record was created on.
    created_on = db.Column(db.DateTime(), 
        default=datetime.datetime.utcnow, index=True)

    # The SQLAlchemy relation for the issues contained within this
    # repository.
    issues = db.relationship('Issue')

    __table_args__ = (UniqueConstraint('name', 'owner'), )

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

Repository模型实例将包含与Issue模型的一对多关系相关的给定 Git 存储库的元数据,我们将在下面定义。我们在这个Repository类中声明的字段在大部分情况下应该是不言自明的,唯一的例外是__table__args__ dunder

注意

dunder是一个 Python 特有的新词,用于指代以两个下划线开头的任何变量或方法:双下划线dunder。有几个内置的 dunder 方法(例如,__init__)和属性(例如,__name__),任何您声明并以两个下划线前缀的属性/方法/函数也将属于这个类别。

这个类属性允许我们能够为创建的底层 SQLAlchemy 表指定特定于表的配置。在我们的情况下,我们将用它来指定一个 UniqueConstraint 键,这个键是由名称和所有者的组合值组成的,否则通过典型的基于属性的字段定义是不可能的。

此外,我们定义了一个 issues 属性,其值是与Issue模型的关系;这是经典的一对多关系,访问存储库实例的 issues 属性将产生与相关存储库关联的问题列表。

注意

请注意,指定的关系不包括与查询性质或相关数据加载行为有关的任何参数。我们正在使用此应用程序的默认行为,这对于包含大量问题的存储库来说并不是一个好主意——在这种情况下,可能会更好地选择先前章节中使用的动态延迟加载方法。

我们在Repository模型中提到的Issue模型旨在包含与此处托管的 Git 存储库相关联的 GitHub 问题元数据。由于问题只在存储库的上下文中有意义,我们确保repository_id外键存在于所有问题中:

class Issue(db.Model):
    """Holds the meta information regarding an issue that
    belongs to a repository."""

    # The autoincremented ID of the issue.
    id = db.Column(db.String(length=40), primary_key=True)
    # The repository ID that this issue belongs to.

    #
    # This relationship will produce a `repository` field
    # that will link back to the parent repository.
    repository_id = db.Column(db.Integer(), 
        db.ForeignKey('repository.id'))

    # The title of the issue
    title = db.Column(db.String(length=255), nullable=False)

    # The issue number
    number = db.Column(db.Integer(), nullable=False)

    state = db.Column(db.Enum('open', 'closed'), nullable=False)

    def __repr__(self):
        """Representation of this issue by number."""
        return '<Issue {}>'.format(self.number)

每个Issue模型的实例将封装关于创建的 GitHub 问题的非常有限的信息,包括问题编号、问题的状态(关闭打开)以及问题的标题。

在以前的章节中,我们会创建一个database.py脚本来初始化在数据库中构建我们的 SQLAlchemy 模型。然而,在本章中,我们将使用 Flask-Script 来编写一个小的 CLI 命令,它将做同样的事情,但为我们提供一个更一致的框架来编写这些小的管理工具,并避免随着时间的推移而困扰任何非平凡应用的独立脚本文件的问题。

manage.py 文件

按照惯例,Flask-Script 的主要入口点是一个名为manage.py的 Python 文件,我们将其放在application/包的同级目录中,就像我们在本章开头描述的项目布局一样。虽然 Flask-Script 包含了相当多的选项-配置和可定制性-我们将使用最简单的可用调用来封装我们在以前章节中使用的database.py Python 脚本的功能,以处理我们数据库的初始化。

我们实例化了一个Manager实例,它将处理我们各种命令的注册。Manager构造函数接受一个 Flask 应用实例作为参数,但它也(幸运地!)可以接受一个实现可调用接口并返回应用实例的函数或类:

from flask.ext.script import Manager
from application import create_app, db

# Create the `manager` object with a
# callable that returns a Flask application object.
manager = Manager(app=create_app)

现在我们有了一个manager实例,我们可以使用这个实例的command方法来装饰我们想要转换为 CLI 命令的函数:

@manager.command
def init_db():
 """Initialize SQLAlchemy database models."""

 db.create_all()

注意

请注意,默认情况下,我们用command方法包装的函数名称将是 CLI 调用中使用的标识符。

为了使整个过程运行起来,当我们直接调用manage.py文件时,我们调用管理器实例的run方法:

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

此时,我们可以通过 Python 解释器执行我们的 CLI 命令:

$ python manage.py init_db

假设一切都按预期工作,我们应该看不到任何结果(或错误),并且我们的数据库应该被初始化为我们在模型定义中指定的表、列和索引。

让我们创建一个截然相反的命令,允许我们销毁本地数据库;在开发过程中对数据模型进行大量更改时,这有时会很方便:

@manager.command
def drop_db():
 if prompt_bool(
 "Are you sure you want to lose all your data"):
 db.drop_all()

我们以与之前定义的init_db命令相同的方式调用这个新创建的drop_db命令:

$ python manage.py drop_db

内置默认命令

除了让我们能够快速定义自己的 CLI 命令之外,Flask-Script 还包括一些默认值,这样我们就不必自己编写它们:

usage: manage.py [-?] {shell,drop_db,init_db,runserver} ...

positional arguments:
 {shell,drop_db,init_db,runserver}
 shell           Runs a Python shell inside Flask application 
 context.
 drop_db
 init_db         Initialize SQLAlchemy database models.
 runserver       Runs the Flask development server i.e. 
 app.run()

optional arguments:
 -?, --help            show this help message and exit

注意

Flask-Script 会根据相关函数的docstrings自动生成已注册命令的帮助文本。此外,运行manage.py脚本而没有指定命令或使用help选项将显示可用顶级命令的完整列表。

如果出于任何原因,我们想要自定义默认设置,这是相对容易实现的。例如,我们需要开发服务器在 6000 端口上运行,而不是默认的 5000 端口:

from flask.ext.script import Manager, prompt_bool, Server
# …

if __name__ == '__main__':
    manager.add_command('runserver', Server(port=6000))
    manager.run()

在这里,我们使用了定义 CLI 命令的另一种方法,即使用manager.add_command方法,它将一个名称和flask.ext.script.command的子类作为第二个参数。

同样地,我们可以覆盖默认的 shell 命令,以便我们的交互式 Python shell 包含对我们配置的 Flask-SQLAlchemy 数据库对象的引用,以及 Flask 应用对象:

def _context():
    """Adds additional objects to our default shell context."""
    return dict(db=db, repositories=repositories)

if __name__ == '__main__':
    manager.add_command('runserver', Server(port=6000))
    manager.add_command('shell', Shell(make_context=_context))
    manager.run()

我们可以通过执行manage.py脚本来验证我们的db对象是否已经被包含,以调用交互式 shell。

$ python manage.py shell

>>> type(db)
<class 'flask_sqlalchemy.SQLAlchemy'>
>>>

验证默认的 Flask 应用服务器是否在我们指定的端口上运行:

$ python manage.py runserver
 * Running on http://127.0.0.1:6000/ (Press CTRL+C to quit)

Flask-Script 为默认的runservershell命令提供了几个配置选项,包括禁用它们的能力。您可以查阅在线文档以获取更多详细信息。

Blueprints 中的 Flask-Script 命令

在我们应用程序级别的manage.py中创建临时 CLI 命令的能力既是一种祝福又是一种诅咒:祝福是因为它需要非常少的样板代码就可以运行起来,诅咒是因为它很容易变成一堆难以管理的代码混乱。

为了避免任何非平凡应用程序的不可避免的最终状态,我们将使用 Flask-Script 中子管理器的未充分利用的功能,以创建一组 CLI 命令,这些命令将存在于蓝图中,但可以通过标准的manage.py调用访问。这应该使我们能够将命令行界面的领域逻辑保存在与我们基于 Web 的组件的领域逻辑相同的位置。

子管理器

我们的第一个 Flask-Script 子管理器将包含解析 GitHub 项目 URL 的逻辑,以获取我们需要创建有效的Repository模型记录的组件部分:

$ python manage.py repositories add "https://github.com/mitsuhiko/flask"\
 --description="Main Flask repository"

总体思路是,我们希望能够使用从“repositories”子管理器的“add”函数提供的位置和命名参数解析出名称、所有者和描述,从而创建一个新的Repository对象。

让我们开始创建一个模块,该模块将包含我们的存储库 CLI 命令,即application/repositories/cli.py,目前为空的add函数:

from flask.ext.script import Manager

repository_manager = Manager(
    usage="Repository-based CLI actions.")

@repository_manager.command
def add():
    """Adds a repository to our database."""
    pass

请注意,我们的repository_manager实例是在没有应用程序实例或可返回应用程序实例的可调用对象的情况下创建的。我们将新创建的子管理器实例注册到我们的主应用程序管理器中,而不是在此处提供应用程序对象。

from flask.ext.script import Manager, prompt_bool, Server, Shell
from application import create_app, db, repositories
from application.repositories.cli import repository_manager

# Create the `manager` object with a
# callable that returns a Flask application object.
manager = Manager(app=create_app)

# …
# …

if __name__ == '__main__':
    manager.add_command('runserver', Server(port=6000))
    manager.add_command('shell', Shell(make_context=_context))
 manager.add_command('repositories', repository_manager)
    manager.run()

这将使我们能够调用repositories管理器并显示可用的子命令:

$ python manage.py repositories --help
usage: Repository-based CLI actions.

Repository-based CLI actions.

positional arguments:
 {add}
 add       Adds a repository to our database.

optional arguments:
 -?, --help  show this help message and exit

虽然这将不会产生任何结果(因为函数体是一个简单的 pass 语句),但我们可以调用我们的add子命令:

$ python manage.py repositories add

所需和可选参数

在 Flask-Script 管理器中注册的任何命令都可以有零个或多个必需参数,以及任意默认值的可选参数。

我们的add命令需要一个强制参数,即要添加到我们数据库中的存储库的 URL,以及一个可选参数,即此存储库的描述。命令装饰器处理了许多最基本的情况,将命名函数参数转换为它们的 CLI 参数等效项,并将具有默认值的函数参数转换为可选的 CLI 参数。

这意味着我们可以指定以下函数声明来匹配我们之前写下的内容:

@repository_manager.command
def add(url, description=None):
    """Adds a repository to our database."""

    print url, description

这使我们能够捕获提供给我们的 CLI 管理器的参数,并在我们的函数体中轻松地使用它们:

$ python manage.py repositories add "https://github.com/mitsuhiko/flask" --description="A repository to add!"

https://github.com/mitsuhiko/flask A repository to add!

由于我们已经成功地编码了 CLI 工具的所需接口,让我们添加一些解析,以从 URL 中提取出我们想要的相关部分:

@repository_manager.command
def add(url, description=None):
    """Adds a repository to our database."""

 parsed = urlparse(url)

 # Ensure that our repository is hosted on github
 if parsed.netloc != 'github.com':
 print "Not from Github! Aborting."
 return 1

 try:
 _, owner, repo_name = parsed.path.split('/')
 except ValueError:
 print "Invalid Github project URL format!"
        return 1

注意

我们遵循*nix约定,在脚本遇到错误条件时返回一个介于 1 和 127 之间的非零值(约定是对语法错误返回 2,对其他任何类型的错误返回 1)。由于我们期望我们的脚本能够成功地将存储库对象添加到我们的数据库中,任何情况下如果这种情况没有发生,都可以被视为错误条件,因此应返回一个非零值。

现在我们正确捕获和处理 CLI 参数,让我们使用这些数据来创建我们的Repository对象,并将它们持久化到我们的数据库中:

from flask.ext.script import Manager
from urlparse import urlparse
from application.repositories.models import Repository
from application import db
import sqlalchemy

# …

@repository_manager.command
def add(url, description=None):
    """Adds a repository to our database."""

    parsed = urlparse(url)

    # Ensure that our repository is hosted on github
    if parsed.netloc != 'github.com':
        print "Not from Github! Aborting."
        return 1

    try:
        _, owner, repo_name = parsed.path.split('/')
    except ValueError:
        print "Invalid Github project URL format!"
        return 1

 repository = Repository(name=repo_name, owner=owner)
 db.session.add(repository)

 try:
 db.session.commit()
 except sqlalchemy.exc.IntegrityError:
 print "That repository already exists!"
 return 1

 print "Created new Repository with ID: %d" % repository.id
    return 0

注意

请注意,我们已经处理了向数据库添加重复存储库(即具有相同名称和所有者的存储库)的情况。如果不捕获IntegrityError,CLI 命令将失败并输出指示未处理异常的堆栈跟踪。

现在运行我们新实现的 CLI 命令将产生以下结果:

$ python manage.py repositories add "https://github.com/mitsuhiko/flask" --description="A repository to add!"

Created new Repository with ID: 1

成功创建我们的Repository对象可以在我们的数据库中进行验证。对于 SQLite,以下内容就足够了:

$ sqlite3 hublot.db
SQLite version 3.8.5 2014-08-15 22:37:57
Enter ".help" for usage hints.

sqlite> select * from repository;

1|flask|mitsuhiko|A repository to add!|2015-07-22 04:00:36.080829

Flask 扩展 - 基础知识

我们花了大量时间安装、配置和使用各种 Flask 扩展(Flask-Login、Flask-WTF、Flask-Bcrypt 等)。它们为我们提供了一个一致的接口来配置第三方库和工具,并经常集成一些使应用程序开发更加愉快的 Flask 特定功能。然而,我们还没有涉及如何构建自己的 Flask 扩展。

注意

我们只会查看创建有效的 Flask 扩展所需的框架,以便在项目中本地使用。如果您希望打包您的自定义扩展并在 PyPi 或 GitHub 上发布它,您将需要实现适当的setup.py和 setuptools 机制,以使这成为可能。您可以查看 setuptools 文档以获取更多详细信息。

何时应该使用扩展?

Flask 扩展通常属于以下两类之一:

  • 封装第三方库提供的功能,确保当同一进程中存在多个 Flask 应用程序时,该第三方库将正常运行,并可能添加一些使与 Flask 集成更具体的便利函数/对象;例如,Flask-SQLAlchemy

  • 不需要第三方库的模式和行为的编码,但确保应用程序具有一组一致的功能;例如,Flask-Login

您将在野外遇到或自己开发的大多数 Flask 扩展都属于第一类。第二类有点异常,并且通常是由在多个应用程序中观察到的常见模式抽象和精炼而来,以至于可以将其放入扩展中。

我们的扩展 - GitHubber

本章中我们将构建的扩展将封装Github API 的一个小部分,这将允许我们获取先前跟踪的给定存储库的问题列表。

注意

Github API 允许的功能比我们需要的更多,文档也很好。此外,存在几个第三方 Python 库,封装了大部分Github API,我们将使用其中一个。

为了简化与 GitHub 的 v3 API 的交互,我们将在本地虚拟环境中安装github3.py Python 包:

$ pip install github3.py

由于我们正在在我们的 Hublot 应用程序中开发扩展,我们不打算引入自定义 Flask 扩展的单独项目的额外复杂性。然而,如果您打算发布和/或分发扩展,您将希望确保它以这样的方式结构化,以便可以通过 Python 包索引提供并通过 setuptools(或 distutils,如果您更愿意只使用标准库中包含的打包工具)进行安装。

让我们创建一个extensions.py模块,与application/repositories/ package同级,并引入任何 Flask 扩展都应包含的基本结构:

class Githubber(object):
    """
    A Flask extension that wraps necessary configuration
    and functionality for interacting with the Github API
    via the `github3.py` 3rd party library.
    """

    def __init__(self, app=None):
        """
        Initialize the extension.

        Any default configurations that do not require
        the application instance should be put here.
        """

        if app:
            self.init_app(app)

    def init_app(self, app):
        """
        Initialize the extension with any application-level 
        Configuration requirements.
        """
        self.app = app

对于大多数扩展,这就是所需的全部。请注意,基本扩展是一个普通的 Python 对象(俗称为 POPO)定义,增加了一个init_app实例方法。这个方法并不是绝对必要的。如果您不打算让扩展使用 Flask 应用程序对象(例如加载配置值)或者不打算使用应用程序工厂模式,那么init_app是多余的,可以省略。

我们通过添加一些配置级别的检查来完善扩展,以确保我们具有GITHUB_USERNAMEGITHUB_PASSWORD以进行 API 身份验证访问。此外,我们将当前扩展对象实例存储在app.extensions中,这使得扩展的动态使用/加载更加简单(等等):

    def init_app(self, app):
        """
        Initialize the extension with any application-level 
        Configuration requirements.

        Also store the initialized extension and application state
        to the `app.extensions`
        """

        if not hasattr(app, 'extensions'):
            app.extensions = {}

        if app.config.get('GITHUB_USERNAME') is None:
            raise ValueError(
                "Cannot use Githubber extension without "
                "specifying the GITHUB_USERNAME.")

        if app.config.get('GITHUB_PASSWORD') is None:
            raise ValueError(
                "Cannot use Githubber extension without "
                "specifying the GITHUB_PASSWORD.")

        # Store the state of the currently configured extension in
        # `app.extensions`.
        app.extensions['githubber'] = self
        self.app = app

注意

Github API 进行身份验证请求需要某种形式的身份验证。GitHub 支持其中几种方法,但最简单的方法是指定帐户的用户名和密码。一般来说,这不是你想要要求用户提供的东西:最好在这些情况下使用 OAuth 授权流程,以避免以明文形式存储用户密码。然而,对于我们相当简单的应用程序和自定义扩展,我们将放弃扩展的 OAuth 实现(我们将在后面的章节中更广泛地讨论 OAuth),并使用用户名和密码组合。

单独使用,我们创建的扩展并没有做太多事情。让我们通过添加一个装饰属性的方法来修复这个问题,该方法实例化github3.py Github API 客户端库:

from github3 import login

class Githubber(object):
    # …
    def __init__(self, app=None):

        self._client = None
        # …

    @property
    def client(self):
        if self._client:
            return self._client

        gh_client = login(self.app.config['GITHUB_USERNAME'],
                password=self.app.config['GITHUB_PASSWORD'])

        self._client = gh_client
        return self._client

在前面的client方法中,我们实现了缓存属性模式,这将确保我们只实例化一个github3.py客户端,每个创建的应用程序实例只实例化一次。此外,扩展将在第一次访问时延迟加载Github API 客户端,这通常是一个好主意。一旦应用程序对象被初始化,这让我们可以使用扩展的客户端属性直接与github3.py Python 库进行交互。

现在我们已经为我们的自定义 Flask 扩展设置了基本的设置,让我们在application/__init__.py中的应用工厂中初始化它并配置扩展本身:

from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from application.extensions import Githubber

# …
hubber = Githubber()

def create_app(config=None):
    app = Flask(__name__)
    # …

    # Initialize any extensions and bind blueprints to the
    # application instance here.
    db.init_app(app)
 hubber.init_app(app)

    return app

注意hubber = Githubber()的初始化和赋值发生在工厂本身之外,但实际的init_app(app)方法调用和隐含的扩展配置发生在我们初始化 Flask 应用程序对象之后的工厂中。你可能已经注意到了这种分割模式(我们在之前的章节中也讨论过几次),但现在你已经通过开发自己的扩展看到了它的原因。

考虑到这一点,我们在application/repositories/cli.py模块中添加了一个额外的函数,以增加一些额外的 CLI 工具功能:

from flask.ext.script import Manager
from urlparse import urlparse
from application.repositories.models import Repository, Issue
from application import db, hubber
import sqlalchemy

# …

@repository_manager.command
def fetch_issues(repository_id):
    """Fetch all commits for the given Repository."""

    try:
        repo = Repository.query.get(repository_id)
    except sqlalchemy.orm.exc.NoResultFound:
        print "No such repository ID!"
        return 1

    r = hubber.client.repository(repo.owner, repo.name)
    issues = []

    for issue in r.iter_issues():
        i = Issue(repository_id=repo.id, title=issue.title,
                number=issue.number, state=issue.state)

        issues.append(i)

    db.session.add_all(issues)

       print "Added {} issues!".format(len(issues))

从数据库中获取存储库对象(基于通过 CLI 参数指定的 ID 值),我们调用了我们的Githubber扩展的client.repository()方法,我们将其导入为hubber,这是在工厂序言中分配的名称。由于我们的扩展的一部分负责使用所需的凭据进行初始化,因此我们不需要在调用它的 CLI 工具中处理这个问题。

一旦我们获得了对远程 GitHub 存储库的引用,我们就通过github3.py提供的iter_issues()方法迭代注册的问题,然后创建Issue实例,将其持久化到 SQLAlchemy 会话中。

注意

对当前的Issue模型的一个受欢迎的改进是在repository_id和数字上引入一个复合索引,并使用唯一约束来确保在同一存储库上多次运行前面的命令时不会重复导入问题。

在前面的 CLI 命令中,对重复插入的异常处理也需要发生。实现留给读者作为一个(相对简单的)练习。

这些类型的 CLI 工具非常有用,可以脚本化动作和行为,这些动作和行为在典型的 Web 应用程序的当前用户请求中可能被认为成本太高。你最不希望的是你的应用程序的用户等待几秒,甚至几分钟,以完成一些你几乎无法控制的操作。相反,最好让这些事件在带外发生。实现这一目标的流行方法包括 cron 作业和作业/任务队列,例如 Celery 实现的那些(可能是事件驱动的,而不是按照 cron 作业那样定期运行),等等。

摘要

阅读完本章后,您应该对 Flask 扩展和基于命令行的应用程序接口(通过 Flask-Script)的内部工作方式更加熟悉。

我们首先创建了一个简单的应用程序,用于存储在 GitHub 上托管的存储库和问题的数据,然后安装和配置了我们的manage.py脚本,以充当 Flask-Script 默认 CLI runserver 和 shell 命令的桥梁。我们添加了drop_dbinit_db全局命令,以替换我们在之前章节中使用的database.py脚本。完成后,我们将注意力转向在蓝图中创建子管理器的脚本,我们可以通过主manage.py接口脚本进行控制。

最后,我们实现了自己的 Flask 扩展,包装了一些基本配置和资源实例化的github3.py Github API 客户端。完成后,我们回到之前创建的子管理脚本,并添加了获取存储在 GitHub 上的给定存储库 ID 的问题列表所需的功能。

在下一章中,我们将深入研究第三方 API,我们将构建一个应用程序,该应用程序使用 OAuth 授权协议,以实现通过 Twitter 和 Facebook 进行用户帐户创建和登录。

第七章:Dinnerly - 食谱分享

在本章中,我们将探讨所谓的社交登录的现代方法,其中我们允许用户使用来自另一个网络应用程序的派生凭证对我们的应用程序进行身份验证。目前,支持这种机制的最广泛的第三方应用程序是 Twitter 和 Facebook。

虽然存在其他几种广泛的网络应用程序支持这种集成类型(例如 LinkedIn、Dropbox、Foursquare、Google 和 GitHub 等),但您潜在用户的大多数将至少拥有 Twitter 或 Facebook 中的一个帐户,这两个是当今主要的社交网络。

为此,我们将添加、配置和部署 Flask-OAuthlib 扩展。该扩展抽象出了通常在处理基于 OAuth 的授权流程时经常遇到的一些困难和障碍(我们将很快解释),并包括功能以快速设置所需的默认值来协商提供者/消费者/资源所有者令牌交换。作为奖励,该扩展将为我们提供与用户代表的这些远程服务的经过身份验证的 API 进行交互的能力。

首先是 OAuth

让我们先把这个搞清楚:OAuth 可能有点难以理解。更加火上浇油的是,OAuth 框架/协议在过去几年中经历了一次重大修订。第 2 版于 2012 年发布,但由于各种因素,仍有一些网络应用程序继续实施 OAuth v1 协议。

注意

OAuth 2.0 与 OAuth 1.0 不兼容。此外,OAuth 2.0 更像是授权框架规范,而不是正式的协议规范。现代网络应用程序中大多数 OAuth 2.0 实现是不可互操作的。

为了简单起见,我们将概述 OAuth 2.0 授权框架的一般术语、词汇和功能。第 2 版是两个规范中更简单的一个,这是有道理的:后者的设计目标之一是使客户端实现更简单,更不容易出错。大部分术语在两个版本中是相似的,如果不是完全相同的。

虽然由于 Flask-OAuthlib 扩展和处理真正繁重工作的底层 Python 包,OAuth 授权交换的复杂性大部分将被我们抽象化,但对于网络应用程序和典型实现的 OAuth 授权框架(特别是最常见的授权授予流程)的一定水平的了解将是有益的。

为什么使用 OAuth?

适当的在线个人安全的一个重大错误是在不同服务之间重复使用访问凭证。如果您用于一个应用的凭证被泄露,这将使您面临各种安全问题。现在,您可能会在使用相同一组凭证的所有应用程序上受到影响,唯一的后期修复方法是去到处更改您的凭证。

比在不同服务之间重复使用凭证更糟糕的是,用户自愿将他们的凭证交给第三方服务,比如 Twitter,以便其他服务,比如 Foursquare,可以代表用户向 Twitter 发出请求(例如,在他们的 Twitter 时间轴上发布签到)。虽然不是立即明显,但这种方法的问题之一是凭证必须以明文形式存储。

出于各种原因,这种情况并不理想,其中一些原因是您作为应用程序开发人员无法控制的。

OAuth 在框架的 1 版和 2 版中都试图通过创建 API 访问委托的开放标准来解决跨应用程序共享凭据的问题。OAuth 最初设计的主要目标是确保应用程序 A 的用户可以代表其委托应用程序 B 访问,并确保应用程序 B 永远不会拥有可能危害应用程序 A 用户帐户的凭据。

注意

虽然拥有委托凭据的应用程序可以滥用这些凭据来执行一些不良操作,但根凭据从未被共享,因此帐户所有者可以简单地使被滥用的委托凭据无效。如果根帐户凭据简单地被提供给第三方应用程序,那么后者可以通过更改所有主要身份验证信息(用户名、电子邮件、密码等)来完全控制帐户,从而有效地劫持帐户。

术语

关于 OAuth 的使用和实施的大部分混乱源于对用于描述基本授权流的基本词汇和术语的误解。更糟糕的是,有几个流行的 Web 应用程序已经实施了 OAuth(以某种形式),并决定使用自己的词汇来代替官方 RFC 中已经决定的词汇。

注意

RFC,或称为请求评论,是来自互联网工程任务组IETF)的一份文件或一组文件的备忘录式出版物,IETF 是管理大部分互联网建立在其上的开放标准的主要机构。RFC 通常由一个数字代码表示,该代码在 IETF 中唯一标识它们。例如,OAuth 2.0 授权框架 RFC 编号为 6749,可以在 IETF 网站上完整找到。

为了帮助减轻一些混乱,以下是 OAuth 实施中大多数基本组件的简化描述:

  • 消费者:这是代表用户发出请求的应用程序。在我们的特定情况下,Dinnerly 应用程序被视为消费者。令人困惑的是,官方的 OAuth 规范是指客户端而不是消费者。更令人困惑的是,一些应用程序同时使用消费者和客户端术语。通常,消费者由必须保存在应用程序配置中的密钥和秘钥表示,并且必须受到良好的保护。如果恶意实体获得了您的消费者密钥和秘钥,他们就可以在向第三方提供商发出授权请求时假装成您的应用程序。

  • 提供者:这是消费者代表用户试图访问的第三方服务。在我们的情况下,Twitter 和 Facebook 是我们将用于应用程序登录的提供者。其他提供者的例子可能包括 GitHub、LinkedIn、Google 以及任何其他提供基于授权流的 OAuth 授权的服务。

  • 资源所有者:这是有能力同意委托资源访问的实体。在大多数情况下,资源所有者是所涉及应用程序的最终用户(例如,Twitter 和 Dinnerly)。

  • 访问令牌:这是客户端代表用户向提供者发出请求以访问受保护资源的凭据。令牌可以与特定的权限范围相关联,限制其可以访问的资源。此外,访问令牌可能会在由提供者确定的一定时间后过期;此时需要使用刷新令牌来获取新的有效访问令牌。

  • 授权服务器:这是负责在资源所有者同意委托他们的访问权限后向消费者应用程序发放访问令牌的服务器(通常由 URI 端点表示)。

  • 流程类型:OAuth 2.0 框架提供了几种不同的授权流程概述。有些最适合于没有网络浏览器的命令行应用程序,有些更适合于原生移动应用程序,还有一些是为连接具有非常有限访问能力的设备而创建的(例如,如果您想将 Twitter 帐户特权委托给您的联网烤面包机)。我们最感兴趣的授权流程,不出所料,是为基本基于网络浏览器的访问而设计的。

有了上述词汇表,您现在应该能够理解官方 OAuth 2.0 RFC 中列出的官方抽象协议流程:

 +--------+                               +---------------+
 |        |--(A)- Authorization Request ->|   Resource    |
 |        |                               |     Owner     |
 |        |<-(B)-- Authorization Grant ---|               |
 |        |                               +---------------+
 |        |
 |        |                               +---------------+
 |        |--(C)-- Authorization Grant -->| Authorization |
 | Client |                               |     Server    |
 |        |<-(D)----- Access Token -------|               |
 |        |                               +---------------+
 |        |
 |        |                               +---------------+
 |        |--(E)----- Access Token ------>|    Resource   |
 |        |                               |     Server    |
 |        |<-(F)--- Protected Resource ---|               |
 +--------+                               +---------------+

以下是从 RFC 6749 中列出的流程图中列出的步骤的描述,并且为了我们的目的更加相关:

  1. 客户端(或消费者)请求资源所有者授予授权。这通常是用户被重定向到远程提供者的登录屏幕的地方,比如 Twitter,在那里解释了客户端应用程序希望访问您控制的受保护资源。同意后,我们进入下一步。

  2. 客户端从资源所有者(用户)那里收到授权凭证,这是代表资源所有者对提供者实施的特定类型授权流程的授权的临时凭证。对于大多数 Web 应用程序来说,这通常是授权代码授予流程。

  3. 一旦客户端收到授权凭证,它会将其发送到授权服务器,以代表资源所有者请求认证令牌。

  4. 授权服务器验证授权凭证并对发出请求的客户端进行身份验证。在满足这两个要求后,服务器将有效的认证令牌返回给客户端,然后客户端可以使用该令牌代表用户向提供者发出经过认证的请求。

那么 OAuth 1.0 有什么问题呢?

理论上:没有太多问题。实际上:对于消费者来说,正确实施起来有些困难,而且极易出错。

在实施和使用 OAuth 1.0 提供程序时的主要困难围绕着消费者应用程序未能正确执行所需的加密请求签名。参数和参数必须从查询字符串中收集,还必须从请求正文和各种 OAuth 参数(例如,oauth_nonceoauth_signature_methodoauth_timestamp等)中收集,然后进行 URL 编码(意味着非 URL 安全值被特殊编码以确保它们被正确传输)。一旦键/值对已被编码,它们必须按键的字典顺序进行排序(记住,编码后的键而不是原始键值),然后使用典型的 URL 参数分隔符将它们连接成一个字符串。此外,要提交请求的 HTTP 动词(例如,GETPOST)必须预先添加到我们刚刚创建的字符串中,然后跟随请求将被发送到的 URL。最后,签名密钥必须由消费者秘钥和 OAuth 令牌秘钥构建,然后传递给 HMAC-SHA1 哈希算法的实现,以及我们之前构建的有效载荷。

假设您已经全部正确理解了这些(很容易出现简单错误,比如按字母顺序而不是按字典顺序对密钥进行排序),那么请求才会被视为有效。此外,在发生签名错误的情况下,没有简单的方法确定错误发生的位置。

OAuth 1.0 需要这种相当复杂的过程的原因之一是,该协议的设计目标是它应该跨不安全的协议(如 HTTP)运行,但仍确保请求在传输过程中没有被恶意方修改。

尽管 OAuth 2.0 并不被普遍认为是 OAuth 1.0 的值得继任者,但它通过简单要求所有通信都在 HTTPS 上进行,大大简化了实现。

三步授权

在 OAuth 框架的所谓三步授权流程中,应用程序(consumer)代表用户(resource owner)发出请求,以访问远程服务(provider)上的资源。

注意

还存在一个两步授权流程,主要用于应用程序之间的访问,资源所有者不需要同意委托访问受保护资源。例如,Twitter 实现了两步和三步授权流程,但前者在资源访问和强制 API 速率限制方面没有与后者相同的访问范围。

这就是 Flask-Social 将允许我们为 Twitter 和 Facebook 实现的功能,我们选择的两个提供者,我们的应用程序将作为消费者。最终结果将是我们的 Dinnerly 应用程序将拥有这两个提供者的访问令牌,这将允许我们代表我们的用户(资源所有者)进行经过身份验证的 API 请求,这对于实现任何跨社交网络发布功能是必要的。

设置应用程序

再次,让我们为我们的项目设置一个基本的文件夹,以及相关的虚拟环境,以隔离我们的应用程序依赖关系:

$ mkdir –p ~/src/dinnerly
$ mkvirtualenv dinnerly
$ cd ~/src/dinnerly

创建后,让我们安装我们需要的基本包,包括 Flask 本身以及 Flask-OAuthlib 扩展,我们值得信赖的朋友 Flask-SQLAlchemy 和我们在之前章节中使用过的 Flask-Login:

$ pip install flask flask-oauthlib flask-sqlalchemy flask-login flask-wtf

我们将利用我们在过去章节中表现良好的 Blueprint 应用程序结构,以确保坚实的基础。现在,我们将有一个单一的用户 Blueprint,其中将处理 OAuth 处理:

-run.py
-application
 ├── __init__.py
 └── users
     ├── __init__.py
     ├── models.py
    └── views.py

一旦建立了非常基本的文件夹和文件结构,让我们使用应用程序工厂来创建我们的主应用程序对象。现在,我们要做的只是在application/__init__.py中实例化一个非常简单的应用程序,其中包含一个 Flask-SQLAlchemy 数据库连接:

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

# Deferred initialization of the db extension
db = SQLAlchemy()

def create_app(config=None):
    app = Flask(__name__, static_folder=None)

    if config is not None:
        app.config.from_object(config)

    db.init_app(app)
    return app

为了确保我们实际上可以运行应用程序并创建数据库,让我们使用简单的run.pydatabase.py脚本,将它们放在application文件夹的同级目录。run.py的内容与我们在之前章节中使用的内容类似:

from application import create_app

app = create_app(config='settings')
app.run(debug=True)

注意

在本章的后面,我们将探讨运行 Dinnerly 应用程序的替代方法,其中大部分更适合生产部署。在app.run()上调用的 Werkzeug 开发服务器非常不适合除了本地开发之外的任何其他用途。

我们的database.py同样简单明了:

from application import db, create_app
app = create_app(config='settings')
db.app = app

db.create_all()

这将允许我们根据我们的模型定义在数据库中创建相关的模式,但我们还没有声明模型;现在运行脚本基本上不会有任何操作。这没关系!在这变得有用之前我们还有很多工作要做。

声明我们的模型

与大多数应用程序一样,我们首先声明我们的数据模型和它们需要的任何关系。当然,我们需要一个User模型,它将是 OAuth 授权和令牌交换的核心。

正如您可能还记得我们对 OAuth 术语和基本的三步授权授予流程的简要概述,访问令牌是允许客户端(我们的 Dinnerly 应用程序)查询远程服务提供商(例如 Twitter 或 Facebook)资源的东西。由于我们需要这些令牌来向列出的服务提供商发出请求,我们希望将它们存储在某个地方,以便我们可以在没有用户为每个操作重新进行身份验证的情况下使用它们;这将非常繁琐。

我们的User模型将与我们以前使用过的User模型非常相似(尽管我们删除了一些属性以简化事情),我们将把它放在application/users/models.py的明显位置:

import datetime
from application import db

class User(db.Model):

    # The primary key for each user record.
    id = db.Column(db.Integer, primary_key=True)

    # The username for a user. Might not be
    username = db.Column(db.String(40))

    #  The date/time that the user account was created on.
    created_on = db.Column(db.DateTime,
        default=datetime.datetime.utcnow)

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

注意

请注意,我们没有包括有关密码的任何内容。由于此应用程序的意图是要求使用 Facebook 或 Twitter 创建帐户并登录,我们放弃了典型的用户名/密码凭据组合,而是将身份验证委托给这些第三方服务之一。

为了帮助我们的用户会话管理,我们将重用我们在之前章节中探讨过的 Flask-Login 扩展。以防您忘记,扩展的基本要求之一是在用于表示经过身份验证的用户的任何模型上声明四种方法:is_authenticatedis_activeis_anonymousget_id。让我们将这些方法的最基本版本附加到我们已经声明的User模型中:

class User(db.Model):

   # …

    def is_authenticated(self):
        """All our registered users are authenticated."""
        return True

    def is_active(self):
        """All our users are active."""
        return True

    def is_anonymous(self):
        """All users are not in an anonymous state."""
        return False

    def get_id(self):
        """Get the user ID as a Unicode string."""
        return unicode(self.id)

现在,您可能已经注意到User模型上没有声明的 Twitter 或 Facebook 访问令牌属性。当然,添加这些属性是一个选择,但我们将使用稍微不同的方法,这需要更多的前期复杂性,并且将允许添加更多提供程序而不会过度污染我们的User模型。

我们的方法将集中在创建用户与各种提供程序类型之间的多个一对一数据关系的想法上,这些关系将由它们自己的模型表示。让我们在application/users/models.py中添加我们的第一个提供程序模型到存储:

class TwitterConnection(db.Model):

    # The primary key for each connection record.
    id = db.Column(db.Integer, primary_key=True)

    # Our relationship to the User that this
    # connection belongs to.
    user_id = db.Column(db.Integer(),
        db.ForeignKey('user.id'), nullable=False, unique=True)

    # The twitter screen name of the connected account.
    screen_name = db.Column(db.String(), nullable=False)

    # The Twitter ID of the connected account
    twitter_user_id = db.Column(db.Integer(), nullable=False)

    # The OAuth token
    oauth_token = db.Column(db.String(), nullable=False)

    # The OAuth token secret
    oauth_token_secret = db.Column(db.String(), nullable=False)

前面的模型通过user_id属性声明了与User模型的外键关系,除了主键之外的其他字段存储了进行身份验证请求所需的 OAuth 令牌和密钥,以代表用户访问 Twitter API。此外,我们还存储了 Twitter 的screen_nametwitter_user_id,以便将此值用作相关用户的用户名。保留 Twitter 用户 ID 有助于我们将 Twitter 上的用户与本地 Dinnerly 用户匹配(因为screen_name可以更改,但 ID 是不可变的)。

一旦TwitterConnection模型被定义,让我们将关系添加到User模型中,以便我们可以通过twitter属性访问相关的凭据:

Class User(db.Model):
  # …

  twitter = db.relationship("TwitterConnection", uselist=False,
    backref="user")

这在UserTwitterConnection之间建立了一个非常简单的一对一关系。uselist=False参数确保配置的属性将引用标量值,而不是列表,这将是一对多关系的默认值。

因此,一旦我们获得了用户对象实例,我们就可以通过user.twitter访问相关的TwitterConnection模型数据。如果没有附加凭据,那么这将返回None;如果有附加凭据,我们可以像预期的那样访问子属性:user.twitter.oauth_tokenuser.twitter.screen_name等。

让我们为等效的FacebookConnection模型做同样的事情,它具有类似的属性。与TwitterConnection模型的区别在于 Facebook OAuth 只需要一个令牌(而不是组合令牌和密钥),我们可以选择存储 Facebook 特定的 ID 和名称(而在其他模型中,我们存储了 Twitter 的screen_name):

class FacebookConnection(db.Model):

    # The primary key for each connection record.
    id = db.Column(db.Integer, primary_key=True)

    # Our relationship to the User that this
    # connection belongs to.
    user_id = db.Column(db.Integer(),
        db.ForeignKey('user.id'), nullable=False)

    # The numeric Facebook ID of the user that this
    # connection belongs to.
    facebook_id = db.Column(db.Integer(), nullable=False)

    # The OAuth token
    access_token = db.Column(db.String(), nullable=False)

    # The name of the user on Facebook that this
    # connection belongs to.
    name = db.Column(db.String())

一旦我们建立了这个模型,我们就会想要像之前为TwitterConnection模型一样,将这种关系引入到我们的User模型中:

class User(db.Model):

       # …

    facebook = db.relationship("FacebookConnection", 
        uselist=False, backref="user")

user实例的前述facebook属性的功能和用法与我们之前定义的twitter属性完全相同。

在我们的视图中处理 OAuth

有了我们基本的用户和 OAuth 连接模型,让我们开始构建所需的 Flask-OAuthlib 对象来处理授权授予流程。第一步是以我们应用程序工厂的通常方式初始化扩展。在此期间,让我们也初始化 Flask-Login 扩展,我们将用它来管理已登录用户的认证会话:

from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from flask_oauthlib.client import OAuth
 from flask.ext.login import LoginManager

# Deferred initialization of our extensions
db = SQLAlchemy()
oauth = OAuth()
login_manager = LoginManager()

def create_app(config=None):
    app = Flask(__name__, static_folder=None)

    if config is not None:
        app.config.from_object(config)

    db.init_app(app)
 oauth.init_app(app)
 login_manager.init_app(app)

    return app

现在我们有了一个oauth对象可供我们使用,我们可以为每个服务提供商实例化单独的 OAuth 远程应用程序客户端。让我们将它们放在我们的application/users/views.py 模块中:

from flask.ext.login import login_user, current_user
from application import oauth

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

facebook = oauth.remote_app(
    'facebook',
    consumer_key='<facebook app id>',
    consumer_secret='<facebook app secret>',
    request_token_params={'scope': 'email,publish_actions'},
    base_url='https://graph.facebook.com',
    request_token_url=None,
    access_token_url='/oauth/access_token',
    access_token_method='GET',
    authorize_url='https://www.facebook.com/dialog/oauth')

现在,在实例化这些 OAuth 对象时似乎有很多事情要做,但其中大部分只是告诉通用的 OAuth 连接库各种三方 OAuth 授权授予流程的服务提供商 URI 端点在哪里。然而,有一些参数值需要您自己填写:消费者密钥(对于 Twitter)和应用程序密钥(对于 Facebook)。要获得这些值,您必须在相应的服务上注册一个新的 OAuth 客户端应用程序,您可以在这里这样做:

  • Twitter: apps.twitter.com/app/new,然后转到KeysAccess Tokens选项卡以获取消费者密钥和消费者密钥。

  • Facebook: developers.facebook.com/apps/,同意服务条款并注册您的帐户进行应用程序开发。然后,选择要添加的网站类型应用程序,并按照说明生成所需的应用程序 ID 和应用程序密钥。

在 Facebook 的情况下,我们通过request_token_params参数的scope键的publish_actions值请求了发布到相关用户的墙上的权限。这对我们来说已经足够了,但如果您想与 Facebook API 互动不仅仅是推送状态更新,您需要请求正确的权限集。Facebook 文档中有关于第三方应用程序开发者如何使用权限范围值执行不同操作的额外信息和指南。

一旦您获得了所需的密钥和密钥,就将它们插入到前述oauth远程应用程序客户端配置中留下的占位符中。

现在,我们需要让我们的应用程序处理授权流程的各个部分,这些部分需要用户从服务提供商那里请求授予令牌。我们还需要让我们的应用程序处理回调路由,服务提供商将在流程完成时重定向到这些路由,并携带各种 OAuth 令牌和密钥,以便我们可以将这些值持久化到我们的数据库中。

让我们创建一个用户 Blueprint 来对application/users/views.py中的各种路由进行命名空间处理,同时,我们还可以从 Flask 和 Flask-Login 中导入一些实用程序来帮助我们的集成:

from flask import Blueprint, redirect, url_for, request
from flask.ext.login import login_user, current_user

from application.users.models import (
    User, TwitterConnection, FacebookConnection)
from application import oauth, db, login_manager
import sqlalchemy

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

根据 Flask-Login 的要求,我们需要定义一个user_loader函数,它将通过 ID 从我们的数据库中获取用户:

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

以非常相似的方式,Flask-OAuthlib 要求我们定义一个方法(每个服务一个)作为令牌获取器;而 Flask-Login 需要user_loader通过 ID 从数据库中获取用户。OAuthlib 需要一个函数来获取当前登录用户的 OAuth 令牌。如果当前没有用户登录,则该方法应返回None,表示我们可能需要开始授权授予流程来获取所需的令牌:

@twitter.tokengetter
def get_twitter_token():
    """Fetch Twitter token from currently logged
    in user."""
    if (current_user.is_authenticated() and
            current_user.twitter):
        return (current_user.twitter.oauth_token,
                current_user.twitter.oauth_token_secret)
    return None

@facebook.tokengetter
def get_facebook_token():
    """Fetch Facebook token from currently logged
    in user."""
    if (current_user.is_authenticated() and
            current_user.facebook):
        return (current_user.facebook.oauth_token, )
    return None

注意

请注意,我们使用了 Flask-Login 提供的current_user代理对象来访问当前经过身份验证的用户的对象,然后我们调用了在本章前面定义的User模型中的is_authenticated方法。

接下来,我们需要定义路由和处理程序来启动三方授权授予。我们的第一个用户蓝图路由将处理使用 Twitter 作为第三方提供商的尝试登录:

@users.route('/login/twitter')
def login_twitter():
    """Kick-off the Twitter authorization flow if
    not currently authenticated."""

    if current_user.is_authenticated():
        return redirect(url_for('recipes.index'))
    return twitter.authorize(
        callback=url_for('.twitter_authorized',
            _external=True))

前面的路由首先确定当前用户是否已经经过身份验证,并在他们已经经过身份验证时将其重定向到主recipes.index路由处理程序。

注意

我们已经为recipes.index路由设置了一些重定向,但我们还没有定义。如果您打算在我们设置这些之前测试应用程序的这一部分,您将不得不在蓝图路由中添加一个存根页面,或者将其更改为其他内容。

如果用户尚未经过身份验证,我们通过twitter.authorize方法调用来启动授权授予。这将启动 OAuth 流程,并在授权成功完成后(假设用户同意允许我们的应用程序访问他们的第三方受保护资源),Twitter 将调用 GET 请求到我们提供的回调 URL 作为第一个参数。这个请求将包含 OAuth 令牌和他们认为有用的任何其他信息(如screen_name)在查询参数中,然后由我们来处理请求,提取出我们需要的信息。

为此,我们定义了一个twitter_authorized路由处理程序,其唯一目的是提取出 OAuth 令牌和密钥,以便我们可以将它们持久化到我们的数据库中,然后使用 Flask-Login 的login_user函数为我们的 Dinnerly 应用程序创建一个经过身份验证的用户会话:

@users.route('/login/twitter-authorized')
def twitter_authorized():
  resp = twitter.authorized_response()

  try:
    user = db.session.query(User).join(
      TwitterConnection).filter(
        TwitterConnection.oauth_token == 
          resp['oauth_token']).one()
    except sqlalchemy.orm.exc.NoResultFound:
      credential = TwitterConnection(
        twitter_user_id=int(resp['user_id']),
        screen_name=resp['screen_name'],
        oauth_token=resp['oauth_token'],
        oauth_token_secret=resp['oauth_token_secret'])

        user = User(username=resp['screen_name'])
        user.twitter = credential

        db.session.add(user)
        db.session.commit()
        db.session.refresh(user)

  login_user(user)
  return redirect(url_for('recipes.index'))

在前面的路由处理程序中,我们首先尝试从授权流中提取 OAuth 数据,这些数据可以通过twitter.authorized_response()提供给我们。

注意

如果用户决定拒绝授权请求,那么twitter.authorized_response()将返回None。处理这种错误情况留给读者作为一个练习。

提示:闪存消息和重定向到描述发生情况的页面可能是一个很好的开始!

一旦从授权流的 OAuth 数据响应中提取出 OAuth 令牌,我们就会检查数据库,看看是否已经存在具有此令牌的用户。如果是这种情况,那么用户已经在 Dinnerly 上创建了一个帐户,并且只希望重新验证身份。(也许是因为他们正在使用不同的浏览器,因此他们没有之前生成的会话 cookie 可用。)

如果我们系统中没有用户被分配了 OAuth 令牌,那么我们将使用我们刚刚收到的数据创建一个新的User记录。一旦这个记录被持久化到 SQLAlchemy 会话中,我们就使用 Flask-Login 的login_user函数将他们登录。

虽然我们在这里专注于路由处理程序和 Twitter OAuth 授权授予流程,但 Facebook 的流程非常相似。我们的用户蓝图附加了另外两个路由,这些路由将处理希望使用 Facebook 作为第三方服务提供商的登录:

@users.route('/login/facebook')
def login_facebook():
    """Kick-off the Facebook authorization flow if
    not currently authenticated."""

    if current_user.is_authenticated():
        return redirect(url_for('recipes.index'))
    return facebook.authorize(
        callback=url_for('.facebook_authorized',
            _external=True))

然后,我们定义了facebook_authorized处理程序,它将以与twitter_authorized路由处理程序非常相似的方式通过查询参数接收 OAuth 令牌参数:

@users.route('/login/facebook-authorized')
def facebook_authorized():
  """Handle the authorization grant & save the token."""

  resp = facebook.authorized_response()
  me = facebook.get('/me')

  try:
    user = db.session.query(User).join(
      FacebookConnection).filter(
        TwitterConnection.oauth_token ==
          resp['access_token']).one()
    except sqlalchemy.orm.exc.NoResultFound:
      credential = FacebookConnection(
        name=me.data['name'],
        facebook_id=me.data['id'],
        access_token=resp['access_token'])

        user = User(username=resp['screen_name'])
        user.twitter = credential

        db.session.add(user)
        db.session.commit()
        db.session.refresh(user)

  login_user(user)
  return redirect(url_for('recipes.index'))

这个处理程序与我们之前为 Twitter 定义的处理程序之间的一个不容忽视的区别是调用facebook.get('/me')方法。一旦我们执行了授权授予交换,facebook OAuth 对象就能够代表用户对 Facebook API 进行经过身份验证的请求。我们将利用这一新发现的能力来查询有关委托授权凭据的用户的一些基本细节,例如该用户的 Facebook ID 和姓名。一旦获得,我们将存储这些信息以及新创建用户的 OAuth 凭据。

创建食谱

现在我们已经允许用户使用 Twitter 或 Facebook 在 Dinnerly 上创建经过身份验证的帐户,我们需要在这些社交网络上创建一些值得分享的东西!我们将通过application/recipes/models.py模块创建一个非常简单的Recipe模型:

import datetime
from application import db

class Recipe(db.Model):

    # The unique primary key for each recipe created.
    id = db.Column(db.Integer, primary_key=True)

    # The title of the recipe.
    title = db.Column(db.String())

    # The ingredients for the recipe.
    # For the sake of simplicity, we'll assume ingredients
    # are in a comma-separated string.
    ingredients = db.Column(db.Text())

    # The instructions for each recipe.
    instructions = db.Column(db.Text())

    #  The date/time that the post was created on.
    created_on = db.Column(db.DateTime(),
        default=datetime.datetime.utcnow,
        index=True)

    # The user ID that created this recipe.
    user_id = db.Column(db.Integer(), db.ForeignKey('user.id'))

    # User-Recipe is a one-to-many relationship.
    user = db.relationship('User',
            backref=db.backref('recipes'))

我们刚刚定义的Recipe模型并没有什么特别之处;它有一个标题、配料和说明。每个食谱都归属于一个用户,我们已经创建了必要的基于关系的字段和我们模型中的ForeignKey条目,以便我们的数据以通常的关系数据库方式正确链接在一起。有一些字段用于存储任何食谱中你所期望的典型内容:titleingredientsinstructions。由于 Dinnerly 的目的是在各种社交网络上分享食谱片段,我们应该添加一个方法来帮助生成食谱的简短摘要,并将其限制在 140 个字符以下(以满足 Twitter API 的要求):

def summarize(self, character_count=136):
    """
    Generate a summary for posting to social media.
    """

    if len(self.title) <= character_count:
        return self.title

    short = self.title[:character_count].rsplit(' ', 1)[0]
    return short + '...'

前面定义的summarize方法将返回Recipe的标题,如果标题包含的字符少于 140 个。如果包含的字符超过 140 个,我们将使用空格作为分隔符将字符串拆分成列表,使用rsplit(它从字符串的末尾而不是str.split所做的开头开始),然后附加省略号。

注意

我们刚刚定义的summarize方法只能可靠地处理 ASCII 文本。存在一些 Unicode 字符,可能与 ASCII 字符集中的空格相似,但我们的方法不会正确地在这些字符上拆分。

将食谱发布到 Twitter 和 Facebook

在发布新食谱时,我们希望自动将摘要发布到已连接到该用户的服务。当然,有许多方法可以实现这一点:

  • 在我们尚未定义的食谱视图处理程序中,我们可以在成功创建/提交Recipe对象实例后调用相应的 OAuth 连接对象方法。

  • 用户可能需要访问特定的 URI(或提交具体数据的表单),这将触发跨发布。

  • Recipe对象提交到数据库时,我们可以监听 SQLAlchemy 发出的after_insert事件,并将我们的摘要推送到连接的社交网络上。

由于前两个选项相对简单,有点无聊,并且到目前为止我们在这本书中还没有探讨过 SQLAlchemy 事件,所以第三个选项是我们将要实现的。

SQLAlchemy 事件

SQLAlchemy 的一个不太为人所知的特性是事件 API,它发布了几个核心和 ORM 级别的钩子,允许我们附加和执行任意代码。

注意

事件系统在精神上(如果不是在实现上)与我们在前一章中看到的 Blinker 分发系统非常相似。我们不是创建、发布和消费基于 blinker 的信号,而是简单地监听 SQLAlchemy 子系统发布的事件。

大多数应用程序永远不需要实现对已发布事件的处理程序。它们通常是 SQLAlchemy 的插件和扩展的范围,允许开发人员增强其应用程序的功能,而无需编写大量的样板连接器/适配器/接口逻辑来与这些插件或扩展进行交互。

我们感兴趣的 SQLAlchemy 事件被归类为 ORM 事件。即使在这个受限的事件范围内(还有大量其他已发布的核心事件,我们甚至不会在这里讨论),仍然有相当多的事件。大多数开发人员通常感兴趣的是映射器级别的事件:

  • before_insert:在发出与该实例对应的INSERT语句之前,此函数接收一个对象实例

  • after_insert:在发出与该实例对应的INSERT语句之后,此函数接收一个对象实例

  • before_update:在发出与该实例对应的UPDATE语句之前,此函数接收一个对象实例

  • after_update:在发出与该实例对应的UPDATE语句之后,此函数接收一个对象实例

  • before_delete:在发出与该实例对应的DELETE语句之前,此函数接收一个对象实例

  • after_delete:在发出与该实例对应的DELETE语句之后,此函数接收一个对象实例

每个命名事件都会与 SQLAlchemy 的Mapper对象一起发出(该对象定义了class属性与数据库列的对应关系),将被用于执行查询的连接对象,以及被操作的目标对象实例。

通常,开发人员会使用原始连接对象来执行简单的 SQL 语句(例如,增加计数器,向日志表添加一行等)。然而,我们将使用after_insert事件来将我们的食谱摘要发布到 Twitter 和 Facebook。

为了从组织的角度简化事情,让我们将 Twitter 和 Facebook 的 OAuth 客户端对象实例化移到它们自己的模块中,即application/users/services.py中:

from application import oauth

twitter = oauth.remote_app(
    'twitter',
    consumer_key='<consumer key>',
    consumer_secret='<consumer secret>',
    base_url='https://api.twitter.com/1/',
    request_token_url='https://api.twitter.com/oauth/request_token',
    access_token_url='https://api.twitter.com/oauth/access_token',
    authorize_url='https://api.twitter.com/oauth/authenticate',
    access_token_method='GET')

facebook = oauth.remote_app(
    'facebook',
    consumer_key='<consumer key>',
    consumer_secret='<consumer secret>',
    request_token_params={'scope': 'email,publish_actions'},
    base_url='https://graph.facebook.com',
    request_token_url=None,
    access_token_url='/oauth/access_token',
    access_token_method='GET',
    authorize_url='https://www.facebook.com/dialog/oauth')

将此功能移动到一个单独的模块中,我们可以避免一些更糟糕的循环导入可能性。现在,在application/recipes/models.py模块中,我们将添加以下函数,当发出after_insert事件并由listens_for装饰器标识时将被调用:

from application.users.services import twitter, facebook
from sqlalchemy import event

@event.listens_for(Recipe, 'after_insert')
def listen_for_recipe_insert(mapper, connection, target):
    """Listens for after_insert event from SQLAlchemy
    for Recipe model instances."""

    summary = target.summarize()

    if target.user.twitter:
        twitter_response = twitter.post(
            'statuses/update.json',
            data={'status': summary})
        if twitter_response.status != 200:
            raise ValueError("Could not publish to Twitter.")

    if target.user.facebook:
        fb_response = facebook.post('/me/feed', data={
            'message': summary
        })
        if fb_response.status != 200:
            raise ValueError("Could not publish to Facebook.")

我们的监听函数只需要一个目标(被操作的食谱实例)。我们通过之前编写的Recipe.summarize()方法获得食谱摘要,然后使用 OAuth 客户端对象的post方法(考虑到每个服务的不同端点 URI 和预期的负载格式)来创建用户已连接到的任何服务的状态更新。

提示

我们在这里定义的函数的错误处理代码有些低效;每个 API 可能返回不同的 HTTP 错误代码,很可能一个服务可能会接受帖子,而另一个服务可能会因为某种尚未知的原因而拒绝它。处理与多个远程第三方 API 交互时可能出现的各种故障模式是复杂的,可能是一本书的主题。

寻找共同的朋友

大多数现代的社交型网络应用程序的一个非常典型的特性是能够在你已经熟悉的应用程序上找到其他社交网络上的用户。这有助于您为应用程序实现任何类型的友谊/关注者模型。没有人喜欢在新平台上没有朋友,所以为什么不与您在其他地方已经交过的朋友联系呢?

通过找到用户在 Twitter 上正在关注的账户和当前存在于 Dinnerly 应用程序中的用户的交集,这相对容易实现。

注意

两个集合 A 和 B 的交集 C 是存在于 A 和 B 中的共同元素的集合,没有其他元素。

如果您还不了解数学集合的基本概念以及可以对其执行的操作,那么应该在您的阅读列表中加入一个关于天真集合论的入门课程。

我们首先添加一个路由处理程序,经过身份验证的用户可以查询该处理程序,以查找他们在application/users.views.py模块中的共同朋友列表。

from flask import abort, render_template
from flask.ext.login import login_required

# …

@users.route('/twitter/find-friends')
@login_required
def twitter_find_friends():
    """Find common friends."""

    if not current_user.twitter:
        abort(403)

    twitter_user_id = current_user.twitter.twitter_user_id

    # This will only query 5000 Twitter user IDs.
    # If your users have more friends than that,
    # you will need to handle the returned cursor
    # values to iterate over all of them.
    response = twitter.get(
        'friends/ids?user_id={}'.format(twitter_user_id))

    friends = response.json().get('ids', list())
    friends = [int(f) for f in friends]

    common_friends = User.query.filter(
        User.twitter_user_id.in_(friends))

    return render_template('users/friends.html',
        friends=common_friends)

注意

在前面的方法中,我们使用了简单的abort()调用,但是没有阻止您创建模板,这些模板会呈现附加信息,以帮助最终用户理解为什么某个操作失败了。

前面的视图函数使用了我们可靠的 Flask-Login 扩展中的login_required装饰器进行包装,以确保对此路由的任何请求都是由经过身份验证的用户发出的。未经身份验证的用户由于某种明显的原因无法在 Dinnerly 上找到共同的朋友。

然后,我们确保经过身份验证的用户已连接了一组 Twitter OAuth 凭据,并取出twitter_user_id值,以便我们可以正确构建 Twitter API 请求,该请求要求用户的 ID 或screen_name

提示

虽然screen_name可能比长数字标识符更容易调试和推理,但请记住,一个人随时可以在 Twitter 上更新screen_name。如果您想依赖这个值,您需要编写一些代码来验证并在远程服务上更改时更新本地存储的screen_name值。

一旦对远程服务上账户关注的人的 Twitter ID 进行了GET请求,我们解析这个结果并构建一个整数列表,然后将其传递给 User-mapped 类上的 SQLAlchemy 查询。现在我们已经获得了一个用户列表,我们可以将这些传递给我们的视图(我们不会提供实现,这留给读者作为练习)。

当然,找到共同的朋友只是方程的一半。一旦我们在 Twitter 上找到了我们的朋友,下一步就是在 Dinnerly 上也关注他们。为此,我们需要向我们的应用程序添加一个(最小的!)社交组件,类似于我们在上一章中实现的内容。

这将需要添加一些与数据库相关的实体,我们可以使用更新/添加相关模型的常规程序,然后重新创建数据库模式,但我们将利用这个机会来探索一种更正式的跟踪模式相关变化的方法。

插曲 - 数据库迁移

在应用程序开发的世界中,我们使用各种工具来跟踪和记录随时间变化的代码相关变化。一般来说,这些都属于版本控制系统的范畴,有很多选择:Git、Mercurial、Subversion、Perforce、Darcs 等。每个系统的功能略有不同,但它们都有一个共同的目标,即保存代码库的时间点快照(或代码库的部分,取决于所使用的工具),以便以后可以重新创建它。

Web 应用程序的一个方面通常难以捕捉和跟踪是数据库的当前状态。过去,我们通过存储整个 SQL 快照以及应用程序代码来解决这个问题,并指示开发人员删除并重新创建他们的数据库。对此的下一级改进将是创建一些小型基于 SQL 的脚本,应按特定顺序逐渐构建底层模式,以便在需要修改时,将另一个小型基于 SQL 的脚本添加到列表中。

虽然后一种方法非常灵活(它几乎可以适用于任何依赖关系数据库的应用程序),但是稍微抽象化,可以利用我们已经使用的 SQLAlchemy 对象关系模型的功能,这将是有益的。

Alembic

这样的抽象已经存在,它叫做 Alembic。这个库由 SQLAlchemy 的相同作者编写,允许我们创建和管理对应于我们的 SQLAlchemy 数据模型所需的模式修改的变更集。

和我们在本书中讨论过的大多数库一样,Flask-Alembic 也被封装成了一个 Flask 扩展。让我们在当前的虚拟环境中安装它:

$ pip install flask-alembic

由于大多数 Flask-Alembic 的功能可以和应该通过 CLI 脚本来控制,所以该软件包包括了启用 Flask-Script 命令的钩子。因此,让我们也安装这个功能:

$ pip install flask-script

我们将创建我们的manage.py Python 脚本来控制我们的 CLI 命令,作为我们application/包的兄弟,并确保它包含用于集成 Flask-Alembic 的 db 钩子:

from flask.ext.script import Manager, Shell, Server
from application import create_app, db
from flask_alembic.cli.script import manager as alembic_manager

# Create the `manager` object with a
# callable that returns a Flask application object.
manager = Manager(app=create_app)

def _context():
    """Adds additional objects to our default shell context."""
    return dict(db=db)

if __name__ == '__main__':
 manager.add_command('db', alembic_manager)
    manager.add_command('runserver', Server(port=6000))
    manager.add_command('shell', Shell(make_context=_context))
    manager.run()

现在我们已经安装了这两个扩展,我们需要配置 Flask-Alembic 扩展,以便它了解我们的应用对象。我们将在应用程序工厂函数中以通常的方式来做这个:

# …
from flask.ext.alembic import Alembic

# …
# Intialize the Alembic extension
alembic = Alembic()

def create_app(config=None):
    app = Flask(__name__, static_folder=None)

    if config is not None:
        app.config.from_object(config)

    import application.users.models
    import application.recipes.models
       # …
 alembic.init_app(app)

    from application.users.views import users
    app.register_blueprint(users, url_prefix='/users')

    return app

让我们捕获当前数据库模式,这个模式是由我们在应用程序中定义的 SQLAlchemy 模型描述的:

$ python manage.py db revision 'Initial schema.'

这将在migrations/文件夹中创建两个新文件(在第一次运行此命令时创建),其中一个文件将以一堆随机字符开头,后跟_initial_schema.py

注意

看起来随机的字符实际上并不那么随机:它们是基于哈希的标识符,可以帮助迁移系统在多个开发人员同时为应用程序的不同部分工作迁移时以更可预测的方式运行,这在当今是相当典型的。

另一个文件script.py.mako是 Alembic 在调用命令时将使用的模板,用于生成这些自动修订摘要。这个脚本可以根据您的需要进行编辑,但不要删除任何模板${foo}变量!

生成的迁移文件包括两个函数定义:upgrade()downgrade()。当 Alembic 获取当前数据库修订版(此时为None)并尝试将其带到目标(通常是最新)修订版时,将运行升级函数。downgrade()函数也是如此,但是方向相反。拥有这两个函数对于回滚类型的情况非常方便,当在包含不同迁移集的代码分支之间切换时,以及其他一些边缘情况。许多开发人员忽略了生成和测试降级迁移,然后在项目的生命周期的后期非常后悔。

根据您使用的关系数据库,您的确切迁移可能会有所不同,但它应该看起来类似于这样:

"""Initial schema.

Revision ID: cd5ee4319a3
Revises:
Create Date: 2015-10-30 23:54:00.990549

"""

# revision identifiers, used by Alembic.
revision = 'cd5ee4319a3'
down_revision = None
branch_labels = ('default',)
depends_on = None

from alembic import op
import sqlalchemy as sa

def upgrade():
    ### commands auto generated by Alembic - please adjust! ###
    op.create_table('user',
    sa.Column('id', sa.Integer(), nullable=False),
    sa.Column('username', sa.String(length=40), nullable=True),
    sa.Column('created_on', sa.DateTime(), nullable=True),
    sa.PrimaryKeyConstraint('id')
    )
    op.create_table('facebook_connection',
    sa.Column('id', sa.Integer(), nullable=False),
    sa.Column('user_id', sa.Integer(), nullable=False),
    sa.Column('facebook_id', sa.Integer(), nullable=False),
    sa.Column('access_token', sa.String(), nullable=False),
    sa.Column('name', sa.String(), nullable=True),
    sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
    sa.PrimaryKeyConstraint('id'),
    sa.UniqueConstraint('user_id')
    )
    op.create_table('recipe',
    sa.Column('id', sa.Integer(), nullable=False),
    sa.Column('title', sa.String(), nullable=True),
    sa.Column('ingredients', sa.Text(), nullable=True),
    sa.Column('instructions', sa.Text(), nullable=True),
    sa.Column('created_on', sa.DateTime(), nullable=True),
    sa.Column('user_id', sa.Integer(), nullable=True),
    sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
    sa.PrimaryKeyConstraint('id')
    )
    op.create_index(
        op.f('ix_recipe_created_on'), 'recipe',
        ['created_on'], unique=False)
    op.create_table('twitter_connection',
    sa.Column('id', sa.Integer(), nullable=False),
    sa.Column('user_id', sa.Integer(), nullable=False),
    sa.Column('screen_name', sa.String(), nullable=False),
    sa.Column('twitter_user_id', sa.Integer(), nullable=False),
    sa.Column('oauth_token', sa.String(), nullable=False),
    sa.Column('oauth_token_secret', sa.String(), nullable=False),
    sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
    sa.PrimaryKeyConstraint('id'),
    sa.UniqueConstraint('user_id')
    )
    ### end Alembic commands ###

def downgrade():
    ### commands auto generated by Alembic - please adjust! ###
    op.drop_table('twitter_connection')
    op.drop_index(
        op.f('ix_recipe_created_on'), table_name='recipe')
    op.drop_table('recipe')
    op.drop_table('facebook_connection')
    op.drop_table('user')
    ### end Alembic commands ###

现在,在这个脚本中有很多事情要做,或者至少看起来是这样。upgrade()函数中正在发生的是创建与我们在应用程序中定义的模型元数据和属于它们的字段相对应的表。通过比较当前模型定义和当前活动数据库模式,Alembic 能够推断出需要生成什么,并输出所需的命令列表来同步它们。

如果您熟悉关系数据库术语(列、主键、约束等),那么大多数语法元素应该相对容易理解,您可以在 Alembic 操作参考中阅读它们的含义:alembic.readthedocs.org/en/latest/ops.html

生成了初始模式迁移后,现在是应用它的时候了:

$ python manage.py db upgrade

这将向您在 Flask-SQLAlchemy 配置中配置的关系型数据库管理系统发出必要的 SQL(基于生成的迁移)。

摘要

在这个相当冗长且内容丰富的章节之后,您应该会对 OAuth 及与 OAuth 相关的实现和一般术语感到更加放心,此外,数据库迁移的实用性,特别是由 Alembic 生成的与应用程序模型中声明的表和约束元数据同步的迁移风格。

本章从深入探讨 OAuth 授权授予流程和术语开始,考虑到 OAuth 的复杂性,这并不是一件小事!一旦我们建立了一定的知识基础,我们就实现了一个应用程序,利用 Flask-OAuthlib 为用户提供了创建账户并使用 Twitter 和 Facebook 等第三方服务进行登录的能力。

在完善示例应用程序的数据处理部分之后,我们转向了 Alembic,即 SQLAlchemy 数据迁移工具包,以将我们模型中的更改与我们的关系型数据库同步。

在本章开始的项目对于大多数具有社交意识的网络应用程序来说是一个很好的起点。我们强烈建议您利用本章和前几章学到的知识来创建一个现代、经过高度测试的、功能齐全的网络应用程序。

posted @ 2024-05-20 16:51  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报