Flask-示例-全-

Flask 示例(全)

原文:zh.annas-archive.org/md5/93A989EF421129FF1EAE9C80E14340DD

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

理论上,没有什么是有效的,但每个人都知道为什么。实践中,一切都有效,但没有人知道为什么。在这里,我们结合理论和实践;没有什么有效,也没有人知道为什么!

学习计算机科学必须始终是理论和实践的结合;你需要知道你在做什么(理论),但你也需要知道如何去做(实践)。我学习如何创建 Web 应用程序的经验是,很少有老师找到了这种平衡的甜蜜点;要么我读了很多关于继承、虚拟环境和测试驱动开发的页面,想知道它们如何适用于我,要么我安装了一堆工具、框架和库,看着魔术发生,却不知道它是如何工作的。

接下来的内容,我希望是一个很好的平衡。从第一章开始,你将拥有一个 Flask Web 应用程序,全世界都可以访问,即使它只是用“你好,世界!”来欢迎访客,这也是相当实用的。在接下来的章节中,我们将一起构建三个有趣且有用的项目。总的来说,我们会尽可能地自己构建东西。虽然重新发明轮子不是好事,但在接触解决方案之前接触问题是很好的。在你写一行 CSS 之前学习 CSS 框架会让你感到困惑,你会想,“但我真的需要这个吗?”,对于许多其他框架和工具也是如此。因此,我们将从零开始,看看为什么这很困难,然后介绍工具来让我们的生活变得更容易。我认为这是理论和实践之间的理想平衡。

当我告诉别人我正在写一本关于 Flask 的书时,常见的回答是“为什么?已经有很多关于 Flask 的书和教程了。”这是一个合理的问题,对它的回答为这本书提供了一个很好的概述。《Flask 实例》与其他 Flask 教育材料不同,原因如下。

我们不会让你陷入困境

许多 Flask 教程向您展示如何开发一个 Flask 应用程序并在本地计算机上运行它,然后就结束了。这作为第一步是很好的,但如果您有兴趣构建 Web 应用程序,您可能希望它们能够在网络上访问,这样您的朋友、家人、同事和客户就可以在不经过您家的情况下欣赏到您的手工艺品。从我们的第一个项目开始,我们的应用程序将在虚拟专用服务器(VPS)上运行,并且可以被全世界访问。

我们不会构建博客应用程序

如果你读过任何 Web 应用程序开发教程,你一定会注意到几乎每一个教程都是关于如何使用 x 和 y 构建一个博客。我对博客示例感到相当厌倦(实际上,我再也不想看到有人告诉我如何构建博客了)。相反,你将学习如何使用 Flask 开发一些有趣、原创,甚至可能有用的项目。

我们将专注于安全

最近,网络犯罪已经成为一个热门词汇。可以说,我们几乎每天都会读到关于主要 Web 应用程序被黑客攻击的消息,这是因为很多开发人员不了解 SQL 注入、CSRF、XSS、如何存储密码等许多应该被视为基本知识的东西。在本书中,当我们开发这三个项目时,我们将花时间详细解释一些核心安全概念,并向您展示如何加固我们的应用程序,以防潜在的恶意攻击者。

我们将提供深入的解释

我们不仅会给你一些代码然后告诉你去运行它。在任何可能的情况下,我们都会解释我们在做什么,为什么这样做,以及我们是如何做的。这意味着你将能够从所有项目中汲取灵感,将它们与你自己的想法结合起来,在阅读完本书后立即开始构建原创内容。

因此,我希望这本书对你有所帮助,无论你是刚开始涉足计算机科学和编程世界,还是拥有著名大学的计算机科学学位,耳朵里充满了编译器理论,但现在想要构建一些实用和有趣的东西。希望你在完成这些项目时和我在组织它们时一样开心!

本书涵盖的内容

第一章,“你好,世界!”,教你如何设置我们的开发环境和 Web 服务器,并编写我们的第一个 Flask 应用程序。

第二章,“开始我们的头条新闻项目”,向您展示了当用户访问 URL 时如何运行 Python 代码以及如何向用户返回基本数据。我们还将看看如何使用 RSS 订阅自动获取最新的头条新闻。

第三章,“在我们的头条新闻项目中使用模板”,介绍了 Jinja 模板,并将它们整合到我们的头条新闻项目中。我们将展示如何通过从 Python 代码传递数据到模板文件来提供动态 HTML 内容。

第四章,“我们头条新闻项目的用户输入”,展示了如何从互联网上获取用户输入,并使用这些输入来定制我们将向用户展示的内容。我们将看看如何通过 JSON API 访问当前天气信息,并将这些信息包含在我们的头条新闻项目中。

第五章,“改善我们的头条新闻项目的用户体验”,指导您向我们的头条新闻项目添加 cookie,以便我们的应用程序可以记住我们用户的选择。我们还将通过添加一些基本的 CSS 来为我们的应用程序添加样式。

第六章,“构建交互式犯罪地图”,介绍了我们的新项目,即犯罪地图。我们将介绍关系数据库,在服务器上安装 MySQL,并了解如何从我们的 Flask 应用程序与我们的数据库交互。

第七章,“向我们的犯罪地图项目添加谷歌地图”,指导您添加谷歌地图小部件,并演示如何根据我们的数据库添加和删除地图上的标记。我们将添加一个带有各种输入的 HTML 表单,供用户提交新的犯罪信息,并显示现有的犯罪信息。

第八章,“在我们的犯罪地图项目中验证用户输入”,通过确保用户不能意外地或通过恶意制作的输入来破坏它,完善了我们的第二个项目。

第九章,“构建服务员呼叫应用程序”,介绍了我们的最终项目,这是一个在餐厅呼叫服务员到餐桌的应用程序。我们将介绍 Bootstrap,并设置一个使用 Bootstrap 作为前端的基本用户账户控制系统。

第十章,“在服务员呼叫项目中使用模板继承和 WTForms”,介绍了 Jinja 的模板继承功能,以便我们可以添加类似的页面而不重复代码。我们将使用 WTForms 库使我们的 Web 表单更容易构建和验证。

第十一章,“在我们的服务员呼叫项目中使用 MongoDB”,讨论了如何在服务器上安装和配置 MongoDB,并将其链接到我们的服务员呼叫项目。我们将通过向我们的数据库添加索引和向我们的应用程序添加一个网站图标来完成我们的最终项目。

附录,未来的一瞥,概述了一些重要的主题和技术,我们无法详细介绍,并指出了更多关于这些内容的学习指引。

本书需要什么

我们将使用的所有示例都假定您在开发机器上使用 Ubuntu 操作系统,并且可以访问运行 Ubuntu Server 的服务器(我们将在第一章讨论如何设置后者)。如果您强烈偏好另一个操作系统,并且已经设置了 Python 环境(包括 Python 包管理器 pip),那么这些示例将很容易转换。

本书中使用的所有其他软件和库都是免费提供的,我们将在需要时详细演示如何安装和配置它们。

本书适合谁

您是否看过 PHP 并讨厌那笨重的语法?或者,您是否看过.Net 并希望它更加开放和灵活?您是否尝试过 Python 中的 GUI 库,并发现它们难以使用?如果您对这些问题的任何一个答案是肯定的,那么这本书就是为您而写的。

本书还适用于那些了解 Python 基础知识并希望学习如何使用它构建具有 Web 前端的强大解决方案的人。

约定

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

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

代码块设置如下:

@app.route("/")
def get_news():
return "no news is good news"

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

import feedparserfrom flask import Flask
app = Flask(__name__)BBC_FEED = "http://feeds.bbci.co.uk/news/rss.xml"

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

sudo apt-get update
sudo apt-get install git

新术语重要单词以粗体显示。例如,屏幕上看到的单词,例如菜单或对话框中的单词,会以这样的方式出现在文本中:"单击下一步按钮将您移至下一个屏幕。"

注意

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

提示

提示和技巧显示如下。

第一章:你好,世界!

你好,读者!让我们开始构建一些 Flask 应用程序。Flask 足够简约,以便为您提供选择和灵活性;与较大的框架不同,您可以选择要做什么,然后操纵 Flask 来完成您的要求,它足够完整,可以直接使用。

我们将一起开发三个 Web 应用程序;第一个很简单,将允许您在构建一个非平凡的 Web 应用程序时熟悉 Flask 和新技术和术语;第二个将让您开始构建一个使用传统 SQL 数据库的 Web 应用程序;最后一个将使用NoSQL数据库和前端框架来创建一个有用且外观良好的 Web 应用程序。

在本章中,我们将简要介绍 Flask 是什么,也许更重要的是,它不是什么。我们将继续设置我们的基本开发环境以及 Web 服务器,并安装 Python 包管理器以及 Flask 本身。到本章结束时,我们将有我们第一个应用程序的轮廓,并且按照古老的传统,我们将使用我们的新技能来显示文本“Hello, World!”。

简而言之,我们将涵盖以下主题:

  • 介绍 Flask

  • 创建我们的开发环境

  • 编写“Hello, World!”

  • 部署我们的应用程序到生产环境

介绍 Flask

Flask 是 Python Web 开发的微框架。框架,简单来说,是一个库或一组库,旨在解决通用问题的一部分,而不是完全特定的问题。在构建 Web 应用程序时,总会有一些问题需要解决,例如从 URL 到资源的路由,将动态数据插入 HTML,以及与最终用户交互。

Flask 是微框架,因为它只实现了核心功能(包括路由),但将更高级的功能(包括身份验证和数据库 ORM)留给了扩展。这样做的结果是对于第一次使用者来说初始设置更少,对于有经验的用户来说有更多的选择和灵活性。这与“更完整”的框架形成对比,例如Django,后者规定了自己的 ORM 和身份验证技术。

正如我们将讨论的那样,在 Flask 中,我们的 Hello World 应用程序只需要七行代码就可以编写,整个应用程序只包含一个文件。听起来不错吗?让我们开始吧!

创建我们的开发环境

开发环境包括开发人员在构建软件时使用的所有软件。首先,我们将安装 Python 包管理器(pip)和 Flask 包。在本书中,我们将展示在Ubuntu 14.04的干净安装上使用Python 2.7进行开发的详细步骤,但是一切都应该很容易转换到 Windows 或 OS X。

安装 pip

对于我们的 Hello World 应用程序,我们只需要 Python Flask 包,但在我们的三个应用程序的开发过程中,我们将安装几个 Python 包。为了管理这些包,我们将使用 Python 包管理器 pip。如果您到目前为止一直在 Python 中开发而没有使用包管理器,您会喜欢使用 pip 下载、安装、删除和更新包的简便性。如果您已经使用它,那么跳到下一步,我们将使用它来安装 Flask。

pip 管理器包含在 Python 的 3.4+和 2.7.9+版本中。对于较旧版本的 Python,需要安装 pip。要在 Ubuntu 上安装 pip,请打开终端并运行以下命令:

sudo apt-get update
sudo apt-get install python-pip

注意

要在 Windows 或 OS X 上安装 pip,您可以从 pip 主页pip.pypa.io/en/latest/installing/#install-or-upgrade-pip下载并运行get-pip.py文件。

就是这样!现在您可以通过 pip 轻松安装任何 Python 包。

安装 Flask

通过 pip 安装 Flask 再简单不过了。只需运行以下命令:

pip install –-user flask

您可能会在终端中看到一些警告,但最后,您也应该看到成功安装了 Flask。现在,您可以像导入其他库一样将 Flask 导入 Python 程序中。

注意

如果您习惯于在 Python 开发中使用 VirtualEnv,您可以在 VirtualEnv 环境中安装 Flask。我们将在附录 A.未来的一瞥中进一步讨论这个问题。

编写“你好,世界!”

现在,我们将创建一个基本的网页,并使用 Flask 的内置服务器将其提供给localhost。这意味着我们将在本地机器上运行一个 Web 服务器,我们可以轻松地从本地机器上发出请求。这对开发非常有用,但不适用于生产应用程序。稍后,我们将看看如何使用流行的 Apache Web 服务器来提供 Flask Web 应用程序。

编写代码

我们的应用程序将是一个单独的 Python 文件。在您的主目录中创建一个名为firstapp的目录,然后在其中创建一个名为hello.py的文件。在hello.py文件中,我们将编写代码来提供一个包含静态字符串“Hello, World!”的网页。代码如下所示:

from flask import Flask

app = Flask(__name__)

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

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

提示

下载示例代码

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

您可以按照以下步骤下载代码文件:

  • 使用您的电子邮件地址和密码登录或注册我们的网站。

  • 将鼠标指针悬停在顶部的 SUPPORT 标签上。

  • 单击“代码下载和勘误”。

  • 在搜索框中输入书名。

  • 选择您要下载代码文件的书籍。

  • 从下拉菜单中选择您购买此书的地方。

  • 单击“下载代码”。

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

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

让我们来分解一下这段代码。第一行应该很熟悉;它只是从flask包中导入 Flask。第二行使用我们模块的名称作为参数创建了一个 Flask 对象的实例。Flask 使用这个来解析资源,在复杂的情况下,可以在这里使用其他东西而不是__name__。对于我们的目的,我们将始终使用__name__,这将我们的模块链接到 Flask 对象。

第 3 行是一个 Python 装饰器。Flask 使用装饰器进行 URL 路由,因此这行代码意味着直接下面的函数应该在用户访问我们网页应用程序的主页面时被调用(由单个斜杠定义)。如果您不熟悉装饰器,这些是美丽的 Python 快捷方式,起初似乎有点像黑魔法。实质上,它们调用一个函数,该函数接受在装饰器下定义的函数(在我们的情况下是index())并返回一个修改后的函数。

接下来的两行也应该很熟悉。它们定义了一个非常简单的函数,返回我们的消息。由于这个函数是由 Flask 在用户访问我们的应用程序时调用的,因此这个返回值将是对请求我们的着陆页面的用户发送的响应。

第 6 行是您可能熟悉的 Python 习语。这是一个简单的条件语句,如果我们的应用程序直接运行,则评估为True。它用于防止 Python 脚本在被导入其他 Python 文件时意外运行。

最后一行在我们的本地机器上启动了 Flask 的开发服务器。我们将其设置为在端口 5000上运行(我们将在生产中使用端口 80),并将调试设置为True,这将帮助我们在网页浏览器中直接查看详细的错误。

运行代码

要运行我们的开发 Web 服务器,只需打开一个终端并运行hello.py文件。如果你使用了前一节中概述的相同结构,命令将如下所示:

cd firstapp/hello
python hello.py

你应该得到类似下面截图中的输出:

运行代码

此外,你应该看到进程继续运行。这是我们的网络服务器在等待请求。所以,让我们发出一个请求!

打开一个网络浏览器——我使用的是 Ubuntu 自带的 Firefox——并导航到localhost:5000

URL 中的localhost部分是指向回环地址的快捷方式,通常是127.0.0.1,它要求你的计算机向自己发出网络请求。冒号后面的数字(5000)是它应该发出请求的端口。默认情况下,所有 HTTP(网络)流量都通过端口 80进行传输。现在,我们将使用5000,因为它不太可能与任何现有服务冲突,但在生产环境中我们将切换到端口 80,这是常规的,这样你就不必担心冒号了。

你应该在浏览器中看到“Hello, World!”字符串显示,就像下面的截图一样。恭喜,你已经使用 Flask 构建了你的第一个网络应用!

运行代码

将我们的应用部署到生产环境

拥有一个运行的应用程序是很棒的,但作为网络应用程序的概念固有的是我们希望其他人能够使用它。由于我们的应用程序是基于 Python 的,我们在如何在 Web 服务器上运行我们的应用程序方面有一些限制(许多传统的 Web 主机只配置为运行 PHP 和/或.NET 应用程序)。让我们考虑如何使用运行 Ubuntu 服务器、Apache 和 WSGI 的虚拟专用服务器VPS)来提供 Flask 应用程序。

从这一点开始,我们将维护两个环境。第一个是我们的开发环境,我们刚刚设置好,在这里我们将编写代码并使用在localhost上运行的 Flask 服务器查看其结果(就像我们刚刚做的那样)。第二个将是生产环境。这将是一个服务器,我们可以在其中部署我们的网络应用程序,并使它们对世界可访问。当我们在开发环境安装新的 Python 库或其他软件时,我们通常希望在生产环境中复制我们的操作。

设置虚拟专用服务器

尽管理论上你可以在本地主机上托管你的网络应用并允许其他人使用,但这有一些严重的限制。首先,每次关闭电脑时,你的应用都将不可用。此外,你的电脑可能通过互联网服务提供商(ISP)和可能的无线路由器连接到互联网。这意味着你的 IP 地址是动态的,经常会变化,这使得你的应用程序的用户难以跟上!最后,很可能你的互联网连接是不对称的,这意味着你的上传速度比下载速度慢。

在服务器上托管你的应用程序可以解决所有这些问题。在“云”变得流行之前,托管网络应用的传统方式是购买一台物理服务器并找到一个数据中心来托管它。如今,情况简单得多。在几分钟内,你可以启动一个虚拟服务器,对你来说它看起来就像一台物理服务器——你可以登录、配置它,并完全控制它——但实际上它只是云提供商拥有和控制的一台虚拟“片”。

在撰写本文时,云服务提供商领域的主要参与者包括亚马逊网络服务、微软 Azure、谷歌云计算和 Digital Ocean。所有这些公司都允许你按小时支付来租用一个虚拟服务器或多台虚拟服务器。如果你是作为爱好学习 Flask,并且不愿意支付任何人来托管你的网络应用程序,你可能会很容易地在这些提供商中找到一个免费试用。任何提供商的最小服务都足以托管我们将运行的所有应用程序。

选择前述提供商之一或您选择的其他提供商。如果您以前从未做过类似的事情,Digital Ocean 通常被认为是注册并创建新机器的最简单过程。选择提供商后,您应该能够按照其各自的说明启动运行 Ubuntu Server 14.04 并通过 SSH 连接到它的 VPS。您将完全控制该机器,只有一个细微的区别:您将没有显示器或鼠标。

您将在本地终端上输入命令,实际上将在远程机器上运行。有关如何连接到您的 VPS 的详细说明将由提供商提供,但如果您使用 Ubuntu,只需运行以下命令即可:

ssh user@123.456.789.000

或者,如果您使用公共-私有密钥身份验证进行设置,其中yourkey.pem是您的私钥文件的完整路径,以下是要运行的命令:

ssh user@123.456.78.000 –i yourkey.pem

这里,user是 VPS 上的默认用户,yourkey是您的私钥文件的名称。

其他操作系统的 SSH:

提示

从 OS X 进行 SSH 应该与 Ubuntu 相同,但如果您使用 Windows,您将需要下载 PuTTY。请参阅www.putty.org/进行下载和完整的使用说明。请注意,如果您使用密钥文件进行身份验证,您将需要将其转换为与 PuTTY 兼容的格式。在 PuTTY 网站上也可以找到转换工具。

一旦我们连接到 VPS,安装 Flask 的过程与以前相同:

sudo apt-get update
sudo apt-get install python-pip
pip install --user Flask

要安装我们的 Web 服务器 Apache 和 WSGI,我们将运行以下命令:

sudo apt-get install apache2
sudo apt-get install libapache2-mod-wsgi

Apache 是我们的 Web 服务器。它将监听 Web 请求(由我们的用户使用他们的浏览器访问我们的 Web 应用程序生成)并将这些请求交给我们的 Flask 应用程序。由于我们的应用程序是用 Python 编写的,我们还需要WSGI(Web 服务器网关接口)

这是 Web 服务器和 Python 应用程序之间的常见接口,它允许 Apache 与 Flask 进行通信,反之亦然。架构概述可以在以下图表中看到:

设置虚拟专用服务器

配置我们的服务器

现在我们已经安装了 Apache,我们可以看到我们的第一个结果。您可能习惯于使用 URL 访问网站,例如http://example.com。我们将直接使用 VPS 的 IP 地址访问我们的 Web 应用程序。您的 VPS 应该有一个静态的公共地址。静态意味着它不会定期更改,公共意味着它是全局唯一的。当您通过 SSH 连接到 VPS 时,您可能使用了公共 IP 地址。如果找不到它,请在 VPS 上运行以下命令,您应该会在输出中看到一个包含您的公共 IP 的inet addr部分:

ifconfig

IP 地址应该类似于123.456.78.9。将您的 IP 地址输入到浏览器的地址栏中,您应该会看到一个页面,上面写着“Apache2 Ubuntu 默认页面:It Works!”或类似的内容,如下面的屏幕截图所示:

配置我们的服务器

这意味着我们现在可以向任何有互联网连接的人提供 Web 内容!但是,我们仍然需要:

  • 将我们的代码复制到 VPS

  • 连接 Apache 和 Flask

  • 配置 Apache 以提供我们的 Flask 应用程序

在第一步中,我们将在本地机器上设置一个 Git 存储库,并将存储库克隆到 VPS。在第二步中,我们将使用与 Apache 一起安装的 WSGI 模块。最后,我们将看一下如何编写虚拟主机,使 Apache 默认提供我们的 Flask 应用程序。

安装和使用 Git

Git 是一个版本控制系统。版本控制系统除其他功能外,还会自动保存我们代码库的多个版本。这对于撤消意外更改甚至删除非常有用;我们可以简单地恢复到我们代码的以前版本。它还包括许多分布式开发的功能,即许多开发人员在一个项目上工作。然而,我们主要将其用于备份和部署功能。

要在本地计算机和 VPS 上安装 Git,请在每台计算机上运行以下命令:

sudo apt-get update
sudo apt-get install git

注意

确保您对使用终端在自己的计算机上运行命令和通过 SSH 连接在服务器上运行命令之间的区别感到满意。在许多情况下,我们需要两次运行相同的命令 - 分别针对每个环境运行一次。

现在您已经拥有了软件,您需要一个托管 Git 存储库或“repos”的地方。两个受欢迎且免费的 Git 托管服务是 GitHub(github.com)和 Bitbucket(bitbucket.org)。前往其中一个,创建一个帐户,并按照提供的说明创建一个新存储库。在给存储库命名的选项时,将其命名为firstapp,以匹配我们将用于代码库的目录的名称。创建新存储库后,您应该会得到一个唯一的存储库 URL。请记下这一点,因为我们将使用它来使用git推送我们的Hello, World!应用程序,然后部署到我们的 VPS。

在本地计算机上,打开终端并将目录更改为 Flask 应用程序。通过以下命令初始化一个新存储库,并将其链接到您的远程 Git 存储库:

cd firstapp
git init
git remote add origin <your-git-url>

告诉git您是谁,以便它可以自动向您的代码更改添加元数据,如下所示:

git config --global user.email "you@example.com"
git config --global user.name "Your Name"

Git 允许您完全控制哪些文件是存储库的一部分,哪些不是。即使我们在firstapp目录中初始化了 Git 存储库,我们的存储库目前不包含任何文件。按照以下步骤将我们的应用程序添加到存储库中,提交,然后推送:

git add hello.py
git commit -m "Initial commit"
git push -u origin master

这些是我们将在本书中使用的主要 Git 命令,因此让我们简要了解每个命令的作用。add命令将新文件或修改的文件添加到我们的存储库中。这告诉 Git 哪些文件实际上是我们项目的一部分。将commit命令视为对我们项目当前状态的快照。此快照保存在我们的本地计算机上。对代码库进行重大更改时,最好进行新的commit,因为我们可以轻松地恢复到以前的commits,如果后来的commit破坏了我们的应用程序。最后,push命令将我们的本地更改推送到远程 Git 服务器。这对备份很有用,并且还将允许我们在我们的 VPS 上获取更改,从而使我们的本地计算机上的代码库与我们的 VPS 上的代码库保持同步。

现在,再次 SSH 到您的 VPS 并获取我们的代码,如下所示:

cd /var/www
git clone <your-git-url>

注意

上述命令中的<your-git-url>部分实际上是对 Git 存储库的 URL 的占位符。

如果尝试克隆 Git 存储库时出现permission denied错误,则可能需要为您正在使用的 Linux 用户的/var/www目录所有权。如果您使用tom@123.456.789.123登录到服务器,可以运行以下命令,这将使您的用户拥有/var/www的所有权,并允许您将 Git 存储库克隆到其中。再次,tom是以下情况中使用的占位符:

sudo chown -R tom /var/www

如果您将firstapp用作远程存储库的名称,则应创建一个名为firstapp的新目录。使用以下命令验证我们的代码是否存在:

cd firstapp
ls

您应该看到您的hello.py文件。现在,我们需要配置 Apache 以使用 WSGI。

使用 WSGI 为我们的 Flask 应用提供服务

首先,在我们的应用程序目录中创建一个非常简单的.wsgi文件。然后,在 Apache 查找可用站点的目录中创建一个 Apache 配置文件。

这两个步骤中唯一稍微棘手的部分是,我们将直接在我们的 VPS 上创建文件,而我们的 VPS 没有显示器,这意味着我们必须使用命令行界面文本编辑器。当然,我们可以像为我们的代码库做的那样,将文件本地创建然后传输到我们的 VPS,但是对于对配置文件进行小的更改,这往往比值得的努力更多。使用没有鼠标的文本编辑器需要一点时间来适应,但这是一个很好的技能。Ubuntu 上的默认文本编辑器是 Nano,其他流行的选择是 vi 或 Vim。有些人使用 Emacs。如果您已经有喜欢的,就用它。如果没有,我们将在本书的示例中使用 Nano(它已经安装并且可以说是最简单的)。但是,如果您想要更上一层楼,我建议学习使用 Vim。

假设您仍然连接到您的 VPS,并已经像最近的步骤一样导航到/var/www/firstapp目录,运行以下命令:

nano hello.wsgi

这将创建hello.wsgi文件,您现在可以通过 Nano 进行编辑。输入以下内容:

import sys
sys.path.insert(0, "/var/www/firstapp")
from hello import app as application

这只是 Python 语法,它将我们的应用程序补丁到 PATH 系统中,以便 Apache 可以通过 WSGI 找到它。然后我们将app(我们在hello.py应用程序中使用app = Flask(__name__)行命名)导入命名空间。

Ctrl + X退出 Nano,并在提示时输入Y以保存更改。

现在,我们将创建一个 Apache 配置文件,指向我们刚刚创建的.wsgi文件,如下所示:

cd /etc/apache2/sites-available
nano hello.conf

注意

如果您在编辑或保存文件时遇到权限问题,您可能还需要取得apache2目录的所有权。运行以下命令,将用户名替换为您的 Linux 用户:

sudo chown –R tom /etc/apache2

在这个文件中,我们将为 Apache 虚拟主机创建一个配置。这将允许我们从单个服务器上提供多个站点,这在以后想要使用我们的单个 VPS 来提供其他应用程序时将非常有用。在 Nano 中,输入以下配置:

<VirtualHost *>
    ServerName example.com

    WSGIScriptAlias / /var/www/firstapp/hello.wsgi
    WSGIDaemonProcess hello
    <Directory /var/www/firstapp>
       WSGIProcessGroup hello
       WSGIApplicationGroup %{GLOBAL}
        Order deny,allow
        Allow from all
    </Directory>
</VirtualHost>

这可能看起来很复杂,但实际上非常简单。我们将创建一个virtualhost并指定我们的域名,我们的.wsgi脚本所在的位置,我们的应用程序的名称以及谁被允许访问它。我们将在最后一章讨论域名,但现在,您可以将其保留为example.com,因为我们将通过其 IP 地址访问我们的应用程序。

注意

如果您在这一步遇到问题,Flask 网站上有一个关于配置和故障排除 Apache 配置的很好的资源。您可以在flask.pocoo.org/docs/0.10/deploying/mod_wsgi/找到它。

Ctrl + X,然后在再次提示时输入Y以保存并退出文件。现在,我们需要启用配置并将其设置为我们的默认站点。

配置 Apache 以提供我们的 Flask 应用程序

Apache 站点的工作方式如下:有一个sites-available目录(我们在其中创建了新的虚拟主机配置文件)和一个sites-enabled目录,其中包含我们希望处于活动状态的所有配置文件的快捷方式。默认情况下,您会在sites-available目录中看到一个名为000-default.conf的文件。这就是我们第一次安装 Apache 时看到默认的It works Apache 页面的原因。我们不再想要这个了;相反,我们希望使用我们的应用程序作为默认站点。因此,我们将禁用默认的 Apache 站点,启用我们自己的站点,然后重新启动 Apache 以使更改生效。运行以下命令来执行此操作:

sudo a2dissite 000-default.conf
sudo a2ensite hello.conf
sudo service apache2 reload

注意

所需的 Apache 配置和命令可能会根据您使用的平台而有所不同。如果您使用推荐的 Ubuntu 服务器,上述内容应该都能顺利工作。如果不是,您可能需要稍微了解一下如何为您的特定平台配置 Apache。

您应该注意输出中的重新加载 web 服务器 apache2。如果显示错误,则可能在前面的命令中配置错误。如果是这种情况,请仔细阅读错误消息,并返回查看之前的步骤,看看为什么事情没有按预期工作。

为了测试一切是否正常工作,请在本地机器上的 Web 浏览器中打开并再次在地址栏中键入您的 IP 地址。您应该在浏览器中看到Hello, World!而不是之前看到的默认 Apache 页面。

如果您收到错误 500,这意味着我们的应用程序出现了一些问题。不要担心;最好现在就习惯处理这个错误,因为修复可能会很简单,而不是以后,当我们添加了更多可能出错或配置错误的组件时。要找出出了什么问题,运行以下命令在您的 VPS 上:

sudo tail –f /var/log/apache2/error.log

tail命令只是输出作为参数传递的文件的最后几行。-f是用于跟踪,这意味着如果文件更改,输出将被更新。如果您无法立即确定哪些行是我们正在寻找的错误的指示,再次在本地机器上的 Web 浏览器中访问该站点,您将看到tail命令的输出相应地更新。以下截图显示了tail命令在没有错误时的输出;但是,如果出了任何问题,您将看到错误输出打印在所有信息消息中。

配置 Apache 以提供我们的 Flask 应用程序

一些可能的绊脚石是错误配置的 WSGI 和 Apache 文件(例如,确保您的WSGIDaemonProcessdaemon name匹配)或错误配置的 Python(您可能忘记在 VPS 上安装 Flask)。如果您无法弄清楚错误消息的含义,互联网搜索消息(删除应用程序的错误特定部分,如名称和路径)通常会指引您朝正确的方向。如果失败,Stack Overflow 和 Google Groups 上有强大而友好的 Flask 和 WSGI 社区,通常会有人愿意帮助初学者。请记住,如果您遇到问题并且找不到现有的解决方案,请不要感到难过;您将帮助无数面临类似问题的人。

摘要

在本章中,我们涉及了相当多的材料!我们进行了一些初始设置和日常工作,然后使用 Flask 编写了我们的第一个 Web 应用程序。我们看到这在本地运行,然后讨论了如何使用 Git 将我们的代码复制到服务器。我们配置了服务器以向公众提供我们的应用程序;但是,我们的应用程序只是一个静态页面,向访问我们页面的人打印“Hello, World!”字符串。这对许多人来说并不有用,并且可以使用静态 HTML 页面更简单地实现。但是,通过我们付出的额外努力,现在我们的应用程序背后拥有 Python 的所有功能;我们只是还没有使用它!

在下一章中,我们将发现如何利用 Python 使我们的 Web 应用程序更有用!

第二章:开始我们的头条项目

现在我们的 Hello World 应用程序已经启动运行,我们已经完成了所有必要的工作,可以创建一个更有用的应用程序。在接下来的几章中,我们将创建一个头条应用程序,向用户显示最新的新闻头条,天气信息和货币汇率。

在本章中,我们将介绍 RSS 订阅,并展示如何使用它们自动检索特定出版物的最新新闻文章。在下一章中,我们将讨论如何使用模板向用户显示检索到的文章的标题和摘要。第四章,我们头条项目的用户输入,将向您展示如何从用户那里获取输入,以便他们可以自定义他们的体验,并且还将讨论如何向我们的应用程序添加天气和货币数据。我们将在第五章中完成项目,改善我们头条项目的用户体验,通过添加一些 CSS 样式,并研究如何在用户的下一次访问中记住他们的偏好。

在本章结束时,您将学会如何创建一个更复杂的 Flask 应用程序。我们将从真实世界的新闻故事中提取原始数据,并构建 HTML 格式以向用户显示这些内容。您还将了解更多关于路由的知识,即不同的 URL 触发应用程序代码的不同部分。

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

  • 搭建我们的项目和 Git 仓库

  • 创建一个新的 Flask 应用程序

  • 介绍 RSS 和 RSS 订阅

设置我们的项目和 Git 仓库

我们可以简单地编辑我们的 Hello World 应用程序以添加所需的功能,但更干净的做法是开始一个新项目。我们将为每个项目创建一个新的 Git 仓库,一个新的 Python 文件,一个新的.wsgi文件和一个新的 Apache 配置文件。这意味着书中的所有三个项目以及原始的 Hello World 应用程序都可以从我们的 Web 服务器访问。

设置与我们在第一章中为我们的 Hello World 应用程序所做的非常相似,但我们将再次简要地介绍这些步骤,因为我们不必重复大部分配置和安装,如下所示:

  1. 登录到您的 GitHub 或 BitBucket 帐户,并创建一个名为headlines的新存储库。记下您获得的此空存储库的 URL。

  2. 在您的本地计算机上,在您的主目录或者您放置firstapp目录的任何地方创建一个名为headlines的新目录。

  3. 在此目录中创建一个名为headlines.py的新文件。

  4. 在您的终端中,将目录更改为headlines目录,并通过执行以下命令初始化 Git 存储库:

cd headlines
git init
git remote add origin <your headlines git URL>
git add headlines.py
git commit -m "initial commit"
git push –u origin master

现在,我们几乎准备好将代码推送到我们的新仓库;我们只需要先编写它。

创建一个新的 Flask 应用程序

首先,我们将创建新的 Flask 应用程序的框架,这与我们的 Hello World 应用程序几乎相同。在编辑器中打开headlines.py并写入以下代码:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def get_news():
  return "no news is good news"

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

这与以前完全一样。您可以在终端中使用python headlines.py运行它。打开浏览器并导航到localhost:5000,以查看显示的没有新闻就是好消息字符串。然而,尽管这句古话可能是真的,但糟糕的消息是我们的应用程序除了这个之外没有做任何更有用的事情。让我们让它向用户显示实际的新闻。

介绍 RSS 和 RSS 订阅

RSS 是一种古老但仍然广泛使用的技术,用于管理内容订阅。它已经存在了很长时间,以至于有人争论 RSS 这几个字母实际上代表什么,有人说是真正简单的聚合,有人说是丰富的站点摘要。这有点无关紧要,因为每个人都只是称它为 RSS。

RSS 使用 XML 以有序和结构化的格式呈现内容。它有几种用途,其中较常见的用途之一是供人们消费新闻文章。在新闻网站上,新闻通常以类似于印刷报纸的方式布局,重要的文章会占用更多的空间,并且会在页面上停留更长的时间。这意味着经常访问页面的用户会重复看到一些内容,并且必须寻找新内容。另一方面,有些网页更新非常不频繁,比如一些作者的博客。用户必须继续检查这些页面,看它们是否有更新,即使它们大部分时间都没有变化。RSS 源解决了这两个问题。如果网站配置为使用 RSS 源,所有新内容都会发布到一个源中。用户可以订阅他或她选择的源,并使用 RSS 阅读器来消费这些内容。他或她订阅的所有源的新故事将出现在阅读器中,并在标记为已读后消失。

由于 RSS 源具有正式的结构,它们允许我们在 Python 中以编程方式轻松解析标题、文章文本和日期。我们将使用一些主要新闻出版物的 RSS 源来向我们应用程序的用户显示新闻。

尽管 RSS 遵循严格的格式,我们可以不费太多力气地编写逻辑来解析源,但我们将使用 Python 库来完成这项工作。该库将抽象出诸如不同版本的 RSS 之类的东西,并允许我们以完全一致的方式访问所需的数据。

有几个 Python 库可以用来实现这一点。我们将选择feedparser。要安装它,打开你的终端并输入以下内容:

pip install --user feedparser

现在,让我们去找一个要解析的 RSS 源!大多数主要出版物都提供 RSS 源,而建立在流行平台上的较小网站,如 WordPress 和 Blogger,通常也会默认包含 RSS。有时需要一点努力才能找到 RSS 源;然而,由于没有关于它应该位于何处的标准,你经常会在主页的某个地方看到 RSS 图标(查看页眉和页脚),它看起来类似于这样:

RSS 和 RSS 源简介

此外,寻找标有RSSFeed的链接。如果这种方法失败,尝试访问site.com/rsssite.com/feed,其中site.com是你正在寻找 RSS 源的网站的根 URL。

我们将使用主要 BBC 新闻页面的 RSS 源。在撰写本文时,它位于feeds.bbci.co.uk/news/rss.xml。如果你感兴趣,你可以在浏览器中打开这个 URL,在页面的某个地方右键单击,然后点击查看源代码或等效选项。你应该会看到一些结构化的 XML,格式类似于以下内容:

<?xml version="1.0" encoding="UTF-8"?>
  <channel>
    <title>FooBar publishing</title>
    <link>http://dwyer.co.za</link>
    <description>A mock RSS feed</description> 
    <language>en-gb</language>  
    <item> 
      <title>Flask by Example sells out</title>
      <description>Gareth Dwyer's new book, Flask by Example sells out in minutes</description>
      <link>http://dwyer.co.za/book/news/flask-by-example</link>
      <guid isPermalink="false">http://dwyer.co.za/book/news/flask-by-example</guid>
      <pubDate>Sat, 07 Mar 2015 09:09:19 GMT</pubDate>
    </item>
  </channel>
</rss>

在源的顶部,你会看到一两行描述源本身的内容,比如它使用的 RSS 版本以及可能一些关于样式的信息。之后,你会看到与源的发布者相关的信息,然后是一系列<item>标签。其中每个代表一个故事——在我们的情况下,是一篇新闻文章。这些项目包含诸如标题、摘要、发布日期和完整故事的链接等信息。让我们开始解析吧!

使用 Python 从 RSS 获取信息

在我们的headlines.py文件中,我们将进行修改以导入我们安装的feedparser库,解析 feed,并获取第一篇文章。我们将围绕第一篇文章构建 HTML 格式,并在我们的应用程序中显示这个。如果你对 HTML 不熟悉,它代表超文本标记语言,用于定义网页中文本的外观和布局。它非常简单,但如果对你来说完全是新的,你应该花一点时间去学习一下初学者教程,以熟悉它的最基本用法。有许多免费的在线教程,快速搜索应该能找到几十个。一个受欢迎且非常适合初学者的教程可以在www.w3schools.com/html/找到。

我们的新代码添加了新库的导入,定义了一个新的全局变量用于 RSS feed URL,并进一步添加了一些逻辑来解析 feed,获取我们感兴趣的数据,并将其插入到一些非常基本的 HTML 中。它看起来类似于这样:

import feedparser
from flask import Flask

app = Flask(__name__)

BBC_FEED = "http://feeds.bbci.co.uk/news/rss.xml"

@app.route("/")
def get_news():
 feed = feedparser.parse(BBC_FEED)
 first_article = feed['entries'][0]
 return """<html>
 <body>
 <h1> BBC Headlines </h1>
 <b>{0}</b> <br/>
 <i>{1}</i> <br/>
 <p>{2}</p> <br/>
 </body>
</html>""".format(first_article.get("title"), first_article.get("published"), first_article.get("summary"))

if __name__ == "__main__":
  app.run(port=5000, debug=True)

这个函数的第一行将 BBC 的 feed URL 传递给我们的feedparser库,该库下载 feed,解析它,并返回一个 Python 字典。在第二行,我们仅从 feed 中获取了第一篇文章并将其分配给一个变量。feedparser返回的字典中的entries条目包含了包括我们之前提到的新闻故事的所有项目的列表,因此我们从中取出了第一个,并从中获取了标题或title,日期或published字段以及文章的摘要(即summary)。在return语句中,我们在一个三引号的 Python 字符串中构建了一个基本的 HTML 页面,其中包括所有 HTML 页面都有的<html><body>标签,以及描述我们页面的<h1>标题;<b>,这是一个加粗标签,显示新闻标题;<i>,代表斜体标签,显示文章的日期;和<p>,这是一个段落标签,用于显示文章的摘要。由于 RSS feed 中几乎所有项目都是可选的,我们使用了python.get()运算符而不是使用索引表示法(方括号),这意味着如果有任何信息缺失,它将简单地从我们最终的 HTML 中省略,而不会导致运行时错误。

为了清晰起见,我们在这个例子中没有进行任何异常处理;但是请注意,feedparser在尝试解析 BBC URL 时可能会抛出异常。如果你的本地互联网连接不可用,BBC 服务器宕机,或者提供的 feed 格式不正确,那么feedparser将无法将 feed 转换为 Python 字典。在一个真实的应用程序中,我们会添加一些异常处理并在这里重试逻辑。在一个真实的应用程序中,我们也绝不会在 Python 字符串中构建 HTML。我们将在下一章中看看如何正确处理 HTML。打开你的网络浏览器,看看结果。你应该看到一个非常基本的页面,看起来类似于以下内容(尽管你的新闻故事将是不同的):

使用 Python 的 RSS

这是一个很好的开始,我们现在为我们应用程序的假设用户提供了动态内容(即根据用户或外部事件自动更改的内容)。然而,最终,它并不比静态字符串更有用。谁想要看到来自他们无法控制的单一出版物的单一新闻故事呢?

为了完成本章,我们将看看如何根据 URL 路由从不同的出版物中显示文章。也就是说,我们的用户将能够在我们的网站上导航到不同的 URL,并查看来自几种出版物中的文章。在此之前,让我们稍微详细地看一下 Flask 如何处理 URL 路由。

Flask 中的 URL 路由

你还记得我们在上一章中简要提到了 Python 装饰器吗?它们由我们主要函数上面的有趣的@app.route("/")行表示,它们指示 Flask 应用程序的哪些部分应该由哪些 URL 触发。我们的基本 URL 通常类似于site.com,但在我们的情况下是我们 VPS 的 IP 地址,它被省略了,我们将在装饰器中指定剩下的 URL(即路径)。之前,我们使用了一个斜杠,表示当我们的基本 URL 被访问时,没有指定路径时应该触发该函数。现在,我们将设置我们的应用程序,以便用户可以访问类似site.com/bbcsite.com/cnn的 URL,选择他们想要看到文章的出版物。

我们需要做的第一件事是收集一些 RSS URL。在撰写本文时,以下所有内容都是有效的:

首先,我们将考虑如何使用静态路由来实现我们的目标。这绝不是最好的解决方案,因此我们将仅为我们的两个出版物实现静态路由。一旦我们完成这项工作,我们将考虑如何改用动态路由,这是许多问题的更简单和更通用的解决方案。

我们将建立一个 Python 字典,封装所有的 RSS 订阅,而不是为每个 RSS 订阅声明一个全局变量。我们将使我们的get_news()方法通用化,并让我们装饰的方法使用相关的出版物调用它。我们修改后的代码如下:

import feedparser
from flask import Flask

app = Flask(__name__)

RSS_FEEDS = {'bbc': 'http://feeds.bbci.co.uk/news/rss.xml',
             'cnn': 'http://rss.cnn.com/rss/edition.rss',
             'fox': 'http://feeds.foxnews.com/foxnews/latest',
             'iol': 'http://www.iol.co.za/cmlink/1.640'}

@app.route("/")
@app.route("/bbc")
def bbc():
    return get_news('bbc')

@app.route("/cnn")
def cnn():
    return get_news('cnn')

def get_news(publication):
  feed = feedparser.parse(RSS_FEEDS[publication])
  first_article = feed['entries'][0]
  return """<html>
    <body>
        <h1>Headlines </h1>
        <b>{0}</b> </ br>
        <i>{1}</i> </ br>
        <p>{2}</p> </ br>
    </body>
</html>""".format(first_article.get("title"), first_article.get("published"), first_article.get("summary"))

if __name__ == "__main__":
  app.run(port=5000, debug=True)

Common mistakes:

提示

如果您复制或粘贴函数并编辑@app.route装饰器,很容易忘记编辑函数名。虽然我们的函数名在很大程度上是无关紧要的,因为我们不直接调用它们,但我们不能让不同的函数共享与最新定义相同的名称,因为最新的定义将始终覆盖任何先前的定义。

我们仍然默认返回 BBC 新闻订阅,但如果用户访问 CNN 或 BBC 路由,我们将明确从各自的出版物中获取头条新闻。请注意,我们可以在一个函数中有多个装饰器,这样我们的bbc()函数就会在访问我们的基本 URL 或/bbc路径时触发。另外,请注意函数名不需要与路径相同,但在前面的例子中我们遵循了这个常见的约定。

接下来,当用户访问/cnn页面时,我们可以看到我们应用程序的输出。显示的标题现在来自 CNN 订阅。

Flask 中的 URL 路由

现在我们知道了 Flask 中路由的工作原理,如果能更简单就好了,不是吗?我们不想为我们的每个订阅定义一个新的函数。我们需要的是函数根据路径动态获取正确的 URL。这正是动态路由所做的事情。

在 Flask 中,如果我们在 URL 路径的一部分中使用尖括号< >,那么它将被视为一个变量,并传递给我们的应用程序代码。因此,我们可以重新使用单个get_news()函数,并传入一个<publication>变量,该变量可用于从我们的字典中进行选择。装饰器指定的任何变量都必须在我们函数的定义中考虑到。更新后的get_news()函数的前几行如下所示:

@app.route("/")
@app.route("/<publication>")
def get_news(publication="bbc"):
    # rest of code unchanged  

在前面显示的代码中,我们将<publication>添加到路由定义中。这将创建一个名为publication的参数,我们需要将其作为函数的参数直接添加到路由下面。因此,我们可以保留出版物参数的默认值为bbc,但如果用户访问 CNN,Flask 将传递cnn值作为出版物参数。

代码的其余部分保持不变,但是删除现在未使用的bbc()cnn()函数定义非常重要,因为我们需要默认路由来激活我们的get_news()函数。

很容易忘记在函数定义中catch URL 变量。路由的任何动态部分都必须在函数中包含同名的参数才能使用该值,因此要注意这一点。请注意,我们给我们的 publication 变量一个默认值bbc,这样当用户访问我们的基本 URL 时,我们就不需要担心它未定义。但是,再次强调,如果用户访问我们字典中没有的任何 URL,我们的代码将抛出异常。在真实的 Web 应用程序中,我们会捕获这种情况并向用户显示错误,但我们将把错误处理留到以后的章节。

发布我们的头条应用程序

这是我们在本章中将应用的最远程度。让我们将结果推送到我们的服务器,并配置 Apache 默认显示我们的头条新闻应用程序,而不是我们的 Hello World 应用程序。

首先,将更改添加到 Git 存储库中,对其进行提交,并将其推送到远程。您可以通过运行以下命令来完成此操作(在打开终端并切换到头条目录后):

git add headlines.py
git commit –m "dynamic routing"
git push origin master

然后,使用以下命令通过 SSH 连接到 VPS 并在那里克隆新项目:

ssh –i yourkey.pem root@123.456.789.123
cd /var/www
git clone https://<yourgitrepo>

不要忘记安装我们现在依赖的新库。在服务器上忘记安装依赖关系是一个常见的错误,可能会导致令人沮丧的调试。请记住这一点。以下是此命令:

pip install --user feedparser

现在,创建.wsgi文件。我假设您在创建远程存储库时将 Git 项目命名为headlines,并且在执行前面的 Git 克隆命令时,在您的/var/www目录中创建了一个名为headlines的目录。如果您将项目命名为其他名称,并且现在有一个具有不同名称的目录,请将其重命名为 headlines(否则,您将不得不相应地调整我们即将进行的大部分配置)。在 Linux 中重命名目录,请使用以下命令:

mv myflaskproject headlines

之前使用的命令将目录称为myflaskproject重命名为headlines,这将确保接下来的所有配置都能正常工作。现在,运行以下命令:

cd headlines
nano headlines.wsgi

然后,插入以下内容:

import sys
sys.path.insert(0, "/var/www/headlines")
from headlines import app as application

通过按下Ctrl + X键组合退出 Nano,并在提示保存更改时输入Y

现在,转到 Apache 中的sites-available目录,并使用以下命令创建新的.conf文件:

cd /etc/apache2/sites-available
nano headlines.conf

接下来,输入以下内容:

<VirtualHost *>
    ServerName example.com

    WSGIScriptAlias / /var/www/headlines/headlines.wsgi
    WSGIDaemonProcess headlines
    <Directory /var/www/headlines>
       WSGIProcessGroup headlines
       WSGIApplicationGroup %{GLOBAL}
        Order deny,allow
        Allow from all
    </Directory>
</VirtualHost>

保存文件并退出 nano。现在,通过运行以下命令禁用我们的旧站点,启用新站点,并重新启动 Apache:

sudo a2dissite hello.conf
sudo a2enssite headlines.conf
sudo service apache2 reload

尝试从本地机器访问 VPS 的 IP 地址,如果一切如预期般进行,您应该像以前一样看到新闻标题!如果没有,不要担心。在某些配置中犯错误是很容易的。最有可能的是您的headlines.wsgiheadlines.conf文件有一个小错误。找到这个最简单的方法是查看 Apache 错误日志中的最近错误,这些错误在您尝试访问站点时会触发。使用以下命令再次查看:

sudo tail –fn 20 /var/log/apache2/error.log

摘要

这就是本章的全部内容。本章的主要要点是看一下 Flask 如何处理静态和动态路由。您还学会了一种相当混乱的使用 HTML 格式化数据并将其返回给用户的方法。

在下一章中,我们将看一下使用 Jinja 模板更清晰地分离 HTML 代码和 Python 代码的方法。我们还将让我们的应用程序显示不止一个新闻故事。

第三章:在我们的头条项目中使用模板

在上一章中,我们看到了一种将静态 HTML 与动态内容结合起来创建网页的方法。但这很混乱,我们不想在 Python 字符串中构建我们的网页。混合 HTML 和 Python 并不理想,原因有几个:首先,这意味着如果我们想要更改静态文本,比如出现在标题中的文本,我们必须编辑我们的 Python 文件,这也涉及重新加载这些文件到 Apache。如果我们雇佣前端开发人员来处理 HTML,他们有可能会不小心破坏陌生的 Python 代码,并且更难以正确地构建任何其他前端代码,比如 JavaScript 和 CSS。理想情况下,我们应该在前端和后端组件之间实现完全的隔离。我们可以在很大程度上使用 Jinja 来实现这一点,但与生活的大多数方面一样,一些妥协是必要的。

在本章结束时,我们将扩展我们的应用程序,以显示所选出版物的不止一个头条新闻。我们将为每个出版物显示多篇文章,每篇文章都有一个指向原始文章的链接,我们的逻辑和视图组件将在很大程度上分离。在本章中,我们将涵盖以下主题:

  • 介绍 Jinja

  • Jinja 模板的基本用法

  • Jinja 模板的高级用法

介绍 Jinja

Jinja 是一个 Python 模板引擎。它允许我们轻松地定义由 Python 填充的动态 HTML 块。HTML 模板即使对于具有多个页面的静态网站也是有用的。通常,每个页面都有一些共同的元素,比如标题和页脚。虽然对于静态网站来说,可以维护每个页面,但如果对共享部分进行更改,则需要在多个位置进行单个更改。Flask 是建立在 Jinja 之上的,因此虽然可以在没有 Flask 的情况下使用 Jinja,但 Jinja 仍然是 Flask 的固有部分,并且 Flask 提供了几种直接与 Jinja 一起工作的方法。一般来说,Flask 对于应用程序的结构假设没有任何东西,除了你告诉它的内容,并且更喜欢通过可选插件提供功能。Jinja 在某种程度上是一个例外。Flask 默认为您提供 Jinja,并假设您将所有 Jinja 模板存储在名为templates的应用程序子目录中。

创建模板后,我们将从我们的 Flask 应用程序中调用渲染这些模板。渲染涉及解析 Jinja 代码,插入任何动态数据,并创建纯 HTML 以返回给用户的浏览器。尽管所有这些都是在幕后完成的,但可能会让人有点困惑,不知道在哪里完成了什么。我们将一步一步地进行。

Jinja 模板的基本用法

使用 Jinja 模板的第一步是在我们的应用程序中创建一个目录来包含我们的模板文件,所以导航到您的headlines目录,并创建一个名为templates的目录。与之前的步骤不同,这个名称是应用程序的其他部分所期望的,并且区分大小写,因此在创建时要小心。在最基本的级别上,Jinja 模板可以只是一个 HTML 文件,我们将为所有的 Jinja 模板使用.html扩展名。在templates目录中创建一个名为home.html的新文件。这将是我们的用户访问我们的应用程序时看到的页面,并且将包含我们以前在 Python 字符串中的所有 HTML。

注意

在本书中,我们只会使用 Jinja 来构建 HTML 文件,但 Jinja 足够灵活,可以用于生成任何基于文本的格式。虽然我们使用.html扩展名来命名我们的 Jinja 模板,但这些文件本身并不总是纯 HTML。

现在,将以下静态 HTML 代码放入此文件中。我们将在下一步中看如何在 Python 和我们的模板之间传递动态数据。

<html>
    <head>
        <title>Headlines</title>
    </head>
    <body>
        <h1>Headlines</h1>
        <b>title</b><br />
        <i>published</i><br />
        <p>summary</p>
    </body>
</html>

现在在我们的 Python 代码中,我们将渲染这个模板并返回它,而不是在我们的路由函数中构建字符串并返回它。在headlines.py中,在顶部添加一个导入:

from flask import render_template

render_template函数是一个魔术,它以 Jinja 模板作为输入,并产生纯 HTML 作为输出,可以被任何浏览器读取。目前,一些魔术已经失去了,因为我们将纯 HTML 作为输入,并在浏览器中查看相同的输出。

渲染基本模板

在你的get_news()函数中,删除包含我们三引号 HTML 字符串的return语句。保留之前从feedparser获取数据的行,因为我们很快会再次使用它。

更新return语句,使得get_news()函数现在如下所示:

@app.route("/")
@app.route("/<publication>"
def get_news(publication="bbc"):
  feed = feedparser.parse(RSS_FEEDS[publication])
  first_article = feed['entries'][0]
 return render_template("home.html")

尽管我们当前的 HTML 文件是纯 HTML,还没有使用我们稍后将看到的 Jinja 语法,但实际上我们已经做了相当多的魔术。这个调用在我们的templates目录中查找名为home.html的文件,读取它,解析任何 Jinja 逻辑,并创建一个 HTML 字符串返回给用户。一旦你做了以上两个更改,再次用python headlines.py运行你的应用程序,并在浏览器中导航到localhost:5000

再次,我们为了前进而后退了一步。如果你现在运行应用程序并在浏览器中查看结果,你应该会看到与我们原始页面类似的东西,只是现在你会看到字符串titlepublishedsummary,如下图所示:

渲染基本模板

让我们看看如何在render_template调用中填充这些字段,以便我们可以再次看到真实的新闻内容。

将动态数据传递给我们的模板

首先,在我们的 Python 文件中,我们将把每个作为命名变量传递。再次更新get_news()函数,并将所有需要显示给用户的数据作为参数传递给render_template(),如下所示:

@app.route("/")
@app.route("/<publication>"
def get_news(publication="bbc"):
  feed = feedparser.parse(RSS_FEEDS[publication])
  first_article = feed['entries'][0]
 render_template("home.html",title=first_article.get("title"),published=first_article.get("published"),summary=first_article.get("summary"))

render_template函数以模板的文件名作为第一个参数,然后可以接受任意数量的命名变量作为后续参数。每个变量中的数据将在模板中使用变量名可用。

在我们的模板中显示动态数据

在我们的home.html文件中,我们只需要在占位符的两侧放上两个大括号。更改后的样子如下:

<html>
    <head>
        <title>Headlines</title>
    </head>
    <body>
        <h1>Headlines</h1>
        <b>{{title}}</b><br />
        <i>{{published}}</i><br />
        <p>{{summary}}</p>
    </body>
</html>

双大括号,{{ }}, 表示对 Jinja 来说,它们内部的任何内容都不应被视为字面 HTML 代码。因为我们的占位符titlepublishedsummary与我们传递给render_template调用的 Python 变量名相同,只需添加周围的大括号,render_template调用将用真实数据替换这些,返回一个纯 HTML 页面。试一下,确保我们可以再次看到真实的新闻数据,如下图所示:

在我们的模板中显示动态数据

Jinja 模板的高级用法

现在我们完全分离了后端和前端组件,但我们的应用程序并没有比以前做更多的事情。让我们看看如何从所选出版物中显示多个新闻文章。我们不想为每篇文章的render_template调用添加三个新参数(或者如果我们决定要显示的不仅仅是文章的标题、日期和摘要,那么可能会添加几十个额外的参数)。

幸运的是,Jinja 可以接管 Python 的一些逻辑。这就是我们需要小心的地方:我们花了很多精力来分离逻辑和视图组件,当我们发现 Jinja 语言实际上有多么强大时,很容易将大部分逻辑移到我们的模板文件中。这将使我们回到最初的状态,代码难以维护。然而,在某些情况下,我们的前端代码需要处理一些逻辑,比如现在我们不想用太多重复的参数来污染我们的后端代码。

使用 Jinja 对象

首先要学习的是 Jinja 如何处理对象。所有基本的 Python 数据结构,如变量、对象、列表和字典,Jinja 都能理解,并且可以以与 Python 非常相似的方式进行处理。例如,我们可以将first_article对象传递给模板,而不是将文章的三个组件分别传递给模板,然后在 Jinja 中处理分离。让我们看看如何做到这一点。将 Python 代码更改为向render_template传递单个命名参数,即first_article,并将前端代码更改为从中提取所需的部分。

render_template调用现在应该是这样的:

render_template("home.html", article=first_article)

模板现在有一个名为article的引用,我们可以使用它来获得与之前相同的结果。更改 home.html 中相关部分如下:

<b>{{article.title}}</b><br />
<i>{{article.published</i><br />
<p>{{article.summary}}</p>

请注意,在 Jinja 中访问字典中的项与 Python 中略有不同。我们使用句点来访问属性,因此要访问文章的标题,我们使用{{article.title}},而不是 Python 中的article["title"]article.get("title")。我们的代码再次更整洁,但没有额外的功能。

向我们的模板添加循环逻辑

几乎没有额外的努力,我们可以使所有文章列表可用于 Jinja。在 Python 代码中,更改render_template调用如下:

render_template("home.html", articles=feed['entries'])

您可以删除代码中直接在前一行上定义first_article变量的行,因为我们不再需要它。我们的模板现在可以访问我们通过feedparser获取的完整文章列表。

在我们的 Jinja 模板中,我们现在可以添加{{articles}}{{articles[0]}}来查看我们现在传递的所有信息的完整转储,或者仅查看第一篇文章的转储。如果您感兴趣,可以尝试这个中间步骤,但在下一步中,我们将循环遍历所有文章并显示我们想要的信息。

通过向模板提供更多数据,我们传递了一些理想情况下应该由 Python 代码处理的逻辑责任,但我们也可以在 Jinja 中处理得非常干净。类似于我们使用双大括号{{ }}表示变量的方式,我们使用大括号和百分号的组合{% %}表示控制逻辑。通过示例来看会更清楚。更改模板代码中的<body>部分如下:

<body>
    <h1>Headlines</h1>
    {% for article in articles %}
        <b>{{article.title}}</b><br />
        <i>{{article.published}}</i><br />
        <p>{{article.summary}}</p>
        <hr />
    {% endfor %}
</body>

我们可以看到 Jinja 的 for 循环与 Python 类似。它循环遍历我们从 Python 代码传递进来的articles列表,并为循环的每次迭代创建一个新变量article,每次引用列表中的下一个项目。然后可以像其他 Jinja 变量一样使用article变量(使用双大括号)。因为 Jinja 中的空格是无关紧要的,不像 Python,我们必须用{% endfor %}行定义循环的结束位置。最后,在 HTML 中的<hr />创建一个作为每篇文章之间分隔符的水平线。

使用新的模板文件在本地运行应用程序,并在浏览器中查看结果。您应该看到类似以下图片的东西:

向我们的模板添加循环逻辑

向我们的模板添加超链接

现在我们想要将每个标题链接到原始文章。我们的用户可能会发现这很有用 - 如果一个标题看起来有趣,他或她可以轻松地获取文章的全文来阅读。RSS 订阅的所有者通常也会要求或要求使用该订阅的任何人链接回原始文章。(再次检查大多数大型订阅发布的条款和条件。)因为我们已经将整个article对象传递给我们的模板,所以我们不需要对我们的 Python 代码进行进一步的更改来实现这一点;我们只需要利用我们已经可用的额外数据。

在模板文件中,搜索以下内容:

<b>{{article.title}}</b><br />

将此行更改为以下内容:

<b><a href="{{article.link}}">{{article.title}}</a></b><br />

如果您对 HTML 不熟悉,那么这里有很多事情要做。让我们分解一下:HTML 中的<a>标签表示超链接(通常在大多数浏览器中默认显示为蓝色并带有下划线),href属性指定链接的目的地或 URL,并且链接以</a>标签结束。也就是说,<a></a>之间的任何文本都将是可点击的,并且将由我们用户的浏览器以不同的方式显示。请注意,我们可以在双引号中使用双大括号来指示变量,即使在用于定义目标属性的双引号内也可以。

如果您在浏览器中刷新页面,现在应该看到标题是粗体链接,如下图所示,并且点击其中一个链接应该会带您到原始文章。

向我们的模板添加超链接

将我们的代码推送到服务器

现在是将代码推送到我们的 VPS 的好时机。这是我们将分解如何做这件事的最后一次,但希望你现在对 Git 和 Apache 已经很熟悉,不会有任何意外发生。在本地机器上,从headlines目录运行:

git add headlines.py
git add templates
git commit -m "with Jinja templates"
git push origin master

然后在您的 VPS 上(像往常一样通过 SSH 登录),切换到适当的目录,从 Git 存储库中拉取更新,并重新启动 Apache 以重新加载代码:

cd /var/www/headlines
git pull
sudo service apache2 reload

确保一切都已经通过从本地机器的网络浏览器访问 VPS 的 IP 地址并检查是否看到与我们在本地看到的相同的输出来运行,如下图所示:

将我们的代码推送到服务器

摘要

现在我们有了一个基本的新闻摘要网站!您可以从许多不同的网站显示最近的新闻,查看每篇最近文章的标题,日期和摘要,并且可以点击任何标题访问原始文章。不过,您只看到了 Jinja 语言的一小部分功能 - 随着我们扩展这个项目和将来章节中的其他项目,您将看到它如何用于继承、条件语句等等。

在下一章中,我们将向我们的应用程序添加天气和货币信息,并探讨与用户互动的方式。

第四章:我们 Headlines 项目的用户输入

还记得我们是如何允许用户使用 URL 中的<variable>部分指定要查看的出版物的吗?尽管我们实际上是在从用户那里获取输入,但这种检索输入的方式有一些相当严重的限制。让我们看看与用户交互的更强大的方法,并向我们的应用程序添加一些更有用的信息。从现在开始,我们将对我们的代码文件进行相当多的增量更改,因此请记住,如果您需要概述,您可以随时参考附带的代码包。

在本章中,我们将看一些更灵活和强大的获取输入的方法。我们还将在这个过程中遇到一些更高级的 Git 功能,并花点时间解释如何使用它们。

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

  • 使用 HTTP GET 获取用户输入

  • 使用 HTTP POST 获取用户输入

  • 添加天气和货币数据

使用 HTTP GET 获取用户输入

HTTP GET 请求是从用户那里检索输入的最简单方式。在浏览网页时,您可能已经注意到 URL 中的问号。在网站的搜索框中提交一个术语时,您的搜索术语通常会出现在 URL 中,看起来像这样:

example.com/search?query=weather

问号后面的部分表示一个命名的 GET 参数。名称是query,值是weather。尽管这些参数通常是通过 HTML 输入框自动生成的,但用户也可以手动将它们插入到 URL 中,或者它们可以是发送给用户的可点击链接的一部分。HTTP GET 旨在从用户那里获取有限的、非敏感的信息,以便服务器根据 GET 参数返回所请求的页面。按照惯例,GET 请求不应该以产生副作用的方式修改服务器状态,也就是说,用户应该能够多次发出完全相同的请求,并始终得到完全相同的结果。

因此,GET 请求非常适合让用户指定要查看的出版物。让我们扩展我们的 Headlines 项目,以根据 GET 请求选择一个标题。首先,让我们修改 Python 代码以执行以下操作:

  • 从 Flask 导入请求上下文

  • 删除动态 URL 变量

  • 检查用户是否已输入有效的出版物作为 GET 参数

  • 将用户查询和出版物传递给模板

按照以下方式更新headlines.py文件:

import feedparser
from flask import Flask
from flask import render_template
from flask import request

app = Flask(__name__)

RSS_FEEDS = {'bbc': 'http://feeds.bbci.co.uk/news/rss.xml',
             'cnn': 'http://rss.cnn.com/rss/edition.rss',
             'fox': 'http://feeds.foxnews.com/foxnews/latest',
             'iol': 'http://www.iol.co.za/cmlink/1.640'}

@app.route("/")
def get_news():
 query = request.args.get("publication")
 if not query or query.lower() not in RSS_FEEDS:
 publication = "bbc"
 else:
 publication = query.lower()
        feed = feedparser.parse(RSS_FEEDS[publication])
 return render_template("home.html",articles=feed['entries']

if __name__ == "__main__":
    app.run(port=5000, debug=True)

第一个新变化是 Flask 请求上下文的新导入。这是 Flask 魔法的另一部分,使我们的生活更轻松。它提供了一个全局上下文,我们的代码可以使用它来访问关于最新请求的信息。这对我们很有用,因为用户作为请求的一部分传递的 GET 参数会自动在request.args中可用,我们可以像使用 Python 字典一样访问键值对(尽管它是不可变的)。请求上下文还简化了请求处理的其他部分,这意味着我们不必担心线程或请求的顺序。您可以在以下网站上阅读有关请求上下文工作原理及其功能的更多信息:

flask-cn.readthedocs.org/en/latest/reqcontext/

我们使用get()方法来检查是否已设置出版物键,如果键不存在,则返回None。如果参数存在,我们确保值是有效的(即它在我们的RSS_FEEDS映射中),如果是,则返回匹配的出版物。

我们可以通过访问我们的 URL 后跟 get 参数来测试代码,例如:localhost:5000/?publication=bbc。不幸的是,从我们的用户体验来看,我们使应用程序变得不太用户友好,而不是更加用户友好。为什么我们要这样做呢?原来我们的用户不必手动修改 URL——通过一个非常小的更改,我们可以自动填充 URL 参数,这样用户根本不必触摸 URL。修改 home.html 模板,并在标题下方添加以下 HTML:

<form>
  <input type="text" name="publication" placeholder="search" />
  <input type="submit" value="Submit" />
</form>

这很简单,但让我们分解一下看看它是如何工作的。首先,我们创建了一个 HTML 表单元素。默认情况下,当提交时,这将创建一个 HTTP GET 请求,通过将任何输入作为 GET 参数传递到 URL 中。我们有一个名为 publication 的单个文本输入。这个名称很重要,因为 GET 参数将使用它。placeholder 是可选的,但它会让我们的用户有更好的体验,因为浏览器会用它来指示文本字段的用途。最后,我们有另一个类型为 submit 的输入。这将自动为我们的表单创建一个漂亮的提交按钮,当按下时,它将获取输入框中的任何文本并将其提交到我们的 Python 后端。

保存模板,重新加载页面以查看它现在的工作方式。您应该在页面顶部看到输入表单,如下面的截图所示。我们为四行 HTML 获得了很多功能,现在我们可以看到,尽管 GET 参数最初看起来像是在创建更多的任务和管理员,但实际上它们使我们的 Web 应用程序更简单、更用户友好。

使用 HTTP GET 获取用户输入

使用 HTTP POST 获取用户输入

HTTP GET 的替代方法是 HTTP POST,并不总是立即明显应该使用哪一个。HTTP POST 用于向服务器发布更大的数据块或更敏感的数据。通过 POST 请求发送的数据在 URL 中不可见,尽管这并不使其本质上更安全(它默认不提供加密或验证),但它确实提供了一些安全优势。URL 经常被浏览器缓存,并通过自动完成功能建议下次用户输入类似的 URL 时。

因此,通过 GET 请求发送的数据可能会被保留。使用 POST 还可以防止他人通过窥视用户的肩膀(肩部冲浪)来查看数据。特别是密码通常在输入时通过使用 HTML 密码字段而被遮蔽,使其在浏览器中显示为星号(********)或点(••••••••)。然而,如果使用 GET 发送,数据仍然会在 URL 中清晰可见,因此应始终使用 POST。

虽然我们的搜索查询并不是机密的或过长的,但我们现在要花点时间来看看如何使用 POST 而不是 GET 来实现相同的功能。如果您只想继续完成我们的 Headlines 应用程序,可以跳过本节,但请记住,我们将在后续项目中使用 POST 请求而不进行详细解释。完成 POST 示例后,我们将把我们的应用程序恢复到当前状态(使用 GET 请求),因为这更适合我们的用例。

在 Git 中创建分支

对我们的代码库进行更改,我们不确定是否想要,我们将使用 Git 的分支功能。把分支想象成是路上的岔路口,除了我们随时可以改变主意并返回决策点。首先,我们需要确保我们当前的分支(master)是最新的——即所有本地更改都已提交。打开终端,从 headlines 目录运行以下命令:

git add headlines.py
git add templates/home.html
git commit -m "Using GET"
git push origin master

我们不严格需要将其推送到服务器——Git 在本地保留完整的修订历史,即使没有推送,我们的更改理论上仍然是安全的。然而,我们的代码处于工作状态,因此进行远程备份也没有坏处。现在我们将创建新的分支并切换到使用它来进行下一组更改:

git branch post-requests
git checkout post-requests

我们现在正在我们代码库的一个新分支上工作。通常,我们最终会将此分支合并回主分支,但在我们的情况下,一旦我们完成所需的工作,我们将放弃它。由于 Git 大多数操作都是在后台进行,很难将发生的事情可视化,因此如果您感兴趣并且可能会在将来的项目中使用 Git,那么值得阅读有关 Git 的内容。否则,只需将其视为一个检查点,以便我们可以自由地进行实验,而不必担心搞乱我们的代码。

在 Flask 中添加 POST 路由

要使用 POST 请求,我们需要对 Python 和 HTML 代码进行一些小的更改。在headlines.py文件中,进行以下更改:

  • request.args.get更改为request.form.get

  • @app.route("/")更改为@app.route("/", methods=['GET', 'POST'])

第一个更改的原因是我们现在从表单中获取用户数据,因此 Flask 会自动将其提供给我们的request.form。这与request.get的工作方式相同,只是它从 POST 请求而不是从 GET 请求中收集数据。第二个更改并不那么明显。我们之前没有提到的是,所有路由装饰器都可以指定函数如何被访问:通过 GET 请求、POST 请求或两者兼有。默认情况下,只允许 GET,但我们现在希望我们的默认页面可以通过 GET(当我们只是访问主页并且默认给出 BBC 时)或 POST(当我们通过带有额外查询数据的表单请求页面时)来访问。methods参数接受一个 HTTP 方法的列表,这些方法应该被允许访问我们应用程序的特定路由。

使我们的 HTML 表单使用 POST

我们的模板需要进行类似的更改。将home.html文件中的开头<form>标签更改为:

<form action="/" method="POST">

与 Flask 一样,HTML 表单默认使用 GET,因此我们必须明确定义我们要使用 POST 而不是 GET。action属性并不是绝对必要的,但通常当我们使用 POST 时,我们会将用户重定向到确认页面或类似的页面,接下来的页面的 URL 将出现在这里。在这种情况下,我们明确表示我们希望在提交表单后重定向到同一个页面。

保存 Python 和 HTML 文件的更改,并在浏览器中刷新页面以查看更改生效。功能应该完全相同,只是我们在 URL 中看不到任何数据。对于许多应用程序来说,这可能更清晰,但在我们的情况下,这不是我们想要的。首先,我们希望用户的浏览器可以缓存搜索词。如果用户习惯性地查询 FOX,我们希望浏览器在他开始在我们的应用程序的 URL 中输入时能够自动完成这一点。此外,我们希望我们的用户能够轻松地分享包括查询的链接。

如果用户(让我们称之为鲍勃)在将cnn输入到我们的应用程序后看到一堆有趣的标题,并希望与另一个用户(简)分享所有这些标题,我们不希望鲍勃不得不给简发消息,告诉她访问我们的网站,并在搜索表单中输入特定的查询。相反,鲍勃应该能够分享一个 URL,让简直接访问页面,就像他看到的那样(例如,example.com/?publication=cnn)。简只需点击鲍勃发送的链接,就可以查看相同的标题(假设她在 RSS 订阅更新之前访问我们的页面)。

恢复我们的 Git 存储库

我们需要将代码恢复到之前的状态。因为上一节中的所有更改都是在我们的实验性 post 请求分支中进行的,所以我们不需要手动重新编辑我们更改的行。相反,我们将提交我们的更改到这个分支,然后切换回我们的主分支,在那里我们会发现一切都和我们离开时一样。在您的终端中运行以下命令:

git add headlines.py
git add templates/home.html
git commit –m "POST requests"
git checkout master

打开headlines.pytemplates/home.html文件,确保它们与我们在进行 POST 实验之前保持一致!

添加天气和货币数据

现在让我们添加一些更多功能。我们正在显示来自三个不同来源的媒体头条,但我们的用户可能对更多内容感兴趣。我们将看看在页面顶部显示当前天气和一些汇率有多容易。对于天气数据,我们将使用 OpenWeatherMap API,对于货币数据,我们将使用 Open Exchange Rates。在撰写本文时,这些 API 是免费提供的,尽管它们都需要注册。

介绍 OpenWeatherMap API

在您的网络浏览器中,访问 URL api.openweathermap.org/data/2.5/weather?q=London,uk&units=metric&appid=cb932829eacb6a0e9ee4f38bfbf112ed。您应该看到类似以下截图的内容:

介绍 OpenWeatherMap API

这是伦敦的 JSON 天气数据,设计成自动读取而不是人工读取。在看如何将这些数据读入我们的 Headlines 应用程序之前,请注意我们访问的 URL 有一个appid参数。尽管天气数据是免费提供的,但每个访问数据的开发人员都需要在 OpenWeatherMap 注册一个免费账户,并获取一个唯一的 API 密钥作为appid参数的值。这是为了防止人们滥用 API,进行过多的请求,并占用可用的带宽。在撰写本文时,OpenWeatherMap 允许每分钟对 API 进行 60 次调用,每天 50000 次作为他们的免费访问计划的一部分,因此我们的项目不太可能达到这些限制。

注册 OpenWeatherMap

您应该注册自己的 API 密钥,而不是使用本书中发布的密钥。通常,您的 API 密钥应保持秘密,并且应避免共享它(尤其是避免在书中发布它)。要获取您自己的 API 密钥,请转到www.openweathermap.org,并通过单击页面顶部的注册链接完成他们的注册流程。填写电子邮件地址,用户名和密码。注册页面应该类似于以下截图:

注册 OpenWeatherMap

检索您的 OpenWeatherMap API 密钥

注册后,您将能够登录 OpenWeatherMap。您可以通过导航到home.openweathermap.org并向下滚动到API 密钥文本框找到您的个人 API 密钥。您应该在以下图像中看到您的 API 密钥,如红色矩形所示:

检索您的 OpenWeatherMap API 密钥

将密钥复制到剪贴板,因为我们很快将在我们的 Python 代码中使用它。

使用 Python 解析 JSON

现在我们可以通过使用 URL 在 HTTP 上访问结构化的天气数据。但是在浏览器中这样做并没有太大用处,因为我们希望从我们的 Python 代码自动读取这些数据。幸运的是,Python 有一堆有用的标准库,正好适用于这种用例!

介绍 JSON

JSON 是一种结构化数据格式,非常类似于 Python 字典,从前面的示例中应该很明显。实际上,在这种情况下,它是相同的,我们可以非常简单地将其转换为 Python 字典,以便在我们的 Flask 应用程序中使用,方法是将其加载为字符串,然后在其上运行内置的 Python eval函数。然而,JSON 并不总是与 Python 字典相同。例如,它使用truefalse而不是TrueFalse(注意大小写的区别)-将任何我们无法完全控制的东西传递给eval()通常是一个坏主意。因此,我们将使用Python json库来安全地解析它。我们还将使用 Python urllib2库从网络上下载数据,并使用 Python urllib库正确编码 URL 参数。

在 Python 中检索和解析 JSON

对于在 Python 中检索和解析 JSON,第一步是向我们的headlines.py文件添加我们需要的三个新导入:

import json
import urllib2
import urllib

风格提示:

提示

为了良好的 Python 风格,保持导入按字母顺序排列。您可以在以下网站阅读有关导入排序约定的更多信息:www.python.org/dev/peps/pep-0008/#imports

现在添加一个新函数get_weather(),它将使用特定查询调用天气 API。这很简单,代码如下。用你从 OpenWeatherMap 页面复制的 API 密钥替换<your-api-key-here>占位符。

def get_weather(query):
    api_url = http://api.openweathermap.org/data/2.5/weather?q={}&units=metric&appid=<your-api-key-here>
    query = urllib.quote(query)
    url = api_url.format(query)
    data = urllib2.urlopen(url).read()
    parsed = json.loads(data)
    weather = None
    if parsed.get("weather"):
        weather = {"description":parsed["weather"][0]["description"],"temperature":parsed["main"]["temp"],"city":parsed["name"]
                  }
    return weather

我们在浏览器中使用与之前相同的 URL,但是我们使查询部分可配置,以便检索天气数据的城市是动态的。我们在查询变量上使用urllib.quote(),因为 URL 中不能有空格,但是我们想要检索天气的城市的名称可能包含空格。quote()函数通过将空格转换为"%20"(这是 URL 中表示空格的方式)来处理这个问题。然后我们使用urllib2库将数据通过 HTTP 加载到 Python 字符串中。与我们的 feedparsing 示例一样,通过互联网下载数据总是潜在不稳定的,对于真实的应用程序,我们需要在这里添加一些异常处理和重试逻辑。

然后我们使用 json 库的loads()函数(加载字符串)将我们下载的 JSON 字符串转换为 Python 字典。最后,我们根据 API 返回的 JSON 构建一个更简单的 Python 字典,因为 OpenWeatherMap 提供了一大堆我们不需要的属性。

使用我们的天气代码

现在对get_news()函数进行两个小改动,以便使用我们的get_weather()函数。我们需要调用get_weather()函数(现在我们只会传入伦敦作为常量),然后将天气数据传递给我们的模板。get_news()函数现在应该如下所示:

@app.route("/")
def get_news():
        query = request.args.get("publication")
        if not query or query.lower() not in RSS_FEEDS:
                publication = "bbc"
        else:
                publication = query.lower()
        feed = feedparser.parse(RSS_FEEDS[publication])
        weather = get_weather("London,UK")
        return render_template("home.html",articles=feed["entries"],weather=weather)

现在将伦敦的简化数据加载到天气变量中,并将其传递给我们的模板文件,以便我们可以向用户显示数据。

显示天气数据

现在我们只需要调整我们的模板来适应额外的数据。我们将在新闻标题上方显示天气数据,并添加一些二级标题以保持我们应用程序的不同部分有序。

在开头的<h1>标签后面,向 home.html 模板添加以下三行:

<body>
  <h1>Headlines</h1>
  <h2>Current weather</h2>
  <p>City: <b>{{weather.city}}</b></p>
  <p>{{weather.description}} |{{weather.temperature}}℃</p>
  <h2>Headlines</h2>

这里没有我们之前没有见过的东西。我们只需使用大括号从我们的天气变量中获取我们想要的部分。有趣的&#8451;部分是为了显示摄氏度符号。如果你是那些能够理解华氏度概念的人之一,那么从 API URL 中删除&units=metric(这将告诉 OpenWeatherData 以华氏度给我们温度),并在模板中使用&#8457;代替F符号来显示给我们的用户。

允许用户自定义城市

如前所述,我们并不总是想显示伦敦的天气。让我们为城市添加第二个搜索框!搜索通常很困难,因为用户输入的数据从来都不一致,而计算机喜欢一致。幸运的是,我们正在使用的 API 非常灵活,因此我们将直接传递用户的输入,并将困难的部分留给其他人处理。

在我们的模板中添加另一个搜索框

我们将搜索框添加到我们的模板中,就像以前一样。这个表单直接放在home.html文件中“当前天气”标题下面。

<form>
  <input type="text" name="city" placeholder="weather search">
  <input type="submit" value="Submit">
</form>

在前面的代码片段中定义的表单简单地使用了一个命名文本输入和一个提交按钮,就像我们为出版物输入添加的那样。

在我们的 Python 代码中使用用户的城市搜索

在我们的 Python 代码中,我们需要在 GET 请求中查找city参数。我们的“get_news()”函数不再命名良好,因为它不仅仅是获取新闻。让我们进行一些重构。之后,我们将有一个“home()”函数,该函数调用获取新闻和天气数据(以及以后的货币数据),我们的“get_news()”函数将再次只负责获取新闻。我们还将有很多不同事物的默认值,因此我们将添加一个DEFAULTS字典作为全局变量,每当我们的代码无法在 GET 参数中找到信息时,它将返回到那里获取所需的信息。我们代码的更改部分(不包括导入、全局 URL 和最后的主要部分)现在看起来像这样:

# ...

DEFAULTS = {'publication':'bbc',
            'city': 'London,UK'}

@app.route("/")
def home():
    # get customized headlines, based on user input or default
    publication = request.args.get('publication')
    if not publication:
        publication = DEFAULTS['publication']
    articles = get_news(publication)
    # get customized weather based on user input or default
    city = request.args.get('city')
    if not city:
        city = DEFAULTS['city']
    weather = get_weather(city)
return render_template("home.html", articles=articles,weather=weather)

def get_news(query):
    if not query or query.lower() not in RSS_FEEDS:
        publication = DEFAULTS["publication"]
    else:
        publication = query.lower()
    feed = feedparser.parse(RSS_FEEDS[publication])
    return feed['entries']

def get_weather(query):
    query = urllib.quote(query)
    url = WEATHER_URL.format(query)
    data = urllib2.urlopen(url).read()
    parsed = json.loads(data)
    weather = None
    if parsed.get('weather'):
        weather = {'description':parsed['weather'][0]['description'],'temperature':parsed['main']['temp'],'city':parsed['name']}
    return weather

现在我们有了良好的关注点分离-我们的“get_weather()”函数获取天气数据,我们的“get_news()”函数获取新闻,我们的“home()”函数将两者结合起来,并处理用户的输入,向我们的访问者显示定制数据。

检查我们的新功能

如果一切顺利,我们现在应该有一个显示可定制新闻和天气数据的网站。如前所述,天气搜索非常灵活。尝试一些不同的输入-您应该会看到一个类似以下图像的页面:

检查我们的新功能

处理重复的城市名称

OpenWeatherMap API 很好地处理了重复的城市名称,尽管默认值有时有点反直觉。例如,如果我们搜索伯明翰,我们将得到美国的那个。如果我们想要查找英国的伯明翰,我们可以搜索伯明翰,英国。为了不让观众感到困惑,我们将对显示城市旁边的国家进行小修改。然后他们将立即能够看到是否得到了与他们预期的城市不同的结果。如果您检查我们的天气调用的完整 API 响应,您会发现国家代码列在sys下-我们将获取它,添加到我们的自定义字典中,然后在我们的模板中显示它。

get_weather函数中,修改我们构建字典的行:

weather = {'description': parsed['weather'][0]['description'],
           'temperature': parsed['main']['temp'],
           'city': parsed['name'],
 'country': parsed['sys']['country']
          }

并在我们的模板中修改显示城市的行如下:

<p>City: <b>{{weather.city}}, {{weather.country}}</b></p>

检查它是否工作-如果您重新启动应用程序并重新加载页面,您应该会看到在“当前天气”搜索框中键入“伯明翰”现在显示城市名称旁边的国家代码。

处理重复的城市名称

货币

货币数据被认为比天气数据更有价值。许多商业服务提供经常更新且非常可靠的 API。但是,免费的 API 有点罕见。一个提供有限免费 API 的服务是 Open Exchange Rates-再次,我们需要注册一个免费帐户以获得 API 密钥。

获取 Open Exchange Rates API 的 API 密钥

转到openexchangerates.com,并完成他们的注册流程。 点击注册链接后,它可能看起来他们只有付费计划,因为这些更加突出显示。 但是,在大型付费计划选项下方,有一行描述其免费提供的单行文本,并附有选择它的链接。 点击这个链接,并输入您的详细信息。

如果您没有自动重定向,请转到他们网站上的仪表板,您会看到您的API 密钥(应用程序 ID)显示出来。 复制这个,因为我们需要将其添加到我们的 Python 代码中。 您可以在以下截图中看到如何找到您的 API 密钥的示例:

获取 Open Exchange Rates API 的 API 密钥

使用 Open Exchange Rates API

currency API 返回的 JSON 与weather API 一样,因此我们可以非常容易地将其集成到我们的 Headlines 应用程序中。 我们需要将 URL 添加为全局变量,然后添加一个新函数来计算汇率。 不幸的是,API 的免费版本受限于返回所有主要货币相对于美元的汇率,因此我们将不得不为不涉及美元的转换计算我们自己的近似汇率,并依赖于一个完美的市场尽可能地保持我们的信息准确(参见en.wikipedia.org/wiki/Triangular_arbitrage)。

在现有的WEATHER_URL下面的全局变量中添加变量CURRENCY_URL,如下面的代码片段所示。 您需要替换自己的 App ID。

WEATHER_URL = "http://api.openweathermap.org/data/2.5/weather?q={}&units=metric&APPID=<your-api-key-here>"
CURRENCY_URL = "https://openexchangerates.org//api/latest.json?app_id=<your-api-key-here>"

添加get_rates()函数如下:

def get_rate(frm, to):
        all_currency = urllib2.urlopen(CURRENCY_URL).read()

        parsed = json.loads(all_currency).get('rates')
        frm_rate = parsed.get(frm.upper())
        to_rate = parsed.get(to.upper())
        return to_rate/frm_rate

请注意我们在最后进行的计算。 如果请求是从美元到其他任何货币,我们可以简单地从返回的 JSON 中获取正确的数字。 但在这种情况下,计算是足够简单的,因此不值得添加额外的逻辑步骤来判断我们是否需要进行计算。

使用我们的货币功能

现在我们需要从我们的home()函数中调用get_rates()函数,并将数据传递给我们的模板。 我们还需要向我们的DEFAULTS字典添加默认货币。 根据以下突出显示的代码进行更改:

DEFAULTS = {'publication':'bbc',
            'city': 'London,UK',
 'currency_from':'GBP',
 'currency_to':'USD'
}

@app.route("/")
def home():
    # get customized headlines, based on user input or default
    publication = request.args.get('publication')
    if not publication:
        publication = DEFAULTS['publication']
    articles = get_news(publication)
    # get customized weather based on user input or default
    city = request.args.get('city')
    if not city:
        city = DEFAULTS['city']
    weather = get_weather(city)
    # get customized currency based on user input or default
    currency_from = request.args.get("currency_from")
    if not currency_from:
        currency_from = DEFAULTS['currency_from']
    currency_to = request.args.get("currency_to")
    if not currency_to:
        currency_to = DEFAULTS['currency_to']
    rate = get_rate(currency_from, currency_to)
    return render_template("home.html", articles=articles,weather=weather,
                           currency_from=currency_from, currency_to=currency_to, rate=rate)

在我们的模板中显示货币数据

最后,我们需要修改我们的模板以显示新数据。 在home.html中的天气部分下面添加:

<h2>Currency</h2>
1 {{currency_from}} = {{currency_to}} {{rate}}

像往常一样,在浏览器中检查一切是否正常运行。 您应该看到英镑兑美元的默认货币数据,如下图所示:

在我们的模板中显示货币数据

为用户选择货币添加输入

现在我们需要添加另一个用户输入来自定义要显示的货币。 我们可以像之前两个一样轻松地添加另一个文本搜索,但这会变得混乱。 我们需要用户的两个输入:货币和货币。 我们可以添加两个输入,或者我们可以要求用户将两者输入到同一个输入中,但前者会使我们的页面变得非常凌乱,而后者意味着我们需要担心正确地拆分用户输入数据(这几乎肯定不一致)。 相反,让我们看看另一个输入元素,HTML select。 您几乎肯定在其他网页上看到过这些——它们是带有用户可以选择的值列表的下拉菜单。 让我们看看如何在 HTML 中构建它们,以及如何在 Flask 中抓取它们的数据。

创建 HTML 选择下拉元素

首先,在每个下拉菜单中硬编码四种货币。 代码应该插入在home.html模板中货币标题的下方,代码如下:

<form>
    from: <select name="currency_from">
            <option value="USD">USD</option>
            <option value="GBP">GBP</option>
            <option value="EUR">EUR</option>
            <option value="ZAR">ZAR</option>
          </select>

     to: <select name="currency_to">
           <option value="USD">USD</option>
           <option value="GBP">GBP</option>
           <option value="EUR">EUR</option>
           <option value="ZAR">ZAR</option>
         </select>
         <input type="submit" value="Submit">
</form>

用于 GET 请求参数的名称是选择标签本身的属性(类似于我们在<input type="text">标签中使用的名称属性)。在我们的情况下,这些是currency_fromcurrency_to,这些是我们之前在 Python 代码中指定的。值稍微有些棘手——我们有在 GET 请求中传递的值(例如currency_from=EUR),然后是显示给用户的值。在这种情况下,我们将两者都使用相同的——货币代码——但这不是强制的。例如,我们可以在显示值中使用货币的全名,如美元,在请求中传递代码。参数值被指定为<option>标签的属性,每个都是<select>的子元素。显示值插入在开放和关闭的<option></option>标签之间。

测试一下,确保它能正常工作,保存模板并重新加载页面。您应该会看到下拉输入框出现,如下图所示:

创建 HTML 选择下拉元素

将所有货币添加到选择输入中

当然,我们可以像前一节那样对完整列表进行操作。但是我们是程序员,不是数据捕捉者,所以我们将使列表动态化,使用for循环插入选项,并保持我们的模板更新和清晰。为了获取货币列表,我们可以简单地获取 JSON all_currency对象的键,以便使我们的get_rate()函数返回一个元组——计算出的汇率和货币列表。然后我们可以将(排序后的)列表传递给我们的模板,模板可以循环遍历它们并用它们构建下拉列表。更改如下所示:

home()函数中进行以下更改:

        if not currency_to:
          currency_to=DEFAULTS['currency_to']
 rate, currencies = get_rate(currency_from, currency_to)
 return render_template("home.html", articles=articles,weather=weather, currency_from=currency_from, currency_to=currency_to,    rate=rate,currencies=sorted(currencies))

get_rate()函数中:

frm_rate = parsed.get(frm.upper())
to_rate = parsed.get(to.upper())
return (to_rate / frm_rate, parsed.keys())

home.html模板中:

        <h2>Currency</h2>
        <form>
                from: <select name="currency_from">
 {% for currency in currencies %}
 <optionvalue="{{currency}}">{{currency}}</option>
 {% endfor %}
                      </select>

                to: <select name="currency_to">
 {% for currency in currencies %}
 <option value="{{currency}}">{{currency}}</option>
 {% endfor %}

                    </select>
                <input type="submit" value="Submit">
        </form>
        1 {{currency_from}} = {{currency_to}} {{rate}}

在下拉输入中显示所选货币

之后,我们应该能够轻松地查看任何我们想要的货币的汇率。一个小小的烦恼是下拉框总是默认显示顶部项目。如果它们显示当前选定的值会更直观。我们可以通过在我们的选择标签中设置selected="selected"属性和一个简单的一行 Jinja if语句来实现这一点。更改我们home.html模板中货币输入的for循环如下:

对于currency_from循环:

{% for currency in currencies %}
    <option value="{{currency}}" {{'selected="selected"' if currency_from==currency}}>{{currency}}</option>
{% endfor %}

对于currency_to循环:

{% for currency in currencies %}
    <option value="{{currency}}" {{'selected="selected"' if currency_to==currency}}>{{currency}}</option>
{% endfor %}

重新加载应用程序和页面,现在您应该能够从两个选择输入中选择任何可用的货币,并且在页面加载所需的货币数据后,选择输入应该自动显示当前货币,如下图所示。单击选择输入后,您还应该能够在键盘上输入并根据您输入的首字母选择选项。

在下拉输入中显示所选货币

现在我们可以同时看到新闻、天气和货币数据!您可以从本章的代码包中参考完整的代码。

总结

在本章中,我们看了一下 HTTP GET 和 POST 请求之间的区别,并讨论了在何时使用哪种请求。虽然目前我们没有好的用途来使用 HTTP POST,但在未来的项目中,我们将从用户那里获取登录数据时使用它。幸运的是,我们对 HTTP POST 的解释工作并没有白费——我们还看了一些 Git 可以帮助我们进行版本控制的更高级的方法,我们未使用的代码安全地存储在代码库的不同分支中,以防以后需要参考。最后但并非最不重要的是,我们将天气和货币数据添加到了我们的应用程序中,并研究了一些不同的选项,以允许用户向我们的应用程序输入数据。我们的第一个项目快要完成了!

在下一章中,我们将进行一些修饰性的润色,并考虑如何记住我们的用户,这样他们就不必每次访问我们的网站时都执行完全相同的操作。

第五章:改进我们的头条项目的用户体验

富有的商人们为了不断给人留下良好印象以保持有利可图的关系,有时会雇佣个人助理来研究他们的熟人。然后,个人助理会站在社交活动中富有的人的身后,对即将接触的人耳语几句关键的话。这些话必须简洁但具有信息量,比如“保罗·史密斯。一个孩子,吉尔。最近去了毛里求斯”。现在,我们的商人可以假装接近的人是一个亲密的朋友,并且长篇大论地谈论他的孩子和旅行,而实际上并不知道这个人是谁。这会让其他人觉得重要和受欢迎,这有助于我们假设的百万富翁变得更加富有。

为什么这与 Web 应用程序相关呢?好吧,我们想做的就是这样。我们网站的用户觉得重要和被记住,更有可能回来,所以我们需要一个数字助理,让用户觉得我们花了时间和精力来记住他们是谁以及他们喜欢什么。我们可以建立一个用户数据库,存储他们通常计算的货币转换和他们感兴趣的城市天气,然后默认显示给他们。这种策略的问题在于我们需要他们在每次访问时进行身份识别,而大多数用户会觉得输入用户名,可能还有密码,这一额外步骤很烦人。

输入 HTTP cookie。这些狡猾的小东西将潜伏在我们用户的计算机上,在用户第二次访问我们的网站时,充当我们的数字助理,给我们提供我们以前获得但没有记住的信息。这听起来相当不光彩。有一段时间,欧盟也是这么认为的,并试图对 cookie 的使用进行监管,但它们无处不在,简单而有用,监管尝试有点令人失望(请看silktide.com/the-stupid-cookie-law-is-dead-at-last/)。

在最简单的形式中,cookie 只是我们存储在用户计算机上的键值对,并要求他们的浏览器在访问我们的网站时自动发送给我们。这样做的好处是我们不必保留和维护数据库,也不必明确要求用户告诉我们他们是谁。然而,缺点是我们无法控制这些信息,如果用户更换计算机、Web 浏览器,甚至只是删除我们的 cookie,我们将无法再识别他或她。因此,cookie 非常适合我们构建的应用程序;如果用户不得不点击几次才能回到上次搜索的媒体、货币和天气信息,这并不是世界末日,但如果我们能记住以前的选择并自动显示这些信息,那就很好。

当我们谈论用户体验(通常称为 UX)时,我们的网站看起来好像是上世纪 80 年代制作的。我们将在后面的章节中更加注重美学,但现在我们也将看看如何向我们的网站添加一些基本的布局和颜色。因为我们专注于功能和简单性,所以它仍然远非“现代化”,但我们将向我们的工具包添加一些基本组件,以便以后更加谨慎地使用。我们将使用层叠样式表(通常简称为 CSS)来实现这一点。CSS 是一个很好的工具,可以进一步分离关注点;我们已经主要将逻辑(即我们的 Python 脚本)与内容(即我们的 HTML 模板)分开。现在,我们将看看 CSS 如何帮助我们将格式(颜色、字体、布局等)与我们的其他内容(例如模板文件中的静态文本)分开。

现在我们已经概述了 cookies 和 CSS,我们将开始研究如何在 Flask 中实现它们。这是我们第一个项目的最后一章,到最后,我们将拥有一个包括 cookies 和 CSS 的 Headlines 应用程序。

在本章中,我们将研究以下主题:

  • 向我们的 Headlines 应用程序添加 cookies

  • 向我们的 Headlines 应用程序添加 CSS

向我们的 Headlines 应用程序添加 cookies

在这一点上,我们的应用程序有一些问题。让我们想象一个名叫鲍勃的用户,他住在西雅图。鲍勃访问我们的网站,看到了 BBC,伦敦和将 GBP 转换为 USD 的默认值。鲍勃想要看到西雅图的天气,所以他在天气搜索栏中输入西雅图并按下回车键。他浏览返回的天气,感到很沮丧,因为天气一如既往地寒冷和下雨,所以他从页面底部的天气中看向 BBC 的头条新闻。他更喜欢 CNN 的头条新闻,所以他从下拉菜单中选择了这个出版物并点击提交。他读了几条头条新闻后意识到时事新闻甚至比天气更沉闷和令人沮丧。所以,他的眼睛再次移回页面顶部来振作自己。他感到困惑;自从更改了他的出版物偏好后,天气又默认回到了伦敦,那里的天气甚至更糟糕!他关闭了我们的应用程序,不再回来。如果他回来,一切都会再次显示默认值。

两个直接问题是:

  • 即使用户在我们的网站上停留,也不记住用户的选择

  • 用户关闭我们的网站并在以后重新访问时不记住用户的选择

让我们解决这两个问题。

使用 Flask 处理 cookies

如前所述,cookies 可以被视为我们可能或可能不会从返回访客那里收到的键值对。我们需要改变我们的应用程序,这样当用户做出选择时,我们创建或更新他们的 cookie 以反映这些更改,当用户请求我们的网站时,我们检查是否存在 cookie,并尽可能多地从中读取未指定的信息。首先,我们将看看如何设置 cookies 并让用户的浏览器自动记住信息,然后我们将看看如何检索我们以前使用 cookies 存储的信息。

在 Flask 中设置 cookies

Flask 使处理 cookies 变得非常容易。首先,我们需要更多的导入;我们将使用 Python 的datetime库来设置即将存在的 cookies 的寿命,我们将使用 Flask 的make_response()函数来创建一个响应对象,我们可以在其上设置 cookies。在headlines.py文件的导入部分中添加以下两行:

import datetime
from flask import make_response

之前,我们只是用自定义参数渲染我们的模板,然后将其返回给用户的网络浏览器。为了设置 cookies,我们需要额外的步骤。首先,我们将使用新的make_response()函数创建一个响应对象,然后使用这个对象设置我们的 cookie。最后,我们将返回整个响应,其中包括渲染的模板和 cookies。

用以下行替换headlines.pyhome()函数的最后一行:

response = make_response(render_template("home.html",
  articles=articles,
  weather=weather,
  currency_from=currency_from,
  currency_to=currency_to,
  rate=rate,
  currencies=sorted(currencies)))
expires = datetime.datetime.now() + datetime.timedelta(days=365)
response.set_cookie("publication", publication, expires=expires)
response.set_cookie("city", city, expires=expires)
response.set_cookie("currency_from",
  currency_from, expires=expires)
response.set_cookie("currency_to", currency_to, expires=expires)
return response

这与我们之前简单的返回语句相比是一个相当大的改变,所以让我们来详细分析一下。首先,我们将在我们的render_template()调用周围包装一个make_response()调用,而不是直接返回渲染的模板。这意味着我们的 Jinja 模板将被渲染,所有的占位符将被替换为正确的值,但是我们不会直接将这个响应返回给用户,而是将它加载到一个变量中,以便我们可以对它进行一些更多的添加。一旦我们有了这个响应对象,我们将创建一个值为今天日期后 365 天的datetime对象。然后,我们将在我们的response对象上进行一系列的set_cookie()调用,保存所有用户的选择(或刷新以前的默认值),并将到期时间设置为从设置 cookie 的时间开始的一年,使用我们的datetime对象。

最后,我们将返回包含渲染模板的 HTML 和我们的四个 cookie 值的response对象。在加载页面时,用户的浏览器将保存这四个 cookies,如果同一用户再次访问我们的应用程序,我们将能够检索这些值。

在 Flask 中检索 cookies

如果我们不对信息进行任何处理,那么记住这些信息也没有太大的意义。在向用户发送响应之前,我们现在将 cookies 设置为最后一步。然而,当用户向我们发送请求时,我们需要检查保存的 cookies。如果你还记得我们如何从 Flask 的请求对象中获取命名参数,你可能猜到如何获取保存的 cookies。如果存在,以下行将获取名为publication的 cookie:

request.cookies.get("publication")

这很简单,对吧?唯一棘手的部分是正确获取我们的回退逻辑。我们仍然希望显式请求具有最高优先级;也就是说,如果用户输入文本或从下拉菜单中选择一个值,这将是他或她想要的,而不管我们对以前的访问期望如何。如果没有显式请求,我们将查看 cookies,以检查是否可以从中获取默认值。最后,如果我们仍然没有任何内容,我们将使用我们硬编码的默认值。

编写回退逻辑以检查 cookies

首先,让我们只为publication实现这个逻辑。在headlines.pyhome()函数中的 publication 逻辑中添加一个新的if块,使其匹配以下内容:

# get customised headlines, based on user input or default
publication = request.args.get("publication")
if not publication:
 publication = request.cookies.get("publication")
    if not publication:
        publication = DEFAULTS["publication"]

现在,我们将查看 GET 参数,必要时回退到保存的 cookies,最后回退到我们的默认值。让我们看看这个工作。打开你的网络浏览器,导航到localhost:5000。在Publication搜索栏中搜索Fox,等待页面重新加载,显示 Fox News 的头条新闻。现在,关闭你的浏览器,重新打开它,再次加载localhost:5000。这次,你应该看到 Fox 的头条新闻,而不需要再搜索它们,就像下面的截图一样。

请注意,URL 中没有publication参数,但是头条新闻现在是来自 Fox News。

编写回退逻辑以检查 cookies

检索其他数据的 cookies

我们的 publication 有基本的 cookies 工作,但我们仍然希望读取我们可能为天气和货币选项保存的 cookies。我们可以简单地在代码的每个部分添加相同的 if 语句,将citycurrency_fromcurrency_to替换为相关的publication,但是在代码的许多部分进行相同的更改是我们需要进行一些重构的明显迹象。

让我们创建一个get_value_with_fallback()函数,它在更抽象的层面上实现了我们的回退逻辑。将新函数添加到headlines.py文件中,并从home()函数中调用它,如下所示:

def get_value_with_fallback(key):
    if request.args.get(key):
        return request.args.get(key)
    if request.cookies.get(key):
        return request.cookies.get(key)
    return DEFAULTS[key]

@app.route("/")
def home():
    # get customised headlines, based on user input or default
    publication = get_value_with_fallback("publication")
    articles = get_news(publication)

    # get customised weather based on user input or default
    city = get_value_with_fallback("city")
    weather = get_weather (city)

    # get customised currency based on user input or default
    currency_from = get_value_with_fallback("currency_from")
    currency_to = get_value_with_fallback("currency_to")
    rate, currencies = get_rate(currency_from, currency_to)

    # save cookies and return template
    response = make_response(render_template("home.html", articles=articles, weather=weather, currency_from=currency_from, currency_to=currency_to, rate=rate, currencies=sorted(currencies)))
    expires = datetime.datetime.now() + datetime.timedelta(days=365)
    response.set_cookie("publication", publication, expires=expires)
    response.set_cookie("city", city, expires=expires)
    response.set_cookie("currency_from", currency_from, expires=expires)
    response.set_cookie("currency_to", currency_to, expires=expires)
    return response

现在,我们应该能够以任何顺序提交表单,并且所有的选项都能被记住,就像我们期望的那样。此外,每当我们访问我们的网站时,它都会自动配置为我们最近使用的选项。试一试吧!您应该能够搜索货币、天气和头条新闻;然后关闭浏览器;再次访问网站。您最近使用的输入应该默认显示出来。

在下面的截图中,我们可以看到 URL 中没有传递任何参数,但我们正在显示南非伊丽莎白港的天气数据;从人民币CNY)到圣赫勒拿镑SHP)的货币数据;以及来自福克斯新闻的头条新闻。

检索其他数据的 cookies

向我们的 Headlines 应用程序添加 CSS

我们的网站仍然相当简陋。有很多白色和一些黑色。大多数用户更喜欢颜色、动画、边框、边距等。如前所述,我们现在不会真正关注美学,但我们会添加一些基本的颜色和样式。

外部、内部和内联 CSS

CSS 可以以几种方式添加到网页中。最好的方法是将其与 HTML 完全分开,并将其保存在一个外部文件中,该文件在 HTML 中通过<link>元素包含。这有时被称为外部 CSS。最糟糕的方法被称为内联 CSS。使用内联方法,CSS 是根据每个元素定义的;这被认为是不好的做法,因为对样式的任何更改都需要在 HTML 中查找相关部分。

此外,页面上的许多元素通常具有相同或至少相关的样式,以保持整个站点的颜色方案和样式。因此,使用内联样式通常会导致大量的代码重复,我们知道要避免这种情况。

对于这个项目,我们将采取一个折中的方法。我们将保持我们在.html模板文件中定义的 CSS,但我们将把它们都定义在一个地方。这是因为我们还没有看过 Flask 如何按照惯例处理文件,所以现在把所有的代码放在一个地方更简单。

添加我们的第一个 CSS

CSS 非常简单;我们将通过类型、ID、类等描述页面的元素,并为这些元素定义许多属性,如颜色、布局、填充、字体等。CSS 被设计为级联,也就是说,如果我们没有为更具体的元素指定属性,它将自动继承为更一般的元素定义的属性。我们将快速浏览 CSS 本身,所以如果您以前从未听说过它,并且想了解更多关于它的信息,现在是一个适当的时机休息一下,查看一些特定于 CSS 的资源。在线有很多这样的资源,一个快速搜索就会揭示出来;如果您喜欢我们之前提到的 W3Schools HTML 教程,您可以在这里找到类似的 CSS 教程www.w3schools.com/css/。或者,通过接下来的示例和简要解释来深入了解!

首先,让我们为我们的网站添加一个更好的标题。我们将在顶级标题下方添加一个标语,并用一个新的<div>标签将其包围起来,以便我们可以在即将到来的 CSS 中修改整个标题。修改home.html模板的开头如下所示:

<div id="header">
    <h1>Headlines</h1>
 <p>Headlines. Currency. Weather.</p>
 <hr />
</div>

<div>标签本身并没有做任何事情,您可以将其视为一个容器。我们可以使用它将逻辑相关的元素分组到同一个元素中,这对于 CSS 非常有用,因为我们可以一次性地为<div>标签中的所有元素设置样式。

CSS 应该添加到我们模板的<head>部分中的<style>标签中。在我们的home.html模板中的<title>标签下面,添加以下代码:

<style>
html {
    font-family: "Helvetica";
    background: white;
}

body {
    background: lightgrey;
    max-width: 900px;
    margin: 0 auto;
}

#header {
    background: lightsteelblue;
}
</style>

我们明确定义了三个元素的样式:外部<html>元素,<body>元素和具有id="header"属性的任何元素。由于我们所有的元素都在<html>元素内部,字体会自动向下级元素级联(尽管仍然可以被子元素显式覆盖)。我们将页面中所有可见项包含在<body>元素中,并将其最大宽度设置为 900 像素。margin: 0 auto;表示<body>顶部和底部没有边距,左右两侧有自动边距。这会使页面上的所有内容居中。background: white;background: lightgrey;表示我们将在较大的窗口内有一个居中的主要元素,其背景为浅灰色,而窗口本身为白色。最后,我们定义的头部div将具有浅钢蓝色的背景。保存添加样式的页面并刷新以查看效果。它应该看起来类似于以下图片:

添加我们的第一个 CSS

让我们看看如何在下一节中改善美学。

浏览器和缓存

提示

浏览器通常会在本地缓存不经常更改的内容,以便在下次访问页面时更快地显示页面。这对开发来说并不理想,因为您希望在进行更改时看到更改。如果您的样式似乎没有达到您的预期,请清除浏览器的缓存,然后重试。在大多数浏览器上,可以通过按下Ctrl + Shift + ESC并从弹出的菜单中选择相关选项来完成此操作。

向我们的 CSS 添加填充

这比白色背景上的黑色略有趣,但仍然相当丑陋。一个问题是文本紧贴着颜色的边缘,没有任何空间。我们可以使用CSS 填充来解决这个问题,它可以通过指定的数量从顶部、右侧、底部、左侧或任何组合移动所有内容。

我们可以直接向我们的<body>标签添加填充,因为我们希望所有文本都有一个漂亮的左侧缓冲区。如果您尝试这样做,您会看到一个直接的问题;填充会影响所有内容,包括我们的<div>头部和将其与其余内容分隔开的<hr>标签,这意味着会有一条我们不想要的奇怪的灰色条纹。我们将以一种您很快会用于几乎所有与 CSS 相关的事情的方式来解决这个问题——只需添加更多的 div!我们需要一个main<div>头部,围绕所有的子标题和一个内部头部 div,这样我们就可以填充头部的文本,而不填充背景颜色或分隔符。

在我们的 CSS 中添加更多样式

将以下部分添加到您的 CSS 中,为我们的主要和内部头部 div 定义左侧填充,并更新#header部分以包括一些顶部填充:

#header {
  padding-top: 5;
  background: lightsteelblue;
}
#inner-header {
  padding-left: 10;
}
#main{
  padding-left: 10;
}

将 div 标签添加到模板文件

现在,让我们添加 div 本身;home.html中的模板代码应更新为如下所示:

    <body>
        <div id="header">
            <div id="inner-header">
                <h1>Headlines</h1>
                <p>Headlines. Currency. Weather.</p>
             </div>
           <hr />
        </div>
        <div id="main">
            <h2>Current weather</h2>

... [ rest of the content code here ] ...

            {% endfor %}
        </div>
    </body>

为我们的输入添加样式

这使得布局看起来更加愉悦,因为文本看起来不像是试图溜走。接下来的一个主要问题是我们的输入元素,它们非常无聊。让我们也为它们添加一些样式。在我们迄今为止的 CSS 底部,添加以下文本:

input[type="text"], select {
    color: grey;
    border: 1px solid lightsteelblue;
    height: 30px;
    line-height:15px;
    margin: 2px 6px 16px 0px;
}
input[type="submit"] {
    padding: 5px 10px 5px 10px;
    color: black;
    background: lightsteelblue;
    border: none;
    box-shadow: 1px 1px 1px #4C6E91;
}
input[type="submit"]:hover{
    background: steelblue;
}

第一部分样式化了我们的文本输入和选择(即下拉)元素。文本颜色是灰色,它有一个与我们标题相同颜色的边框,我们将通过高度和行高使它们比以前的默认值稍微大一点。我们还需要调整边距,使文本更自然地适应新的大小(如果你感兴趣,可以在第一部分的底部留出边距行,看看结果)。第二和第三部分是为了美化我们的提交按钮;一个是定义它们通常的外观,另一个是定义当鼠标移动到它们上面时的外观。再次保存这些更改并刷新页面,看看它们的外观。你应该看到类似以下截图的东西。

美化我们的输入

最终结果仍然不会赢得任何设计奖,但至少你已经学会了 CSS 的基础知识。设计网页最令人沮丧的部分之一是,每个浏览器对 CSS 的解释略有不同(或在某些情况下,差异很大)。跨浏览器测试和验证是每个网页开发人员的死敌,在后面的章节中,我们将看一些工具和框架,可以用来减轻由于潜在的不一致性而引起的问题。

摘要

在本章中,我们使我们的网站在功能上更加用户友好(通过使用 cookie 记住用户的选择)和美观(使用 CSS)。在以后的项目中,我们将回到这两个主题,其中我们将使用 cookie 允许用户登录和一些更高级的 CSS。这是我们 Headlines 项目的结束;我们有一个可以显示新闻、天气和货币信息的 Headlines 应用程序。

在下一章中,我们将开始建立一个新项目:一个交互式犯罪地图。

第六章:构建交互式犯罪地图

我们的第一个项目明显缺乏任何形式的长期存储。虽然我们通过使用 cookie 来模拟长期存储来解决问题,但我们也看到了这些方法的局限性。在这个项目中,我们将构建一个交互式犯罪地图,允许用户标记见证或经历的犯罪活动的位置细节。由于我们希望长期保留数据并使其对许多用户可用,我们不能依赖于用户的本地和临时存储。

因此,我们项目的第一步将是在我们的 VPS 上设置一个 MySQL 数据库,并将其与一个新的 Flask Web 应用程序进行链接。我们将使用 Google Maps API 允许用户查看我们的地图并向其添加新的标记(其中每个标记代表一种犯罪)。

我们的新项目将具有比我们以前的项目更高级的用户输入,允许用户过滤他们对地图的视图,并向地图添加相当复杂的数据。因此,我们将更加关注输入验证和净化。

我们项目的目标是创建一个包含交互地图的网页。用户应该能够通过选择地图上的位置并输入犯罪的日期、类别和描述来提交新的犯罪。用户还应该能够查看地图上以图标形式记录的所有先前记录的犯罪,并通过选择地图上相关图标来查看任何特定犯罪的更多细节。地图的目的是能够轻松查看犯罪率高的地区,以及帮助调查人员发现犯罪的模式和趋势。

本章的相当大一部分内容都是关于在我们的 VPS 上设置 MySQL 数据库并为犯罪数据创建数据库。接下来我们将设置一个包含地图和文本框的基本页面。我们将看到如何通过将输入到文本框中的数据存储到我们的数据库中,将 Flask 与 MySQL 进行链接。

与上一个项目一样,我们将避免在“现实世界”项目中几乎肯定会使用的框架和自动化工具。由于我们将专注于学习,较低级别的抽象是有用的。因此,我们不会为我们的数据库查询使用对象关系映射(ORM),也不会为用户输入和交互使用 JavaScript 框架。这意味着会有一些繁琐的 SQL 和纯 JavaScript 编写,但在盲目使用这些工具和框架之前,充分理解它们存在的原因以及它们解决的问题是非常重要的。

在本章中,我们将涵盖:

  • 设置一个新的 Git 存储库

  • 理解关系数据库

  • 在我们的 VPS 上安装和配置 MySQL

  • 在 MySQL 中创建我们的犯罪地图数据库

  • 创建一个基本的数据库 Web 应用程序

设置一个新的 Git 存储库

我们将为我们的新代码库创建一个新的 Git 存储库,因为尽管一些设置将是相似的,但我们的新项目应该与我们的第一个项目完全无关。如果您需要更多关于此步骤的帮助,请返回到第一章,“你好,世界!”并按照“安装和使用 Git”部分中的详细说明进行操作。如果您感到自信,请检查您是否可以仅使用以下摘要完成此操作:

  • 前往 Bitbucket、GitHub 或您用于第一个项目的任何托管平台的网站。登录并创建一个新的存储库

  • 将您的存储库命名为crimemap并记下您收到的 URL

  • 在您的本地计算机上,打开终端并运行以下命令:

mkdir crimemap
cd crimemap
git init
git remote add origin <git repository URL>

我们将暂时将此存储库保留为空,因为我们需要在我们的 VPS 上设置一个数据库。一旦我们安装了数据库,我们将回到这里设置我们的 Flask 项目。

理解关系数据库

在其最简单的形式中,关系数据库管理系统,如 MySQL,就是一个类似于 Microsoft Excel 的高级电子表格程序。我们用它来以行和列的形式存储数据。每一行都是一个“thing”,每一列都是有关相关行中“thing”的特定信息。我在“thing”中加了引号,因为我们不仅仅局限于存储对象。事实上,在现实世界和解释数据库中,关于人的数据是最常见的“thing”。一个关于电子商务网站客户信息的基本数据库可能看起来类似于以下内容:

ID 电子邮件地址 电话
1 弗罗多 巴金斯 fbaggins@example.com +1 111 111 1111
2 比尔博 巴金斯 bbaggins@example.com +1 111 111 1010
3 山姆怀斯 甘吉 sgamgee@example.com +1 111 111 1001

如果我们从左到右查看单行,我们将得到有关一个人的所有信息。如果我们从上到下查看单列,我们将得到每个人的一条信息(例如,电子邮件地址)。这两种方式都很有用;如果我们想要添加一个新的人或联系特定的人,我们可能会对特定行感兴趣。如果我们想向所有客户发送通讯,我们只对电子邮件列感兴趣。

那么,为什么我们不能只使用电子表格而不是数据库呢?嗯,如果我们进一步考虑电子商务店的例子,我们很快就会看到限制。如果我们想要存储我们提供的所有物品的清单,我们可以创建另一个类似于前面的表,其中包含Item nameDescriptionPriceQuantity in stock等列。我们的模型仍然很有用;然而,现在,如果我们想要存储弗罗多曾经购买的所有物品的清单,就没有一个合适的地方来存放这些数据。我们可以在我们的客户表中添加 1000 列(如前所示),比如Purchase 1Purchase 2,一直到Purchase 1000,并希望弗罗多永远不会购买超过 1000 件物品。这既不可扩展,也不容易处理。我们如何获取弗罗多上周二购买的物品的描述?我们只是将name项存储在我们的新列中吗?那些没有唯一名称的物品会发生什么?

很快,我们会意识到我们需要反向思考。我们需要创建一个名为Orders的新表,将每个订单中购买的物品存储在Customers表中,同时在每个订单中存储对客户的引用。因此,一个订单“知道”它属于哪个客户,但客户本身并不知道属于他/她的订单。

尽管我们的模型仍然可以勉强放入电子表格中,但随着我们的数据模型和规模的增长,我们的电子表格变得更加繁琐。我们需要进行复杂的查询,比如“我想看到所有库存中的物品,过去六个月至少被订购一次,并且价格超过 10 美元的物品”。

进入关系数据库管理系统RDBMS)。它们已经存在了几十年,是一种经过验证的解决常见问题的方法(例如以有组织和可访问的方式存储具有复杂关系的数据)。我们不会在我们的犯罪地图中涉及它们的全部功能(事实上,如果需要,我们可能可以将我们的数据存储在文本文件中),但如果你有兴趣构建 Web 应用程序,你将在某个时候需要一个数据库。因此,让我们从小处着手,将强大的 MySQL 工具添加到我们不断增长的工具箱中。

我强烈建议您了解更多关于数据库的知识!如果您对构建我们当前项目的经验感兴趣,那就去阅读和学习关于数据库的知识吧。关系数据库管理系统的历史很有趣,而规范化和数据库种类的复杂性和微妙性(包括我们下一个项目中将会看到的 NoSQL 数据库)值得更多的学习时间,而这本书专注于 Python web 开发,我们无法花太多时间在这方面。

在我们的 VPS 上安装和配置 MySQL

安装和配置 MySQL 是一个非常常见的任务。因此,您可以在预构建的镜像或为您构建整个stacks的脚本中找到它。一个常见的 stack 被称为LAMP stack,代表LinuxApacheMySQLPHP,许多 VPS 提供商提供一键式的 LAMP stack 镜像。

由于我们将使用 Linux 并且已经手动安装了 Apache,在安装 MySQL 后,我们将非常接近传统的 LAMP stack;我们只是使用 P 代替 PHP。为了符合我们“教育第一”的目标,我们将手动安装 MySQL,并通过命令行进行配置,而不是安装 GUI 控制面板。如果您以前使用过 MySQL,请随意按照您的意愿进行设置。

MySQL 和 Git

注意

请记住,我们的 MySQL 设置和我们存储在其中的数据都不是我们 Git 存储库的一部分。任何在数据库级别上的错误,包括错误配置或删除数据,都将更难以撤消。

在我们的 VPS 上安装 MySQL

在我们的服务器上安装 MySQL 非常简单。通过 SSH 登录到您的 VPS 并运行以下命令:

sudo apt-get update
sudo apt-get install mysql-server

您应该看到一个界面提示您输入 MySQL 的 root 密码。在提示时输入密码并重复输入。安装完成后,您可以通过输入以下内容获得一个实时的 SQL shell:

mysql –p

然后,在提示时输入您之前选择的密码。我们可以使用这个 shell 创建数据库和模式,但我们宁愿通过 Python 来做这件事;所以,如果您打开了 MySQL shell,请输入quit并按下Enter键来终止它。

为 MySQL 安装 Python 驱动程序

由于我们想要使用 Python 来访问我们的数据库,我们需要安装另一个软件包。Python 有两个主要的 MySQL 连接器:PyMySQLMySQLdb。从简单性和易用性的角度来看,第一个更可取。它是一个纯 Python 库,这意味着它没有依赖性。MySQLdb 是一个 C 扩展,因此有一些依赖性,但理论上它会更快一些。一旦安装,它们的工作方式非常相似。在本章的示例中,我们将使用 PyMySQL。

要安装它,请在您的 VPS 上运行以下命令:

pip install --user pymysql

在 MySQL 中创建我们的犯罪地图数据库

对 SQL 语法的一些了解将对本章的其余部分有所帮助,但您应该能够跟上。我们需要做的第一件事是为我们的 Web 应用程序创建一个数据库。如果您习惯使用命令行编辑器,您可以直接在 VPS 上创建以下脚本,这样可以更容易调试,而且我们不会在本地运行它们。然而,在 SSH 会话中进行开发远非理想;因此,我建议您在本地编写它们,并使用 Git 在运行之前将它们传输到服务器上。

这可能会使调试有点令人沮丧,因此在编写这些脚本时要特别小心。如果您愿意,您可以直接从本书附带的代码包中获取它们。在这种情况下,您只需要正确填写dbconfig.py文件中的用户和密码字段,一切都应该正常工作。

创建一个数据库设置脚本

在本章开始时我们初始化 Git 存储库的crimemap目录中,创建一个名为db_setup.py的 Python 文件,其中包含以下代码:

import pymysql
import dbconfig
connection = pymysql.connect(host='localhost',
                             user=dbconfig.db_user,
                             passwd=dbconfig.db_password)

try:
        with connection.cursor() as cursor:
                sql = "CREATE DATABASE IF NOT EXISTS crimemap"
                cursor.execute(sql)
                sql = """CREATE TABLE IF NOT EXISTS crimemap.crimes (
id int NOT NULL AUTO_INCREMENT,
latitude FLOAT(10,6),
longitude FLOAT(10,6),
date DATETIME,
category VARCHAR(50),
description VARCHAR(1000),
updated_at TIMESTAMP,
PRIMARY KEY (id)
)"""
                cursor.execute(sql);
        connection.commit()
finally:
        connection.close()

让我们看看这段代码做了什么。首先,我们导入了刚刚安装的PyMySQL库。我们还导入了dbconfig,稍后我们将在本地创建并填充数据库凭据(我们不希望将这些凭据存储在我们的存储库中)。然后,我们将使用localhost(因为我们的数据库安装在与我们的代码相同的机器上)和尚不存在的凭据创建到我们的数据库的连接。

现在我们已经连接到我们的数据库,我们可以获取一个游标。您可以将游标想象成文字处理器中的闪烁对象,指示当您开始输入时文本将出现的位置。数据库游标是一个指向数据库中我们想要创建、读取、更新或删除数据的位置的对象。一旦我们开始处理数据库操作,就会出现各种异常。我们始终希望关闭与数据库的连接,因此我们将在try块中创建一个游标(并执行所有后续操作),并在finally块中使用connection.close()finally块将在try块成功与否时执行)。

游标也是一个资源,所以我们将获取一个并在with:块中使用它,这样当我们完成后它将自动关闭。设置完成后,我们可以开始执行 SQL 代码。

当我们调用cursor.execute()函数时,我们将传入的 SQL 代码将使用数据库引擎运行,并且如果适当的话,游标将被填充结果。我们将在后面讨论如何使用游标和execute()函数读取和写入数据。

创建数据库

SQL 读起来与英语类似,因此通常很容易弄清楚现有的 SQL 代码的作用,即使编写新代码可能有点棘手。我们的第一个 SQL 语句将创建一个crimemap数据库(如果尚不存在),这意味着如果我们回到这个脚本,我们可以保留这行而不必每次删除整个数据库。我们将把我们的第一个 SQL 语句作为一个字符串创建,并使用sql变量来存储它。然后,我们将使用我们创建的游标执行该语句。

查看我们的表列

现在我们知道我们有一个数据库,我们可以创建一个表。该表将存储我们记录的所有犯罪的数据,每起犯罪在表的一行中。因此,我们需要几列。我们的create table语句中可以看到每列以及将存储在该列中的数据类型。为了解释这些,我们有:

  • id:这是一个唯一的数字,对于我们记录的每一起犯罪都会自动记录。我们不需要太担心这个字段,因为 MySQL 会在我们每次添加新的犯罪数据时自动插入它,从 1 开始递增。

  • 纬度和经度:这些字段将用于存储每起犯罪的位置。在浮点数后面我们将指定(10, 6),这意味着每个浮点数最多可以有 10 位数字,小数点后最多可以有 6 位数字。

  • 日期:这是犯罪的日期和时间。

  • 类别:我们将定义几个类别来对不同类型的犯罪进行分类。这将有助于以后过滤犯罪。VARCHAR(50)表示这将是可变长度的数据,最长为 50 个字符。

  • 描述:这类似于类别,但最多为 1000 个字符。

  • Updated_at:这是另一个我们不需要担心的字段。当我们插入数据或编辑数据时,MySQL 会将其设置为当前时间。例如,如果我们想要删除特定时间错误插入的一堆数据,这可能会很有用。

索引和提交

我们create table查询的最后一行指定了我们的id列为主键。这意味着它将被索引(因此,如果我们在查询我们的数据库时使用它,我们将能够非常有效地找到数据),并且将具有各种其他有用的属性,比如强制存在和唯一性。

一旦我们定义了这个更复杂的 SQL 片段,我们将在下一行执行它。然后,我们将提交我们对数据库的更改。把这看作是保存我们的更改;如果我们在没有提交的情况下关闭连接,我们的更改将被丢弃。

SQL 提交

提示

忘记提交更改是 SQL 初学者的常见错误。如果您到达一个点,您的数据库表现不如预期,并且您无法弄清楚原因,检查一下您的代码中是否忘记了提交。

使用数据库设置脚本

将我们的脚本保存在本地并推送到存储库。请参考以下命令的顺序:

git add db_setup.py
git commit –m "database setup script"
git push origin master

通过以下命令 SSH 到您的 VPS,并将新存储库克隆到您的/var/www 目录:

ssh user@123.456.789.123
cd /var/www
git clone <your-git-url>
cd crimemap

向我们的设置脚本添加凭据

现在,我们仍然没有我们的脚本依赖的凭据。在使用设置脚本之前,我们将做两件事:

  • 创建dbconfig.py文件,其中包含数据库和密码

  • 将此文件添加到.gitignore中,以防止它被添加到我们的存储库中

使用以下命令在您的 VPS 上直接创建和编辑dbconfig.py文件:

nano dbconfig.py

然后,使用您在安装 MySQL 时选择的密码输入以下内容:

db_user = "root"
db_password = "<your-mysql-password>"

按下Ctrl + X保存,并在提示时输入Y

现在,使用类似的nano命令来创建、编辑和保存.gitignore,其中应包含以下内容:

dbconfig.py
*.pyc

第一行防止我们的dbconfig文件被添加到 Git 存储库中,这有助于防止未经授权使用我们的数据库密码。第二行防止编译的 Python 文件被添加到存储库中,因为这些只是运行时优化,并且与我们的项目相关。

运行我们的数据库设置脚本

完成后,您可以运行:

python db_setup.py

假设一切顺利,现在你应该有一个用于存储犯罪的表的数据库。Python 将输出任何 SQL 错误,允许您在必要时进行调试。如果您从服务器对脚本进行更改,请运行与您从本地机器运行的相同的git addgit commitgit push命令。

git 状态:

提示

您可以从终端运行git status(确保您在存储库目录中)来查看已提交文件的摘要。您现在可以使用这个(在git push之前)来确保您没有提交dbconfig文件。

这就结束了我们的初步数据库设置!现在,我们可以创建一个使用我们的数据库的基本 Flask 项目。

创建一个基本的数据库 Web 应用程序

我们将首先构建我们的犯罪地图应用程序的框架。它将是一个基本的 Flask 应用程序,只有一个页面:

  • 显示我们的数据库中crimes表中的所有数据

  • 允许用户输入数据并将这些数据存储在数据库中

  • 有一个清除按钮,可以删除之前输入的所有数据

尽管我们将存储和显示的内容现在还不能真正被描述为犯罪数据,但我们将把它存储在我们之前创建的crimes表中。我们现在只使用description字段,忽略所有其他字段。

设置 Flask 应用程序的过程与我们之前所做的非常相似。我们将把数据库逻辑分离到一个单独的文件中,留下我们的主要crimemap.py文件用于 Flask 设置和路由。

设置我们的目录结构

在您的本地机器上,切换到crimemap目录。如果您在服务器上创建了数据库设置脚本或对其进行了任何更改,请确保将更改同步到本地。然后,通过运行以下命令(或者如果您愿意,使用 GUI 文件浏览器)创建templates目录并触摸我们将使用的文件:

cd crimemap
git pull origin master
mkdir templates
touch templates/home.html
touch crimemap.py
touch dbhelper.py

查看我们的应用程序代码

将以下代码添加到crimemap.py文件中。这里没有什么意外的内容,应该都是我们在 Headlines 项目中熟悉的。唯一需要指出的是DBHelper()类,我们将在下一步考虑它的代码。我们将在初始化应用程序后简单地创建一个全局的DBHelper实例,然后在相关方法中使用它来从数据库中获取数据,将数据插入数据库,或者从数据库中删除所有数据:

from dbhelper import DBHelper
from flask import Flask
from flask import render_template
from flask import request

app = Flask(__name__)
DB = DBHelper()

@app.route("/")
def home():
    try:
        data = DB.get_all_inputs()
    except Exception as e:
        print e
        data = None
    return render_template("home.html", data=data)

@app.route("/add", methods=["POST"])
def add():
  try:
    data = request.form.get("userinput")
    DB.add_input(data)
  except Exception as e:
    print e
  return home()

@app.route("/clear")
def clear():
  try:
    DB.clear_all()
  except Exception as e:
    print e
  return home()

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

查看我们的 SQL 代码

从我们的数据库辅助代码中还有一些 SQL 需要学习。将以下代码添加到dbhelper.py文件中:

import pymysql
import dbconfig

class DBHelper:

  def connect(self, database="crimemap"):
    return pymysql.connect(host='localhost',
              user=dbconfig.db_user,
              passwd=dbconfig.db_password,
              db=database)

  def get_all_inputs(self):
  connection = self.connect()
    try:
      query = "SELECT description FROM crimes;"
      with connection.cursor() as cursor:
        cursor.execute(query)
      return cursor.fetchall()
    finally:
      connection.close()

  def add_input(self, data):
    connection = self.connect()
    try:
      # The following introduces a deliberate security flaw. See section on SQL injection below
      query = "INSERT INTO crimes (description) VALUES ('{}');".format(data)
      with connection.cursor() as cursor:
        cursor.execute(query)
        connection.commit()
    finally:
      connection.close()

  def clear_all(self):
    connection = self.connect()
    try:
      query = "DELETE FROM crimes;"
      with connection.cursor() as cursor:
        cursor.execute(query)
        connection.commit()
    finally:
      connection.close()

就像在我们的设置脚本中一样,我们需要与数据库建立连接,然后从连接中获取一个游标以执行有意义的操作。同样,我们将在try: finally:块中执行所有操作,以确保连接被关闭。

在我们的辅助程序中,我们将考虑四个主要数据库操作中的三个。CRUD创建,读取,更新删除)描述了基本的数据库操作。我们要么创建和插入新数据,读取现有数据,修改现有数据,或者删除现有数据。在我们的基本应用程序中,我们不需要更新数据,但创建,读取和删除肯定是有用的。

读取数据

让我们从阅读开始,假设我们的数据库中已经有一些数据了。在 SQL 中,这是使用SELECT语句来完成的;我们将根据一组条件选择要检索的数据。在我们的情况下,get_all_inputs函数中的查询是SELECT description FROM crimes;。稍后我们会看一下如何完善SELECT查询,但这个查询只是获取我们crimes表中每一行的description字段。这类似于我们在本章开头讨论的例子,那时我们想要发送一封新闻简报,需要每个客户的电子邮件地址。在这里,我们想要每个犯罪的描述。

一旦游标执行了查询,它将指向一个包含结果的数据结构的开头。我们将在游标上执行fetchall(),将我们的结果集转换为列表,以便我们可以将它们传回我们的应用程序代码。(如果你在 Python 中使用了生成器,可能会觉得数据库游标就像一个生成器。它知道如何遍历数据,但它本身并不包含所有数据)。

插入数据

接下来是我们的add_input()函数。这个函数会获取用户输入的数据,并将其插入数据库中。在 SQL 中,使用INSERT关键字来创建数据。我们的查询(假设foobar是我们传入的数据)是INSERT into crimes (description) VALUES ('foobar')

这可能看起来比实际做的事情要复杂,但请记住,我们仍然只处理一个字段(描述)。我们稍后会讨论INSERT是如何设计来接受多个但是任意列的,这些列可以在第一组括号中命名,然后为每个列提供匹配的值,在VALUES之后的第二组括号中给出。

由于我们对数据库进行了更改,我们需要提交我们的连接以使这些更改永久化。

删除数据

最后,我们将看一下 SQL 中DELETE语句有多简洁。DELETE FROM crimes会清除我们crimes数据库中的所有数据。稍后我们会考虑如何通过指定条件来删除部分数据,使这个关键字的行为不那么像核武器。

同样,这会对我们的数据库进行更改,所以我们需要提交这些更改。

如果所有新的 SQL 命令似乎太多了,那就去在线沙盒或者我们之前讨论过如何访问的实时 SQL shell 中玩一下。你会发现,SQL 在一段时间后会变得非常自然,因为它的大部分关键词都来自自然语言,而且它使用的符号非常少。

最后,让我们来看一下我们的 HTML 模板。

创建我们的视图代码

Python 和 SQL 编写起来很有趣,它们确实是我们应用程序的主要部分。但是,目前我们有一个没有门或窗户的房子;困难和令人印象深刻的部分已经完成,但它是不可用的。让我们添加一些 HTML 代码,以便世界可以与我们编写的代码进行交互。

templates/home.html中,添加以下内容:

<html>
<body>
  <head>
    <title>Crime Map</title>
  </head>

  <h1>Crime Map</h1>
  <form action="/add" method="POST">
    <input type="text" name="userinput">
    <input type="submit" value="Submit">
    </form>
  <a href="/clear">clear</a>
  {% for userinput in data %}
    <p>{{userinput}}</p>
    {% endfor %}
</body>
</html>

这里没有我们以前没有见过的东西。在这里,我们有一个带有单个文本输入的表单,通过调用我们应用程序的/add函数向我们的数据库添加数据,并且直接在其下面,我们循环遍历所有现有数据,并在<p>标签中显示每个片段。

在我们的 VPS 上运行代码

最后,我们需要使我们的代码对世界可访问。这意味着将其推送到我们的git存储库,将其拉到 VPS 上,并配置 Apache 进行服务。在本地运行以下命令:

git add .
git commit –m "Skeleton CrimeMap"
git push origin master
ssh <username>@<vps-ip-address>

现在,在您的 VPS 上运行以下命令:

cd /var/www/crimemap
git pull origin master

现在,我们需要一个.wsgi文件将 Python 链接到 Apache,可以通过运行以下命令创建:

nano crimemap.wsgi

.wsgi文件应包含以下内容:

import sys
sys.path.insert(0, "/var/www/crimemap")
from crimemap import app as application

现在,按下Ctrl + X,然后在提示保存时输入Y

我们还需要创建一个新的 Apache.conf文件,并将其设置为默认文件(而不是headlines,即我们当前默认的.conf文件)。运行以下命令创建文件:

cd /etc/apache2/sites-available
nano crimemap.conf

接下来,添加以下代码:

<VirtualHost *>
    ServerName example.com

 WSGIScriptAlias / /var/www/crimemap/crimemap.wsgi
 WSGIDaemonProcess crimemap
 <Directory /var/www/crimemap>
 WSGIProcessGroup crimemap
       WSGIApplicationGroup %{GLOBAL}
        Order deny,allow
        Allow from all
    </Directory>
</VirtualHost>

这与我们为以前的项目创建的headlines.conf文件非常相似,您可能会发现最好只需复制以前的文件并根据需要进行替换。

最后,我们需要停用旧站点并激活新站点,如下所示:

sudo a2dissite headlines.conf
sudo a2ensite crimemap.conf
sudo service apache2 reload

现在,一切应该都正常工作。如果您手动复制了代码,几乎可以肯定会有一两个 bug 需要处理。不要因此而感到沮丧;记住调试预计将成为开发的一个重要部分!如有必要,运行tail –f /var/log/apache2/error.log,同时加载站点以注意任何错误。如果失败,请在crimemap.pydbhelper.py中添加一些打印语句,以缩小故障位置。

一切都正常工作后,您应该能够看到一个带有单个文本输入的网页。当您通过输入提交文本时,您应该能够在页面上看到文本显示,就像以下示例一样:

在我们的 VPS 上运行代码

请注意,我们从数据库获取的数据是一个元组,因此它被括号括起来,并且有一个尾随逗号。这是因为我们只从我们的crimes表中选择了一个字段,'description',而在理论上,我们可能会处理每个犯罪的许多列(很快我们将这样做)。

减轻 SQL 注入

我们的应用程序存在一个致命缺陷。我们从用户那里获取输入,并使用 Python 字符串格式化将其插入到我们的 SQL 语句中。当用户输入正常的字母数字字符串时,这样做效果很好,但是如果用户是恶意的,他们实际上可以注入自己的 SQL 代码并控制我们的数据库。尽管 SQL 注入是一种古老的攻击方式,大多数现代技术都会自动减轻其影响,但每年仍然有数十起针对主要公司的攻击,其中由于 SQL 注入漏洞而泄漏了密码或财务数据。我们将花一点时间讨论什么是 SQL 注入以及如何防止它。

向我们的数据库应用程序注入 SQL

转到我们的 Web 应用程序,点击清除链接以删除任何保存的输入。现在,在输入框中输入Bobby,然后点击提交按钮。页面现在应该类似于以下图片:

向我们的数据库应用程序注入 SQL

在此输入中,现在键入:

'); DELETE FROM crimes; --

所有字符在这里都很重要。

输入需要以单引号开头,后跟一个闭括号,然后是一个分号,然后是删除语句,另一个分号,一个空格,最后是两个破折号。当页面刷新时,您可能期望看到第二行,列出这个看起来奇怪的字符串,位于Bobby输出下面,但实际上,您将看到一个空白页面,看起来类似于下面的屏幕截图:

向我们的数据库应用程序注入 SQL

这很奇怪,对吧?让我们看看发生了什么。在我们的DBHelper类中,我们的插入语句有以下行:

query = "INSERT INTO crimes (description) VALUES ('{}');".format(data)

这意味着用户的输入会在我们运行代码之前添加到 SQL 代码中。当我们将之前使用的看起来奇怪的输入放入 SQL 语句的占位符中时,我们将得到以下字符串:

"INSERT INTO crimes (description) VALUES (''); DELETE FROM crimes; -- ');"

这是两个 SQL 语句而不是一个。我们用一个空值关闭了INSERT语句,然后用DELETE语句删除了crimes表中的所有内容。末尾的两个破折号形成了一个 SQL 注释,这样额外的闭引号和括号就不会引起任何语法错误。当我们输入我们的数据时,我们向数据库插入了一个空行,然后删除了crimes表中的所有数据!

当然,一个有创造力的攻击者可以在我们选择的DELETE语句的位置运行任何 SQL 语句。他们可以删除整个表(参考xkcd.com/327/中的一个幽默的例子),或者他们可以运行一个选择语句来绕过数据库登录功能。或者,如果您存储信用卡信息,类似的攻击可以用来获取数据并将其显示给攻击者。总的来说,我们不希望我们的 Web 应用程序的用户能够在我们的数据库上运行任意代码!

防止 SQL 注入

防范 SQL 注入涉及对用户输入进行消毒,并确保如果用户输入可能被解释为 SQL 语法的特殊字符,则忽略这些字符。有不同的方法可以做到这一点,我们将使用我们的 Python SQL 库自动提供的一个简单方法。有关此主题的更全面信息,请参阅www.owasp.org/index.php/SQL_Injection_Prevention_Cheat_Sheet

dbhelper.py文件中,将add_input()方法更改为以下内容:

def add_input(self, data):
    connection = self.connect()
  try:
      query = "INSERT INTO crimes (description) VALUES (%s);"
      with connection.cursor() as cursor:
          cursor.execute(query, data)
          connection.commit()
      finally:
          connection.close()

我们在这里使用的%s标记是一个字符串占位符,类似于%d,它在普通 Python 字符串中用作占位符,也是大括号的旧替代方法。但是,我们不会使用 Python 的str.format()函数,而是将要插入到占位符中的字符串和值传递给 PyMySQL 的cursor.execute()函数。这将自动转义所有对 SQL 有意义的字符,这样我们就不必担心它们被执行。

现在,如果您再次尝试输入,您将看到它们按预期显示-包括特殊字符-如下面的屏幕截图所示:

防范 SQL 注入

在本书的最后一章中,我们将简要讨论可以提供更强大防范 SQL 注入攻击的 ORM 技术。虽然似乎我们通过转义一些特殊字符解决了一个简单的问题,但实际上可能会变得相当微妙。诸如sqlmapsqlmap.org/)之类的工具可以尝试对相同的想法(即输入特殊字符针对数据库)进行数百种不同的变体,直到找到意外的结果并发现漏洞。请记住,为了使您的应用程序安全,它必须受到对每种可能的漏洞的保护;而要使其不安全,它只需要对一个漏洞进行攻击。

摘要

这就是我们犯罪地图项目介绍的全部内容。我们讨论了如何在我们的 VPS 上安装 MySQL 数据库以及如何将其连接到 Flask。我们看了看如何创建、读取、更新和删除数据,并创建了一个基本的数据库 Web 应用程序,可以接受用户输入并再次显示出来。最后,我们看了看 SQL 注入漏洞以及如何保护自己免受其影响。

接下来,我们将添加一个谷歌地图小部件和一些更好的美学设计。

第七章:将 Google 地图添加到我们的犯罪地图项目

在上一章中,我们设置了一个数据库,并讨论了如何通过 Flask 向其中添加和删除数据。现在有了一个可以进行长期存储的输入和输出的网络应用程序,我们现在拥有了几乎所有网络应用程序所需的基本组件,只受我们想象力的限制。

在本章中,我们将比上一章的纯文本界面添加更多功能;我们将添加嵌入式 Google 地图,允许用户以直观的方式查看和选择地理坐标。

Google Maps 是用 JavaScript 编写的,我们需要编写一些 JavaScript 代码来适应我们的需求。与往常一样,我们将为以前从未使用过 JavaScript 的读者做一个快速教程,但如果您有兴趣巩固您的全面网络应用知识,现在是快速浏览一些特定于 JavaScript 的教程的好时机。如果您以前从未见过任何 JavaScript 代码,可以在www.w3schools.com/js/default.asp找到一个类似于我们之前提供链接的 HTML 和 CSS 教程的简单介绍。

可以说,犯罪地图最重要的部分是地图本身。我们将使用 Google Maps API,这对开发人员来说简单而强大,对用户来说直观。作为第一步,我们将只添加一个基本地图,加载到我们选择的区域和缩放级别。一旦我们完成了这一步,我们将添加功能以允许标记。标记对我们的地图有两个目的:首先,我们将在地图上显示我们在数据库中保存的每起犯罪的位置;其次,当用户点击地图时,它将添加一个新的标记,并允许用户提交新的犯罪报告(最终通过在表单字段中添加描述和日期)。

然而,首先我们需要能够再次在本地运行我们的应用程序进行开发和调试。将其链接到数据库,这有点棘手;因此,我们将看看如何解决这个常见问题。

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

  • 在本地运行数据库应用程序

  • 将嵌入式 Google 地图小部件添加到我们的应用程序

  • 为新犯罪添加一个输入表单

  • 在地图上显示现有的犯罪

在本地运行数据库应用程序

为了在本地开发和调试,我们需要能够运行应用程序。然而,目前这是不可能的,因为 MySQL 只安装在我们的 VPS 上。有三种主要选项来在本地开发我们的数据库应用程序:

  • 即使在本地机器上运行 Flask,也要连接到我们 VPS 上的数据库

  • 在本地机器上安装 MySQL

  • 使用 Python 在内存中创建我们数据库的“模拟”

虽然任何一个都可以工作,但我们将选择第三个选项。连接到我们的生产数据库会导致我们受到延迟的影响,如果我们在离我们的 VPS 很远的地方开发,这也意味着我们将对我们的生产数据库运行测试代码,这绝不是一个好主意。第二个选项将限制我们开发环境的可移植性,增加切换到新开发环境时的设置时间,并且在最坏的情况下,会消耗大量的本地资源。

创建我们数据库的模拟

如果您尝试在本地运行crimemap.py文件,您将看到的第一个错误是ImportError,因为我们没有dbconfig.py文件。在上一章中,我们直接在我们的 VPS 上创建了这个文件,并且没有将其检入 git,因为它包含敏感的数据库凭据。我们将创建dbconfig.py的本地副本,这表明我们的应用程序应该使用模拟数据库。我们将在我们的 VPS 上更新dbconfig.py文件,以指示在那里运行应用程序时应使用真实的数据库。我们将使用一个简单的布尔标志来实现这一点。

添加一个测试标志

在您的本地crimemap目录中,创建一个新的dbconfig.py文件,并添加一行代码:

test = True

现在,SSH 进入您的 VPS,并将标志添加到生产配置中;尽管这里,值应设置为False,如下所示:

ssh user@123.456.789.123
cd /var/www/crimemap
nano dbconfig.py

在文件顶部添加以下内容:

test = False

然后,键入Ctrl + X,然后Y保存并退出文件

现在,退出 SSH 会话。这将解决ImportErrordbconfig.py文件现在存在于我们的 VPS 和本地),并且我们的应用程序现在知道它是在测试还是生产环境中运行。

编写模拟代码

尽管我们的标志目前实际上并没有做任何事情,我们也不想在测试应用程序时触发所有的异常。相反,我们将编写我们数据库代码的“模拟”(dbhelper.py文件中的代码),它将返回基本静态数据或None。当我们的应用程序运行时,它将能够正常调用数据库函数,但实际上并没有数据库。相反,我们将有几行 Python 来模拟一个非常基本的数据库。在您的crimemap目录中创建mockdbhelper.py文件,并添加以下代码:

class MockDBHelper:

  def connect(self, database="crimemap"):
    pass

  def get_all_inputs(self):
    return []

  def add_input(self, data):
    pass

  def clear_all(self):
    pass

正如您所注意到的,我们用于基本数据库应用程序的方法都存在,但并没有做任何事情。get_all_inputs()方法返回一个空列表,我们仍然可以将其传递给我们的模板。现在,我们只需要告诉我们的应用程序在测试环境中使用这个方法,而不是真正的DBHelper类。在crimemap.py的导入部分的末尾添加以下代码,确保删除现有的import for DBHelper

import dbconfig
if dbconfig.test:
    from mockdbhelper import MockDBHelper as DBHelper
else:
    from dbhelper import DBHelper

我们使用dbconfig中的测试标志来指定是否导入真正的DBHelper(它依赖于与 MySQL 的连接)或导入模拟的DBHelper(它不需要数据库连接)。如果我们导入模拟助手,我们可以更改名称,以便代码的其余部分可以继续运行而无需对测试标志进行条件检查。

验证我们的期望

现在,您应该能够像以前添加数据库依赖项之前一样在本地运行代码。在您的终端中运行:

python crimemap.py

然后,在浏览器中访问localhost:5000,查看您的应用程序加载情况。检查终端的输出,确保没有触发异常(如果您尝试运行真正的DBHelper代码而不是我们刚刚制作的模拟代码,就会触发异常)。尽管我们的应用程序不再“工作”,但至少我们可以运行它来测试不涉及数据库的代码。然后,当我们部署到生产环境时,一切应该与我们的测试一样正常工作,但实际上插入了一个真正的数据库。

将嵌入式谷歌地图小部件添加到我们的应用程序

现在,我们想要在我们的应用程序中添加地图视图,而不是基本输入框。谷歌地图允许您创建地图而无需注册,但您只能进行有限次数的 API 调用。如果您创建了这个项目,在网上发布了一个链接,并且它变得火爆,您有可能达到限制(目前每天最多 2500 次地图加载)。如果您认为这将是一个限制因素,您可以注册地图 API,并有选择向谷歌支付更多容量。然而,免费版本对于开发甚至生产来说都足够了,如果您的应用程序不太受欢迎的话。

将地图添加到我们的模板

我们想在我们应用程序的主页上显示地图,这意味着编辑我们templates目录中的home.html文件中的代码。删除所有现有代码,并用以下代码替换:

<!DOCTYPE html>
<html lang="en">
  <head>
    <script type="text/javascript"
      src="img/js">
    </script>

    <script type="text/javascript">
      function initialize() { 
        var mapOptions = {
          center: new google.maps.LatLng(- 33.30578381949298, 26.523442268371582),
          zoom: 15
        };
        var map = new
        google.maps.Map(document.getElementById("map- canvas"),mapOptions);
      }
     </script>

  </head>
    <body onload="initialize()">
    <div id="map-canvas" style="width:80%; height:500px;"></div>
    </body>
</html>

引入 JavaScript

让我们看看这里发生了什么。第一行告诉我们的用户浏览器,我们正在使用 HTML5。第 4 到 6 行包括我们页面中需要的地图资源。请注意,这是在<script>标签之间,表示这是 JavaScript。在这种特殊情况下,我们实际上并没有编写 JavaScript 代码 - 我们只是链接到它托管在谷歌服务器上的位置。把它想象成 Python 的import语句,除了我们甚至不需要在本地安装包;它只是在您的用户浏览器运行时“导入”。

紧随其后的是我们的设置脚本,用于显示基本地图。同样,这是在<script>标签之间,以表明这是 JavaScript 而不是 HTML。尽管在括号、大括号和for循环方面,它的语法与 Java 类似,但除此之外,它与 Java 之间几乎没有关系。

我们的 JavaScript 代码的第一行是一个函数定义;类似于 Python 的“def”,我们使用function关键字来定义一个名为initialise()的新函数。我们声明了一个变量var mapOptions =,并将一个类似于 Python 字典的新 JavaScript 对象分配给了这个变量。我们使用经纬度元组的方式定义了一个位置,这是因为我们可以访问到第 4 到 6 行的内容,该对象还包含一个“zoom”级别。这些选项描述了我们的初始地图:应该显示哪个区域以及以什么缩放级别。

最后,我们创建了一个新变量map,并初始化了一个 Google 地图对象,传入了一个 HTML 元素的 ID(我们将在下一节中详细解释)和我们刚刚定义的地图选项。然后我们到达了 JavaScript 代码的末尾,所以我们关闭了<script>标签。

我们的 HTML 代码的主体

虽然我们的<body>部分只有几行,但其中有一些微妙之处。第一行打开了<body>标签,并定义了onload参数。此参数接受一个 JavaScript 函数的名称,该函数将在页面加载时自动调用。请注意,函数名称(在我们的例子中是“initialize”)被引号括起来。如果你想到 Python,这可能有些反直觉,因为引号主要用于字符串字面量。将其视为将函数名称传递给 body 块,但请注意我们仍然使用开闭括号作为名称的一部分。

下一行创建了一个<div>元素。通常,<div>除了包含更多的 HTML 之外什么也不做,但这并不意味着空的<div>块,就像我们这里有的一样,是毫无意义的。请注意我们给<div>的 ID,map-canvas。这与我们 JavaScript 代码中的名称相匹配;也就是说,JavaScript 函数将查找一个名为map-canvas的 HTML 元素(使用document.getElementById())并将其转换为 Google 地图小部件。因此,使用<div>元素是有意义的,因为我们希望 JavaScript 代码使用一个空元素。

最后,我们的<div>元素还包括一些内联 CSS。我们可以使用 CSS 的heightwidth属性来定义地图的宽度和高度(这是 Google Maps API 的要求)。在这种情况下,我们将地图的height值定义为常量500像素,width值定义为页面的80%。宽度的百分比很有用,因为滚动功能通常会与缩放功能重叠。也就是说,如果用户想要在触摸板或鼠标滚轮上向下滚动页面,并且光标位于地图上,地图将放大而不是页面向下滚动。因此,右侧的 20%的“空白”空间为用户提供了滚动时移动鼠标的位置。同样,对于触摸屏,用户在尝试滚动时会在地图周围“平移”,但可以利用这个空间来放置手指。

测试和调试

我们现在应该能够在本地运行我们的 Web 应用程序并查看嵌入的 Google 地图。如果您的应用程序尚未运行,请使用终端再次启动它,并在浏览器中导航到localhost:5000。由于我们不在本地存储 Google 地图的代码,因此需要从 Google 的服务器获取,因此我们的本地机器需要在线才能正常工作(类似于获取我们 Headlines 应用程序所需的数据)。

调试 JavaScript 代码有点棘手,因为任何错误都不会被 Flask 注册,因此不会在应用程序输出中看到。如果您的网页是空白的或执行任何意外操作,首先要查看的地方是您的浏览器开发者控制台。这是开发人员的工具,在所有主要浏览器中都可以找到,通常通过按下Ctrl + Shift + C并导航到出现的窗口或侧边栏中的“控制台”选项卡。在这里,您将注意到代码触发的任何 JavaScript 错误或警告,因此此工具在调试 Web 应用程序中非常宝贵。

尽管控制台应该报告错误以及行号,但有时确切地追踪出错的地方可能有些困难。JavaScript 是一种动态类型的语言,以其一些相当古怪和反直觉的行为而臭名昭著。如果有必要,您还可以在 HTML 的<script>标签之间添加 JavaScript 行,这些行除了在开发人员工具控制台中记录外什么也不做。要做到这一点,请使用以下内容:

console.log("A message");

这类似于 Python 的print语句,您可以传递变量和大多数对象,以查看它们的字符串表示形式记录到输出中。使用+符号进行连接。例如,如果您有一个名为“a”的变量,并且想要在代码的特定点看到它的值,可以添加以下行:

console.log("The value of a is: " + a);

对于更复杂的调试方法,请查看开发人员工具窗口(或浏览器中的等效窗口)中的调试器选项卡,并尝试在 JavaScript 中设置断点。开发人员工具通常是一套功能强大的工具,很遗憾,其全部功能超出了本书的范围。以下屏幕截图显示了 Mozilla Firefox 开发人员控制台,在加载地图之前设置了断点:

测试和调试

一旦所有错误都被消除(或者如果您非常细心和幸运,可以立即看到),您应该在浏览器中看到一个包含嵌入的 Google 地图的页面,该地图以格雷厄姆斯敦,南非为中心。通过使用 JavaScript 代码中的mapOptions变量设置的缩放级别和坐标来获取您选择的初始地图。单击并在地图上按住将允许“平移”或在世界各地移动。通过使用您的中间鼠标滚轮滚动,使用触摸板或在触摸屏上进行“捏合缩放”来进行缩放。结果应该与以下屏幕截图类似:

测试和调试

现在让我们继续使我们的地图更加交互和有用。

使我们的地图交互起来

我们将为我们的应用程序添加的第一个功能允许用户在地图上放置一个标记。这将最终允许用户通过指示犯罪发生地点来添加犯罪报告,从而增加我们的众包犯罪数据库。我们还将在 JavaScript 中实现标记功能,使用“侦听器”。

添加标记

JavaScript 是事件驱动的。诸如鼠标移动或鼠标单击之类的操作都是事件,我们可以通过设置事件侦听器来对这些事件做出反应。侦听器只是在后台运行,等待特定事件,然后在检测到事件时触发指定的操作。我们将为鼠标单击设置一个侦听器,如果检测到,我们将在单击时在鼠标位置放置一个地图标记。

使用 Google 地图 API,可以用几行代码实现这一点。首先,我们将使我们的map变量全局化。然后,我们将创建一个placeMarker()函数,该函数将引用我们的map变量,并在调用时在其上放置一个标记。在我们现有的initalise()函数中,我们将添加一个点击侦听器,当触发时将调用placeMarker()函数。

完整的 JavaScript 代码可以在此处查看,修改的行已突出显示:

<script type="text/javascript"
  src="img/js">
</script>

<script type="text/javascript">

 var map;
  function initialize() { 
  var mapOptions = {
    center: new google.maps.LatLng(-33.30578381949298, 26.523442268371582),
    zoom: 15
  };
 map = new google.maps.Map(document.getElementById("map- canvas"), mapOptions);
 google.maps.event.addListener(map, 'click', function(event){ 
 placeMarker(event.latLng);
 });
  }

 function placeMarker(location) {
 var marker = new google.maps.Marker({
 position: location, 
 map: map
 });
  }
</script>

特别注意从var map = new google.maps.Mapmap = new google.maps.Map的更改。我们删除了var声明,这意味着我们将新的地图分配给我们的全局map变量,而不是创建一个新的局部变量。

下一行调用了addListener(),这可能看起来有点奇怪。addListener()函数接受一个mapeventfunction,当监听器被触发时调用。与 Python 一样,JavaScript 有一流的函数,这意味着我们可以将函数作为参数传递给其他函数。与 Python 不同的是,我们不需要使用lambda关键字来创建匿名函数;我们可以简单地声明我们想要传递的函数,而不是参数。在这种情况下,我们创建了一个匿名函数,它接受一个event参数,然后调用我们的placeMarker()函数,将eventlatLng属性传递给它。在我们的情况下,event是监听器捕获的鼠标点击,latLng属性是鼠标点击的位置。

在我们的placeMarker()函数中,我们接受一个位置并创建一个新的Marker对象,将其放置在我们地图上传入的位置(这就是为什么我们将地图设为全局的;现在我们可以在这个新函数中引用它)。

总之,当页面加载时,我们将添加一个监听器,它会在后台等待点击。当检测到点击时,监听器会调用placeMarker(),传入它检测到的点击的坐标。placeMarker()函数然后在指定的坐标处添加一个标记,这意味着用户在点击地图时会看到一个标记出现在地图上。如果出现意外情况,请像之前一样在浏览器中使用控制台和调试器进行尝试。您应该看到每次点击地图都会放置一个新的标记,并且能够生成类似于以下截图的地图:

添加标记

使用单个标记

为每次点击创建一个新标记并不理想。实际上,我们希望用户能够在每次点击时移动标记,而不是创建一个新的标记。一次添加多个犯罪将会变得过于复杂,也不是特别有用。

为了实现这一点,在现有的全局map变量下创建另一个全局marker变量。然后,在placeMarker()函数中添加一个简单的条件,只有在没有标记时才创建一个新的标记,否则移动现有标记的位置。

完整的代码,再次突出显示修改的行,如下所示。再次注意,我们从创建新的marker变量的行中删除了var,因此使用全局变量而不是创建一个局部变量。有了这些改变,每次点击地图时都应该移动标记,而不是创建一个新的标记。试一试:

<script type="text/javascript"
  src="img/js">
</script>

<script type="text/javascript">

  var map;
 var marker;
  function initialize() { 
    var mapOptions = {
    center: new google.maps.LatLng(-33.30578381949298, 26.523442268371582),
    zoom: 15
    };
    map = new google.maps.Map(document.getElementById("map- canvas"), mapOptions);
    google.maps.event.addListener(map, 'click', function(event){  
      placeMarker(event.latLng);
    });
  }

  function placeMarker(location) {
 if (marker) {
 marker.setPosition(location);
 } else {
 marker = new google.maps.Marker({
       position: location,
       map: map
     });
    }
  }
</script>

为新犯罪添加输入表单

我们希望用户能够指定比简单位置更多的信息。下一步是创建一个表单,用户可以使用该表单向犯罪提交添加日期、类别和描述数据。这些信息中的每一个都将存储在我们在上一章中创建的数据库列中。创建网络表单是一个很常见的任务,有许多框架和插件可以帮助尽可能自动化这个过程,因为大多数表单都需要一个漂亮的前端,其中包括错误消息,如果用户输入了意外的输入,以及后端逻辑来处理数据并进行更彻底的验证,以防止格式不正确或不正确的数据污染数据库。

然而,为了学习的目的,我们现在将从头开始创建一个网络表单的后端和前端。在我们的下一个项目中,我们将看看如何使用各种工具来做类似的事情,以使这个过程不那么费力。

我们的目标是在地图的右侧有一些输入字段,允许用户指定关于目击或经历的犯罪的详细信息,并将其提交以包含在我们现有的数据中。表单应该有以下输入:

  • 类别:一个下拉菜单,允许用户选择犯罪属于哪个类别

  • 日期:一个允许用户轻松输入犯罪日期和时间的日历

  • 描述:一个更大的文本框,允许用户以自由形式描述犯罪

  • 纬度和经度:根据使用标记选择的位置自动填充的文本框

在填写前面的字段后,用户应该能够单击提交按钮,并查看刚刚提交的犯罪在地图上显示出来。

表单的 HTML 代码

我们表单所需的 HTML 代码与我们之前项目中创建的表单非常相似,但也有一些新元素,即<textarea><label>以及一个带有type="date"的输入。<textarea>元素与我们之前注意到的标准文本字段非常相似,但显示为更大的正方形,以鼓励用户输入更多文本。标签元素可以定义一个for属性来指定我们要标记的内容。在开放和关闭的label标签之间的文本将显示在要标记的元素附近。

这对我们的表单很有用,因为我们可以提示用户在每个字段中输入什么数据。日期字段将提供一个漂亮的日历下拉菜单来选择日期。不幸的是,这是 HTML 的一个相当新的添加,不是所有浏览器都支持。在不支持的浏览器(包括 Firefox)中,这将与文本输入相同,因此我们将在本章末尾讨论如何处理用户输入的日期。

另外,请注意,我们将表单放在一个<div>元素中,以便更容易地在页面上进行样式和定位(我们稍后也会这样做)。我们的 HTML 页面的完整<body>元素现在如下所示(请注意,我们在地图上方添加了一个标题和段落,而表单是在地图下方添加的)。看一下以下代码:

<body onload="initialize()">
  <h1>CrimeMap</h1>
  <p>A map of recent criminal activity in the Grahamstown area.</p>
  <div id="map-canvas" style="width:70%; height:500px"></div>

  <div id="newcrimeform">
   <h2>Submit new crime</h2>
   <form action="/submitcrime" method="POST">
    <label for="category">Category</label>
    <select name="category" id="category">
     <option value="mugging">Mugging</option>
     <option value="breakin">Break-in</option>
    </select>
    <label for="date">Date</label>
    <input name="date" id="date" type="date">
    <label for="latitude">Latitude</label>
    <input name="latitude" id="latitude" type="text">
    <label for="longitude">Longitude</label>
    <input name="longitude" id="longitude" type="text">
    <label for="description">Description</label>
    <textarea name="description" id="description" placeholder="A brief but detailed  description of the crime"></textarea>
    <input type="submit" value="Submit">
    </form></div>
</body>

刷新页面以查看地图下方的表单。您会注意到它看起来非常糟糕,字段大小不同,布局水平,如下面的截图所示:

表单的 HTML 代码

让我们添加一些 CSS 来修复这个问题。

将外部 CSS 添加到我们的 Web 应用程序

为了使表单出现在地图的右侧,我们将使用 CSS。我们已经为我们的地图添加了一些 CSS,我们可以以类似的方式添加更多的 CSS。但是,请参考我们在第五章中对内联、内部和外部 CSS 的讨论,改进我们的头条项目的用户体验,在向我们的头条应用程序添加 CSS部分,记住将所有 CSS 放在一个单独的文件中是最佳实践。因此,我们将创建一个style.css文件,并考虑如何将其链接到我们的 Flask 应用程序。

在我们的目录结构中创建 CSS 文件

在 Flask 中,默认情况下,我们的静态文件应该保存在一个名为static的目录中。我们最终会想在这里保存各种文件,如图像、JavaScript 和 CSS,因此我们将创建一个名为CSS的子目录,并在其中创建我们的style.css文件。在终端中导航到您的项目目录,并运行以下命令将此目录结构和文件添加到我们的项目中:

mkdir –p static/css
touch static/css/style.css

添加 CSS 代码

将以下 CSS 代码插入到这个新文件中:

body {
 font-family: sans-serif;
 background: #eee;
}

input, select, textarea {
 display: block;
 color: grey;
 border: 1px solid lightsteelblue;
 line-height: 15px;
 margin: 2px 6px 16px 0px;
 width: 100%;
}

input[type="submit"] {
 padding: 5px 10px 5px 10px;
 color: black;
 background: lightsteelblue;
 border: none;
 box-shadow: 1px 1px 1px #4C6E91;
}

input[type="submit"]:hover {
 background: steelblue;
}

#map-canvas {
 width: 70%;
 height: 500px;
 float: left;
}

#newcrimeform {
 float: right;
 width: 25%;
}

您可能会注意到我们在头条项目中使用的 CSS 代码的相似之处。但是,仍然有一些重要的要点需要注意:

  • 我们在这里定义了具有 IDmap-canvas的任何元素的“宽度”和“高度”(在倒数第二个块中),因此我们可以从我们的body.html文件中删除内联样式。

  • 我们使用了 CSS 的浮动功能,将我们的表单显示在地图的右侧而不是下方。地图占页面宽度的70%,表单占25%(最后的 5%留下了地图和表单之间的一些空间。我们的地图浮动到页面的左侧,而表单浮动到右侧。因为它们的宽度加起来不到 100%,所以它们将在浏览器中并排显示。

配置 Flask 使用 CSS

通常在 HTML 页面中,我们可以通过给出样式表的相对路径来链接到外部 CSS 文件。由于我们使用的是 Flask,我们需要配置我们的应用程序将 CSS 文件作为静态文件返回。默认情况下,Flask 从项目根目录中名为static的目录中提供文件,这就是为什么将 CSS 文件放在这里很重要,就像之前描述的那样。Flask 可以使用url_for函数为我们需要链接到的 CSS 文件生成 URL。在home.html模板中,在<head>部分的顶部添加以下行:

<link type="text/css" rel="stylesheet" href="{{url_for('static', filename='css/style.css') }}" />

这创建了我们的 HTML 和 CSS 之间的链接。我们使用属性来描述链接为text/css文件,并且它是一个样式表。然后使用url_for()函数给出了它的位置。

我们还需要添加一行 JavaScript 代码,以便在地图上的标记被创建或移动时自动填充位置输入。通过在placeMarker()函数中添加以下突出显示的行来实现这一点:

function placeMarker(location) {
 if (marker) {
  marker.setPosition(location);
 } else {
  marker = new google.maps.Marker({
   position: location,
   map: map
  });
 }
 document.getElementById('latitude').value = location.lat();
 document.getElementById('longitude').value = location.lng();
}

这些行只是找到纬度和经度框(通过它们的id属性标识)并插入用于放置标记的位置。当我们将表单POST到服务器时,我们将能够在后端读取这些值。

最后,删除我们之前添加的内联 CSS,因为这个功能现在是我们外部样式表的责任。查看home.html文件中的以下行:

<div id="map-canvas" style="width:70%; height:500px"></div>

前面的行可以修改为以下内容:

<div id="map-canvas"></div>

查看结果

重新加载浏览器中的页面以查看结果。请记住,浏览器通常会缓存 CSS 和 JavaScript,因此如果看到意外行为,请按Ctrl + R进行强制刷新。如果Ctrl + R不起作用,请尝试按Ctrl + Shift + Delete,然后在浏览器菜单中选择缓存选项并清除浏览数据,然后再次刷新。

带有表单的样式地图应该类似于以下屏幕截图:

查看结果

请注意,现在单击地图会用标记的坐标填充纬度和经度框。

发布结果

我们有表单、地图和一些 CSS,现在是将结果推送到我们的 VPS 的好时机,这样我们就可以看到它在不同设备上的外观,或者向人们征求反馈意见。

要推送我们的更改,打开终端,将目录更改为根文件夹,然后运行以下命令:

git add crimemap.py
git add templates/home.html
git add static
git commit –m "Map with form and CSS"
git push origin master

然后,通过运行以下命令,SSH 进入您的 VPS 并拉取新代码:

cd /var/www/crimemap
git pull origin master
sudo service apache2 reload

访问您的 VPS 的 IP,检查页面是否正常工作并且外观正确。如果发生意外情况,请查看/var/log/apache2/error.log

将表单链接到后端

拥有漂亮的表单来接受用户输入是很好的,但目前,我们只是丢弃任何提交的数据。与我们在头条应用程序中实时处理输入不同,我们希望捕获输入并将其存储在我们的数据库中。让我们看看如何实现这一点。

设置 URL 以收集 POST 数据

与我们的头条项目一样,第一步是在我们的服务器上设置一个 URL,以便可以将数据发布到该 URL。在我们创建的 HTML 表单中,我们将此 URL 设置为/submitcrime,因此让我们在 Flask 应用程序中创建这个路由。在crimemap.py中,添加以下函数:

@app.route("/submitcrime", methods=['POST'])
def submitcrime():
 category = request.form.get("category")
 date = request.form.get("date")
 latitude = float(request.form.get("latitude"))
 longitude = float(request.form.get("longitude"))
 description = request.form.get("description")
 DB.add_crime(category, date, latitude, longitude, description)
 return home()

在这里,我们只是获取用户输入的所有数据并将其传递给我们的数据库助手。在前面的代码中,我们使用了DB.add_crime()函数,但这个函数还不存在。我们需要它来真正将新数据添加到我们的数据库中,对于我们真正的DBHelper,我们还需要这个函数的存根。让我们看看如何添加这些。

添加数据库方法

MockDBHelper.py中,这个函数很简单。它需要接受相同的参数,然后不执行任何操作。将以下内容添加到mockdbhelper.py中:

def add_crime(self, category, date, latitude, longitude, description):
  pass

真实的功能需要添加到dbhelper.py中,而且涉及的内容更多。它看起来像这样:

def add_crime(self, category, date, latitude, longitude, description):
  connection = self.connect()
  try:
    query = "INSERT INTO crimes (category, date, latitude, longitude, description) \
      VALUES (%s, %s, %s, %s, %s)"
    with connection.cursor() as cursor:
      cursor.execute(query, (category, date, latitude, longitude, description))
      connection.commit()
  except Exception as e:
    print(e)
  finally:
    connection.close()

在这里我们没有看到任何新东西。我们使用了占位符值,并且只在cursor.execute()语句中填充它们,以避免 SQL 注入,并且我们在finally块中关闭了连接,以确保它总是发生。

在服务器上测试代码

现在是提交所有更改到存储库并快速检查错误的好时机。一旦新代码在您的 VPS 上运行,尝试通过访问您的 IP 地址并填写我们制作的表单向数据库添加犯罪记录。在您的 VPS 上,您可以通过运行以下命令来检查数据是否成功添加。请注意,这将启动一个实时的 SQL shell——直接连接到您的数据库,应谨慎使用。输入错误的命令可能导致数据不可挽回地丢失或损坏。运行以下命令:

mysql –p
<your database password>
use database crimemap
select * from crimes;

您将看到 MySQL 打印了一个漂亮的 ASCII 表,显示了数据库中数据的摘要,如下面的屏幕截图所示(在这种情况下,显示了crimemap数据库的crimes表中的所有记录和列):

在服务器上测试代码

在地图上显示现有的犯罪记录

现在,用户可以向我们的犯罪数据库添加新的犯罪记录,但我们也希望地图显示已经添加的犯罪记录。为了实现这一点,每当页面加载时,我们的应用程序需要调用数据库以获取最新的犯罪数据。然后,我们需要将这些数据传递给我们的模板文件,循环遍历每个犯罪记录,并在地图上的正确位置放置一个标记。

现在,我们的数据存储在 MySQL 数据库中。我们将在服务器端使用 Python 访问它,并希望在客户端使用 JavaScript 显示它;因此,我们需要花一些时间将我们的数据转换为适当的格式。当我们通过 Python 的pymysql驱动访问数据时,我们将收到一个元组。为了使用 JavaScript 显示数据,我们希望将其转换为 JSON。你可能还记得我们在 Headlines 项目中提到过 JSON,它是 JavaScript 对象表示法,是一种 JavaScript 可以轻松读取和操作的结构化数据格式。与我们之前的项目一样,我们将利用 Python 字典与 JSON 非常相似的事实。我们将从我们的数据库中获取的元组创建一个 Python 字典,将其转换为 JSON 字符串,并将其传递给我们的模板,模板将使用 JavaScript 将数据显示为地图上的标记。

从 SQL 获取数据

我们将从我们的DBHelper类开始——添加一个方法来返回我们在数据库中每个犯罪记录所需的字段。将以下方法添加到您的dbhelper.py文件中:

def get_all_crimes(self):
 connection = self.connect()
 try:
  query = "SELECT latitude, longitude, date, category, description FROM crimes;"
  with connection.cursor() as cursor:
   cursor.execute(query)
  named_crimes = []
  for crime in cursor:
   named_crime = {
    'latitude': crime[0],
    'longitude': crime[1],
    'date': datetime.datetime.strftime(crime[2], '%Y- %m-%d'),
    'category': crime[3],
    'description': crime[4]
   }
   named_crimes.append(named_crime)
  return named_crimes
 finally:
  connection.close()

此外,通过以下方式将我们需要的datetime模块的新import添加到dbhelper.py的顶部:

import datetime

我们忽略了idupdated_at字段,因为用户对这些不感兴趣,使用SELECT操作符选择所有其他字段。由于我们没有WHERE子句,这个查询将返回我们数据库中的所有犯罪。一旦我们有了所有的犯罪,我们可以简单地以它们的默认表示形式返回它们,即元组的元组。然而,这会使我们的应用程序的维护变得困难。我们不想记住latitude是我们元组的第一个元素,longitude是第二个元素,依此类推。这将使得开发我们应用程序的 JavaScript 部分变得痛苦,因为我们不得不不断地参考我们的DBHelper,以了解如何准确地获取,例如,我们数据的category元素。如果我们将来想要对我们的应用程序进行更改,可能需要在这里和我们的 JavaScript 代码中进行相同的更改。

相反,我们将从我们的每条记录中创建一个字典并返回这些字典。这有两个优点:首先,这样开发会更容易,因为我们可以通过名称而不是索引来引用我们数据的元素;其次,我们可以轻松地将我们的字典转换为 JSON,以在我们的 JavaScript 代码中使用。对于我们字典中的大多数项目,我们将简单地使用数据库列名作为键,数据本身作为值。唯一的例外是日期;我们的数据库驱动程序将其返回为 Python 的datetime对象,但我们希望将其显示为一个字符串供用户使用,因此我们将在存储到字典中之前将其格式化为"yyyy-mm-dd"。

我们可以向我们的MockDBHelper中添加这个方法的存根,以便我们可以继续在本地运行我们的代码而不需要数据库。在这种情况下,我们不仅返回一个空列表,还会返回一个模拟犯罪,格式与我们真正的DBHelper所期望的相同。制作任何模拟类时,让你创建的模拟类的行为类似于它们的真实等价物是一个好的做法,因为这可以帮助我们在本地测试时捕捉开发错误。

将以下函数添加到mockdbhelper.py中:

def get_all_crimes(self):
 return [{ 'latitude': -33.301304,
    'longitude': 26.523355,
    'date': "2000-01-01",
    'category': "mugging",
    'description': "mock description" }]

将数据传递给我们的模板

现在我们有了通过调用单个函数从数据库中检索所需数据的能力,让我们看看我们将如何在我们的主要 Flask 应用程序中使用它,并将其传递到我们的模板文件中。

每当用户访问我们的主页时,我们希望从数据库中获取犯罪数据,并以 JSON 格式将其传递给模板,以便在用户的浏览器中使用 JavaScript 显示。由于大部分工作都是在我们的DBHelper类中完成的,我们可以保持我们的home()函数相当整洁。整个函数如下所示:

@app.route("/")
def home():
 crimes = DB.get_all_crimes()
 crimes = json.dumps(crimes)
 return render_template("home.html", crimes=crimes)

我们将使用json.dumps()函数,这是我们在第一个项目中使用的json.loads()的相反操作,用于为我们的字典创建一个 JSON 字符串(dumps中的字母"s"代表"string"),然后将 JSON 字符串传递给我们的模板,以便它可以用它来填充地图。

我们还需要为 JSON 库添加一个导入。在crimemap.py的顶部附近,添加以下行:

import json

在我们的模板中使用数据

我们的模板现在可以访问我们数据库中所有犯罪的 JSON 格式化列表,并且我们可以使用这个列表在地图上显示标记——每个现有犯罪一个标记。我们希望使用位置数据来选择放置标记的位置,然后我们希望将categorydatedescription嵌入到我们的标记上作为标签。这意味着当用户将鼠标移动到标记中的一个时,将显示有关这个标记所代表的犯罪的信息。

我们需要在home.html文件中的 JavaScript 代码中添加一个新的函数。在initialize()函数下面,添加以下内容:

function placeCrimes(crimes) {
 for (i=0; i<crimes.length; i++) {
  crime = new google.maps.Marker( {
   position: new google.maps.LatLng(crimes[i].latitude, crimes[i].longitude),
   map: map,
   title: crimes[i].date + "\n" + 
    crimes[i].category + "\n" + crimes[i].description
   }
  );
 }
}

此函数将crimes作为参数,循环遍历它,并为列表中的每个犯罪在我们的地图上创建一个新标记(我们现在可以引用它,因为我们之前将其作为全局变量)。我们使用调用google.maps.Marker()来创建标记,并传递参数字典(在本例中是google.maps.LatLng() "position",我们从我们的latitudelongitude参数构造);我们的地图的引用,即map;以及我们的datecategorydescription的连接,用换行字符分隔作为title

提示

自定义 Google 地图标记

我们放置的标记可以进行相当大的定制。我们可以传递的所有选项的完整列表可以在developers.google.com/maps/documentation/javascript/reference?hl=en#MarkerOptions上看到。

现在要做的就是在我们的initialize()函数中调用我们的新函数,并传入我们在 Python 中构建的 JSON 地图列表。整个initialize()函数如下所示,其中突出显示了新部分:

function initialize() { 
 var mapOptions = {
  center: new google.maps.LatLng(-33.30578381949298, 26.523442268371582),
  zoom: 15
 };
 map = new google.maps.Map(document.getElementById("map- canvas"), mapOptions);
 google.maps.event.addListener(map, 'click', function(event){  
  placeMarker(event.latLng);
 });
 placeCrimes({{crimes | safe}});
}

我们只是调用了我们的placeCrimes()函数并传入了犯罪。请注意,我们使用了 Jinja 内置的safe函数,通过使用|(管道)符号并传入我们的crimes数据。这是必要的,因为默认情况下,Jinja 会转义大多数特殊字符,但我们需要我们的 JSON 字符串以原始形式解释,所有特殊字符都是原样的。

但是,通过使用safe函数,我们告诉 Jinja 我们知道我们的数据是安全的,但在这个阶段,情况并非一定如此。仅仅因为我们没有恶意意图,并不意味着我们所有的数据都是绝对安全的。请记住,我们的大多数数据都是由用户提交的,因此我们的数据绝对不安全。在确保它按预期工作(正常预期使用)之后,我们将看一下我们在应用程序中打开的重大安全漏洞。

注意

如果您熟悉*nix shell,|或管道应该是非常简单的语法。如果不熟悉,请将其视为具有输入和输出的常规函数。我们不是通过括号中的参数传递输入,并使用某种形式的return函数来获取输出,而是将我们的输入放在|符号的左侧,并将函数名称放在右侧(在本例中为safe)。输入通过函数进行传递,我们得到输出。这种语法可以非常有用,可以将许多函数链接在一起,因为每个外部函数都简单地放在另一个|符号之后的右侧。

查看结果

首先,在本地测试代码。这将确保一切仍然运行,并可能会捕捉一些更微妙的错误。由于我们在数据库函数中使用了模拟,因此在 VPS 上运行之前,我们对此的工作没有太多信心。

在终端中运行python crimemap.py并在浏览器中访问localhost:5000后,您应该会看到以下内容:

查看结果

我们可以注意到一个单一的标记,其中包含我们在MockDBHelper中指定的细节。在截图中,我们将鼠标移动到标记上,使title显示出犯罪的所有细节。

现在是时候commitgit并推送到我们的 VPS 了。从您的crimemap目录中在本地运行以下命令:

git add crimemap.py
git add dbhelper.py
git add mockdbhelper.py
git add templates/home.html
git commit –m "add new crimes functionality"
git push origin master

然后,SSH 到您的 VPS 以拉取新更改:

ssh username@123.456.789.123
cd /var/www/crimemap
git pull origin master
sudo service apache2 reload

如果现在访问 VPS 的 IP 地址,我们应该会看到我们在能够显示它们之前添加的两起犯罪。由于我们在生产站点上使用了真实的DBHelper和我们的 MySQL 数据库,因此我们应该能够使用表单添加犯罪,并实时将每起犯罪添加为地图上的标记。希望您会得到类似以下截图的结果:

查看结果

如果事情不如预期那样顺利,像往常一样在您的 VPS 上运行以下命令,并在访问网站时查看输出:

tail –f /var/log/apache2/error.log

此外,通过按下Ctrl + Shift + C 使用浏览器的调试器来捕获可能出现的任何 JavaScript 错误。

我们的犯罪地图现在已经可以使用,可以开始跟踪城镇的犯罪并让人们保持知情。然而,在进行最终项目之前,我们还将在下一章中添加一些最后的修饰。

摘要

在本章中,我们学习了如何在 Python 中创建一个模拟数据库,以便我们可以开发我们的应用程序而不需要访问真实的数据库。我们还向我们的应用程序添加了一个谷歌地图小部件,并允许用户通过单击地图轻松提交纬度和经度,同时能够查看现有犯罪的位置和描述。

在下一章中,我们将看看另一个注入漏洞,XSS,并讨论如何保护以及输入验证。

第八章:在我们的犯罪地图项目中验证用户输入

用户总是以你意想不到或意料之外的方式使用你的应用程序,无论是出于无知还是恶意意图。用户有任何控制权的输入都应该经过验证,以确保其符合预期。

通过确保用户无法意外或通过恶意输入破坏我们的第二个项目。

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

  • 选择在哪里进行验证

  • 尝试 XSS 示例

  • 验证和清理

选择在哪里进行验证

在验证用户输入和提供帮助他们纠正任何错误的反馈时,有一些选择要做。主要选择是在哪里进行验证:在浏览器中,在服务器上,或两者兼顾。

我们可以在用户的浏览器中使用 JavaScript 进行验证。这种方法的优点是用户会得到更快的反馈(他们不必等待将数据发送到我们的服务器,进行验证,然后再发送响应),而且还减轻了我们服务器的负担;如果我们不使用 CPU 周期和网络带宽来验证用户数据,这意味着我们在运行服务器时有更低的成本。这种方法的缺点是我们无法保证用户不会绕过这些检查;如果检查在用户的浏览器中运行,那么用户就完全控制它们。这意味着经过客户端检查验证的数据仍然不能保证是我们期望的。

我们可以在用户提交数据后在服务器上进行验证。这种方法的优缺点与前面描述的相反。我们使用了更多的处理时间,但我们对检查的完整性有额外的保证。另一方面,用户通常需要等待更长时间才能得到有关合法(而非恶意)错误的反馈。

最后的选择是两者兼顾。这样可以让我们兼顾各方面的利益;我们可以在 JavaScript 中快速向用户提供反馈,然后在服务器端重新检查结果,以确保没有绕过客户端检查。另一方面,这样做的缺点是我们最终会浪费 CPU 周期来检查合法数据两次,而且我们还需要在开发中付出更多的努力,因为我们需要在 JavaScript 和 Python 中编写验证检查。

在这个项目中,由于我们将从头开始实现表单管理,我们只会在服务器端进行一些非常基本的检查,而不会在客户端进行检查。在我们下一个项目中,当我们使用框架来处理用户输入时,我们将讨论如何轻松使用一些更复杂的验证方法。

识别需要验证的输入

我们已经注意到,并非所有浏览器都支持 HTML5 的"date"类型输入。这意味着,就我们的网站而言,一些用户可能会手动输入犯罪日期,这意味着我们需要能够处理用户以各种格式输入日期。我们的数据库期望 yyyy-mm-dd(例如,2015-10-10 代表 2015 年 10 月 10 日),但我们的用户不一定会遵守这个格式,即使我们告诉他们。因此,“日期”字段是我们希望验证的输入之一。

我们的“纬度”和“经度”字段也可以由用户编辑,因此用户可能会在其中输入文本或其他无效的坐标。我们可以为这些字段添加验证检查,但是,由于用户实际上不应该需要编辑这些值,我们将考虑如何将它们设置为只读。我们将添加验证检查,以确保用户没有将它们留空。

描述是最明显危险的字段。用户可以在这里自由输入文本,这意味着用户有机会注入代码到我们的应用中。这意味着用户可以在这里输入干扰我们期望运行的代码的 JavaScript 或 HTML 代码,而不是填写文本描述,正如我们可能期望的那样。这样做将是所谓的 XSS 或跨站点脚本攻击的一个例子,我们将看一些用户可能在这里使用的恶意输入。

我们的最后一个输入是类别。这可能看起来非常安全,因为用户必须从下拉列表中进行选择。然而,重要的是下拉列表只是一种便利,实际上,具有一些基本知识的用户也可以在这里使用自由格式的文本。这是因为浏览器使用表单中的信息创建POST请求,然后将其发送到我们的服务器。由于POST请求只是以某种方式结构化并通过 HTTP 发送的文本,所以我们的技术娴熟的用户可以构造POST请求而不使用 Web 浏览器(他们可以使用 Python 或其他编程语言,甚至一些更专门的,但免费提供的软件,如 BURP Suite)。

正如我们所看到的,我们所有的输入都需要以某种形式进行验证。在我们看一下如何验证输入之前,让我们简要看一下如果我们决定不实施验证,恶意用户可能会做些什么。

尝试 XSS 示例

恶意用户最渴望的攻击之一是所谓的持久性 XSS 攻击。这意味着攻击者不仅成功地将代码注入到您的 Web 应用程序中,而且这些注入的代码还会在较长时间内保留。通常情况下,这是通过欺骗应用程序将恶意注入的代码存储在数据库中,然后在后续访问页面时运行代码来实现的。

注意

在接下来的示例中,我们将破坏我们的应用程序,特定输入到我们的表单。然后,您需要登录到 VPS 上的数据库,手动清除这些使我们的应用程序处于破碎状态的输入。

就我们目前的应用而言,攻击者可以通过填写类别日期纬度经度字段,然后在描述字段中使用以下内容来进行持久性 XSS 攻击:

</script><script>alert(1);</script>

这可能看起来有点奇怪,但试一试。你应该会看到以下内容:

尝试 XSS 示例

在你点击弹出窗口上的确定后,你可能会注意到页面顶部的 JavaScript 代码片段(你的longitude值将不同,取决于你放置标记的位置)。

", "longitude": 26.52799}]); } function placeCrimes(crimes) { for (i=0; i

让我们看看这里发生了什么。如果我们查看页面的完整源代码,就会更清楚地理解。右键单击页面,然后单击查看页面源代码或等效选项。

我们的 JavaScript 代码中initialize函数中的placecrimes()调用现在看起来如下:

placeCrimes([{"latitude": -33.305645, "date": "2015-10-10", "category": "mugging", "description": "</script><script>alert(1);</script>", "longitude": 26.52799}]);

如果您的浏览器使用任何形式的代码高亮,那么更容易看到发生了什么。在我们页面开头附近的开放<script>标签现在被我们第一个犯罪的描述所关闭,因为我们的浏览器知道要解释<script></script>之间的任何内容为 JavaScript 代码。由于我们在"description"的开头有</script>,浏览器关闭了这部分 JavaScript。紧接着,新的 JavaScript 部分由<script>打开,这是我们描述的下一部分。接着,我们有alert(1);,它只是创建了我们之前注意到的带有1的弹出框。这个脚本部分再次关闭,我们页面的其余部分现在被我们的浏览器解释为一团糟。我们可以看到我们 JSON 的其余部分("longitude": … )直到我们for循环的一半被显示给用户,而i<crimes.length中的"<"符号现在被浏览器解释为另一个开放标签,因此随后的 JavaScript 再次被隐藏。

为了修复我们的应用程序,请使用以下命令从数据库中删除所有犯罪数据(您应该在 VPS 上运行这些命令):

mysql crimemap –p
<your database password>
delete from crimes;

您应该看到有关从crimes表中删除了多少犯罪记录的消息,类似于以下截图中看到的消息:

尝试 XSS 示例

持久性 XSS 的潜力

我们的网络应用程序出现故障似乎很糟糕。更糟糕的是,重新加载页面并不是一个解决方案。由于恶意描述存储在我们的数据库中,无论我们多少次重新加载页面,都会出现相同的问题。更糟糕的是,"alert(1);"示例就是这样一个示例,用来显示攻击者有权利运行任何他或她想要的代码。通常,攻击者利用这一点来诱使用户访问另一个(恶意)页面,因为用户相信原始页面,因此更有可能相信其中的内容。可能性实际上只受到我们攻击者想象力的限制。

验证和清理

为了防止上述情况发生,我们已经选择在服务器端检查数据,并确保其符合我们的期望。不过,我们还有一些选择要做。

白名单和黑名单

我们需要创建一些规则来选择可接受的输入和不可接受的输入,有两种主要方法可以做到这一点。一种方法是黑名单输入看起来恶意。使用这种方法,我们将创建一个可能被恶意使用的字符列表,比如"<"和">",并且我们将拒绝包含这些字符的输入。另一种方法是使用白名单方法。这与黑名单相反,我们可以选择一个我们允许的字符列表,而不是选择我们不允许的字符。

这似乎是一个微不足道的区别,但它仍然很重要。如果我们选择黑名单方法,我们更有可能被恶意用户智能地使用我们没有添加到禁止列表的字符来注入代码。

另一方面,使用白名单方法,我们更有可能让想要使用我们没有考虑添加到白名单的字符的用户感到沮丧。

由于我们的应用程序只需要一个"description"输入来进行自由文本,并且因为我们的应用程序是本地化的(在我们使用的示例中,该应用程序是特定于南非格雷厄姆斯敦,因此我们预计我们的用户只需要普通的拉丁字符,而不是例如中文字符),我们应该能够在不妨碍用户的情况下使用白名单。

验证与清理

接下来,我们必须决定如何处理无效输入。我们是完全拒绝它并要求用户重试,还是只剥离用户输入的无效部分并保留其余部分?删除或修改用户输入(例如添加转义字符)被称为净化输入。这种方法的优势是用户通常对此毫不知情;如果他或她在犯罪描述中无意中包含特殊字符,而我们将其删除,这不太可能使描述的其余部分变得难以理解或毫无价值。缺点是,如果用户最终依赖我们列入黑名单的太多字符,它可能会使信息损坏到无法使用甚至误解用户的本意。

实施验证

考虑到所有前述内容,我们希望:

  • 检查用户提交的类别,并确保它在我们期望的类别列表中

  • 检查用户提交的日期,并确保我们可以正确理解它作为日期。

  • 检查用户提交的纬度和经度,并确保这些可以解析为浮点数

  • 检查用户提交的描述,并剥离除了字母数字字符或基本标点字符预选列表之外的所有字符

尽管我们会悄悄编辑“描述”以删除非白名单字符,但如果其他字段不符合我们的预期,我们希望拒绝整个提交并让用户重新开始。因此,我们还希望在用户提交表单后添加一种显示自定义错误消息的方法。让我们添加一些 Python 函数来帮助我们完成所有这些。我们还将重构一些代码以符合不要重复自己(DRY)原则。

验证类别

以前,当我们为“类别”创建下拉列表时,我们在模板中硬编码了我们想要的两个“类别”。这已经不理想,因为这意味着如果我们想要添加或编辑“类别”,我们必须编写更多样板代码(如 HTML 标记)。现在我们还想在 Python 中访问“类别”列表,以便我们可以验证用户是否偷偷使用了不在我们列表中的类别,因此重构一下是有道理的,这样我们只定义一次我们的“类别”列表。

我们将在 Python 代码中定义列表,然后我们可以将其传递给模板以构建下拉列表,并在用户提交表单时使用相同的列表进行验证。在crimemap.py的顶部,与其他全局变量一起,添加以下内容:

categories = ['mugging', 'break-in']

home()函数的return语句中,将此列表作为命名参数传递。该行现在应该类似于这样:

return render_template("home.html", crimes=crimes, categories=categories)

home.html中,更改<select>块以使用 Jinja 的for循环,如下所示:

<select name="category" id="category">
    {% for category in categories %}
        <option value="{{category}}">{{category}}</option>
    {% endfor %}
</select>

通过这些小修改,我们有了一种更容易维护我们的“类别”列表的方法。我们现在还可以使用新列表进行验证。由于类别是由下拉列表提供的,普通用户在这里不会输入无效值,因此我们不必太担心提供礼貌的反馈。在这种情况下,我们将忽略提交并再次返回主页。

submitcrime()函数中加载类别数据到变量中的位置下方直接添加以下if语句:

category = request.form.get("category")
if category not in categories:
    return home()

如果触发了这个“返回”,它会在我们向数据库添加任何内容之前发生,并且我们用户尝试的输入将被丢弃。

验证位置

由于我们的位置数据应该由用户在地图上放置的标记自动填充,我们希望将这些字段设置为readonly。这意味着我们的 JavaScript 仍然可以修改值,因为标记被使用,但字段将拒绝用户键盘的输入或修改。要做到这一点,只需在home.html模板中定义表单的地方添加readonly属性。更新后的input定义应如下所示:

<label for="latitude">Latitude</label>
<input name="latitude" id="latitude" type="text" readonly>
<label for="longitude">Longitude</label>
<input name="longitude" id="longitude" type="text" readonly>

与下拉列表一样,readonly属性仅在浏览器级别执行,并且很容易被绕过。因此,我们还希望添加服务器端检查。为此,我们将使用 Python 的哲学“宁可请求原谅,而不是征得许可”,换句话说,假设一切都会没问题,并在except块中处理其他情况,而不是使用太多的if语句。

如果我们可以将用户的位置数据解析为浮点数,那几乎肯定是安全的,因为只用数字很难做一些事情,比如修改 HTML、JavaScript 或 SQL 代码。在我们解析位置输入的submitcrime()函数部分周围添加以下代码:

try:
    latitude = float(request.form.get("latitude"))
    longitude = float(request.form.get("longitude"))
except ValueError:
    return home()

如果latitudelongitude输入中有任何意外的文本,在我们尝试转换为浮点类型时,将抛出ValueError,然后我们将返回到主页,而不会将任何潜在危险的数据放入我们的数据库。

验证日期

对于date输入,我们可以采取与category相同的方法。大多数情况下,用户将从日历选择器中选择日期,因此将无法输入无效日期。但是,由于并非所有浏览器都支持date输入类型,有时普通用户会手动输入日期,这可能会导致意外错误。

因此,在这种情况下,我们不仅要拒绝无效的输入。我们希望尽可能弄清楚用户的意图,如果我们不能,我们希望向用户显示一条消息,指出需要修复的地方。

为了允许更灵活的输入,我们将使用一个名为dateparser的 Python 模块。该模块允许我们将格式不一致的日期转换为准确的 Python datetime对象。我们需要做的第一件事是通过pip安装它。在本地和 VPS 上运行以下命令:

pip install --user dateparser

如果您以前没有使用过它,您可能会喜欢尝试一下它的可能性。以下独立脚本演示了dateparser提供的一些魔力:

import dateparser
print dateparser.parse("1-jan/15")
print dateparser.parse("1 week and 3 days ago")
print(dateparser.parse("3/4/15")

所有前面的字符串都被正确解析为datetime对象,最后一个可能是例外,因为dateparser使用美国格式,并将其解释为 2015 年 3 月 4 日,而不是 2015 年 4 月 3 日。

还可以在 PyPI 上找到更多示例以及关于dateparser模块的其他信息pypi.python.org/pypi/dateparser

仅使用此软件包将解决我们很多问题,因为我们现在可以将无效输入转换为有效输入,而无需用户的任何帮助。稍微不方便的是,我们已经设置了数据库接受以"yyyy-mm-dd"格式插入的日期;但是,为了利用我们的新dateparser模块,我们将希望将用户的输入转换为datetime对象。稍微反直觉的解决方法是将我们从用户那里收到的字符串输入转换为datetime对象,然后再转换为字符串(始终以正确的格式),然后将其传递到我们的数据库代码中存储在 MySQL 中。

首先,在您的crimemap.py文件中添加以下辅助函数:

def format_date(userdate):
    date = dateparser.parse(userdate)
    try:
        return datetime.datetime.strftime(date, "%Y-%m-%d")
    except TypeError:
        return None    

此外,将crimemap.py的顶部添加datetimedateparser模块的导入,如下所示:

import datetime
import dateparser

我们将通过用户输入的dateuserdate)传递给这个函数,并使用我们的dateparser模块进行解析。如果日期完全无法解析(例如,“aaaaa”),dateparser.parse函数将返回空而不是抛出错误。因此,我们将调用strftime,它将以正确的格式将日期格式化为字符串,放入try except块中;如果我们的date变量为空,我们将得到TypeError,在这种情况下,我们的辅助函数也将返回None

现在,我们需要决定如果无法解析日期该怎么办。与我们之前看到的其他验证情况不同,在这种情况下,我们希望向用户提示一条消息,说明我们无法理解他或她的输入。为了实现这一点,我们将在home()函数中添加一个错误消息参数,并从submitcrime()函数中传递相关的错误消息。修改home()函数以添加参数,并将参数传递到我们的模板中,如下所示:

@app.route("/")
def home(error_message=None):
    crimes = DB.get_all_crimes()
    crimes = json.dumps(crimes)
 return render_template("home.html", crimes=crimes, categories=categories, error_message=error_message)

然后,修改submitcrime()函数,添加一些逻辑来解析用户输入的日期,并在无法解析date时向我们的home()函数传递错误消息,如下所示:

if category not in categories:
    return home()
date = format_date(request.form.get("date"))
if not date:
 return home("Invalid date. Please use yyyy-mm-dd format")

我们还需要在模板文件中添加一个部分来显示错误消息(如果存在的话)。我们将把它添加到表单的顶部,通过以下代码引起用户的注意:

<div id="newcrimeform">
    <h2>Submit new crime</h2>
 {% if error_message %}
 <div id="error"><p>{{error_message}}</p></div>
 {% endif %}
    <form action="/submitcrime" method="POST">

我们将添加前面的if语句,否则当error_message变量具有默认值None时,我们将在表单上方看到单词“None”。另外,请注意,消息本身出现在具有 ID 为 error 的<div>标签中。这允许我们添加一些 CSS 使错误消息以红色显示。在您的静态目录中的style.css文件中添加以下块:

#error {
    color: red;
}

这就是我们验证日期的方法。如果您的浏览器不支持date输入,请尝试创建一个新的犯罪,并输入一个连dateparser也无法解释为合法日期的字符串,以确保您看到预期的错误。它应该看起来类似于以下图片:

验证日期

注意

Flask 提供了一些非常方便的消息闪烁功能,即在页面的特定位置显示可选文本。这比我们讨论的基本示例具有更强大和灵活的功能,并且应该在类似的情况下予以考虑。有关 Flask 中消息闪烁的信息可以在flask.pocoo.org/docs/0.10/patterns/flashing/找到。

验证描述

我们可以假设用户只能使用数字、字母(大写和小写)和一些基本的标点符号来传达有关犯罪的基本信息,因此让我们创建一个简单的 Python 函数,过滤掉除了我们已确定为安全的字符之外的所有字符。在您的crimemap.py文件中添加以下sanitize()函数:

def sanitize_string(userinput):
    whitelist = string.letters + string.digits + " !?$.,;:-'()&"
    return filter(lambda x: x in whitelist, userinput)

然后,在crimemap.py的导入部分添加字符串的导入,如下所示:

import string

我们的sanitize_string()函数非常简洁,并使用了 Python 的一些函数式编程潜力。filter函数对列表中的每个元素重复应用另一个函数,并基于“通过”的元素构建一个新列表。在这种情况下,我们将传递给filter()的函数是一个简单的lambda函数,用于检查字母是否属于我们的白名单。我们函数的结果是一个类似于输入的字符串,但删除了不属于我们白名单的所有字符。

我们的白名单是由所有字母(大写和小写)、数字一到九以及一些基本的标点符号构建而成,人们在输入事件的非正式描述时可能会使用这些标点符号。

要使用我们的新函数,只需将crimemap.pysubmitcrime()函数末尾的行从以下内容更改为以下内容:

description = request.form.get("description")
description = sanitize_string(request.form.get("description"))

请注意,由于我们的 SQL 驱动程序可以减轻 SQL 注入,而我们的json.dumps()函数可以转义双引号,因此我们只需在黑名单中列出一些字符,比如尖括号,我们就可以基本上安全了,我们用它来演示 XSS 攻击。这将为我们的用户提供更多的灵活性,但是恶意用户可能会决心并且有创造力地制作输入,以绕过我们设置的过滤器。参考www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet获取一些例子。首先在本地尝试验证更改,然后,如果一切看起来都很好,就提交到git,将存储库推送到远程,并将其拉到 VPS 上。重新启动 Apache 并访问您的 IP 地址。尝试在description中提交一个使用</script>的犯罪,当您将光标悬停在这个犯罪的标记上时,您会注意到我们存储的只是"script"。我们将删除斜杠和尖括号,从而确保防止 XSS 攻击。

我们已经讨论了黑名单和白名单的利弊,但是为了强调白名单并不是一个完美的方法,看一下这里关于开发人员在为用户的名称设置白名单时经常犯的错误的帖子:www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/

我们可以对我们的dbhelper.pymockdbhelper.pycrimemap.py文件进行的最后一个更改是删除我们不再需要的函数。当我们有一个不特定于犯罪的基本数据库应用程序时,我们在我们的DBHelper类中有get_all_inputs()add_input()clear_all()函数,在我们的crimemap.py文件中有add()clear()函数。所有这些都可以被移除。

总结

我们已经花了一整章的时间来研究验证,但是如果你看一下过去几年面临信息安全漏洞的主要公司,你会同意安全是值得花一些时间的。我们特别关注了跨站脚本攻击或 XSS 攻击,但我们也讨论了一些更一般的输入验证要点。这让我们来到了我们第二个项目的结束。

一个明显缺失的事情是弄清楚是谁添加了哪些犯罪。如果一个恶意用户向我们的数据库添加了一堆虚假的犯罪,他们可能会搞乱我们整个数据集!

在我们的下一个项目中,我们将研究通过用户帐户控制系统对用户进行身份验证,这将使我们对我们允许在我们的网站上的用户以及他们可以做什么有更多的控制。

第九章:建立服务员呼叫应用程序

在经历了头条项目之后,你学习了 Flask 的基础知识,以及 Crimemap 项目,其中你学习了一些更有用的 Flask 功能,比如如何使用数据库和如何编写一些基本的 JavaScript 代码,我们现在准备进行我们迄今为止最复杂的项目!我们将建立一个服务员呼叫网络应用程序,允许餐厅顾客轻松地呼叫服务员到他们的桌子上。餐厅经理将能够轻松注册并开始使用我们的应用程序,而无需投资昂贵的硬件。

我们将深入研究 Flask 世界,看看一些 Flask 扩展,帮助我们进行用户账户控制和网络表单,并且我们还将看看如何在 Jinja 中使用模板继承。我们还将使用 Bootstrap 前端框架,这样我们就不必从头开始编写太多 HTML 和 CSS 代码。

与我们之前应用程序使用的 MySQL 数据库相比,我们将看看一个有争议的替代方案:MongoDB。MongoDB 是一个 NoSQL 数据库,这意味着我们在其中不处理表、行和列。我们还将讨论这究竟意味着什么。

对于服务员来说,最困难的任务之一就是知道顾客需要什么。要么顾客抱怨等待服务员来询问甜点选择的时间太长,要么他们抱怨服务员不断打断对话来询问一切是否顺利。为了解决这个问题,一些餐厅在每张桌子上安装了专用按钮,当按下时,通知服务员需要他的注意。然而,对于规模较小的餐厅来说,专门硬件和安装的成本是不可承受的,对于规模较大的餐厅来说,这往往只是太麻烦了。

在我们现代的时代,几乎所有的餐厅顾客都有智能手机,我们可以利用这一事实为餐厅提供一个成本更低的解决方案。当顾客需要服务时,他们只需在手机上访问一个简短的 URL,服务员就会在一个集中的屏幕上收到通知。

我们希望该应用程序允许多个不相关的餐厅使用同一个网络应用程序,因此每个餐厅都应该有我们系统的私人登录账户。我们希望餐厅经理能够轻松设置;也就是说,当一个新餐厅加入系统时,我们作为开发人员不需要参与其中。

我们应用程序所需的设置如下:

  • 餐厅经理在我们的网络应用程序上注册一个新账户

  • 餐厅经理提供了关于餐厅有多少张桌子的基本信息

  • 网络应用程序为每张桌子提供一个独特的 URL

  • 餐厅经理打印出这些 URL,并确保相关的 URL 可以轻松从每张桌子上访问

我们的应用程序使用应该具有以下功能:

  • 餐厅员工应该能够从一个集中的屏幕登录到网络应用程序并看到一个简单的通知页面。

  • 一些顾客希望通过智能手机获得服务,并访问与他们的桌子相关的 URL,因此这应该是可能的。

  • 服务员应该实时看到通知出现在一个集中的屏幕上。然后服务员会在屏幕上确认通知并为顾客提供服务。

  • 如果在第一个通知被确认之前出现更多通知,后来的通知应该出现在先前的通知下方。

在接下来的三章中,我们将实现一个具有所有前述功能的 Flask 应用程序。我们将拥有一个数据库,用于存储注册使用我们的应用程序的所有个别餐厅的帐户信息,以便我们可以为每个餐厅单独处理顾客的请求。顾客将能够发出请求,这些请求将在数据库中注册,而餐厅工作人员将能够查看他们餐厅的当前关注请求。我们将构建一个用户帐户控制系统,以便餐厅可以为我们的应用程序拥有自己的受密码保护的帐户。

首先,我们将设置一个新的 Flask 应用程序、Git 存储库和 Apache 配置来提供我们的新项目。我们将引入 Twitter 的 Bootstrap 框架作为我们在前端使用的框架。我们将下载一个基本的 Bootstrap 模板作为我们应用程序前端的起点,并对其进行一些更改以将其整合到一个基本的 Flask 应用程序中。然后,我们将设置一个用户帐户控制系统,允许用户通过提供电子邮件地址和密码在我们的应用程序中注册、登录和注销。

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

  • 设置新的git存储库

  • 使用 Bootstrap 启动我们的应用程序

  • 将用户帐户控制添加到我们的应用程序

设置新的 Git 存储库

与以前一样,我们需要创建一个新的git存储库来托管我们的新项目。第一步是登录 BitBucket 或您正在使用的任何代码存储库主机的 Web 界面,选择创建新存储库选项,并选择Git单选按钮,注意它提供给您的 URL。由于接下来的步骤与以前的项目相同,我们只会给您一个摘要。如果您需要更详细的指导,请参考第一章 安装和使用 git部分,你好,世界!

在本地设置新项目

为了设置本地项目结构,请在本地运行以下命令:

mkdir waitercaller
cd waitercaller
git init
git remote add origin <new-repository-url>
mkdir templates
mkdir static
touch waitercaller.py
touch templates/home.html
touch .gitignore

我们希望为这个项目获得最小的运行应用程序,以便在开始开发之前解决任何配置问题。将以下内容添加到您的waitercaller.py文件中:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def home():
   return "Under construction"

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

然后,使用以下命令将项目概述推送到存储库:

git add .
git commit –m "Initial commit"
git push origin master

在我们的 VPS 上设置项目

在您的 VPS 上,运行以下命令来克隆存储库,并设置 Apache2 以将我们的新项目作为默认网站提供服务:

cd /var/www/
git clone <new-repository-url>
cd waitercaller
nano waitercaller.wsgi

将以下代码添加到我们最近创建的.wsgi文件中:

import sys
sys.path.insert(0, "/var/www/waitercaller")
from waitercaller import app as application

现在,按下Ctrl + X,并在提示时选择Y退出 Nano。

最后,通过运行以下命令创建 Apache 配置文件:

cd /etc/apache2/sites-available
nano waitercaller.conf

将以下配置数据添加到我们刚创建的waitercaller.conf文件中:

<VirtualHost *>

    WSGIScriptAlias / /var/www/waitercaller/waitercaller.wsgi
    WSGIDaemonProcess waitercaller
    <Directory /var/www/waitercaller>
       WSGIProcessGroup waitercaller
       WSGIApplicationGroup %{GLOBAL}
        Order deny,allow
        Allow from all
    </Directory>
</VirtualHost>

退出 Nano,保存新文件。现在,为了禁用我们的crimemap项目作为默认站点,并启用我们的新项目,运行以下命令:

sudo a2dissite crimemap.conf
sudo a2ensite waitercaller.conf
sudo service apache2 reload

通过在 Web 浏览器中访问您的 VPS 的 IP 地址来验证一切是否正常。您应该看到正在建设中字符串。如果事情不如预期那样工作,请再次查看您的配置和日志文件。

使用 Bootstrap 启动我们的应用程序

在我们以前的项目中,我们花了相当多的时间在前端工作上,摆弄 CSS 和 HTML,并且甚至没有触及到 Web 应用程序开发人员需要注意的一些前端问题,比如确保我们的内容在任何操作系统上的任何浏览器上的所有屏幕尺寸的所有设备上看起来好看并且功能正常。浏览器和设备的多样性以及它们各自实现某些 JavaScript、HTML 和 CSS 功能的不一致方式是 Web 开发的最大挑战之一,没有解决问题的银弹。然而,像 Bootstrap 这样的前端框架可以减轻一些痛苦,为开发人员提供改进用户体验的捷径。

介绍 Bootstrap

Bootstrap 由 Twitter 开发,并在开放许可下发布。它可以极大地加快 CSS 开发,因为它为不同的 HTML 布局和表单输入提供了许多样式。它还可以提供响应性;也就是说,它可以根据用户设备的屏幕大小自动更改某些元素的布局。我们将在本章后面讨论这对我们和这个项目的确切意义。

注意

Bootstrap 受到了一些批评,但它仍然保持着它的流行度。有许多具有不同优势和劣势的替代品。随着现代网页开发的快速发展,也会定期出现许多新的框架。现有的框架经常会进行重大更新,并且不提供向旧版本的向后兼容性。对于重要的生产网页应用程序,当前研究什么最适合这个项目的特定需求总是至关重要的。

Bootstrap 的主要提供的是可重复使用的 CSS 和 JavaScript 模块。我们主要会用它的 CSS 组件。

查看 Bootstrap 的主页getbootstrap.com/,以及子页面getbootstrap.com/getting-started/#examplesgetbootstrap.com/components/,以了解 Bootstrap 提供了什么。

与从头开始编写 CSS 不同,Bootstrap 允许我们使用各种输入、图标、导航栏和其他经常需要的网站组件,默认情况下看起来很好。

下载 Bootstrap

有几种安装 Bootstrap 的方法,但要记住 Bootstrap 可以被视为一组 JavaScript、CSS 和图标文件的集合,我们不会做太复杂的事情。我们可以简单地下载编译后的代码文件的.zip文件,并在我们的本地项目中使用这些文件。我们将在我们的git存储库中包含 bootstrap,因此无需在我们的 VPS 上安装它。执行以下步骤:

  1. 转到getbootstrap.com/getting-started/#download,选择下载 Bootstrap选项,这应该是已编译和压缩的版本,没有文档。

  2. 解压您下载的文件,您会发现一个名为bootstrap-3.x.x的单个目录(这里,重复的字母 x 代表包含的 Bootstrap 版本的数字)。在目录内,可能会有一些子目录,可能是jscssfonts

  3. jscssfonts目录复制到waitercaller项目的static目录中。您的项目现在应该具有以下结构:

waitercaller/
templates
    home.html
static
    css/
    fonts/
    js
.gitignore
waitercaller.py

由于定期的 Bootstrap 更新,我们在附带的代码包中包含了 Bootstrap 3.3.5 的完整代码副本(在撰写本书时的最新版本)。虽然最新版本可能更好,但它可能与我们提供的示例不兼容。您可以选择使用我们提供的版本来测试,知道示例应该按预期工作,或者直接尝试适应更新的 Bootstrap 代码,必要时尝试适应示例。

Bootstrap 模板

Bootstrap 强烈鼓励用户构建定制的前端页面,而不是简单地使用现有的模板。你可能已经注意到很多现代网页看起来非常相似;这是因为前端设计很困难,人们喜欢走捷径。由于本书侧重于 Flask 开发,我们也会采取一些前端的捷径,并从 Bootstrap 提供的示例模板文件开始。我们将使用的模板文件可以在getbootstrap.com/examples/jumbotron/中找到,我们项目的适配可以在本章的附带代码包中的tempates/home.html中找到。你可以注意到这两个文件的相似之处,我们并没有做太多的工作来获得一个基本的网页,看起来也很好。

从代码包中的templates/home.html文件中复制代码到您之前创建的项目目录中的相同位置。如果您在static文件夹中正确地包含了所有的 Bootstrap 文件,直接在 Web 浏览器中打开这个新文件将会得到一个类似于以下屏幕截图的页面。(请注意,在这个阶段,我们仍然使用纯 HTML,没有使用 Jinja 功能,所以您可以直接在 Web 浏览器中打开文件,而不是从 Flask 应用程序中提供服务。)

Bootstrap templates

我们可以注意到,我们可以用很少的代码实现输入、标题、导航栏和 Jumbotron(靠近顶部的灰色条,上面有超大的服务员呼叫文本)的样式的优势。然而,使用 Bootstrap 最显著的节省时间的元素可能是我们网站的响应性。Bootstrap 基于网格布局,这意味着网格的不同元素可以重新排列以更好地适应任何设备。注意模板中的 HTML 的这一部分:

<div class="row">
 <div class="col-md-4">
 <h2>Simple</h2>

一个"row"有 12 列的空间。我们的 Jumbotron 下面的三个主要内容元素每个占据四列,因此填满了整行(4 x 3 = 12)。我们使用class="col-md-4"属性来指定这一点。可以将其视为大小为四的中等(md)列。您可以在getbootstrap.com/css/上阅读有关网格系统如何工作的更多信息,并查看一些示例。

在前面的屏幕截图中还有一些看起来没有使用的代码,类似于这样:

<button type="button" class="navbar-toggle collapsed" data- toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">

前面的两个摘录可能是使我们的网络应用程序响应的最重要的组成部分。要理解这意味着什么,可以在页面打开时调整浏览器窗口大小。这模拟了我们的页面在较小设备上(如手机和平板电脑)上的显示方式。它应该看起来类似于以下的屏幕截图:

Bootstrap templates

我们可以注意到,我们使用 Bootstrap 网格功能的三个主要内容元素现在排列在彼此下方,而不是并排。这对于较小的设备来说是理想的,用户更习惯于向下滚动,而不是在侧边寻找更多的内容。我们的导航栏也变得更加简洁,登录输入现在被隐藏了。

这些可以通过选择右上角的汉堡包图标来显示;这是一个有争议但非常普遍的网页开发元素。大多数用户本能地知道他们可以触摸图标以获得某种形式的菜单或扩展,但是有许多批评使用这种技术。目前,我们只接受这种正常的做法,不去深究它背后的问题。这绝对比尝试在任何屏幕大小上显示完全相同的内容,并且让我们的用户根据需要逐个部分地放大页面要好得多。

向我们的应用程序添加用户帐户控制

对于用户帐户控制,预期用户将使用密码登录和进行身份验证。例如,当您登录到您的网络邮件帐户时,您在访问页面时输入密码。此后,所有您的操作都将被视为经过身份验证;也就是说,当您发送电子邮件时,您不必再次输入密码。网络邮件客户端记住您已登录,因此允许您完成某些操作。

然而,HTTP 是一种无状态协议,这意味着我们无法直接知道登录的用户是否是发送电子邮件请求的同一用户。为了解决这个问题,我们将在用户最初登录时给用户一个 cookie,然后用户的浏览器将在每个后续请求中将此 cookie 发送给我们。我们将使用我们的数据库来跟踪当前已登录的用户。这使我们能够在每个请求中对用户进行身份验证,而无需多次请求用户的密码。

我们可以使用 Flask cookie 从头开始实现这一点,方式类似于我们在 Headlines 项目中看到的方式。但是,我们需要实现许多步骤,例如选择应用程序中哪些页面需要身份验证,并确保 cookie 是安全的,并参与决定在 cookie 中存储什么信息。

相反,我们将提高一级抽象,并使用Flask-Login扩展。

介绍 Flask-Login

Flask-Login是一个 Flask 扩展,实现了所有用户帐户控制系统所需的基础工作。要使用此功能,我们需要通过pip安装它,然后创建一个遵循特定模式的用户类。您可以在flask-login.readthedocs.org/en/latest/找到Flask-Login的摘要以及全面的文档。

安装和导入 Flask-Login

要安装Flask-Login,运行以下命令:

pip install --user flask-login

与我们安装的所有 Python 模块一样,请记住在本地和 VPS 上都要这样做。

首先,我们将添加可能的最基本的登录功能。我们的应用程序将为经过身份验证的用户显示您已登录,但未输入正确密码的用户将无法看到消息。

使用 Flask 扩展

当我们安装 Flask 扩展时,我们可以通过flask.ext路径自动访问它们。我们将从Flask-Login扩展中使用的第一个类是所谓的LoginManager类。我们还将使用@login_required装饰器指定哪些路由受限于已登录用户。将以下导入添加到您的waitercaller.py文件中:

from flask.ext.login import LoginManager
from flask.ext.login import login_required

现在,我们需要将扩展连接到我们的 Flask 应用程序。在我们使用更多 Flask 扩展时将变得熟悉的模式中,将以下行直接添加到waitercaller.py中创建app变量的位置下面:

app = Flask(__name__)
login_manager = LoginManager(app)

我们实例化的LoginManager类现在引用了我们的应用程序。我们将使用这个新的LoginManager类来管理我们应用程序的登录。

添加受限路由

现在,让我们在/account上为我们的应用程序添加一个路由,并确保只有经过身份验证的用户才能查看此页面。这一步的简单部分是确保经过身份验证的用户不能看到页面,因此我们将从这里开始。

首先,我们希望我们的应用程序默认呈现我们的 Bootstrap 模板。将以下路由添加到waitercaller.py文件中:

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

现在,我们将添加一个受限路由,未登录的用户无法看到。将以下函数添加到waitercaller.py

@app.route("/account")
@login_required
def account():
   return "You are logged in"

请注意,我们正在使用@login_required装饰器。类似于@app.route装饰器,这是一个接受下面的函数作为输入并返回修改后的函数的函数。在这种情况下,它不是路由魔法,而是验证用户是否已登录,如果没有,它将重定向用户到一个未经授权页面,而不是返回我们在return语句中指定的内容。重要的是@app.route装饰器首先出现,@login_required装饰器在其下面,就像前面的例子一样。

注意

在浏览网页时,你可能会有时看到404 页面未找到错误。虽然404尤为臭名昭著,但有许多错误代码是 HTTP 规范的一部分。不同的浏览器在接收到这些错误时可能会显示不同的默认错误消息,也可以定义自定义错误页面在指定错误发生时显示。

由于我们还没有设置任何登录逻辑,没有用户应该能够验证并查看我们创建的新路由。在本地启动你的 Flask 应用程序,尝试访问localhost:5000/account的账户路由。如果一切顺利,你应该会看到类似以下截图的未经授权的错误消息:

添加受限路由

验证用户

互联网可能是一个黑暗和可怕的地方。这就是为什么你需要在许多网络应用程序中输入密码;密码证明你是你所声称的人。通过告诉我们只有你知道的东西,网络应用程序知道你是“你”,而不是冒名顶替者。

实现密码检查系统的最简单方法是在数据库中存储与用户名关联的密码。当用户登录时,你需要首先验证用户名是否存在,如果存在,你需要验证用户刚刚给出的密码是否与注册时使用的密码匹配。

实际上,这是一个糟糕的主意。数据库可能被任意数量的人访问,包括运行网络应用程序的公司的员工,可能还有黑客。相反,我们最终将存储用户密码的加密哈希;但是现在,为了确保我们的登录系统正常工作,我们将使用明文密码。

我们将建立一个模拟数据库,这个数据库与我们在犯罪地图项目中使用的数据库非常相似,并检查是否允许模拟用户查看我们的“账户”页面,只有在输入正确的密码时才允许。

创建一个用户类

由于我们正在使用Flask-Login模块,我们需要创建一个符合严格格式的User类。Flask-Login足够灵活,可以允许一些更高级的登录功能,比如区分活跃非活跃账户以及匿名用户。我们不会使用这些功能,但我们需要创建一个能够与Flask-Login一起工作的User类,因此我们将有一些看起来多余的方法。

在你的waitercaller目录中创建一个名为user.py的新文件。将以下代码添加到其中:

class User:
   def __init__(self, email):
      self.email = email

   def get_id(self):
      return self.email

   def is_active(self):
      return True

   def is_anonymous(self):
      return False

   def is_authenticated(self):
      return True

Flask-Login要求我们在我们的User类中实现一个get_id()方法,返回用户的唯一标识符。我们将使用用户的电子邮件地址,因此在get_id()函数中,我们可以简单地返回它。

我们将把所有用户视为活跃账户;因此,在这个方法中,我们将简单地返回True。对于is_anonymous()函数也是如此;虽然这也是必需的,但我们不会在我们的应用程序中处理匿名登录的概念,所以我们将始终返回False

最后一个函数可能看起来有点奇怪;我们将始终为is_authenticated()返回True。这是因为只有在输入正确的用户名和密码组合时才会创建用户对象,所以如果用户对象存在,它将被验证。

模拟我们的用户数据库

我们将再次创建一个MockDBHelper类,并创建一个配置文件,指示在测试应用程序时应在本地使用它,而不需要访问数据库。它需要有一个函数,接受用户名和密码,并检查它们是否存在于数据库中,并且彼此关联。

首先,在您的waitercaller目录中创建一个名为mockdbhelper.py的文件,并添加以下代码:

MOCK_USERS = {'test@example.com': '123456'}

class MockDBHelper:

   def get_user(self, email):
      if email in MOCK_USERS:
         return MOCK_USERS[email]
      return None

在顶部,我们有一个充当数据库存储的字典。我们有一个单独的get_user()方法,检查用户是否存在于我们的数据库中,并在存在时返回密码。

现在,在waitercaller目录中创建一个config.py文件,并添加以下单行:

test = True

与上一个项目一样,此文件将让我们的应用程序知道它是在我们的测试(本地)环境中运行还是在我们的生产(VPS)环境中运行。与以前的项目不同,我们将稍后向此文件添加其他不涉及数据库的信息,这就是为什么我们将其称为config.py而不是dbconfig.py。我们不希望将此文件检入我们的git存储库,因为它在我们的 VPS 上会有所不同,并且还将包含我们不希望存储的敏感数据库凭据;因此,在您的waitercaller目录中创建一个.gitignore文件,并添加以下行:

config.py
*.pyc

登录用户

我们的模板已经设置了一个登录表单,允许用户输入电子邮件和密码。现在,我们将设置功能,允许我们输入并检查此表单中的输入是否与我们的模拟数据库匹配。如果我们输入的电子邮件和密码存在于我们的模拟数据库中,我们将登录用户并允许访问我们的/account路由。如果不是,我们将重定向回主页(我们将在下一章节的使用 WTForms 添加用户反馈部分中查看向输入无效信息的用户显示反馈)。

添加导入和配置

我们需要导入Flask-Login扩展的login_user函数,以及我们的新User类代码和数据库助手。在waitercaller.py的导入中添加以下行:

from flask.ext.login import login_user

from mockdbhelper import MockDBHelper as DBHelper
from user import User

由于目前除了我们的模拟数据库助手外,我们没有其他数据库助手,所以我们将始终导入模拟数据库助手。稍后,我们将使用config.py中的值来决定要import哪个数据库助手-真实的还是模拟的,就像我们在以前的项目中所做的那样。

我们还需要创建一个DBHelper全局类,以便我们的应用程序代码可以轻松地与我们的数据库交流。在waitercaller.py的导入部分下面添加以下行:

DB = DBHelper()

最后,我们还需要为我们的应用程序配置一个秘密密钥。这用于对Flask-Login在用户登录时分发的会话信息 cookie 进行加密签名。签署 cookie 可以防止用户手动编辑它们,有助于防止欺诈登录。对于这一步,您应该创建一个长而安全的秘密密钥;您永远不必记住它,所以不要把它当作密码或口令来考虑。尽管随机按键盘应该足够,但人类通常很难创建无偏见的随机性,因此您也可以使用以下命令使用/dev/urandom创建一个随机字符串(将100更改为您想要的字符数):

cat /dev/urandom | base64 | head -c 100 ; echo

一旦您有了一长串随机字符,将以下行添加到您的waitercaller.py文件中,在您声明app变量的位置下,用您自己的随机字符替换它:

app.secret_key = 'tPXJY3X37Qybz4QykV+hOyUxVQeEXf1Ao2C8upz+fGQXKsM'

添加登录功能

登录用户有两个主要部分需要考虑。第一部分是用户输入电子邮件地址和密码进行身份验证,第二部分是用户通过发送所需的 cookie 进行身份验证,即他或她仍然处于与成功登录完成时相同的浏览器会话中。

编写登录功能

我们已经为第一个案例创建了登录路由的存根,现在,我们将稍微完善一下,检查输入信息与我们的数据库匹配,并使用Flask-Login来登录用户,如果电子邮件和密码匹配的话。

我们还将介绍一种更清晰的方式,从一个单独的 Flask 路由调用另一个。将以下行添加到waitercaller.py的导入部分:

from flask import redirect
from flask import url_for

第一个函数接受一个 URL,并为一个简单重定向用户到指定 URL 的路由创建一个响应。第二个函数从一个函数名构建一个 URL。在 Flask 应用程序中,你经常会看到这两个函数一起使用,就像下面的例子一样。

waitercaller.py中编写登录函数,以匹配以下代码:

@app.route("/login", methods=["POST"])
def login():
   email = request.form.get("email")
   password = request.form.get("password")
   user_password = DB.get_user(email)
   if user_password and user_password == password:
      user = User(email)
      login_user(user)
      return redirect(url_for('account'))
   return home()

我们还需要为request库添加import。将以下行添加到waitercaller.pyimport部分:

from flask import request 

我们将用户的输入加载到emailpassword变量中,然后将存储的密码加载到user_password变量中。if语句很冗长,因为我们明确验证了是否返回了密码(也就是说,我们验证了用户是否存在),以及密码是否正确,尽管第二个条件暗示了第一个条件。稍后,我们将讨论在向用户提供反馈时区分这两个条件的权衡。

如果一切有效,我们将从电子邮件地址创建一个User对象,现在使用电子邮件地址作为 Flask 登录所需的唯一标识符。然后,我们将把我们的User对象传递给Flask-Login模块的login_user()函数,以便它可以处理认证操作。如果登录成功,我们将重定向用户到账户页面。由于用户现在已经登录,这将返回"You are logged in"字符串,而不是之前得到的"Unauthorized"错误。

请注意,我们将使用url_for()函数为我们的账户页面创建一个 URL。我们将把这个结果传递给redirect()函数,以便用户从/login路由被带到/account路由。这比简单地使用以下方式更可取:

return account()

我们的意图更加明确,用户将在浏览器中看到正确的 URL(也就是说,两者都会把用户带到/account页面),但如果我们不使用redirect()函数,即使在/account页面上,浏览器中仍然会显示/login

创建load_user函数

如果用户已经登录,他们的浏览器将通过Flask-Login在我们调用login_user函数时给他们的 cookie 发送信息。这个 cookie 包含了我们在创建User对象时指定的唯一标识符的引用,即在我们的情况下是电子邮件地址。

Flask-Login有一个现有的函数,我们称之为user_loader,它将为我们处理这个问题;我们只需要将它作为我们自己的函数的装饰器,检查数据库以确保用户存在,并从我们得到的标识符创建一个User对象。

将以下函数添加到你的waitercaller.py文件中:

@login_manager.user_loader
def load_user(user_id):
    user_password = DB.get_user(user_id)
    if user_password:
       return User(user_id)

装饰器指示Flask-Login这是我们要用来处理已经分配了 cookie 的用户的函数,每当一个用户访问我们的网站时,它都会把 cookie 中的user_id变量传递给这个函数,这个用户已经有了一个。类似于之前的操作,我们将检查用户是否在我们的数据库中(如果user_id无效,user_password将为空),如果是,我们将重新创建User对象。我们永远不会显式调用这个函数或使用结果,因为它只会被Flask-Login代码使用,但是如果我们的应用程序通过我们的login()函数给用户分配了一个 cookie,当用户访问网站时Flask-Login找不到这个user_loader()函数的实现,我们的应用程序将抛出一个错误。

在这一步中检查数据库似乎是不必要的,因为我们给用户一个据称是防篡改的令牌,证明他或她是一个有效的用户,但实际上是必要的,因为自用户上次登录以来数据库可能已经更新。如果我们使用户的会话令牌有效时间很长(回想一下,在我们的 Headlines 项目中,我们让 cookies 持续了一年),那么用户的帐户在分配 cookie 后可能已经被修改或删除。

检查登录功能

是时候尝试我们的新登录功能了!在本地启动waitercaller.py文件,并在 Web 浏览器中访问localhost:5000。在我们的模拟数据库中输入电子邮件 IDtest@example.com和密码123456,然后点击登录按钮。您应该会被重定向到http://localhost:5000/account,并看到您已登录的消息。

关闭浏览器,然后重新打开,这次直接访问localhost:5000/account。由于我们没有告诉Flask-Login记住用户,您现在应该再次看到未经授权的错误。

由于我们应用程序的性质,我们预计大多数用户都希望保持登录状态,以便餐厅员工可以在早上简单地打开页面并立即使用功能。Flask-Login使这个改变非常简单。只需更改login()函数中的以下行:

 login_user(user)

您的新login()函数现在应该是这样的:

login_user(user, remember=True)

现在,如果您重复前面的步骤,即使重新启动浏览器,您也应该看到您已登录的消息,如下面的屏幕截图所示:

检查登录功能

现在我们可以登录用户,让我们看看如何让用户注销。

注销用户

Flask-Login提供了一个直接可用的注销功能。我们所要做的就是将其链接到一个路由上。在您的waitercaller.py文件中添加以下路由:

@app.route("/logout")
def logout():
   logout_user()
   return redirect(url_for("home"))

然后,在waitercaller.py的导入部分添加logout_user()函数的import

from flask.ext.login import logout_user

请注意,在此调用中不需要将User对象传递给Flask-Loginlogout()函数只是从用户的浏览器中删除会话 cookie。一旦用户注销,我们就可以将他们重定向回主页。

在浏览器中访问localhost:5000/logout,然后尝试再次访问localhost:5000/account。您应该会再次看到未经授权的错误,因为test@example.com用户已注销。

注册用户

我们可以登录用户是很好的,但目前我们只能使用硬编码到我们数据库中的模拟用户来这样做。当注册表格被填写时,我们需要能够将新用户添加到我们的数据库中。我们仍然会通过我们的模拟数据库来完成所有这些工作,因此每次应用程序重新启动时,所有用户都将丢失(它们只会保存在本地 Python 字典变量中,在应用程序终止时丢失)。

我们提到存储用户密码是一个非常糟糕的主意;因此,首先,我们将简要介绍密码哈希的工作原理以及如何更安全地管理密码。

使用密码进行密码管理的密码哈希

我们不想存储密码,而是想存储密码派生出的东西。当用户注册并给我们一个密码时,我们将对其进行一些修改,并存储修改的结果。然后,用户下次访问我们的网站并使用密码登录时,我们可以对输入密码进行相同的修改,并验证结果是否与我们存储的匹配。

问题在于我们希望我们的修改是不可逆的;也就是说,有权访问修改后的密码的人不应该能够推断出原始密码。

输入哈希函数。这些小片段的数学魔法将字符串作为输入并返回(大)数字作为输出。相同的字符串输入将始终产生相同的输出,但几乎不可能使两个不同的输入产生相同的输出。哈希函数被称为单向函数,因为如果您只有输出,则无法推断输入是可以证明的。

注意

密码存储和管理是一个大课题,我们在这个项目中只能触及一点。有关信息安全的大多数事项的更多信息,请访问www.owasp.org。他们关于安全存储密码的全面指南可以在www.owasp.org/index.php/Password_Storage_Cheat_Sheet找到。

Python hashlib

让我们看看如何在 Python 中使用哈希函数。在 Python shell 中运行以下命令:

import hashlib
hashlib.sha512('123456').hexdigest()

作为输出,您应该看到哈希ba3253876aed6bc22d4a6ff53d8406c6ad864195ed144ab5c87621b6c233b548baeae6956df346ec8c17f5ea10f35ee3cbc514797ed7ddd3145464e2a0bab413,如下面的屏幕截图所示:

Python hashlib

十六进制字符的随机字符串是sha512哈希值的'123456'字符串,这是我们将存储在数据库中的内容。每当用户输入明文密码时,我们将通过哈希函数运行它,并验证这两个哈希是否匹配。如果攻击者或员工在数据库中看到哈希值,他们无法冒充用户,因为他们无法从哈希中推断出'123456'

反向哈希

实际上,这一部分的标题并不完全正确。虽然没有办法反向哈希并编写一个函数,该函数以前面的十六进制字符串作为输入并产生'123456'作为输出,但人们可能会非常坚决。黑客可能仍然尝试每种可能的输入,并通过相同的哈希函数运行它,并继续这样做,直到哈希匹配。当黑客遇到一个输入,产生的输出为ba3253876aed6bc22d4a6ff53d8406c6ad864195ed144ab5c87621b6c233b548baeae6956df346ec8c17f5ea10f35ee3cbc514797ed7ddd3145464e2a0bab413时,他已成功破解了密码。

然而,哈希函数往往需要大量的处理能力,因此通过大量输入(称为暴力破解)并不实际。人们还创建了所谓的彩虹表,其中包含所有常见输入的预先计算和存储在数据库中,以便可以立即找到结果。这是计算机科学中经常看到的经典空间-时间权衡。如果我们计算所有可能的输入的哈希值,将需要很长时间;如果我们想要预先计算每种可能的组合,以便我们可以立即查找结果,我们需要大量的存储空间。

如果您转到哈希反转网站,例如md5decrypt.net/en/Sha512/,并输入您在此处注意到的确切十六进制字符串,它会告诉您解密版本为123456

反向哈希

在所声称的 0.143 秒内,它实际上并没有尝试每种可能的输入组合,但它存储了以前计算哈希时的答案。这样的网站有一个包含映射和明文字符串以及它们的哈希等效项的大型数据库。

如果您对字符串进行哈希处理,例如b⁷⁸asdflkjwe@#xx...&AFs--l,并将生成的哈希粘贴到 md5decrypt 网站上,您会注意到该字符串对于该特定网站来说并不常见,而不是再次获得纯文本,您将看到一个类似于以下屏幕截图的屏幕:

![反向哈希

我们希望我们存储的所有密码都足够复杂,以至于在预先计算的哈希表中不存在。然而,我们的用户更有可能选择常见到已经被预先计算的密码。解决方案是在存储密码之前添加所谓的

给密码加盐

由于用户往往使用弱密码,比如 123456,这些密码很可能存在于预先计算的哈希表中,我们希望为我们的用户做一些好事,并在存储密码时为其添加一些随机值。这样,即使恶意攻击者能够访问存储的哈希值,也更难以获取用户的私人密码,尽管我们将存储与密码一起使用的随机值。这就是所谓的给密码加盐;类似于给食物加盐,我们很容易给密码加一些盐,但希望去除盐是不可能的。

总之,我们想要:

  • 在注册时接受用户的明文密码

  • 为这个密码添加一些随机值(盐)以加强它

  • 对密码和盐的连接进行哈希处理

  • 存储哈希和盐

当用户登录时,我们需要:

  • 从用户那里获取明文密码

  • 查找我们数据库中存储的盐,并将其添加到用户的输入中

  • 对密码和盐的连接进行哈希处理

  • 验证结果是否与我们之前存储的相匹配

在 Python 中实现安全的密码存储

为了实现上述内容,我们将创建一个非常小的 PasswordHelper 类,它将负责哈希处理和生成随机盐。尽管这是非常少量的代码,但当我们使用标准的 hashlibosbase64 Python 库时,将所有加密逻辑抽象到自己的类中是一个良好的实践。这样,如果我们改变了密码管理的实现方式,我们可以将大部分更改都应用到这个新类中,而不必触及主应用程序代码。

我们还需要对我们的 login() 函数进行一些更改,完善我们的 registration() 函数,并为我们的数据库辅助代码创建一个新的方法,用于向我们的模拟数据库中添加新用户。

创建 PasswordHelper 类

让我们从 PasswordHelper 开始。在您的 waitercaller 目录中创建一个名为 passwordhelper.py 的文件,并将以下代码添加到其中:

import hashlib
import os
import base64

class PasswordHelper:

   def get_hash(self, plain):
      return hashlib.sha512(plain).hexdigest()

   def get_salt(self):
      return base64.b64encode(os.urandom(20))

   def validate_password(self, plain, salt, expected):
      return self.get_hash(plain + salt) == expected

前两种方法用于用户首次注册时,并可以解释如下:

  • get_hash() 方法只是我们之前看过的 sha512 哈希函数的包装器。我们将使用它来创建最终存储在我们数据库中的哈希值。

  • get_salt() 方法使用 os.urandom() 生成一个密码学上安全的随机字符串。我们将把它编码为 base64 字符串,因为随机字符串可能包含任何字节,其中一些可能会在我们的数据库中存储时出现问题。

validate_password() 方法在用户登录时使用,并再次给出原始明文密码。我们将传入用户给我们的内容(plain 参数),他们注册时存储的盐,并验证对这两者进行哈希处理是否产生了我们存储的相同哈希值(expected 参数)。

更新我们的数据库代码

现在我们需要为每个用户存储一个密码和盐;我们不能再使用之前的简单电子邮件和密码字典。相反,对于我们的模拟数据库,我们将使用一个字典列表,其中我们需要存储的每个信息都有一个键和值。

我们还将更新 mockdbhelper.py 中的代码如下:

MOCK_USERS = [{"email": "test@example.com", "salt": 
 "8Fb23mMNHD5Zb8pr2qWA3PE9bH0=", "hashed":
  "1736f83698df3f8153c1fbd6ce2840f8aace4f200771a46672635374073cc876c  "f0aa6a31f780e576578f791b5555b50df46303f0c3a7f2d21f91aa1429ac22e"}]

class MockDBHelper:
    def get_user(self, email):
        user = [x for x in MOCK_USERS if x.get("email") == email]
        if user:
            return user[0]
        return None

 def add_user(self, email, salt, hashed):
MOCK_USERS.append({"email": email, "salt": salt, "hashed":hashed})

我们的模拟用户仍然使用密码123456,但潜在的攻击者不再能够通过查找彩虹表中的哈希值来破解密码。我们还创建了add_user()函数,该函数接受新用户的emailsalthashed密码,并存储这些记录。我们的get_user()方法现在需要循环遍历所有模拟用户,以找出是否有任何匹配输入电子邮件地址的用户。这是低效的,但将由我们的数据库更有效地处理,并且由于我们永远不会有数百个模拟用户,所以我们不需要担心这一点。

更新我们的应用程序代码

在我们的主要waitercaller.py文件中,我们需要为密码助手添加另一个import,并实例化密码助手类的全局实例,以便我们可以在register()login()函数中使用它。我们还需要修改我们的login()函数以适应新的数据库模型,并完善我们的register()函数以执行一些验证,并调用数据库代码来添加新用户。

waitercaller.py的导入部分添加以下行:

from passwordhelper import PasswordHelper

然后,在创建DBHelper()对象的地方附近添加以下内容:

PH = PasswordHelper()

现在,修改login()函数如下:

@app.route("/login", methods=["POST"])
def login():
   email = request.form.get("email")
   password = request.form.get("password")
 stored_user = DB.get_user(email)
 if stored_user and PH.validate_password(password, stored_user['salt'], stored_user['hashed']):
      user = User(email)
      login_user(user, remember=True)
      return redirect(url_for('account'))
   return home()

唯一的真正变化在if语句中,我们现在将使用密码助手使用盐和用户提供的密码来验证密码。我们还将用户的变量名称更改为stored_user,因为现在这是一个字典,而不仅仅是以前的密码值。

最后,我们需要构建register()函数。这将使用密码和数据库助手来创建一个新的加盐和哈希密码,并将其与用户的电子邮件地址一起存储在我们的数据库中。

waitercaller.py文件中添加/register路由和相关函数,代码如下:

@app.route("/register", methods=["POST"])
def register():
   email = request.form.get("email")
   pw1 = request.form.get("password")
   pw2 = request.form.get("password2")
   if not pw1 == pw2:
      return redirect(url_for('home'))
   if DB.get_user(email):
      return redirect(url_for('home'))
   salt = PH.get_salt()
   hashed = PH.get_hash(pw1 + salt)
   DB.add_user(email, salt, hashed)
   return redirect(url_for('home'))

我们要求用户在注册表单上两次输入他们的密码,因为用户在注册时很容易出现输入错误,然后无法访问他们的帐户(因为他们使用了与他们打算使用的密码不同的密码)。因此,在这一步中,我们可以确认用户输入的两个密码是相同的。

我们还验证了用户是否已经存在,因为每个用户都需要使用唯一的电子邮件地址。

最后,我们生成了一个盐,从密码和盐创建了一个哈希,并将其存储在我们的数据库中。然后,我们将用户重定向回主页,测试我们的注册功能。

现在是时候再次对应用程序进行测试了。关闭浏览器并在本地重新启动应用程序。访问主页并通过选择电子邮件和密码注册一个帐户。注册后,使用刚刚注册的相同用户名和密码登录。如果一切顺利,您将看到您已登录消息。然后再次访问http://localhost:5000/logout以注销。

总结

在本章中,我们学习了如何使用 Bootstrap 使我们的应用程序在开箱即用时看起来很好,并根据用户的屏幕大小进行响应。我们建立了一个基本的用户帐户控制系统,我们可以注册用户,登录用户,然后再次注销用户。

我们还花了一些时间研究如何使用加密哈希函数和盐来安全存储密码。

在下一章中,我们将构建应用程序的功能,这些功能在本章开头的项目概述中讨论过。我们还将看一种更简单的方法来创建访问者将用来与我们的应用程序交互的表单。

第十章:在服务员呼叫项目中使用模板继承和 WTForms

在上一章中,我们创建了一个基本的用户账户系统。然而,我们只是做了一个非常简单的路由访问控制——只是简单地显示字符串“您已登录”。在本章中,我们将添加一些更多的期望功能,并允许已登录用户添加餐厅桌子,查看与这些桌子相关的 URL,并查看顾客的关注请求。我们将遇到的一个问题是希望在我们的应用程序的不同页面上重用相同的元素。您将看到如何通过使用 Jinja 的继承系统来解决这个问题,而不会出现代码重复。正如在上一章中提到的,当出现错误时,比如输入了错误的密码,我们与用户的沟通并不是很好。为了解决这个问题,我们将看一下另一个 Flask 扩展,WTForms,并看看它如何简化创建和验证表单。

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

  • 将账户和仪表板页面添加到我们的应用程序中

  • 使用 bitly API 缩短 URL

  • 添加处理关注请求的功能

  • 通过 WTForms 添加用户反馈

添加账户和仪表板页面

我们想要在我们的应用程序中添加两个新页面:'仪表板',在这里可以看到特定餐厅的所有顾客请求,以及'账户',在这里餐厅可以管理他们的桌子并查看他们需要在桌子上提供的 URL。

我们可以简单地在我们的templates目录中创建两个新的.html文件,并从头开始编写 HTML。但很快我们会发现,我们需要从我们的主页中使用许多相同的元素(至少包括和配置 Bootstrap 的部分)。然后我们会忍不住只是复制粘贴主页的 HTML,并从那里开始处理我们的新页面。

介绍 Jinja 模板

复制和粘贴代码通常意味着有些地方出了问题。在应用程序代码中,这意味着您没有很好地模块化您的代码,并且需要创建一些更多的类,并可能添加一些import语句来包含重用的代码。使用 Jinja,我们可以遵循一个非常相似的模式,通过使用模板继承。我们首先将我们的主页分成两个单独的模板文件,base.htmlhome.html,其中包含我们想要在基本文件中重用的所有元素。然后我们可以让我们的其他三个页面(主页、账户和仪表板)都继承自基本模板,并且只编写在这三个页面之间有所不同的代码。

Jinja 通过使用blocks的概念来处理继承。每个父模板都可以有命名块,而扩展父模板的子模板可以用自己的自定义内容填充这些块。Jinja 继承系统非常强大,可以处理嵌套块和覆盖现有块。然而,我们只会浅尝其功能。我们的基本模板将包含所有可重用的代码,并包含一个名为content的空块和一个名为navbar的块。我们的三个页面将从基本模板扩展,提供它们自己版本的内容块(用于主页面内容)和导航栏。我们需要使导航栏动态化,因为页面顶部的登录字段只有在用户未登录时才会出现。

创建基本模板

在您的templates目录中创建一个名为base.html的新文件,并插入以下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Waiter Caller</title>

    <!-- Bootstrap core CSS -->
    <link href="../static/css/bootstrap.min.css" rel="stylesheet">

    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!--[if lt IE 9]>
      <script src="img/html5shiv.min.js"></script>
      <script src="img/respond.min.js"></script>
    <![endif]-->

  </head>
  <body>

    {% block navbar %}
    <nav class="navbar navbar-inverse navbar-fixed-top">
      <div class="container">
        <div class="navbar-header">
          <a class="navbar-brand" href="/dashboard">Dashboard</a>
          <a class="navbar-brand" href="/account">Account</a>
        </div>
      </div>
    </nav>
    {% endblock %}

    {% block content %}
    {% endblock %}

    <div class="container">

      <hr>
      <footer>
        <p>&copy; A. Non 2015</p>
      </footer>
    </div>
  <!-- Bootstrap core JavaScript
    ================================================== -->
    <!-- Placed at the end of the document so the pages load faster -->
    <script   src="img/jquery.min.js"></script>
    <script src="img/bootstrap.min.js"></script>
  </body>
</html>

在上面的代码中,我们在一个文件中拥有所有的页眉和页脚代码——这些元素将在所有页面中共同存在。我们使用 Jinja 语法定义了两个块,这与我们之前看到的其他 Jinja 语句类似,即:

{% block content %}
{% endblock %}

{% block navbar %}
[...]
{% endblock %}

在这个例子中,contentnavbar是我们块的名称,我们可以自由选择这些名称,而blockendblock是 Jinja 关键字,{% %}符号用于指示 Jinja 语句,就像之前的例子中一样。这本身就是一个完全有效的 Jinja 模板;即使内容块是空的,我们也可以直接从我们的 Flask 应用程序中呈现模板,我们会看到一个页面,它只是假装内容块不存在。

我们还可以扩展这个模板;也就是说,我们可以使用它作为父模板创建子模板。子模板可以通过再次声明来覆盖任何指定的块。我们将navbar声明为一个块,因为我们的主页将使用我们之前编写的导航栏——包括登录表单。然而,一旦登录,我们的仪表板和账户页面将具有完全相同的导航栏——这是我们在基本模板中定义的导航栏。

创建仪表板模板

我们的仪表板页面最终将显示所有客户的服务请求,以便服务员可以轻松地看到哪些桌子需要关注。不过,现在我们只是创建页面的大纲。在您的templates目录中创建一个名为dashboard.html的新文件,并添加以下代码:

{% extends "base.html" %}

{% block content %}
    <div class="jumbotron">
      <div class="container">
        <h1>Dashboard</h1>
        <p>View all patron requests below</p>
      </div>
    </div>

    <div class="container">
      <div class="row">
        <div class="col-md-12">
          <h2>Requests</h2>
          <p>All your customers are currently satisfied - no requests</p>
        </div>    
      </div>
    </div>
{% endblock %}

在前面的代码片段中,最重要的一行是第一行——我们使用 Jinja 的extends关键字来指示这个模板应该继承另一个模板中包含的所有代码。关键字后面跟着要继承的模板的文件名,包含在引号中。

接下来,我们只需以与基本模板相同的方式创建内容块。这一次,我们不是留空,而是添加一些 HTML 来显示在我们的仪表板页面上。

创建账户模板

账户页面将是用户可以添加新表格、删除表格或获取现有表格的 URL 的页面。同样,由于我们还没有任何应用程序代码来表示表格,我们将只是创建页面的大纲。在您的templates目录中创建一个名为account.html的文件,并添加以下代码:

{% extends "base.html" %}

{% block content %}
    <div class="jumbotron">
      <div class="container">
        <h1>Account</h1>
        <p>Manage tables and get URLs</p>
      </div>
    </div>

    <div class="container">
      <div class="row">
        <div class="col-md-12">
          <h2>Tables</h2>

        </div>    
      </div>
    </div>
{% endblock %}

创建主页模板

home.html模板包含了我们主页的整个特定代码,它不是基本模板的一部分。代码可以在代码包中的templates/home_1.html中看到,但这里没有包含,因为它太长了。看一下它,看看我们如何定义一个包含login表单的新navbar块,并覆盖了基本模板中提供的默认块。同样,它定义了内容块,替换了我们在基本模板中定义的空内容块。最终结果并没有改变——我们仍然会看到完全相同的主页,但现在代码分为base.htmlhome.html文件,允许我们重用它的大部分内容,用于我们之前创建的新页面。

添加路由代码

当访问/account/dashboard时,我们需要我们的 Python 代码返回新的模板文件。在您的waitercaller.py文件中添加dashboard()函数,并修改账户function()如下:

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

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

尝试新页面!像以前一样在本地运行应用程序:

python waitercaller.py

转到http://localhost:5000查看主页。使用表单登录,现在,您应该看到一个更漂亮的账户页面的骨架,如下图所示:

添加路由代码

在顶部的导航栏中点击仪表板链接,您也应该看到该页面的骨架,如下图所示:

添加路由代码

创建餐厅桌子

现在我们需要向我们的应用程序引入的概念,并且能够在我们的数据库和应用程序代码中表示它。一个表应该具有以下属性:

  • 唯一标识我们应用程序所有用户中的该表的 ID 号

  • 一个用户可定义且在特定用户的表格中唯一的名称

  • 一个所有者,以便我们知道表格属于哪个用户

如果我们严格遵循面向对象编程的风格,我们将创建一个Table类,其中包含这些属性。然后,我们还将为应用程序中的所有内容创建一堆其他类。按照这种方法,我们还将创建方法来将我们的每个对象序列化为可以存储在数据库中的内容,并创建更多的方法来反序列化它们,从数据库中恢复为对象。

为了简洁起见,并且因为我们的模型足够简单,我们将采取一种捷径,这肯定会冒犯一些人,简单地使用 Python 字典来表示我们大部分的对象。当我们将 MongoDB 添加到我们的应用程序时,我们将看到这些字典将很容易地写入和从数据库中读取。

编写餐厅表格代码

让我们简要看一下我们的表需要做什么。首先,我们的应用用户需要能够在“账户”页面上添加和删除新表格,无论是最初注册账户时还是以后需要进行更改时。其次,用户应该能够查看与每个表格相关联的 URL,以便这些 URL 可以被打印并在实际表格上提供。当添加新表格时,我们需要创建一个模拟数据库。

我们将从在“账户”页面上为用户提供一个输入框开始,他们可以在其中输入新表格的名称或编号以创建它。创建新表格时,我们将创建一个唯一的 ID 号,并使用它来创建一个新的 URL。然后,我们将使用 bitly API 来创建 URL 的缩短版本,这样我们的用户的顾客将更容易地在智能手机上输入。然后,我们将在我们的模拟数据库中存储表格名称、ID 和缩短的 URL。

添加创建表单

account.html模板中,在<h2>Tables</h2>下面直接添加以下内容:

<h2>Add new table</h2>
<form class="form-inline" action="/account/createtable" method="POST">
  <input type="text" name="tablenumber" placeholder="Table number or name" class="form-control">
  <input type="submit" value="Create" class="btn btn-primary">
</form>

这是一个非常基本的表单,只有一个输入框用于输入新表格的名称和一个提交表单的按钮。如果您加载应用程序并导航到“账户”页面,您现在应该看到类似以下图片的东西:

添加创建表单

添加创建表路由

创建表格后端并不太复杂,但有一些重要的细节需要理解。首先,我们的用户可以给表格任何他们想要的名称。对于大多数用户,这些名称可能只是从 1 开始递增的数字,以餐厅中的表格数量结束,因为这是餐厅命名表格的常见方式。因为许多餐厅经理将使用我们的应用程序,我们不能假设这些名称在所有账户中是唯一的。我们应用程序的大多数用户可能会有一个名为1的表格。因此,当餐厅顾客表示他或她在 1 号桌上并需要服务时,我们必须能够从潜在的许多餐厅中选择正确的 1 号桌。为了解决这个问题,我们数据库中的每个表格都将有一个唯一的 ID,我们将使用它来在 URL 中标识表格,但我们将在“账户”页面上显示用户选择的名称(例如1),以便我们的用户可以轻松管理他们的个人表格列表。

当我们向数据库中插入新项目时,我们将获得该项目的唯一 ID。但是,因为我们想要将 ID 作为 URL 的一部分使用,我们陷入了一种先有鸡还是先有蛋的情况,我们需要将表格插入数据库以获得 ID,但我们也需要 ID 以便在正确地将表格插入数据库之前创建 URL。

为了解决这个问题,我们必须将一个半创建的表格插入到我们的数据库中以获得 ID,然后使用 ID 创建 URL,然后更新我们刚刚创建的表格以将其与 URL 关联起来。

将以下路由添加到waitercaller.py文件中,以执行此操作(或者说,一旦我们在数据库代码中创建了所需的函数,它将执行此操作):

@app.route("/account/createtable", methods=["POST"])
@login_required
def account_createtable():
  tablename = request.form.get("tablenumber")
  tableid = DB.add_table(tablename, current_user.get_id())
  new_url = config.base_url + "newrequest/" + tableid
  DB.update_table(tableid, new_url)
  return redirect(url_for('account'))

请注意,我们将与账户页面相关的应用程序功能结构化为子路由/account/。我们在属于账户的路由的函数名称前加上account_。这有助于我们在应用程序代码中拥有更清晰的部分,随着我们添加更多路由,代码可能会变得混乱和难以维护。

我们必须将每个表与所有者关联起来,因此我们使用FlaskLogin current_user功能来获取当前登录用户的 ID。我们还将使用我们的config.py文件来定义要与表关联的基本 URL。

将以下导入添加到waitercaller.py中,以使用current_user功能并访问我们的config

from flask.ext.login import current_user
import config

将以下内容添加到config.py文件中(请记住,这不是 Git 存储库的一部分,因此此值仅用于本地开发):

base_url = "http://127.0.0.1:5000/"

上述 URL 与我们一直在使用的localhost:5000完全相同,因为127.0.0.1是一个特殊的 IP 地址,总是指向自己的机器。但是,我们将在config中使用 IP 地址而不是localhost,以保持与我们将在本章的下一节中使用的 Bitly API 的兼容性,即缩短 URL。

添加创建表数据库代码

我们的表的模拟数据库代码类似于我们的用户和密码的模拟数据库代码。在mockdbhelper.py文件的顶部创建以下字典列表,用于存储您的表:

MOCK_TABLES = [{"_id": "1", "number": "1", "owner": "test@example.com","url": "mockurl"}]

上述代码还创建了一个单一的表1,并将其分配给我们的模拟用户。请注意,1_id键的值,对于我们的生产系统,它将是所有用户帐户中唯一的 ID 号。number键的值为1是用户选择的值,可能会在系统的不同用户之间重复。因为我们只有一个测试用户,我们将简化我们的模拟代码,并始终为唯一 ID 和用户选择的数字使用相同的值。

对于我们的模拟数据库,添加表就是简单地将代表表的新字典附加到现有的模拟表列表中。将以下方法添加到mockdbhelper.py文件中:

def add_table(self, number, owner):
    MOCK_TABLES.append({"_id": number, "number": number, "owner":owner})
    return number

我们从此函数返回number,这是模拟 ID。在我们的测试代码中,这是输入到此函数的相同值。在我们的真实代码中,这个数字将是生成的 ID,并且将与输入不同。

最后,我们需要添加update_table()方法,这将允许我们将 URL 与表关联起来。将以下方法添加到mockdbhelper.py中:

def update_table(self, _id, url):
    for table in MOCK_TABLES:
        if table.get("_id") == _id:
            table["url"] = url
            break

我们的应用程序代码为上述方法提供了由add_table()方法生成的表 ID 以及要与表关联的 URL。然后,update_table()方法找到正确的表并将 URL 与表关联起来。再次强调,通过列表进行循环可能看起来效率低下,而不是使用字典,但对于我们的模拟数据库代码来说,使用与我们将在下一章中编写的真实数据库代码相同的思想是很重要的。因为我们的真实数据库将存储一系列表,我们的模拟代码通过将它们存储在列表中来模拟这一点。

添加查看表数据库代码

我们现在已经具备了添加新表的功能,但我们还看不到它们。我们希望在账户页面上列出所有现有的表,以便我们可以看到存在哪些表,有能力删除它们,并查看它们的 URL。

将以下方法添加到mockdbhelper.py中,将允许我们访问特定用户的现有表:

  def get_tables(self, owner_id):
    return MOCK_TABLES

再次简化并让我们的测试代码忽略owner_id参数并返回所有表(因为我们只有一个测试用户)。但是,我们的模拟方法必须接受与我们真实方法相同的输入和输出,因为我们不希望我们的应用程序代码知道它是在运行生产代码还是测试代码。

修改账户路由以传递表格数据

我们应该从数据库中获取有关表的最新信息,并在每次加载我们的账户页面时向用户显示这些表。修改waitercaller.py中的/account路由如下:

@app.route("/account")
@login_required
def account():
    tables = DB.get_tables(current_user.get_id())
    return render_template("account.html", tables=tables)

上述方法现在从数据库获取表,并将数据传递给模板。

修改模板以显示表格

我们的模板现在可以访问表格数据,所以我们只需要循环遍历每个表并显示相关信息。此时使用的术语可能会有点令人困惑,因为我们将使用 HTML 表来显示有关我们虚拟餐厅桌子的信息,即使表的用法是不相关的。HTML 表是一种显示表格数据的方式,在我们的情况下是有关餐厅桌子的数据。

account.html文件中,在<h2>tables</h2>行下面添加以下代码:

<table class="table table-striped">
  <tr>
    <th>No.</th>
    <th>URL</th>
    <th>Delete</th>
  </tr>
  {% for table in tables %}
    <form class="form-inline" action="/account/deletetable">
      <tr>
        <td>{{table.number}}</td>
        <td>{{table.url}}</td>
        <td><input type="submit" value="Delete" class="form-control"></td>
        <input type="text" name="tableid" value="{{table._id}}" hidden>
      </tr>
    </form>
  {% endfor %}
</table>

上述代码创建了一个简单的表格,显示了表格编号(用户选择)、URL 和每个表的删除按钮。实际上,每个表都是一个提交请求以删除特定表的表单。为了做到这一点,我们还使用了包含每个表的唯一 ID 的隐藏输入。此 ID 将随着delete请求一起传递,以便我们的应用程序代码知道从数据库中删除哪个表。

在后端代码中添加删除表路由

在您的waitercaller.py文件中添加以下路由,它只接受需要删除的表 ID,然后要求数据库删除它:

@app.route("/account/deletetable")
@login_required
def account_deletetable():
  tableid = request.args.get("tableid")
  DB.delete_table(tableid)
  return redirect(url_for('account'))

mockdbhelper.py中创建以下方法,它接受一个表 ID 并删除该表:

    def delete_table(self, table_id):
        for i, table in enumerate(MOCK_TABLES):
            if table.get("_id") == table_id:
                del MOCK_TABLES[i]
             break

与我们之前编写的更新代码类似,必须在删除之前循环遍历模拟表以找到具有正确 ID 的表。

测试餐厅桌子代码

我们已经在我们的应用程序中添加了相当多的代码。由于我们添加的许多不同代码部分彼此依赖,因此在编写代码时实际运行代码是困难的。但是,现在我们有了创建、查看和删除表的功能,所以我们现在可以再次测试我们的应用程序。启动应用程序,登录,并导航到账户页面。您应该看到单个模拟表,并能够使用创建表单添加更多表。通过添加新表和删除现有表来进行操作。当您添加表时,它们应该根据其编号获得与它们相关联的 URL(请记住,对于我们的生产应用程序,此编号将是一个长的唯一标识符,而不仅仅是我们为表选择的编号)。界面应该如下图所示:

测试餐厅桌子代码

还要通过调整浏览器窗口的大小来再次查看此页面的移动视图,使其变窄以触发布局切换。请注意,由于我们使用了 Bootstrap 的响应式布局功能,删除按钮会靠近 URL,创建按钮会移动到文本输入下方,如下图所示:

测试餐厅桌子代码

这可能看起来不如全尺寸视图那么好,但对于我们的访问者来说肯定会很有帮助,他们想要从手机上使用我们的网站,因为他们不需要担心放大或横向滚动来访问我们网站的所有功能。

使用 bitly API 缩短 URL

我们的用户不想输入我们目前提供的长 URL 来呼叫服务员到他们的桌子。我们现在将使用 bitly API 来创建我们已经创建的 URL 的更短的等价物。这些更短的 URL 可以更容易地输入到地址栏中(特别是在移动设备上),然后将显示为与当前更长的 URL 相关联的相应桌子。

介绍 Bitly

Bitly 及许多类似服务背后的原理很简单。给定任意长度的 URL,该服务返回形式为bit.ly/XySDj72的更短 URL。Bitly 和类似服务通常具有非常短的根域(bit.ly 是五个字母),它们只是维护一个数据库,将用户输入的长 URL 链接到它们创建的短 URL。因为它们使用大小写字母和数字的组合来创建缩短的 URL,所以即使保持 URL 的总长度非常短,也不会缺乏组合。

使用 bitly API

与我们使用过的其他 API 一样,bitly 是免费使用的,但在一定的限制内需要注册才能获得 API 令牌。bitly API 通过 HTTPS 访问,并返回 JSON 响应(与我们之前看到的类似)。为了与 API 进行交互,我们将使用几行 Python 代码以及urllib2json标准库。

获取 bitly oauth 令牌

在撰写本文时,bitly 提供了两种验证其 API 的方式。第一种是在注册时给你的 API 令牌。第二种方式是使用 oauth 令牌。由于 bitly 正在淘汰 API 令牌,我们将使用 oauth 令牌。

第一步是在bitly.com上注册一个帐户并确认您的电子邮件地址。只需转到bitly.com,点击注册按钮,然后提供用户名、电子邮件地址和密码。点击他们发送到提供的电子邮件的确认链接,并登录到您的 bitly 帐户。

要注册 oauth 令牌,请转到bitly.com/a/oauth_apps,并在提示时再次输入密码。现在您应该在屏幕上看到您的新 oauth 令牌。复制这个,因为我们将在接下来要编写的 Python 代码中需要它。它应该看起来像这样:ad922578a7a1c6065a3bb91bd62b02e52199afdb

创建 bitlyhelper 文件

按照我们在构建这个 Web 应用程序的整个过程中使用的模式,我们将创建一个BitlyHelper类来缩短 URL。同样,这是一个很好的做法,因为它允许我们在需要时轻松地用另一个链接缩短服务替换这个模块。在您的waitercaller目录中创建一个名为bitlyhelper.py的文件,并添加以下代码,根据需要替换您的 bitly oauth 令牌。以下代码片段中的令牌对于此 Waiter Caller 应用程序是有效的。您应该按照上述步骤获得的令牌进行替换。

import urllib2
import json

TOKEN = "cc922578a7a1c6065a2aa91bc62b02e41a99afdb"
ROOT_URL = "https://api-ssl.bitly.com"
SHORTEN = "/v3/shorten?access_token={}&longUrl={}"

class BitlyHelper:

    def shorten_url(self, longurl):
        try:
            url = ROOT_URL + SHORTEN.format(TOKEN, longurl)
            response = urllib2.urlopen(url).read()
            jr = json.loads(response)
            return jr['data']['url']
        except Exception as e:
            print e

这个BitlyHelper类提供了一个方法,它接受一个长 URL 并返回一个短 URL。关于最后一个代码片段没有什么难以理解的地方,因为它只是使用了我们在使用基于 JSON 的 API 通过 HTTP 时已经看到的想法。

使用 bitly 模块

要使用我们的 bitly 代码,我们只需要在我们的主应用程序代码中创建一个BitlyHelper对象,然后在每次创建新的餐厅桌子时使用它来创建一个短 URL。修改waitercaller.py的全局部分如下:

DB = DBHelper()
PH = PasswordHelper()
BH = BitlyHelper()

并将BitlyHelper()的导入添加到waitercaller.py的导入部分:

from bitlyhelper import BitlyHelper

现在修改createtable方法如下:

@app.route("/account/createtable", methods=["POST"])
@login_required
def account_createtable():
  tablename = request.form.get("tablenumber")
  tableid = DB.add_table(tablename, current_user.get_id())
 new_url = BH.shorten_url(config.base_url + "newrequest/" + tableid)
  DB.update_table(tableid, new_url)
  return redirect(url_for('account'))

启动应用程序并再次转到账户页面。创建一个新表,你会看到新表的 URL 是一个 bitly URL。如果你在浏览器中访问这个 URL,你会发现它会自动重定向到类似http://127.0.0.1/newrequest/2的东西(这时应该会抛出服务器错误)。

现在我们可以将短网址与每个新创建的表关联起来,我们需要在我们的应用程序中添加请求的概念,这样当我们的用户的顾客访问这些网址时,我们就会通知餐厅需要关注的请求。

添加处理关注请求的功能

我们需要处理关注请求的两个方面。第一个,正如前面讨论的,是当用户访问 URL 时创建新的请求。第二个是允许餐厅的服务员查看这些请求并将它们标记为已解决。

编写关注请求代码

当用户访问 URL 时,我们应该创建一个关注请求并将其存储在数据库中。这个关注请求应该包含:

  • 请求发出的时间

  • 发出请求的桌子

和以前一样,我们将使用 Python 字典来表示关注请求对象。我们需要让我们的应用程序代码创建新的关注请求,并允许这些请求被添加、检索和从数据库中删除。

添加关注请求路由

waitercaller.py中添加以下路由:

@app.route("/newrequest/<tid>")
def new_request(tid):
  DB.add_request(tid, datetime.datetime.now())
  return "Your request has been logged and a waiter will be withyou shortly"

这个路由匹配一个动态的表 ID。由于我们的 URL 使用全局唯一的表 ID 而不是用户选择的表号,我们不需要担心哪个餐厅拥有这张桌子。我们告诉我们的数据库创建一个新的请求,其中包含表 ID 和当前时间。然后我们向顾客显示一条消息,通知他或她请求已成功发出。请注意,这是我们的用户的顾客将使用的应用程序的唯一路由。其余的路由都只用于餐厅经理或服务员自己使用。

我们还需要 Python 的datetime模块来获取当前时间。在waitercaller.py的导入部分添加以下行:

import datetime

添加关注请求数据库代码

关注请求的数据库代码使用了与我们最近添加的处理餐厅桌子的代码相同的思想。在mockdbhelper.py的顶部添加以下全局变量:

MOCK_REQUESTS = [{"_id": "1", "table_number": "1","table_id": "1", "time": datetime.datetime.now()}]

前面的全局变量为表号 1(现有的模拟表)创建了一个单独的模拟关注请求,并将请求时间设置为我们启动waitercaller应用程序时的时间。

python waitercaller.py

每当我们在开发过程中对我们的应用程序进行更改时,服务器都会重新启动,这时的时间也会更新为当前时间。

我们还需要在dbconfig.py文件的顶部添加datetime模块的导入:

import datetime

对于实际的add_request()方法,重要的是要区分表号(用户选择的)和表 ID(在我们所有用户中全局唯一)。用于创建请求的 URL 使用了全局唯一 ID,但服务员希望在请求通知旁边看到可读的表名。因此,在添加请求时,我们找到与表 ID 相关联的表号,并将其包含在存储的请求中。

mockdbhelper.py中添加以下方法:

    def add_table(self, number, owner):
        MOCK_TABLES.append(
            {"_id": str(number), "number": number, "owner": owner})
        return number

同样,我们使用table_id作为表示请求的字典的唯一 ID。和以前一样,当我们添加一个真正的数据库时,我们会在这里生成一个新的请求 ID,这个 ID 不会和我们的表 ID 相同。

添加关注请求的获取和删除方法

在编辑数据库代码的同时,也添加以下方法:

def get_requests(self, owner_id):
    return MOCK_REQUESTS

def delete_request(self, request_id):
    for i, request [...]
        if requests [...]
            del MOCK_REQUESTS[i]
            break

第一个方法获取特定用户的所有关注请求,将用于在我们的仪表板页面上填充所有需要服务员关注的未解决请求。第二个删除特定的请求,并将用于(同样是从仪表板页面)当服务员标记请求为已解决时。

注意

如果我们的 Waiter Caller 应用旨在提供更高级的功能,我们可能会向请求添加一个属性,将它们标记为已解决,而不是直接删除它们。如果我们想要提供有关有多少请求正在进行,平均需要多长时间才能解决等分析,那么保留已解决的请求将是必不可少的。对于我们简单的实现来说,已解决的请求没有进一步的用处,我们只是删除它们。

修改仪表板路由以使用关注请求

当餐厅经理或服务员打开应用程序的仪表板时,他们应该看到所有当前的关注请求以及请求被发出的时间(以便可以优先处理等待时间更长的顾客)。我们有请求被记录的时间,所以我们将计算自请求被发出以来经过的时间。

修改waitercaller.py中的dashboard()路由如下所示:

@app.route("/dashboard")
@login_required
def dashboard():
    now = datetime.datetime.now()
    requests = DB.get_requests(current_user.get_id())
    for req in requests:
        deltaseconds = (now - req['time']).seconds
        req['wait_minutes'] = "{}.{}".format((deltaseconds/60), str(deltaseconds % 60).zfill(2))
    return render_template("dashboard.html", requests=requests)

修改后的dashboard()路由会获取属于当前登录用户的所有关注请求,使用current_user.get_id()和以前一样。我们为每个请求计算一个时间差(当前时间减去请求时间),并将其添加为我们请求列表中每个请求的属性。然后我们将更新后的列表传递到模板中。

修改模板代码以显示关注请求

我们希望我们的仪表板代码检查是否存在任何关注请求,然后以类似于账户页面上显示表格的方式显示每个请求。每个关注请求都应该有一个解决按钮,允许服务员指示他已处理该请求。

如果不存在关注请求,我们应该显示与之前在仪表板页面上显示的相同消息,指示当前所有顾客都满意。

将以下代码添加到dashboard.html的主体中,删除我们之前添加的占位符语句:

<h2>Requests</h2>
{% if requests %}
  <table class="table table-striped">
    <tr>
      <th>No.</th>
      <th>Wait</th>
      <th>Resolve</th>
    </tr>
    {% for request in requests %}
      <tr>
        <form class="form-inline" action="/dashboard/resolve">
          <td>{{request.table_number}}</td>
          <td>{{request.wait_minutes}}</td> 
          <input type="text" name="request_id" value="{{request._id}}" hidden>
          <td><input type="submit" value="Resolve" class="btn btn-primary"></td>
        </form>
      </tr>
    {% endfor %}
  </table>
{% else %}
  <p>All your customers are currently satisfied - no requests</p>
{% endif %}

上述代码与我们在accounts模板中看到的表格代码非常相似。我们没有删除按钮,而是有一个解决按钮,类似地使用包含请求 ID 的隐藏文本输入来解决正确的关注请求。

添加解决请求应用程序代码

让我们添加应用程序代码来处理解决请求。类似于我们在所有账户功能中使用子路由/account的方式,我们在/dashboard中使用了前面讨论过的形式。将以下路由添加到waitercaller.py中:

@app.route("/dashboard/resolve")
@login_required
def dashboard_resolve():
  request_id = request.args.get("request_id")
  DB.delete_request(request_id)
  return redirect(url_for('dashboard'))

我们已经添加了数据库代码来删除关注请求,所以在这里我们只需要使用正确的请求 ID 调用该代码,我们可以从模板中的隐藏字段中获取。

有了这个,我们应用程序的大部分功能应该是可测试的。让我们试试看!

测试关注请求代码

启动应用程序,测试所有新功能。首先,导航到账户页面,然后在新标签中导航到测试表格的 URL(或添加新表格并使用新 URL 重新测试先前的代码)。您应该看到'您的请求已被记录,服务员将很快与您联系'的消息,如下图所示:

测试关注请求代码

现在返回应用程序并导航到仪表板页面。您应该看到模拟请求以及您刚刚通过访问 URL 创建的新请求,如下截图所示:

测试关注请求代码

刷新页面并注意'等待'列中的值适当增加(每次刷新都会重新计算应用程序代码中的时间差)。

自动刷新仪表板页面

服务员不希望不断刷新仪表板以检查新请求并更新现有请求的等待时间。我们将添加一个元 HTML 标签,告诉浏览器页面应定期刷新。我们将在基本模板中添加一个通用的元标签占位符,然后在我们的dashboard.html模板中用刷新标签覆盖它。

dashboard.html文件中,添加一个包含元 HTML 标签的 Jinja 块,位于内容块上方:

{% extends "base.html" %}
{% block metarefresh %} <meta http-equiv="refresh" content="10" > {% endblock %}
{% block content %}

元 HTML 标签指示与我们提供的内容没有直接关系的消息。它们也可以用来添加关于页面作者的信息,或者给出搜索引擎在索引页面时可能使用的关键词列表。在我们的情况下,我们正在指定一个要求浏览器每十秒刷新一次的元标签。

base.html文件中,创建一个等效的空占位符:

 {% block metarefresh %} {% endblock %}
    <title>Waiter Caller</title>

现在再次在浏览器中打开应用程序并导航到仪表板页面。每 10 秒,您应该看到页面刷新并等待时间更新。如果您创建新的关注请求,您还将在自动刷新后看到这些请求。

使用 WTForms 添加用户反馈

现在我们有一个基本上功能齐全的 Web 应用程序,但在提交 Web 表单时仍未能为用户提供有用的反馈。让我们看看如何通过在用户成功或失败完成各种操作时提供反馈来使我们的应用程序更直观。

为了让我们的生活更轻松,我们将使用另一个 Flask 附加组件 WTForms,它让我们通过使用预定义模式或创建自己的模式来验证输入。我们将使用 WTForms 来实现所有我们的 Web 表单,即:

  • 注册表格

  • 登录表格

  • 创建表格表单

引入 WTForms

您可能已经注意到,为新用户创建注册表格以注册我们的 Web 应用程序有点麻烦。我们不得不在模板文件中创建 HTML 表单,然后在表单提交时在我们的 Python 后端代码中获取所有输入数据。为了做到这一点,我们不得不在我们的 HTML 代码(用于name属性)和我们的 Python 代码(将数据从各个字段加载到变量中)中使用相同的字符串,如emailpassword。这些字符串emailpassword是有时被称为魔术字符串的例子。对于我们来说,创建应用程序时,这些字符串必须在两个文件中相同可能是显而易见的,但对于将来可能需要维护应用程序的另一个开发人员,甚至对于我们自己的未来,这种隐含的联系可能会变得不那么明显和更加令人困惑。

此外,我们不得不在应用程序代码中使用相当丑陋的if语句来确保密码匹配。事实证明,我们希望对用户输入进行更多验证,而不仅仅是检查密码是否匹配。我们可能还希望验证电子邮件地址是否看起来像电子邮件地址,密码是否不太短,以及可能还有其他验证。随着用户输入表单变得越来越长,验证规则变得更加复杂,我们可以看到,如果我们继续像迄今为止那样开发表单,我们的应用程序代码很快就会变得非常混乱。

最后,正如前面提到的,当事情出错时,我们的表单未能为用户提供有用的反馈。

WTForms 以一种简单直观的方式解决了所有这些问题。我们很快将解释如何创建代表表单的 Python 类。这些类将包含验证规则、字段类型、字段名称和反馈消息,所有这些都在同一个地方。然后我们的 Jinja 模板和应用程序代码可以使用相同的对象来呈现表单(当用户查看页面时)和处理输入(当用户提交表单时)。因此,使用 WTForms 可以使我们的代码更清晰,并加快开发速度。在深入了解如何使用它来改进我们的应用程序之前,我们将快速了解如何为 Flask 安装 WTForms。

请注意,WTForms 是一个通用的 Python Web 开发附加组件,可以与许多不同的 Python Web 开发框架(如 Flask、Django 等)和模板管理器(如 Jinja2、Mako 等)一起使用。我们将安装一个特定于 Flask 的扩展,该扩展将安装 WTForms 并使其易于与我们的 Flask 应用程序进行交互。

安装 Flask-WTF

我们需要为 Flask 安装 WTForms 附加组件。这与我们之前的扩展相同。只需运行以下命令(如往常一样,请记住在本地和 VPS 上都要运行):

pip install --user Flask-WTF

创建注册表单

现在让我们来看看如何构建表单。我们将构建一些表单,因此我们将在项目中创建一个新的 Python 文件来保存所有这些内容。在您的waitercaller目录中,创建一个名为forms.py的文件,并添加以下代码:

from flask_wtf import Form
from wtforms import PasswordField
from wtforms import SubmitField
from wtforms.fields.html5 import EmailField
from wtforms import validators

class RegistrationForm(Form):
    email = EmailField('email', validators=[validators.DataRequired(), validators.Email()])
    password = PasswordField('password', validators=[validators.DataRequired(), validators.Length(min=8, message="Please choose a password of at least 8 characters")])
    password2 = PasswordField('password2', validators=[validators.DataRequired(), validators.EqualTo('password', message='Passwords must match')])
    submit = SubmitField('submit', [validators.DataRequired()])

RegistrationForm继承自Form,这是我们在flask_wtf扩展中找到的通用表单对象。其他所有内容都直接来自wtforms模块(而不是来自特定于 Flask 的扩展)。表单由许多不同的字段构建 - 在我们的情况下,一个EmailField,两个PasswordField和一个Submit字段。所有这些都将在我们的模板中呈现为它们的 HTML 等效项。我们将每个所需字段分配给变量。

我们将使用这些变量来呈现字段并从字段中检索数据。每次创建字段时,我们传入一些参数。第一个是一个字符串参数,用于命名表单。第二个参数是验证器列表。验证器是一组规则,我们可以使用它们来区分有效输入和无效输入。WTForms 提供了我们需要的所有验证器,但编写自定义验证器也很容易。我们使用以下验证器:

  • DataRequired:这意味着如果字段为空,表单对所有字段都无效。

  • Email:这使用正则表达式来确保电子邮件地址由字母数字字符组成,并且@符号和句点在适当的位置。(有趣的事实:这是一个令人惊讶地复杂的问题!请参阅www.regular-expressions.info/email.html。)

  • EqualTo:这确保在字段中输入的数据与输入到另一个字段中的数据相同。

  • Length:此验证器采用可选的最小和最大参数来定义数据应包含的字符数。我们将其设置为最小 8 个以确保我们的用户不选择非常弱的密码。

回想一下我们对后端和前端验证之间的权衡讨论,并注意这些都是后端验证方法,完成在服务器端。因此,即使用户的浏览器支持 HTML5,仍然值得添加Email验证器;它是一个email字段将阻止用户提交无效的电子邮件地址(使用前端验证检查)。

关于验证器的另一点是,我们可以为每个验证器添加一个消息参数,而不仅仅是为每个字段,每个字段可以有多个验证器。稍后我们将看到如何在特定的验证检查失败时向用户显示此消息。

重要的是要注意,您为每个表单字段选择的变量名(在我们之前创建的注册表单中为emailpasswordpassword2)比大多数变量名更重要,因为最终 HTML 字段的nameid属性将从变量名中获取。

渲染注册表单

下一步是使用我们的表单对象来呈现一个空的注册表单,当用户加载我们的主页时。为此,我们必须修改我们的应用程序代码(创建注册表单类的实例并将其传递给模板)和我们的前端代码(从类的变量中呈现我们的字段,而不是在 HTML 中硬编码它们)。

更新应用程序代码

在我们的waitercaller.py文件中,我们需要导入我们创建的表单,实例化它,并将其传递给我们的模板。

添加我们的注册表单的导入:

from forms import RegistrationForm

现在在我们的home()函数中实例化表单并将表单传递给模板。最终的home()函数应该如下所示:

@app.route("/")
def home():
  registrationform = RegistrationForm()
  return render_template("home.html", registrationform=registrationform)

更新模板代码

现在,我们的模板可以访问一个实例化的RegistrationForm对象,我们可以使用 Jinja 来呈现我们表单的字段。更新home.html中的注册表单如下:

<h2>Register now</h2>
<form class="form-horizontal" action="/register" method="POST">
  {{ registrationform.csrf_token }}
    <div class="form-group">
      <div class="col-sm-9">
        {{ registrationform.email(class="form-control", placeholder="Email Address" )}}
      </div>
    </div>
    <div class="form-group">
      <div class="col-sm-9">
        {{ registrationform.password(class="form-control", placeholder="Password" )}}
      </div>
    </div>
    <div class="form-group">
      <div class="col-sm-9">
        {{ registrationform.password2(class="form-control", placeholder="Confirm Password" )}}
      </div>
    </div>
    <div class="form-group">
      <div class="col-sm-9">
        {{ registrationform.submit(class="btn btn-primary btn-block")}}
      </div>
    </div>
</form>

Bootstrap 样板(指定 Bootstrap 类的 div 标签)保持不变,但现在,我们不再在 HTML 中创建输入字段,而是调用属于从home()路由传入的registrationform变量的函数。我们在RegistrationForm类中声明的每个变量(emailpasswordpassword2submit)都可以作为函数使用,我们可以将额外的 HTML 属性作为参数传递给这些函数。nameid属性将根据我们在编写表单时提供的变量名自动设置,我们还可以通过在这里传递它们来添加其他属性,例如classplaceholder。与以前一样,我们使用“form-control”作为输入的类,并指定“placeholder”值以提示用户输入信息。

我们还在新代码的开头呈现了csrf_token字段。这是 WTForms 提供的一个非常有用的安全默认值。其中一个更常见的 Web 应用程序漏洞称为跨站请求伪造CSRF)。虽然对这种漏洞的详细描述超出了本书的范围,但简而言之,它利用了 cookie 是在浏览器级别而不是在网页级别实现的事实。因为 cookie 用于身份验证,如果您登录到一个容易受到 CSRF 攻击的站点,然后在新标签页中导航到一个可以利用 CSRF 漏洞的恶意站点,那么恶意站点可以代表您在易受攻击的站点上执行操作。这是通过发送合法的 cookie(您在登录到易受攻击的站点时创建的)以及需要身份验证的操作来实现的。在最坏的情况下,易受攻击的站点是您的在线银行,而恶意站点会利用 CSRF 漏洞代表您执行财务交易,而您并不知情。CSRF 令牌通过向每个表单添加一个隐藏字段,其中包含一组加密安全的随机生成的字符,来减轻这种漏洞。因为恶意站点无法访问这个隐藏字段(即使它可以访问我们的 cookie),我们知道包含这些字符的 POST 请求来自我们的站点,而不是来自恶意的第三方站点。如果您对这种级别的 Web 应用程序安全感兴趣,请在开放 Web 应用程序安全项目OWASP)网站上阅读有关 CSRF 漏洞的更多信息([www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)](https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)))。无论如何,您应该始终在所有表单中包含 CSRF 字段,事实上,如果您省略它,验证步骤将失败。

测试新表单

因为我们在表单中使用了与之前相同的 Id 和 name 属性,所以当表单提交时处理数据的应用程序代码仍然有效。因此,启动应用程序,确保在这一点上一切仍然正常工作。如果一切顺利,应用程序的主页将与我们上次测试应用程序时看到的完全相同。您还可以使用浏览器的“查看源代码”功能来检查各种表单字段是否按预期转换为各种 HTML 输入类型。

在我们的应用程序代码中使用 WTForms

下一步是更新我们的应用程序代码,使用 WTForms 来捕获通过表单输入的数据。现在,我们不必记住使用了哪些“name”属性,而是可以简单地实例化一个新的RegistrationForm对象,并从后端接收的 post 数据填充它。我们还可以轻松运行所有的验证规则,并获得每个字段的错误列表。

waitercaller.py中,修改register()函数如下:

@app.route("/register", methods=["POST"])
def register():
  form = RegistrationForm(request.form)
  if form.validate():
    if DB.get_user(form.email.data):
      form.email.errors.append("Email address already registered")
      return render_template('home.html', registrationform=form)
    salt = PH.get_salt()
    hashed = PH.get_hash(form.password2.data + salt)
    DB.add_user(form.email.data, salt, hashed)
    return redirect(url_for("home"))
  return render_template("home.html", registrationform=form)

在上述代码中,第一个更改是函数的第一行。我们实例化了一个新的RegistrationForm,并通过传入request.form对象来填充它,以前我们是从中逐个提取每个字段的。如前所述,现在我们不必硬编码字段名称了!相反,我们可以通过表单属性访问用户的输入数据,比如form.email.data

第二行也是一个重大变化。我们可以调用form.validate()来运行所有的验证规则,只有当所有规则通过时它才会返回True,否则它将填充表单对象的所有相关失败消息。因此,函数的最后一行只有在有验证错误时才会被调用。在这种情况下,我们现在重新渲染我们的主页模板,传递一个新的表单副本(现在有一个指向错误的引用。我们将看到如何在下一步中显示这些错误)。

如果在我们的数据库中找到电子邮件地址,我们现在会向电子邮件字段的错误消息中追加一个错误消息,并重新渲染模板以将此错误传递回前端。

请注意,以前,我们的三个返回选项都只是简单地重定向到主页,使用了 Flask 的redirect()函数。现在我们已经用render_template()调用替换了它们所有,因为我们需要将新的表单(带有添加的错误消息)传递到前端。

向用户显示错误

新注册表单的最后一步是向用户显示任何错误,以便用户可以修复它们并重新提交表单。为此,我们将在我们的模板中添加一些 Jinja if语句,检查表单对象中是否存在任何错误,并在存在时显示它们。然后我们将添加一些 CSS 使这些错误显示为红色。最后,我们将看看如果我们有更多和更大的表单,我们如何更简洁地完成所有这些(如果我们有更多和更大的表单,我们肯定会希望如此)。

在我们的模板中显示错误

要显示错误,我们只需要在每个输入字段上方添加一个if语句,检查是否有任何错误要显示在该字段上(记住 WTForms 在我们运行validate()方法时会自动填充表单对象的错误列表)。如果我们发现要显示在该字段上的错误,我们需要循环遍历所有错误并显示每一个。虽然在我们的情况下,每个字段只能有一个错误,但请记住我们可以为每个字段添加多个验证器,因此每个字段可能有多个错误。我们不希望用户修复一个错误并重新提交,然后发现仍然有其他错误,而是希望用户在一次提交表单后就被告知所有错误。

修改home.html中的注册表单如下:

<div class="form-group">
  <div class="col-sm-9">
 {% if registrationform.email.errors %}
 <ul class="errors">{% for error in registrationform.email.errors %}<li>{{ error }}</li>{% endfor %}</ul>
 {% endif %}
    {{ registrationform.email(class="form-control", placeholder="Email Address" )}}
  </div>
</div>
<div class="form-group">
  <div class="col-sm-9">
 {% if registrationform.password.errors %}
 <ul class="errors">{% for error in registrationform.password.errors %}<li>{{ error }}</li>{% endfor %}</ul>
 {% endif %}
    {{ registrationform.password(class="form-control", placeholder="Password" )}}
  </div>
</div>
<div class="form-group">
  <div class="col-sm-9">
 {% if registrationform.password2.errors %}
 <ul class="errors">{% for error in registrationform.password2.errors %}<li>{{ error }}</li>{% endfor %}</ul>
 {% endif %}
    {{ registrationform.password2(class="form-control", placeholder="Confirm Password" )}}
  </div>
</div>

请注意,我们通过构建列表(在<ul>标签内),并将这些列表分配给errors类属性来显示我们的错误。我们还没有任何 CSS 代码来定义错误列表的外观,所以让我们快速解决这个问题。

为错误添加 CSS

错误的 CSS 代码是我们在项目中将使用的唯一自定义 CSS 代码(我们的其余 CSS 都是使用 Bootstrap 免费提供的)。因此,将我们的 CSS 直接添加到base.html模板文件中是可以的(我们将在其他模板中也使用它),而不是创建一个新的外部 CSS 文件或编辑 Bootstrap 文件。

如果您感兴趣,请查看static/css目录中的bootstrap.min.css文件,并注意它非常难以阅读和修改(它全部都在一行中!)。这是为了使页面加载更快——每个空格和换行符都会使文件变得稍微更大,这意味着我们的用户的浏览器需要更长时间来下载显示网页所需的 CSS 文件。这就是为什么大型 CSS 和 JavaScript 库(如 Bootstrap)都带有minified版本(这就是bootstrap.min.css中的'min'代表的含义)。如果我们想要将新的 CSS 代码添加到 Bootstrap 文件中,我们可能会将其添加到非 minified 版本中,然后重新 minify 它以创建我们在生产中使用的 minified 版本。

base.html文件的<head>标签之间添加以下样式:

<style type="text/css">
  ul.errors {
    list-style-type: none;
    padding: 0;
    color: red;
  }
</style>

上述样式代码中的第一行意味着它只适用于具有错误类的<ul>元素(即我们刚刚添加到主页的反馈消息)。接下来的三行删除了默认使用的列表项目符号,删除了默认使用的缩进,并将字体颜色设置为红色。

测试最终的注册表单

我们的注册表现在已经完成。现在它使用了 WTForms,因此更清洁,更容易维护,我们不必依赖开发人员知道 HTML 的name属性必须与 Python 代码匹配。让我们来看看确保一切仍然正常工作,并且我们的新错误消息在我们期望它们显示时显示,并且在我们不希望它们显示时不显示。

再次运行应用程序,尝试注册一个新帐户。尝试各种错误组合,例如使用已注册的电子邮件地址(请记住,我们的测试数据库在每次重新启动应用程序时都会被清除),使用太短的密码,使用两个password字段的不匹配字符串,或使用无效的电子邮件地址。如果一切按计划进行,您的带有错误的表单应该看起来与下面的表单类似:

测试最终的注册表单

关于最后一张图片有几件有趣的事情需要注意。首先,请注意 HTML5 输入框将电子邮件地址g@1视为有效(前端验证),但Email()验证器不认为它是有效的(后端验证)。这就是为什么我可以提交表单,即使我使用支持 HTML5 电子邮件字段的浏览器,只有在数据传输到后端后才被告知电子邮件地址无效。其次,请注意在提交表单后,电子邮件地址会自动重新填充,而密码字段现在为空。这是大多数浏览器的有用默认设置。我们可能希望在第二次提交类似的信息时,修复错误后,但出于安全原因,我们总是希望尽快摆脱密码。

请注意上图中的“无效的电子邮件地址”消息。在我们的forms.py文件中,我们只为密码太短的情况指定了错误消息,但 WTForms 为其内置验证器提供了默认消息。同样,如果您将密码字段留空,您将看到消息“此字段为必填项”——这是另一个有用的默认消息,我们不需要编写。

这是表单验证和用户反馈的大部分工作。现在你已经对所有东西的工作原理有了很好的概念,我们将快速地再次概述一下:

  • 在用户注册成功时显示反馈(目前,我们似乎只确认失败,但用户会想知道如果一切顺利地注册了一个帐户)。

  • 将我们的登录表单移动到 WTForms,并在用户登录失败时添加反馈。

  • 将我们的“新表格”表单移动到 WTForms,并在必要时添加反馈。

添加成功的注册通知

通常,我们会在成功注册后向用户显示一个新页面,感谢他们注册并告知他们一切都成功了(如果我们是为生产环境编写此应用程序,而不是将其用作教育项目,我们将在下一章中列出我们可以改进的更完整的事项列表)。为了使我们的应用程序尽可能少地使用页面,并防止本书变得太长,我们将向用户显示一个 JavaScript 弹出框。通常,在创建用户界面时,我们希望尽可能避免使用弹出框,因为用户会觉得它们很烦人。然而,有时是必要的,所以在这里使用一个将有助于使我们的应用程序简单,并给我们一个机会学习更多 JavaScript。

JavaScript 是基于事件的。这意味着我们可以编写由用户操作(如鼠标点击)或其他事件(如onload事件,当特定资源在用户的浏览器中加载时触发)触发的代码。在我们的犯罪地图项目中,我们曾经使用它在<body>标签加载后初始化 JavaScript Google 地图小部件。现在我们将做类似的事情,但使用它来显示 JavaScript 警报框。我们还将使我们的消息动态化,并从后端代码传递到前端。

从应用程序代码传递消息

这方面的后端更改很容易。只需将register()函数更改为在处理所有输入数据时传递适当的消息。在waitercaller.py中,更新register()函数如下:

hashed = PH.get_hash(form.password2.data + salt)
DB.add_user(form.email.data, salt, hashed)
return render_template("home.html", registrationform=form, onloadmessage="Registration successful. Please log in.")
return render_template("home.html", registrationform=form)

在模板代码中使用消息

更改在我们的模板中实现起来稍微棘手,因为我们实际上没有访问<body>标签(我们希望在其中指定 JavaScript 警报)在我们的home.html模板中。相反,我们的<body>是在我们的base.html骨架模板中定义的,所有其他模板都继承自它。

要仅在我们的home.html模板中修改<body>标签,我们需要使<body>标签出现在可继承的 Jinja 块内,类似于我们的内容块。为此,我们需要对我们的base.html模板和我们的home.html模板进行更改。

base.html中,当创建<body>标签时进行以下更改:

  </head>
 {% block bodytag %}
  <body>
 {% endblock %}

现在<body>标签可以被子模板覆盖,因为它出现在一个可配置的块内。在home.html中,如果指定了警报消息,我们将在第一行后直接覆盖<body>块。请记住,如果没有指定此消息,home.html模板将简单地继承base.html模板的默认<body>标签。在home.html中,在第一行后直接添加以下代码:

{% block bodytag %}
  <body {% if onloadmessage %} onload="alert('{{onloadmessage}}');" {% endif %}>
{% endblock %}

唯一稍微棘手的部分是匹配onload属性中的所有引号和括号。整个alert函数(我们要运行的 JavaScript)应该出现在双引号内。alert函数内的字符串(实际显示给用户的消息)应该在单引号内。最后,onloadmessage变量应该在双括号内,这样我们可以得到变量的内容而不是变量名的字符串。

现在,在成功注册后,用户将看到一个确认一切顺利进行并且可以登录的警报,如下图所示。最好添加一个新页面,以便向用户正确地通知成功注册,但为了保持我们的应用程序简单(因此我们可以引入通常有用的 onload 功能),我们选择了一种稍微混乱的通信方式。

在模板代码中使用消息

修改登录表单

将登录表单移动到 WTForms 所需的更改与我们为注册表单所做的更改非常相似,因此我们将提供最少讨论的代码。如果您不确定在哪里插入代码或进行更改,请参考代码包。

在应用程序代码中创建新的 LoginForm

forms.py中,添加LoginForm类:

class LoginForm(Form):
    loginemail = EmailField('email', validators=[validators.DataRequired(), validators.Email()])
    loginpassword = PasswordField('password', validators=[validators.DataRequired(message="Password field is required")])
    submit = SubmitField('submit', [validators.DataRequired()])

在这里,我们为密码字段的DataRequired验证器指定了自定义消息,因为错误消息与注册表单的字段不会像注册表单那样对齐。我们还使用变量名loginemailloginpassword,因为这些将成为 HTML 元素的idname属性,最好不要被同一页上注册表单中的loginpassword字段覆盖。

waitercaller.py中,添加登录表单的导入:

from forms import LoginForm

并将login()函数重写如下:

@app.route("/login", methods=["POST"])
def login():
    form = LoginForm(request.form)
    if form.validate():
        stored_user = DB.get_user(form.loginemail.data)
        if stored_user and PH.validate_password(form.loginpassword.data, stored_user['salt'], stored_user['hashed']):
            user = User(form.loginemail.data)
            login_user(user, remember=True)
            return redirect(url_for('account'))
        form.loginemail.errors.append("Email or password invalid")
    return render_template("home.html", loginform=form, registrationform=RegistrationForm())

电子邮件或密码无效”错误似乎相当模糊,可能需要更具体。用户可能会发现知道错误所在很有帮助,因为许多人使用许多不同的电子邮件地址和不同的密码。因此,知道您作为用户是输入了错误的电子邮件并需要尝试记住您注册的电子邮件地址,还是您输入了正确的电子邮件地址但是错误地记住了您的纪念日或出生日期或您用来记住密码的任何助记符,这将是方便的。然而,这种便利性又会带来另一个安全问题。如果用户输入了正确的电子邮件地址但是错误的密码,我们显示“无效密码”,这将允许恶意攻击者对我们的网站尝试大量的电子邮件地址,并慢慢建立属于我们用户的电子邮件地址列表。攻击者随后可以利用这些用户是我们的客户的知识,对这些用户进行网络钓鱼攻击。这是另一个案例,显示了开发人员必须不断警惕他们可能允许攻击者推断出的信息,即使这些信息并不是直接提供的。

我们需要进行的最后一个后端更改是在每次呈现home.html模板时初始化并传递一个新的LoginForm对象。必须进行以下更改:

  • 一旦在home()函数中

  • register()函数中三次

home()函数更改为如下所示:

@app.route("/")
def home():
  return render_template("home.html", loginform=LoginForm(), registrationform=RegistrationForm())

register()函数的最后两行更改为:

  return render_template("home.html", loginform=LoginForm(), registrationform=form, onloadmessage="Registration successful. Please log in.")
  return render_template("home.html", loginform=LoginForm(), registrationform=form)

并且在register()函数中间的return语句为:

  return render_template("home.html", loginform=LoginForm(), registrationform=form)

在模板中使用新的 LoginForm

对于模板更改,home.html现在应该使用以下login表单:

<form class="navbar-form navbar-right" action="/login" method="POST">
  {% if loginform.errors %}
    <ul class="errors">
      {% for field_name, field_errors in loginform.errors|dictsort if field_errors %}
        {% for error in field_errors %}
          <li>{{ error }}</li>
        {% endfor %}
      {% endfor %}
    </ul>
  {% endif %}
  {{ loginform.csrf_token}}
  <div class="form-group">
    {{ loginform.email(class="form-control", placeholder="Email Address")}}
  </div>
  <div class="form-group">
    {{ loginform.password(class="form-control", placeholder="Password")}}
  </div>
  <div class="form-group">
    {{ loginform.submit(value="Sign in", class="btn btn-success")}}
  </div>
</form>

与我们为注册表单所做的方式不同,我们不会在每个字段上方显示错误,而是只会在登录表单上方显示所有错误。为此,我们可以使用loginform.errors属性,它是每个字段到其错误列表的映射字典。因此,错误显示代码稍微更冗长,因为它必须循环遍历此字典的所有键和值,并且我们使用convenient |dictsort Jinja 标记在显示错误之前对字典进行排序。

修改创建表单

我们需要进行的最后一个表单更改是创建表单表单,当已登录用户向其帐户添加新的餐厅桌子时。要添加到forms.py的新表单如下所示:

class CreateTableForm(Form):
  tablenumber = TextField('tablenumber', validators=[validators.DataRequired()])
  submit = SubmitField('createtablesubmit', validators=[validators.DataRequired()])

这也需要在forms.py中进行新的导入:

from wtforms import TextField

waitercaller.py中,我们需要导入新的表单:

from forms import CreateTableForm

更新account_createtable()函数为:

@app.route("/account/createtable", methods=["POST"])
@login_required
def account_createtable():
  form = CreateTableForm(request.form)
  if form.validate():
    tableid = DB.add_table(form.tablenumber.data, current_user.get_id())
    new_url = BH.shorten_url(config.base_url + "newrequest/" + tableid)
    DB.update_table(tableid, new_url)
    return redirect(url_for('account'))

  return render_template("account.html", createtableform=form, tables=DB.get_tables(current_user.get_id()))

account()路由变为:

@app.route("/account")
@login_required
def account():
    tables = DB.get_tables(current_user.get_id())
    return render_template("account.html", createtableform=CreateTableForm(), tables=tables)

最后,account.html模板中的表单应该更改为:

<form class="form-inline" action="/account/createtable" method="POST">
  <div class="form-group">
    {% if createtableform.tablenumber.errors %}
      <ul class="errors"> 
        {% for error in createtableform.tablenumber.errors %}
          <li>{{error}}</li> 
        {% endfor %} 
      </ul> 
    {% endif %}
    {{ createtableform.csrf_token}}
    {{ createtableform.tablenumber(class="form-control", placeholder="Table number or name")}}
    {{ createtableform.submit(value="Create", class="btn btn-primary") }}
  </div>
</form>

目前,如果用户将字段留空并点击创建按钮,我们在创建表格表单上只能显示一个错误,即“此字段为必填项”,如下截图所示:

修改创建表格表单

考虑到这一点,可以讨论的是 for 循环是否应该循环遍历所有错误消息。一方面,过度“未来证明”是不好的,因为你会留下一个包含大量不必要且过于复杂的代码的代码库。另一方面,我们可能会向 WTForm 添加更多的错误消息(例如,如果用户尝试使用已经存在的数字创建表),因此,可以说值得添加 for 循环。

我们还没有将 WTForms 转换为的最后一个表单是删除表格表单。由于这只是一个单独的提交按钮,因此留作练习(将此表单移至 WTForms 仍然是一个值得的收获)。

总结

我们完善了应用程序的功能,现在它更加强大。我们添加了仪表板账户页面,并编写了处理我们需求的所有应用程序代码、数据库代码和前端代码。

我们研究了 Jinja 模板作为避免重复前端代码的一种方法,还学习了如何使用 bitly API 来缩短链接。

然后我们添加了 WTForms,并看到这如何使我们的用户反馈更容易,我们的表单更容易验证,我们的 Web 应用程序更安全。我们的用户现在可以随时了解他们的注册、登录和应用程序的使用情况。

在下一章中,我们将为我们的代码添加一个真正的数据库,然后进行一些最后的润色。

第十一章:在我们的服务员呼叫器项目中使用 MongoDB

我们的网络应用现在几乎具备了所有功能。如果我们计划对这个应用进行货币化,现在就是向潜在客户演示的时候。即使他们的数据(如他们的账户名称和虚拟表数据)每次我们不得不重新启动服务器时都会丢失,这些数据也足够微不足道,使得完全演示应用程序成为可能。

在本章中,我们将为生产环境添加一个适当的数据库。我们将使用 MongoDB——一个略具争议的 NoSQL 数据库管理系统,因其简单性而变得极其流行,可以说这主要是因为其简单性。我们将看看如何在我们的 VPS 上安装它,正确配置它,并使用 Python 驱动程序访问它。然后,我们将实现完整的DBHelper类来替换我们用于测试的MockDBHelper。最后,我们将看看如何向 MongoDB 添加索引和向我们的应用程序添加一个 favicon。

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

  • 介绍 MongoDB

  • 安装 MongoDB

  • 使用 MongoDB shell

  • 介绍 PyMongo

  • 添加一些最后的修饰

介绍 MongoDB

MongoDB 是一个 NoSQL 数据库。这意味着与我们在犯罪地图项目中使用的 MySQL 数据库不同,它不是组织成表、行和列;相反,它是组织成集合、文档和字段。虽然将这些新术语视为我们用于关系数据库的一种翻译可能会有用,但这些概念并不完全相同。如果您有关系数据库的背景,可以在官方 MongoDB 网站上找到有关这些翻译的有用且更完整的参考资料docs.mongodb.org/manual/reference/sql-comparison/

MongoDB 的结构比 SQL 数据库灵活得多——我们的所有数据都不必符合特定的模式,这可以节省开发时间。对于我们的犯罪地图项目,我们不得不花时间来查看我们的数据,并决定如何在数据库中表示它。然后,我们不得不设置一堆字段,指定数据类型、长度和其他约束。相比之下,MongoDB 不需要这些。它比关系数据库管理系统更灵活,并且使用文档来表示数据。文档本质上是类似于我们从使用的 API 中提取的数据的 JSON 数据。这意味着我们可以根据需要轻松添加或删除字段,并且我们不需要为我们的字段指定数据类型。

这样做的缺点是,由于不需要强制结构化和一致,我们很容易变得懒惰,并陷入在单个字段中混合不同数据类型和允许无效数据污染我们数据库的不良做法。简而言之,MongoDB 给了我们更多的自由,但这样做也将一些保持清洁和一致性的责任转移到了我们的肩上。

安装 MongoDB

MongoDB 可以在 Ubuntu 软件仓库中找到,但由于更新频繁且仓库版本往往滞后,强烈建议直接从官方 Mongo 软件包安装。

我们将逐步介绍如何做到这一点,但由于安装过程可能会发生变化,建议从官方安装指南中获取所需 URL 和步骤的更新版本docs.mongodb.org/manual/tutorial/install-mongodb-on-ubuntu/

首先,我们需要导入 MongoDB 的公钥,以便进行身份验证。仅在您的 VPS 上(与以前一样,我们不会在开发机器上安装数据库服务器),运行以下命令:

sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv EA312927

现在我们有了密钥,我们可以使用以下命令将 MongoDB 软件包的链接添加到我们的软件源。请注意,此命令特定于 Ubuntu 14.04“Trusty”,这是写作时最新的长期支持 Ubuntu 版本。如果您的 VPS 运行不同版本的 Ubuntu,请确保从前面提供的 MongoDB 文档链接中获取正确的命令。要发现您使用的 Ubuntu 版本,请在终端中运行lsb_release -a并检查版本号和名称的输出:

echo "deb http://repo.mongodb.org/apt/ubuntu trusty/mongodb-org/3.2 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-3.2.list

现在,我们只需要通过运行以下命令来更新我们的源列表:

sudo apt-get update

最后,通过运行以下命令进行实际安装:

sudo apt-get install -y mongodb-org

前面的命令将使用一些合理的默认值安装 MongoDB 并启动服务器。它还会配置服务器,以便在重新启动 VPS 时自动启动。

使用 MongoDB shell

与我们在 MySQL 中讨论的类似,MongoDB 带有一个简单的 shell。这非常适合运行快速的一次性命令并熟悉语法。让我们运行基本的 CRUD 操作,以熟悉 MongoDB 的工作方式。

与我们之前的项目一样,一旦我们引入 MongoDB,我们将只通过 Python 代码来使用它;然而,首先我们将直接在 shell 中编写命令。这意味着语法上会有一些细微的差异,但由于几乎所有东西都是基于 JSON 的,这些差异不应该是问题。

启动 MongoDB shell

要启动 MongoDB shell,请在您的 VPS 上运行以下命令:

mongo

这将启动交互式 MongoDB shell,如下图所示,您可以随时通过按Ctrl + C或在 shell 中输入exit并按Enter来退出。

启动 MongoDB shell

在 MongoDB shell 中运行命令

与 MySQL 一样,MongoDB 中的顶级概念是数据库。默认情况下,这将连接到名为test的数据库。我们可以使用use命令更改数据库。在 shell 中运行以下命令:

use sandbox

您应该看到输出“切换到 db sandbox”。这是我们可以注意到 MySQL 和 MongoDB 之间的第一个重大差异。对于 MySQL,我们首先必须创建数据库。这是我们将在 MongoDB 中看到的一个常见模式;如果引用一个不存在的数据库、集合或字段,它将自动为您创建。

使用 MongoDB 创建数据

现在,让我们创建一个集合(类似于 Crime Map 项目中的 MySQL 数据库中的表)并向其中添加一个文档(类似于 MySQL 数据库中的表中的行)。在 MongoDB shell 中运行以下命令:

db.people.insert({"name":"John Smith", "age": 35})

在前面的命令中,db指的是当前数据库。紧接着,people指的是这个名称的集合。由于它不存在,当我们尝试使用它时,它将被创建。接下来是insert,这意味着我们想要向数据库添加一些内容。我们将作为参数传递(在圆括号内),这是一个 JSON 结构。在我们的例子中,我们用一个包含人名和年龄的 JSON 对象表示一个人。请注意,除了age字段的值之外,所有内容都在引号中;再次,与 MySQL 不同,我们不必为这些数据指定类型。MongoDB 将把名称存储为字符串,将年龄存储为整数,但不对这些字段施加任何限制。

向数据库添加另一个人,以使我们将尝试的下一个操作更有意义。运行以下命令:

db.people.insert({"name":"Mary Jones"})

使用 MongoDB 读取数据

MongoDB 使用find()命令而不是 SQL 中的SELECT语句。与 SQL 类似,我们可以指定要在数据中搜索的条件,并选择要返回的数据库字段。运行以下命令:

db.people.find()

这是find操作的最基本版本。它将简单地查找检索people集合中的所有数据和所有字段。您应该会看到 MongoDB 输出我们刚刚添加的两个人的所有信息。您会注意到每个人还添加了一个ObjectId字段;MongoDB 会自动为我们的每个文档添加唯一标识符字段,并且这些ID字段也会自动索引。

我们也可以使用单个参数的find。该参数指定条件,MongoDB 只返回与之匹配的文档。运行以下命令:

db.people.find({"name":"John Smith"})

如果名称匹配John Smith,则此命令将返回所有记录的所有字段,因此您应该会看到返回一个单一结果并打印到 shell 中,如下面的截图所示:

使用 MongoDB 读取数据

最后,如果我们不想返回所有字段,可以运行find命令并传入第二个参数来指定我们想要的字段。运行以下命令,您应该会看到以下截图中的结果:

db.people.find({"name":"John Smith"}, {"age":1})

使用 MongoDB 读取数据

第一个参数表示我们只对名为“John Smith”的人感兴趣。第二个参数表示我们只对他们的年龄感兴趣。这里,1是一个标志,表示我们想要这个字段。我们可以使用0来表示我们对一个字段不感兴趣,这样的话,除了这个字段之外,所有字段都会被返回。

请注意,即使我们说我们只对age字段感兴趣,上述命令返回了_id字段。除非明确排除,否则始终返回_id字段。例如,我们可以运行以下命令:

db.people.find({"name":"John Smith"}, {"age":1, "_id": 0})

这将只返回约翰的年龄,没有其他内容。另外,请注意_id字段的键是_id而不是id;这是为了避免与许多编程语言中的id关键字发生冲突,包括 Python。

我们的每个示例都使用了非常基本的 JSON 对象,每个参数只有一个值,但我们可以为每个参数指定多个值。考虑以下命令之间的区别:

db.people.find({"name":"John Smith", "age":1})
db.people.find({"name":"John Smith"}, {"age":1})

第一个命令使用带有单个参数的find,返回所有名为 John Smith 且年龄为 1 岁的人的所有记录。第二个命令使用带有两个参数的find,返回名为 John Smith 的人的age字段(和_id字段)。

与 MySQL 的最后一个区别是,不需要提交新数据。一旦我们运行insert语句,数据将保存在数据库中,直到我们将其删除。

使用 MongoDB 更新数据

更新现有记录稍微复杂一些。MongoDB 提供了一个update方法,可以与insertfind相同的方式调用。它也需要两个参数——第一个指定查找要更新的文档的条件,第二个提供一个新文档来替换它。运行以下命令:

db.people.update({"name":"John Smith"}, {"name":"John Smith", "age":43})

这将找到名为 John Smith 的人,并用一个新的人替换他,新人也叫John Smith,年龄为 43 岁。如果有很多字段,我们只想更改一个字段,那么重新创建所有旧字段是繁琐和浪费的。因此,我们可以使用 MongoDB 的$set关键字,它只会替换文档中指定的字段,而不是替换整个文档。运行以下命令:

db.people.update({"name":"John Smith"}, {$set: {"age":35}})

这将把约翰的年龄再次更新为 35 岁,这对他来说可能是一种解脱。我们只改变了age字段,而不是覆盖整个文档。我们在第二个参数中使用了$set关键字来实现这一点。请注意,update函数仍然需要两个参数,而第二个参数现在具有嵌套的 JSON 结构——输出的 JSON 对象将$set作为键,另一个 JSON 对象作为值。内部 JSON 对象指定了我们想要进行的更新。

使用 MongoDB 删除数据

删除数据就像查找数据一样简单。我们将简单地使用remove函数而不是find,然后在单个参数中指定匹配条件,就像我们在find中所做的那样。运行以下命令从我们的数据库中删除 John:

db.people.remove({"name":"John Smith"})

您将看到一个确认,显示已删除一条记录,如下图所示:

使用 MongoDB 删除数据

您还可以通过运行以下命令来检查 John 是否已被删除:

db.people.find()

现在,只有 Mary 将被返回,如下图所示:

使用 MongoDB 删除数据

要从集合中删除所有文档,我们可以传入一个空参数。运行以下命令以删除所有剩余的人:

db.people.remove({})

在这里,{}指定了一个空的条件,因此匹配所有文档。通过再次运行find命令来检查我们的people集合是否为空,如下所示:

db.people.find()

您将看不到任何输出,如下图所示(包括前面的示例,以便了解上下文),因为我们的people集合现在为空:

使用 MongoDB 删除数据

现在我们已经了解了 MongoDB 的基础知识,让我们看看如何使用 Python 而不是通过 shell 来运行类似的命令。

介绍 PyMongo

PyMongo 是一个实现了 MongoDB 驱动程序的库,它允许我们从应用程序代码中对数据库执行命令。像往常一样,使用以下命令通过 pip 安装它(请注意,与 MongoDB 类似,您只需要在服务器上安装此库):

pip install --user pymongo

现在,我们可以将这个库导入到我们的应用程序中,并构建我们真正的DBHelper类,实现我们在MockDBHelper类中使用的所有方法。

编写 DBHelper 类

我们需要的最后一个类是DBHelper类,它将包含我们的应用程序代码与数据库交互所需的所有函数。这个类将使用我们刚刚安装的pymongo库来运行 MongoDB 命令。在waiter目录中创建一个名为dbhelper.py的文件,并添加以下代码:

import pymongo

DATABASE = "waitercaller"

class DBHelper:

  def __init__(self):
    client = pymongo.MongoClient()
    self.db = client[DATABASE]

这段代码导入了pymongo库,在构造函数中,它创建了一个客户端——一个 Python 对象,让我们可以在数据库上运行我们之前尝试过的 CRUD 操作。我们将我们的数据库名称定义为全局的,并在构造函数的第二行中,使用client连接到指定的数据库。

添加用户方法

对于用户管理,我们需要与我们的模拟类中相同的两个函数。第一个是从数据库中获取用户(以便登录此用户),第二个是向数据库中添加新用户(以便注册新用户)。将以下两个方法添加到DBHelper类中:

    def get_user(self, email):
        return self.db.users.find_one({"email": email})

    def add_user(self, email, salt, hashed):
        self.db.users.insert({"email": email, "salt": salt, "hashed": hashed})

对于第一种方法,我们使用了 PyMongo 的find_one()函数。这类似于我们在 MongoDB shell 中使用的find()方法,但是它只返回单个匹配项,而不是所有匹配的结果。由于我们每个电子邮件地址只允许注册一个用户,所以匹配结果要么是一个,要么是零。在这里使用find()而不是find_one()也可以,但是我们会得到一个产生单个或零元素的 Python 生成器。使用find_one(),我们要么得到一个单个用户的结果,要么得到空,这正是我们的登录代码所需要的。

对于add_user()方法,我们使用了insert(),就像我们在使用 MongoDB shell 时讨论的那样,并插入了一个包含电子邮件地址、盐和密码的盐哈希的新文档。

添加表方法

我们需要处理我们的用户将创建的虚拟表的以下情况的方法:

  • 一个用于添加新表

  • 一个用于更新表(以便我们可以添加缩短的 bitly URL)

  • 一个用于获取所有表(以便我们可以在账户页面中显示它们)

  • 一个用于获取单个表(以便我们可以将本地表号添加到我们的请求中)

  • 一个用于删除表

这是一组不错的方法,因为它演示了所有四种 CRUD 数据库操作。将以下代码添加到DBHelper类中:

def add_table(self, number, owner):
    new_id = self.db.tables.insert({"number": number, "owner": owner})
    return new_id

def update_table(self, _id, url):
    self.db.tables.update({"_id": _id}, {"$set": {"url": url}})

def get_tables(self, owner_id):
    return list(self.db.tables.find({"owner": owner_id}))

def get_table(self, table_id):
    return self.db.tables.find_one({"_id": ObjectId(table_id)})

def delete_table(self, table_id):
    self.db.tables.remove({"_id": ObjectId(table_id)})

对于add_table()方法,每次插入表时,MongoDB 都会分配一个唯一标识符。这为我们提供了真正的多用户支持。我们的模拟代码使用用户选择的表号作为唯一标识符,并且在多个用户选择相同的表号时会出现问题。在add_table()方法中,我们将此唯一标识符返回给应用程序代码,然后可以用它来构建所需的 URL,以便为此特定表发出新的请求。

update_table()方法使用了我们之前讨论过的insert()函数。与我们之前的示例一样,我们使用了$set关键字来保持我们的原始数据完整,并且只编辑了特定字段(而不是覆盖整个文档)。

注意

请注意,与 MongoDB shell 示例不同,我们现在需要在$set周围加上引号;这使得它在语法上成为合法的 Python 代码(字典的所有键都必须是字符串),而 PyMongo 会在后台处理魔术,将我们的 Python 字典转换为 MongoDB 命令和对象。

get_tables()函数使用了find()函数,而不是我们用于用户代码的find_one()函数。这导致 PyMongo 返回一个 Python 生成器,可以生成与find条件匹配的所有数据。由于我们假设总是能够将所有表加载到内存中,因此我们将此生成器转换为列表,然后将其传递给我们的模板。

get_table()函数用于在我们只有访问表 ID 并且需要获取有关表的其他信息时使用。这正是我们处理请求时的情况;请求的 URL 包含了表的唯一 ID,但希望将表号添加到Dashboard页面。MongoDB 生成的唯一标识符实际上是对象而不是简单的字符串,但我们只有来自我们的 URL 的字符串。因此,在使用此 ID 查询数据库之前,我们创建了ObjectId并传入了字符串。ObjectId可以从自动安装的bson库中导入。这意味着我们还需要添加另一个导入语句。将以下行添加到dbhelper.py文件的顶部:

from bson.objectid import ObjectId

最后,delete_table()方法使用了remove()函数,与之前完全相同。在这里,我们通过其唯一标识符删除了一个表,因此我们再次从之前的字符串创建了一个ObjectId对象,然后将其传递给数据库。

添加请求方法

我们需要将最后三个方法添加到DBHelper类中以处理关注请求。我们需要:

  • 当顾客访问提供的 URL 时,添加一个请求

  • 获取特定用户的所有请求,以在Dashboard页面上显示

  • 当用户点击解决按钮时,从数据库中删除请求

将以下方法添加到dbhelper.py文件中:

    def add_request(self, table_id, time):
        table = self.get_table(table_id)
        self.db.requests.insert({"owner": table['owner'], "table_number": table['number'],"table_id": table_id, "time": time})

    def get_requests(self, owner_id):
        return list(self.db.requests.find({"owner": owner_id}))

    def delete_request(self, request_id):
        self.db.requests.remove({"_id": ObjectId(request_id)})

更改应用程序代码

现在我们有了一个真正的DBHelper类,我们需要根据我们所处的环境有条件地导入它。将waitercaller.py文件中MockDBHelper类的导入更改为如下所示:

if config.test
    from mockdbhelper import MockDBHelper as DBHelper
else:
    from dbhelper import DBHelper

确保在config导入下面添加前面四行。

此外,我们的DBHelper类主要处理许多ObjectId实例,而我们的MockDBHelper类使用字符串。因此,我们需要对我们的account_createtable()函数进行小的更改,将ObjectId转换为字符串。查看waitercaller.py中的以下行:

new_url = BH.shorten_url(config.base_url + "newrequest/" + tableid)

现在,将其更改为以下内容:

new_url = BH.shorten_url(config.base_url + "newrequest/" + str(tableid))

这将确保在我们将其连接到我们的 URL 之前,tableid始终是一个字符串。

我们生产环境需要的最后一次代码更改是一个不同的config文件,用于指定 VPS 的正确base_url并指示不应使用MockDBHelper类。由于我们不会将config文件检入git存储库,因此我们需要直接在 VPS 上创建这个文件。

在生产环境中测试我们的应用程序

一旦添加了上述代码,我们的应用程序现在应该是完全可用的!与我们的犯罪地图应用程序的数据库部分一样,这部分是最微妙的,因为我们无法在本地测试DBHelper代码,而必须直接在 VPS 上进行调试。然而,我们有信心,从我们的MockDBHelper类中,我们的应用程序逻辑都是有效的,如果新的数据库代码能够保持下来,其他一切应该都会按预期进行。让我们将代码推送到服务器上并进行测试。

在您的waitercaller目录中本地运行以下命令:

git add .
git commit -m "DBHelper code"
git push origin master

在您的 VPS 上,切换到WaiterCaller目录,拉取新代码,并按以下方式重新启动 Apache:

cd /var/www/waitercaller
git pull origin master

现在,通过运行以下命令使用 nano 创建生产config文件:

nano config.py

在新的config.py文件中输入以下内容,将base_url中的 IP 地址替换为您的 VPS 的 IP 地址。

test = False
base_url = "http://123.456.789.123/

然后,通过按Ctrl + X并在提示时输入Y来保存并退出文件。

现在,运行以下命令以使用新代码重新加载 Apache:

sudo service apache2 reload 

在本地浏览器中访问您的 VPS 的 IP 地址,并对所有功能进行一次全面测试,以确保一切都按预期工作。这包括尝试使用无效数据注册、注册、尝试使用无效数据登录、登录、创建表、创建请求、查看仪表板、等待仪表板刷新、解决请求等。对于全面的测试,所有操作应该以不同的组合多次完成。

你可能会明白,即使对于我们相对简单的应用程序,这也变得很繁琐。对于更复杂的应用程序,值得花费精力创建自动测试——模拟用户在网站上的操作,但也具有内置的对每个步骤应该发生什么的期望。诸如 Selenium(www.seleniumhq.org)之类的工具非常有用,可以用来构建这样的测试。

提示

与往常一样,如果出现任何问题,或者出现可怕的“500:内部服务器错误”,请检查/etc/log/apache2/error.log中的 Apache 错误文件以获取提示。

添加一些最后的修饰

最后,我们将向我们的数据库添加一些索引,以提高效率并防止为单个表打开多个请求。之后,我们将添加一个网站图标来个性化我们的 Web 应用程序。

向 MongoDB 添加索引

数据库索引用于提高效率。通常,要在数据库中找到与特定条件匹配的一组文档(也就是说,每当我们使用 MongoDB 的find()方法时),数据库引擎必须检查每条记录并添加与返回结果匹配的记录。如果我们向特定字段添加索引,数据库将存储更多的元数据,可以将其视为存储该字段的排序副本。在排序列表中查找john@example.com是否出现比在无序列表中查找要高效得多。然而,索引确实会占用额外的存储空间,因此选择在哪里添加索引是计算机科学中经典的“时空权衡”,无处不在。MongoDB 还可以使用索引对字段施加一些约束。在我们的情况下,我们将使用唯一索引,如果索引字段的值已经出现在此集合中的另一个文档中,则阻止向数据库添加新文档。

我们将在 MongoDB 中添加两个索引。我们将在users集合的email字段上添加一个索引,因为我们将使用此字段在登录时查找用户,并且我们希望查找尽可能快。我们还希望在数据库级别确保每个电子邮件地址都是唯一的。我们已经有两个检查:HTML5 字段进行前端检查,我们的应用程序代码进行后端检查。即使数据库检查可能看起来是不必要的,但设置起来很容易,并遵循内置安全性的良好原则(其中检查不仅仅是作为事后添加的,而是尽可能经常验证所有数据),以及应用程序的每个(前端,应用程序层和数据库层在我们的情况下)都不应该盲目地信任从更高层传递的数据的原则。

我们还将在请求集合的table_id字段上添加唯一索引。这将防止单个不耐烦的桌子通过刷新创建新请求的页面来向仪表板发送多个请求。这也很有用,因为我们的请求是使用 GET 请求创建的,可以很容易地复制(通过浏览器预加载页面或社交网络抓取用户访问的链接以了解更多信息)。通过确保每个请求的table_id是唯一的,我们可以防止这两个问题。

我们在哪里添加索引?

当我们构建 MySQL 数据库时,我们有一个独立于我们的犯罪地图 Web 应用程序的设置脚本。此设置脚本构建了数据库的框架,我们用 Python 编写它,以便如果我们需要迁移到新服务器或重新安装我们的数据库,我们可以轻松地再次运行它。

由于 MongoDB 非常灵活,我们不需要设置脚本。我们可以在新服务器上启动我们的应用程序,并且只要安装了 MongoDB,数据库将会在添加新数据或从备份中恢复旧数据时从头开始重新创建。

缺少设置脚本意味着我们实际上没有一个很好的地方可以向我们的数据库添加索引。如果我们通过 MongoDB shell 添加索引,这意味着如果应用程序需要迁移到新服务器,有人必须记住再次添加它们。因此,我们将创建一个独立的 Python 脚本来创建索引。在您的本地计算机上,在waitercaller目录中创建一个 Python 文件,并将其命名为create_mongo_indices.py。添加以下代码:

import pymongo
client = pymongo.MongoClient()
c = client['waitercaller']
print c.users.create_index("email", unique=True)
print c.requests.create_index("table_id", unique=True)

连接代码与我们以前使用的代码相同,用于创建索引的代码足够简单。我们在要在其上创建索引的集合上调用create_index()方法,然后传递要用于创建索引的字段名称。在我们的情况下,我们还传递了unique=True标志,以指定索引也应该添加唯一约束。

现在,我们需要对我们的应用程序进行一些小的更改,以便它可以处理已经打开的相同请求的情况。在dbhelper.py文件中,将add_request()方法更新为以下内容:

    def add_request(self, table_id, time):
        table = self.get_table(table_id)
        try:
            self.db.requests.insert({"owner": table['owner'], "table_number": table['number'], "table_id": table_id, "time": time})
            return True
        except pymongo.errors.DuplicateKeyError:
            return False

如果我们尝试向数据库插入具有重复的table_id字段的请求,将抛出DuplicateKeyError。在更新的代码中,我们将捕获此错误并返回False以指示请求未成功创建。当请求成功时,我们现在也将返回True。为了在应用程序代码中利用这些信息,我们还需要更新new_request()方法。编辑该方法,使其类似于此:

@app.route("/newrequest/<tid>")
def new_request(tid):
        if DB.add_request(tid, datetime.datetime.now()):
            return "Your request has been logged and a waiter will be with you shortly"
        return "There is already a request pending for this table. Please be patient, a waiter will be there ASAP"

现在,我们将检查新请求是否成功创建,或者现有请求是否阻止它。在后一种情况下,我们将返回不同的消息,要求顾客耐心等待。

为了测试新功能,将新的和修改后的文件添加到 Git(waitercaller.pydbhelper.pycreate_mongo_indices.py),提交,然后推送它们。在您的 VPS 上,拉取新的更改,重新启动 Apache,并运行以下命令:

python create_mongo_indices.py

为了创建我们之前讨论过的索引,再次在浏览器中运行一些测试,确保没有出现任何问题,并验证当您重复访问相同的关注请求 URL 时是否显示了新消息,如下图所示:

我们在哪里添加索引?

你可能会发现,由于浏览器预取页面,当您首次通过帐户页面创建表格时,会自动发出关注请求。如果您在不期望时看到上图中显示的消息,请在仪表板页面上解决任何未处理的请求,并再次访问 newrequest URL。

添加网站图标

我们要添加到我们的应用程序的最后一件事是一个网站图标。网站图标是大多数浏览器在打开页面时在标签栏中显示的小图像,如果用户将网站加为书签,则会显示在书签栏上。它们为网站增添了友好的触感,并帮助用户更快地识别网站。

关于网站图标的棘手之处在于它们必须非常小。习惯上使用 16x16 像素的图像作为网站图标,这并不留下太多创意空间。有一些很好的网站可以帮助您为您的网站创建完美的网站图标。其中一个网站是favicon.cc,它允许您从头开始创建网站图标(给您 16x16 的空白像素开始),或者可以导入图像。使用导入功能,您可以使用一个更大的图像,favicon.cc会尝试将其缩小为 16x16 像素,这样做的效果参差不齐,通常对于简单的图像效果更好。代码包中包含一个示例网站图标,放在静态目录中,并在下图中显示了它的放大版本:

添加网站图标

一旦您有了一个图标(您可以使用代码包中提供的图标),就很容易告诉 Flask 将其与页面的其余部分一起提供。确保您的图标被命名为favicon.ico(图标文件的标准扩展名是.ico),并将其放在waitercaller/static目录中。然后,在base.html模板的<head>部分中添加以下行:

<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">

这将创建一个链接到favicon.ico文件,使用 Jinja 的url_for函数生成所需的完整 URL,以便指向静态目录,然后将其简单转换为 HTML(您可以通过浏览器中的查看源代码来查看)。看一下下面的内容:

<link rel="shortcut icon" href="/static/favicon.ico">

现在,如果您再次重新加载页面,您将在标签标题中看到网站图标,如果您将页面加为书签,您也将在浏览器的书签工具栏中看到图标,如下图所示:

添加网站图标

这就是我们最后的项目。当然,没有一个 Web 应用程序是真正完整的,有无数的改进和功能可以添加。在本书的这个阶段,您将拥有足够的知识来开始添加自己的更改,并将您的原创想法变成现实,无论是作为我们在本书中介绍的项目的扩展,还是从头开始,作为全新的 Web 应用程序。

总结

在本章中,我们完成了我们的服务员呼叫器 Web 应用程序。我们在服务器上安装了 MongoDB,学习了如何通过 shell 使用它,然后安装了 PyMongo。使用 PyMongo,我们创建了一个新的数据库助手类,允许我们的应用程序代码在新数据库上运行操作。

最后,我们添加了一个网站图标,使我们的 Web 应用程序更加友好和美观。

在下一章和最后一章中,我们将看看我们的应用程序还可以添加什么来改善可用性和安全性,并以一些指针结束,指出接下来继续学习 Flask 和 Python 进行 Web 开发的地方。

附录 A. 未来的一瞥

在本书中,我们涵盖了各种主题,并且演示了构建三个功能齐全且有用的 Web 应用程序。然而,书籍本质上是有限的,而 Web 开发的世界趋向无限,因此我们无法添加所有内容。在本章中,我们将快速浏览我们无法详细介绍的技术。我们将首先看看可以直接用于扩展或改进本书中创建的项目的技术。然后,我们将研究一些更高级的 Flask 功能,这些功能在我们的项目中并不需要使用,但在其他项目中几乎肯定会有用。最后,我们将简要讨论对 Web 开发有用但不特定于我们在此构建的项目或 Flask 的技术。

扩展项目

我们构建的项目都是功能齐全的,但还不够准备好用于大规模、实时使用。如果它们要扩展到处理成千上万的用户或者是商业应用程序,它们需要一些更多的功能。这些将在接下来的部分中讨论。

添加域名

我们使用 VPS 的 IP 地址访问了所有项目。您几乎肯定习惯了使用域名而不是 IP 地址访问 Web 应用程序。当您使用域名(例如google.com)时,您的浏览器首先向 DNS 服务器发送请求,以找出与此域关联的 IP 地址是什么。DNS 服务器类似于巨大的自动电话簿,专门用于将人类更容易记住的域名(例如google.com)翻译成组织互联网的 IP 地址(例如 123.456.789.123)。

要使用域名而不是 IP 地址,您需要从注册商那里购买一个。通常,您的互联网服务提供商ISP)可以帮助您购买域名(例如yourname.com)。域名通常价格不贵,您可以每年以几美元的价格购买。

一旦购买了域名,您需要正确设置 DNS 设置。大多数 ISP 都有在线控制面板,您可以自己完成这些设置,但您可能需要联系他们来协助您。您的域名需要指向您的 VPS。为此,您需要创建一个将域名映射到您的 IP 的“A”类型 DNS 记录。

一旦您的域名指向您的服务器,您可以配置 Apache 来识别它,而不是使用我们在 Apache 配置文件中放置的example.com占位符,例如/etc/apache2/sites-available/waitercaller.conf

域名的更改也需要一段时间才能传播,即世界上的主要 DNS 服务器需要更新,以便当有人访问您的域名时,DNS 服务器可以将其重定向到您的 IP 地址。DNS 传播可能需要几个小时。

添加 HTTPS

您可能已经注意到,银行、谷歌和微软等大型公司以及越来越多的其他公司的网站都会自动重定向到HTTPS版本。这里的“S”代表安全,因此完整的缩写变成了超文本传输安全协议。每当您在浏览器的导航栏中看到 HTTPS(通常旁边有一个绿色的挂锁)时,这意味着您和服务器之间的所有流量都是加密的。这可以防止所谓的中间人攻击,即位于您和服务器之间的恶意人员可以查看或修改您和服务器交换的内容。

直到最近,这种加密是由网站所有者通过从证书颁发机构CA)购买昂贵的证书来实现的。CA 的工作是充当您和服务器之间的可信第三方,向网站所有者签发一个签名证书。这个证书可以用来建立客户端和服务器之间的加密通道。由于成本过高,HTTPS 只在绝对必要的安全性场合(例如在线银行业务)和像谷歌这样能够支付高额费用的公司中使用。随着每个人开始意识到基于信任的万维网模型本质上存在缺陷,HTTPS 变得越来越受欢迎,即使是对于小型博客和个人网站也是如此。像 Let's Encrypt(letsencrypt.org)这样的公司现在提供免费证书,这些证书可以轻松安装和配置以与流行的 Web 服务器(如 Apache)一起使用。

对于我们的最终项目,由于我们处理敏感数据(特别是密码),对于我们的应用程序的非平凡使用,使用 HTTPS 是必须的,对于我们的其他两个项目也是理想的(HTTPS 总是比 HTTP 更好)。尽管现在设置证书以与您的 Web 服务器一起使用的过程比几年前简单得多,但是如何设置 Apache2 以与 CA 证书一起使用的完整演练超出了本书的范围。

但是,如果您只花时间了解本章提到的技术中的一种,那么应该是这个。这是一个非常简单的 Digital Ocean 教程链接,向您展示如何在 Ubuntu 14.04 上设置证书以与 Apache2 一起使用(这是本书中使用的确切配置):

www.digitalocean.com/community/tutorials/how-to-secure-apache-with-let-s-encrypt-on-ubuntu-14-04

新注册的电子邮件确认

在我们的第三个项目中,您可能注意到我们的注册流程有点不同寻常。新用户在网站上注册的正常方式如下:

  1. 用户填写注册表并提交。

  2. 服务器将数据保存在数据库中。

  3. 服务器生成一个唯一且安全的令牌,并将该令牌与注册关联起来,并将其标记为不完整。

  4. 服务器通过 URL 向用户发送一个唯一且安全的令牌,并请求用户点击该 URL 以确认账户。

  5. 用户点击带有唯一令牌的 URL。

  6. 服务器找到与此令牌关联的不完整注册,并将注册标记为已确认。

上述过程是为了证明用户向我们提供了一个真实的电子邮件地址,并且可以访问该地址。当然,用户不希望等待某人手动发送电子邮件,因此确认电子邮件必须自动发送。这会导致一些复杂情况,包括需要设置邮件服务器以及我们发送的自动确认电子邮件可能最终会出现在用户的垃圾邮件文件夹中,导致所有人都感到沮丧。另一个选择是使用电子邮件作为服务平台,例如亚马逊的简单电子邮件服务SES)。但是,这些通常不是免费的。

一旦用户确认了电子邮件账户,我们也可以用它来允许用户重置忘记的密码。同样,这将涉及向想要重置密码的用户发送自动电子邮件。该电子邮件将再次包含 URL 中的安全唯一令牌,用户将点击该令牌以证明他或她确实发出了密码重置请求。然后,我们将允许用户输入新密码,并使用新的(散列和加盐的)密码更新数据库。请注意,我们不能也不应该发送用户自己的密码,因为我们只存储密码的加盐和散列版本;我们无法发现忘记的密码。

完整的用户帐户系统具有自动电子邮件确认和“忘记密码”功能是相当复杂的。我们可以使用 Python 和 Flask 以及电子邮件服务器来设置它,但在下一节中,我们还将讨论一些更多的 Flask 扩展,这些扩展可以使这个过程更容易。

谷歌分析

如果我们商业运行任何网络应用程序,我们可能会对实际使用它们的人数感兴趣。这将帮助我们决定如何(以及是否)为我们的应用程序收费,并提供其他有用的见解。

通过 Google Analytics 是实现这一目标的最常见方式。这是谷歌提供的一项服务,不仅可以追踪有多少人访问您的网站,还可以追踪他们在网站上花费的时间、他们是如何找到网站的、他们来自哪个国家、关于他们用于网页浏览的设备的信息,以及许多其他有见地的统计数据。Google Analytics 是免费的,要开始使用它,您只需要在analytics.google.com上创建一个帐户(或使用您现有的谷歌帐户)。在填写有关您的网站的一些信息后,您将获得一小段 JavaScript 代码。这段 JavaScript 代码包含一个分配给您的网站的唯一跟踪 ID。您需要将 JavaScript 代码添加到您的网站上,每当有人访问网站时,JavaScript 代码将加载到他们的网络浏览器中,并将有关他们的信息发送到谷歌,然后谷歌将使用唯一 ID 将信息与您关联起来。在 Google Analytics 仪表板上,您可以看到访问者数量、访问时间的图表,以及许多其他信息。

在我们的服务员呼叫项目中,我们将在base.html文件的末尾添加 JavaScript 代码以及 Bootstrap JavaScript 代码。

可扩展性

作为网络应用程序创建者最好的问题是创建了一个太受欢迎的应用程序。如果有很多人访问您的应用程序,这意味着您创造了一些好东西(并且可能开始向人们收费)。我们的小型 VPS 将无法处理大量流量。如果成千上万的人同时访问网站,我们将很快耗尽网络带宽、处理能力、内存和磁盘空间。

关于创建可扩展的 Web 应用程序的完整讨论将是一本专门的书。然而,我们需要采取的一些步骤包括:

  • 在专用机器上运行数据库:目前,我们在同一台物理机器上运行我们的 Web 服务器和数据库。对于较大的 Web 应用程序,数据库将有自己的专用机器,以便大量的数据库使用(例如,许多餐厅顾客创建新请求)不会对只想浏览我们主页的人产生负面影响。通常情况下,数据库机器会有大量的磁盘空间和内存,而运行 Web 服务器的机器将更注重高带宽可用性和处理能力。

  • 运行负载均衡器:如果我们有很多访问者,一台机器无论多么大和强大都无法跟上负载。因此,我们将运行几台重复的 Web 服务器。然后问题将是如何均匀地将新访问者分配到所有不同的机器中。为了解决这个问题,我们将使用一个叫做负载均衡器的东西,它负责接受用户的初始请求(也就是当用户访问您的主页时)并将这个用户分配给一个复制的 Web 服务器。

随着我们的规模越来越大,情况会变得越来越复杂,我们还会添加副本数据库机器。一个受欢迎的网站需要全天候维护,通常需要一个团队的人来维护,因为硬件会出现故障,恶意用户存在,而更新(为了减轻恶意用户的攻击而必要)往往会破坏软件之间的兼容性。好的一面是,如果任何 Web 应用程序变得足够受欢迎,需要上述的情况,那么这个应用程序可能也会产生足够的收入,以至于让所有讨论的问题成为“SEP”,或者是别人的问题。也就是说,我们可以雇佣一个系统管理员,一个数据库管理员和一位首席安全官,让他们解决问题,然后度过余生在海上巡航。在这一点上,让我们来看看一些关于 Flask 的特定扩展,以丰富我们的知识。

扩展你的 Flask 知识

你可能期望 Flask 作为一个微框架,可以在一本书中完整地介绍。然而,Flask 有一些潜在非常有用的部分,我们在我们的三个项目中都不需要。我们将在这里简要概述这些部分。

VirtualEnv

第一个值得一提的库实际上并不是特定于 Flask 的,如果你之前在 Python 开发上花了一些时间,你几乎肯定会遇到它。VirtualEnv是一个 Python 库,它在你的机器上创建一个虚拟的 Python 环境。它可以与 Flask 一起在你的开发机器上使用,也可以在你的开发机器和服务器上同时使用。它的主要目的是将你的整个 Python 环境隔离成一个虚拟的环境,包括你使用的所有 Python 模块。这有两个主要的好处。第一个是有时你需要在同一台机器上运行两个不同的 Python 项目,但每个项目需要不同版本的相同库。使用VirtualEnv,每个项目都会有自己的虚拟化的 Python 设置,因此安装两个不同版本的相同库变得微不足道。第二个优势是你的环境变得更加可移植,理论上,很容易将在VirtualEnv环境中运行的应用程序迁移到另一台安装了VirtualEnv的机器上。

VirtualEnv环境在 Python 开发中被广泛使用,特别是在 Flask 中。我决定不将其包含在书的主体部分中,这一决定在审阅者中引起了很大的争议,其中许多人认为没有包含它的书是不完整的。我决定不包括它有两个原因。第一个原因是,当我学习 Flask 时,我阅读了许多教程和示例,其中包括了 VirtualEnv。我总是觉得为设置和解释VirtualEnv和虚拟环境所需的额外工作会分散教程的主要内容(即使用 Flask)。第二个原因是,即使在我今天构建的 Flask 项目中,我仍然经常不使用它。如果你不运行依赖于特定库的特定版本的旧软件,那么在系统范围内安装有用的 Python 库,以便它们可以被所有的 Python 应用程序使用,是很方便的。此外,有时,VirtualEnv 可能只是一项任务,而没有提供任何价值。

当然,你可能已经对 VirtualEnv 有自己的看法,如果是这样,你可以随意使用它。没有什么能阻止任何人在VirtualEnv环境中构建本书中的任何项目,如果他们有一点经验的话。如果你以前没有使用过,那么值得一试。你可以通过 pip 安装它并尝试一下,看看它到底是做什么的,以及它是否在你的特定场景中有用。你可以在这里阅读更多关于它以及如何使用它的信息:

docs.python-guide.org/en/latest/dev/virtualenvs/

Flask Blueprints

也许我们在本书中没有提到的 Flask 最大的特性是 Flask 蓝图。在构建了三个 Flask 应用程序之后,您一定会注意到一些模式一次又一次地出现。重复的代码是糟糕的代码,即使在多个不同的应用程序中;如果您找到了更好的方法来做某事,或者需要对更新进行一些更改,您不希望在几个应用程序中进行相同的更改。

蓝图提供了一种指定 Flask 应用程序模式的方法。如果您有几个应用程序使用相同的代码来返回模板或连接到数据库,您可以将这些通用代码写在一个蓝图中,然后让所有应用程序注册该蓝图。

您可以在flask.pocoo.org/docs/0.10/blueprints/了解更多关于 Flask 蓝图的信息,查看示例,并学习如何开始使用它们。

Flask 扩展

在我们的三个项目过程中,我们看了很多不同的 Flask 扩展。但是,由于本书的教育重点,我们选择从头开始编写一些代码,可能更适合使用现有的扩展。(通常在开发时,我们希望避免重复造轮子。如果其他人已经考虑解决问题并提供了一个经过深思熟虑和良好维护的解决方案,最好使用他们的成果,而不是试图创建我们自己的。)特别感兴趣的是我们可以使用的扩展,使我们的用户帐户系统更简单更强大,以及那些为我们提供更抽象的方式与数据库交互的扩展。

Flask-SQLAlchemy

本书中另一个有争议的决定是不介绍 Flask-SQLAlchemy 扩展与 MySQL 一起使用。SQLAlchemy 提供了一个 SQL 工具包和 ORM,使从 Python 环境与 SQL 数据库交互更容易和更安全。ORM 提供了另一层抽象,使 Web 应用程序与数据库之间的交互更加简单。与其直接编写 SQL 代码,不如使用 Python 对象调用数据库,然后 ORM 将其转换为 SQL。这样可以更轻松地编写和维护数据库,也更安全(ORM 通常非常擅长减轻潜在的 SQL 注入漏洞)。省略它的原因与省略 VirtualEnv 的原因类似——在学习时,太多的抽象层可能会带来更多的伤害,而且在盲目使用工具之前,首先亲身体验工具解决的问题总是有利的。

对于任何使用 MySQL 数据库的 Flask 应用程序,比如我们的犯罪地图项目,强烈建议使用 ORM,就像大多数 Flask 扩展一样。Flask-SQLAlchemy 只是一个现有的非 Flask 特定库的包装器。您可以在www.sqlalchemy.org/找到更多关于 SQLAlchemy 的信息,以及关于 Flask-SQLAlchemy 的全面指南,包括常见的使用模式:

flask.pocoo.org/docs/0.10/patterns/sqlalchemy/

Flask MongoDB 扩展

有几个 Flask 扩展旨在使与 MongoDB 的交互更容易。由于 MongoDB 相对较新,这些扩展都没有达到 SQLAlchemy 的成熟度,也没有被广泛使用;因此,如果您打算使用其中之一,建议您检查每个以决定哪一个最适合您的需求。

Flask-MongoAlchemy

也许最类似于 SQLAlchemy(不仅仅是名称)的是 Flask-MongoAlchemy。与 SQLAlchemy 类似,MongoAlchemy 也不是 Flask 特定的。您可以在www.mongoalchemy.org找到有关主项目的更多信息。Flask-MongoAlchemy 是 MongoAlchemy 的 Flask 包装器,您可以在这里找到更多信息:

pythonhosted.org/Flask-MongoAlchemy

Flask-PyMongo

一个更薄的 MongoDB 包装器,更接近于直接使用 PyMongo,就像我们在第三个项目中所做的那样,是 Flask-PyMongo。与 MongoAlchemy 不同,它不提供 ORM 等效;相反,它只是提供了一种通过 PyMongo 连接到 MongoDB 的方式,使用的语法更符合 Flask 通常处理外部资源的方式。您可以在其 GitHub 页面上快速了解 Flask-PyMongo:

github.com/dcrosta/flask-pymongo

Flask-MongoEngine

使用 Flask 与 MongoDB 结合的另一个解决方案是 MongoEngine (mongoengine.org)。这很显著,因为它与 WTForms 和 Flask-Security 集成,我们将在接下来的部分中讨论。您可以在pypi.python.org/pypi/flask-mongoengine上了解有关 Mongo Engine 的 Flask 特定扩展的更多信息。

Flask-Mail

如果我们想要实现自动发送电子邮件的解决方案,比如本章前面描述的那样,一个有用的扩展是 Flask-Mail。这允许您轻松地从 Flask 应用程序发送电子邮件,同时处理附件和批量邮寄。正如之前提到的,如今,考虑使用亚马逊的 SES 等第三方服务来发送电子邮件而不是自己发送是值得的。您可以在pythonhosted.org/Flask-Mail上了解更多关于 Flask-Mail 的信息。

Flask-Security

我们将讨论的最后一个扩展是 Flask-Security。这个扩展很显著,因为它的很大一部分实际上是通过组合其他 Flask 扩展构建的。在某种程度上,它偏离了 Flask 的哲学,即尽可能少地做事情,以便有用,并允许用户完全自由地进行自定义实现。它假设您正在使用我们描述的数据库框架之一,并从 Flask-Login、WTForms、Flask-Mail 和其他扩展中汇集功能,试图使构建用户帐户控制系统尽可能简单。如果我们使用这个,我们将有一个集中处理注册帐户、登录帐户、加密密码和发送电子邮件的方式,而不是必须分别实现登录系统的每个部分。您可以在这里了解更多关于 Flask-Security 的信息:

pythonhosted.org/Flask-Security

其他 Flask 扩展

有许多 Flask 扩展,我们只强调了我们认为在许多 Web 开发场景中通常适用的扩展。当然,当您开发一个独特的 Web 应用程序时,您将有更具体的需求,很可能已经有人有类似的需求并创建了解决方案。您可以在这里找到一个广泛的(但不完整)Flask 扩展列表:

flask.pocoo.org/extensions

扩展您的 Web 开发知识

在本书中,我们专注于后端开发——通过 Python 或 Flask 完成。开发 Web 应用程序的一个重要部分是构建一个功能强大、美观、直观的前端。虽然我们提供了 HTML、CSS 和 JavaScript 的坚实基础,但每个主题都足够大,可以有自己的书籍,而且有许多这样的书籍存在。

JavaScript 可能是这三种语言中最重要的。它被称为“Web 的语言”,在过去几年中稳步增长(尽管像所有语言一样,它也有其批评者)。有许多用于构建 JavaScript 密集型 Web 应用程序的框架(事实上,它们的数量之多以及新框架的发布频率已经成为开发人员之间的笑柄)。我们在本书中介绍了 Bootstrap,其中包括基本的 JavaScript 组件,但对于更加交互式的应用程序,存在着更大的框架。其中三个较受欢迎的前端框架包括 AngularJS(由 Google 开发)、React.js(由 Facebook 开发)和 Ember.js(由包括 Yahoo 在内的多家公司赞助)。学习其中任何一个框架或其他许多框架中的一个都将帮助您构建更大更复杂的 Web 应用程序,具有更丰富的前端。

JavaScript 也不再局限于前端,许多现代 Web 应用程序也使用 JavaScript 在服务器端构建。实现这一点的常见方法是通过 Node.js,在我们构建的任何项目中,它完全可以取代 Python 和 Flask。

HTML5 和 CSS3 比它们演变而来的旧技术强大得多。以前,HTML 用于内容,CSS 用于样式,JavaScript 用于操作,分工明确。现在,这三种技术的能力之间有了更多的重叠,一些令人印象深刻的交互式应用程序是仅使用 HTML5 和 CSS3 构建的,而没有通常的 JavaScript 补充。

总结

在这个附录中,我们展望未来,指出了一些关键领域和资源,这些将帮助您超越本书中详细介绍的内容。我们在三个主题中涵盖了这些领域:本书中我们所做的项目、我们没有使用的 Flask 资源以及 Web 开发的一般情况。

这就是结尾。然而,技术世界如此广阔,发展如此迅速,希望这更像是一个开始而不是结束。在您继续冒险,了解更多关于生活、Python 和 Web 开发的知识时,我希望本书中提出的一些想法能够留在您心中。

posted @ 2024-05-20 16:51  绝不原创的飞龙  阅读(56)  评论(0编辑  收藏  举报