Python-谷歌应用引擎指南-全-
Python 谷歌应用引擎指南(全)
原文:
zh.annas-archive.org/md5/0c0b42a6b0dbf798d4c729bc124db6ed译者:飞龙
前言
2008 年 4 月,来自世界各地的 10,000 名开发者有幸获得了访问谷歌 App Engine 预览版账户的机会,这是一个旨在让用户在其自身服务所使用的相同基础设施上运行其网络应用程序的工具。在谷歌 Campfire One 事件期间宣布,App Engine 被描述为易于使用、易于扩展且免费入门;这三个设计目标完美地符合典型试图缩短上市时间的科技初创公司的需求。
在当时,其他大型公司已经提供租赁其部分基础设施的服务,以可负担的、按使用付费的方式销售可靠性和可扩展性,而谷歌通过向开发者提供应用程序构建块而不是简单的硬件访问,将 App Engine 提前一步;这是后来许多其他公司效仿的托管模式。这种模式的目标是让开发者专注于代码,忘记失败的机器、网络问题、可扩展性问题以及性能调整;选择 Python 作为 App Engine 支持的第一种编程语言,对于旨在使编写和运行网络应用程序更简单的工具来说是一个自然的选择。
在 2012 年的 Google I/O 事件期间,谷歌宣布,其自身基础设施的几个其他构建块将以谷歌云平台的名义提供,最初作为合作伙伴计划,然后作为通用可用产品。目前,App Engine 不仅是有名的云平台家族成员,而且是一个成熟且维护良好的平台,被广泛采用,拥有庞大的客户成功故事列表。
本书将教你如何使用 App Engine 在 Python 中编写和运行网络应用程序,充分利用谷歌云平台。从简单的应用程序开始,你将不断添加更多功能,每次都借助谷歌基础设施提供的组件和服务。
本书涵盖的内容
第一章, 入门,将帮助你通过一个非常简单的功能型 Python 应用程序在运行服务器上动手实践。本章从对谷歌云基础设施的概述开始,展示了 App Engine 的位置以及它与其他知名云服务相比如何。然后,它引导读者下载和安装 Linux、Windows 和 OS X 的运行时,编写一个 Hello, World! 应用程序并将其部署到 App Engine 上。最后一部分介绍了开发和生产服务器的管理控制台。
第二章, 一个更复杂的应用,教您如何在 App Engine 上实现一个复杂的 Web 应用。它从介绍捆绑的 webapp2 框架和可能的替代方案开始;然后,您将接触用户身份验证和表单处理,以及 Google 的 Datastore 非关系型数据库的介绍。最后一部分展示了如何通过模板渲染制作 HTML 页面,以及如何提供所有用于页面样式的静态文件。
第三章, 存储和处理用户数据,将向您展示如何从上一章的应用中添加更多功能。该章节首先向您展示如何让用户使用 Google Cloud Storage 上传文件,以及当这些文件包含图像数据时如何使用 Image API 来操作这些文件。接着,它介绍了用于在请求过程之外执行长时间任务(如图像处理)的任务队列,以及如何安排这类任务的批次。最后一部分展示了如何通过 Mail API 发送和接收电子邮件。
第四章, 提高应用性能,首先展示了如何使用 Datastore 的高级特性来提高应用性能。然后,它向您展示如何使用 App Engine 提供的缓存,以及如何使用模块将应用拆分成更小的服务。
第五章, 在 Google Cloud SQL 中存储数据,专注于 Google Cloud SQL 服务。它展示了如何创建和管理数据库实例,以及如何连接和执行查询。然后,它演示了 App Engine 应用如何保存和检索数据,以及如何在开发期间使用本地 MySQL 安装。
第六章, 使用通道实现实时应用,展示了如何使我们的应用变得实时,换句话说,如何在浏览器页面不重新加载的情况下更新客户端看到的内容。第一部分展示了 Channel API 的工作原理,客户端连接时会发生什么,以及消息从服务器到客户端的往返过程。然后,它展示了如何从前面的章节中添加实时功能到我们的应用中。
第七章, 使用 Django 构建应用,教您如何使用 Django Web 框架而不是 webapp2 来构建 App Engine 应用。第一部分向您展示如何配置开发环境,然后使用 Django 提供的一些特性重写之前章节中的应用。最后一部分展示了如何将应用部署到生产服务器。
第八章,使用 Google Cloud Endpoints 公开 REST API,展示了如何修改我们的应用程序的一部分以通过 REST API 公开数据。第一部分探讨了设置和配置项目所需的所有操作以及如何为我们的 API 实现几个端点。最后一部分探讨了如何为 API 端点添加 OAuth 保护。
你需要这本书的什么
为了运行本书中演示的代码,您需要一个 2.7.x 版本的 Python 解释器和 App Engine Python SDK,这些内容在第一章的“下载和安装”部分有描述,入门。
此外,为了访问在 App Engine 上运行后的示例应用程序,你需要一个最新的网络浏览器版本,例如 Google Chrome、Mozilla Firefox、Apple Safari 或 Microsoft Internet Explorer。
这本书是为谁而写的
如果您是一位希望将技能应用于使用 Google App Engine 和 Google Cloud Platform 工具和服务编写网络应用程序的 Python 程序员,这本书就是为您而写的。需要扎实的 Python 编程知识以及基本了解网络应用程序的结构。不假设您有 Google App Engine 的先验知识,也不需要使用类似工具的经验。
通过阅读这本书,你将熟悉 Google Cloud Platform 提供的功能,特别是关于 Google App Engine、Google Cloud Storage、Google Cloud SQL 和 Google Cloud Endpoints 的最新版本,这些内容在撰写本书时是可用的。
习惯用法
在这本书中,您将找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。
文本中的代码词汇如下所示:“如果用户已经登录,get_current_user()方法返回一个User对象,否则返回None参数”。
代码块设置如下:
import webapp2
class HomePage(webapp2.RequestHandler):
def get(self):
self.response.headers['Content-Type'] = 'text/plain'
self.response.out.write('Hello, World!')
app = webapp2.WSGIApplication([('/', HomePage)], debug=True)
新术语和重要词汇将以粗体显示。你在屏幕上看到的,例如在菜单或对话框中的词汇,在文本中会这样显示:“要创建一个新应用程序,请点击创建应用程序按钮。”
注意
警告或重要注意事项将以这样的框显示。
小贴士
小贴士和技巧会这样显示。
读者反馈
我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者的反馈对我们开发您真正能从中获得最大价值的标题非常重要。要发送一般反馈,请简单地发送电子邮件到<feedback@packtpub.com>,并在邮件主题中提及书名。如果您在某个主题上有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在 www.packtpub.com 的账户下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
下载本书中的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表的彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出的变化。您可以从以下链接下载此文件:www.packtpub.com/sites/default/files/downloads/B03710_8194OS_Graphics.pdf
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的某本书中找到错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站,或添加到该标题的错误清单部分。
要查看之前提交的错误清单,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入本书的名称。所需信息将出现在错误清单部分。
盗版
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。我们感谢您在保护我们的作者以及为我们提供有价值内容的能力方面的帮助。
询问
如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。
第一章. 入门
任何可通过互联网访问的软件,通常通过网页浏览器访问,都可以被视为网络应用程序。社交网络、电子商务网站、电子邮件客户端、在线游戏只是被称为 Web 2.0 的趋势的几个例子,这一趋势始于 1990 年代末,并在过去几年中兴起。今天,如果我们想为多个客户和多个用户提供服务,我们很可能会编写一个网络应用程序。
从开发者的角度来看,网络应用程序带来了无数的好处,但每次我们想要让我们的软件对其他用户可用时,都会面临一个主要缺点:我们需要一个连接到互联网的远程服务器来托管应用程序。这个服务器必须始终可用,并在合理的时间内响应用户,无论用户数量多少,否则应用程序将无法使用。
解决托管问题的值得注意的解决方案是云计算,这是一个相当通用的术语,通常指的是以合理的成本、简单快捷的方式在他人基础设施上运行应用程序和服务的机遇,并且能够快速配置和释放所需资源。
在本章中,我们将详细定义云计算这一术语,然后介绍谷歌提供的模型,重点关注对我们这些开发者来说重要的元素,并使用它们在谷歌云平台和谷歌 App Engine 上运行我们的第一个应用程序。
本章我们将涵盖以下主题:
-
对谷歌云平台和谷歌 App Engine 的详细介绍
-
设置 App Engine 代码环境
-
编写一个简单的应用程序
-
在远程服务器上加载和运行应用程序
-
使用管理控制台
云计算堆栈 – SaaS、PaaS 和 IaaS
我们可以选择外包我们的应用程序及其运行的硬件,同时仍然负责整个软件栈,包括操作系统;或者,我们可以简单地使用来自其他供应商的现有应用程序。
我们可以将云计算表示为三个不同类别的堆栈:软件即服务(SaaS)、平台即服务(PaaS)和基础设施即服务(IaaS)如下:

在第一种情况下,云计算模型被定义为 IaaS,我们基本上外包了硬件以及所有固有的服务,如电源供应、冷却、网络和存储系统。我们决定如何分配资源,需要多少网络应用程序或数据库服务器,是否需要使用负载均衡器,如何管理备份等等;安装、监控和维护是我们的责任。IaaS 服务的显著例子包括亚马逊的 EC2 和 Rackspace 云托管。
在第二种情况下,云计算模型被定义为 SaaS,与 IaaS 相反,因为我们只是使用第三方供应商提供的现成软件,该供应商对其运行的基础设施没有技术知识;供应商负责产品的可靠性和安全性。SaaS 的显著例子包括谷歌的 Gmail 和 Salesforce。
在 IaaS 和 SaaS 之间,我们发现 PaaS 模型,这似乎是从开发者角度来看最有趣的解决方案。PaaS 系统提供了一个平台,我们可以用它来构建和运行我们的应用程序,而无需担心底层,无论是硬件还是软件。
Google Cloud Platform
Google Cloud Platform 旨在为开发者提供构建和运行基于谷歌可靠且高度可扩展基础设施的 Web 应用程序所需的工具和服务。该平台由几个云计算产品组成,可以根据我们的需求进行组合和使用,因此了解这些构建块能为开发者做什么以及它们是如何做到这一点的是非常重要的。
如我们从cloud.google.com的主文档页面了解到的,谷歌将 Google Cloud Platform 的组件分为四组:托管 + 计算、存储、大数据和各项服务。
托管 + 计算
如果我们想在 Google Cloud Platform 上托管一个应用程序,有两种选择:
-
Google App Engine:这是谷歌的 PaaS 平台,将在本章后面详细介绍。
-
Google Compute Engine:这是谷歌的 IaaS,允许用户在谷歌的基础设施上运行具有各种硬件和软件配置的虚拟机。
存储
Google Cloud Platform 提供了几种存储和访问用户数据的选择:
Google Cloud Storage:这是一个高度可用和可扩展的文件存储服务,具有版本控制和缓存功能。我们将在第三章,存储和处理用户数据中学习如何使用 Cloud Storage。
Google Cloud SQL:这是一个完全托管的 MySQL 关系型数据库;复制、安全和可用性是谷歌的责任。第五章,在 Google Cloud SQL 中存储数据完全致力于这项服务。
Google Cloud Datastore:这是一个托管的无模式数据库,存储称为实体的非关系型数据对象;它自动扩展,支持事务,并可以使用类似 SQL 的语法进行查询。我们将在第二章,一个更复杂的应用中开始使用它,并在第四章,提高应用性能中学习如何充分利用它。
BigQuery
BigQuery 是由 Google Cloud Platform 提供的工具,允许使用类似 SQL 的语法在几秒钟内对大量数据进行查询。在进行分析之前,数据必须通过其 API 流入 BigQuery 或上传到 Google Cloud Storage。
服务
我们不必从头编写代码,而可以通过 Google 的一些服务轻松地向我们的应用程序添加功能,这些服务通过 Google Cloud Platform 内部高度集成的 API 实现:
-
翻译 API:这个 API 可以在程序中实现将文本翻译成多种语言,从我们的应用程序内部进行。
-
预测 API:这个 API 使用 Google 的机器学习算法预测未来趋势,可以从我们的应用程序内部或通过 表示状态转换(REST)API 使用。REST 是一种无状态架构风格,描述了一个系统如何通过网络与另一个系统通信;我们将在第八章 使用 Google Cloud Endpoints 暴露 REST API 中深入了解 REST。
-
Google Cloud Endpoints:使用这个工具,可以轻松创建暴露 REST 服务的应用程序,同时提供 拒绝服务(DoS)保护和 OAuth2 认证。我们将在第八章 暴露 REST API 的 Google Cloud Endpoints 中学习如何使用它们。
-
Google Cloud DNS:这是一个全球性的域名系统(DNS)服务,运行在 Google 的基础设施上,并从我们的应用程序内部提供可编程的高容量服务。
-
Google Cloud Pub/Sub:这是一个中间件,提供在运行在 Google Cloud Platform 或外部服务之间进行多对多、异步消息传递。
Google Cloud Platform 提供的所有工具和服务都采用按使用付费的模式,这样应用程序可以根据需要扩展或缩减,我们只为实际使用的资源付费。提供了一个方便的计算器,以便我们能够精确地了解根据我们认为将需要的服务和资源而产生的成本。Google Cloud Platform 提供了一定数量的资源,我们可以免费使用;通常,这些免费配额非常适合免费托管流量较低的 Web 应用程序。
Google App Engine 的作用
如前所述,App Engine 是一个平台即服务(PaaS),这意味着我们享有 SaaS 产品的优势,同时由于我们对代码拥有完全控制权,因此具有增强的灵活性。我们还有 IaaS 解决方案的优势,但无需烦恼维护和配置在原始硬件系统上运行应用程序所需的软件环境。
开发人员是 PaaS 产品(如 App Engine)的首选用户,因为该平台以两种方式帮助他们:它提供了一种简单的方法来部署、扩展、调整和监控 Web 应用程序,而无需系统管理员,并且它提供了一套工具和服务,可以加快软件开发过程。让我们详细探讨这两个方面。
运行时环境
App Engine 运行在完全管理的计算单元上,称为实例。我们可以(并且应该)忽略实例上运行的操作系统,因为我们只与运行时环境交互,该环境是操作系统的抽象,提供资源分配、计算管理、请求处理、扩展和负载均衡。
注意
开发人员可以在 App Engine 上使用四种不同的编程语言编写应用程序:Python、Java、超文本预处理程序(PHP)和 Go,但我们将重点关注 Python 环境。
每当客户端联系运行在 App Engine 上的应用程序时,运行时环境中的一个组件,称为调度器,会选择一个能够提供快速响应的实例,如果需要,则用应用程序数据初始化它,并在一个安全、沙箱化的环境中使用 Python 解释器执行应用程序。应用程序接收 HTTP 请求,执行其工作,并将 HTTP 响应发送回环境。运行时环境与应用程序之间的通信使用Web Server Gateway Interface(WSGI)协议进行;这意味着开发人员可以在其应用程序中使用任何 WSGI 兼容的 Web 框架。
提示
WSGI 是一个描述 Web 服务器如何与用 Python 编写的 Web 应用程序通信的规范。它最初在 PEP-0333 中描述,后来在 PEP-3333 中更新,主要是为了在 Python 3.0 发布下提高可用性。
运行时环境被沙箱化以提高安全性并提供在同一实例上运行的应用程序之间的隔离。解释器可以执行任何 Python 代码,导入其他模块,并访问标准库,前提是它不违反沙箱限制。特别是,每当解释器尝试写入文件系统、执行网络连接或导入用 C 语言编写的扩展模块时,它都会引发异常。我们还必须注意的另一种隔离机制是由沙箱提供的,它通过在请求/响应周期整个持续超过 60 秒时引发异常,防止应用程序过度使用实例。
多亏了沙箱化,运行时可以在任何给定时间决定是否在一个实例或多个实例上运行应用程序,请求根据流量分布到所有这些实例上。这种能力,加上负载均衡和调度器设置,使得 App Engine 真正具有可扩展性。
用户可以通过简单的交互式管理控制台,通过提高应用的响应性或优化成本来轻松调整应用程序的性能。我们可以指定实例性能,以内存和 CPU 限制、始终准备满足请求的空闲实例数量以及流量增加时动态启动的实例数量来衡量。我们还可以指定我们容忍的挂起请求的最大时间(以毫秒为单位),并让 App Engine 自动调整设置。
服务
初看之下,运行时环境施加的限制可能显得过于严格。最终,开发者如果不能在磁盘上写入数据、接收传入的网络连接、从外部 Web 应用程序获取资源或启动缓存等实用服务,他们如何能够创建有用的东西呢?这就是为什么 App Engine 提供了一组高级 API/服务,开发者可以使用这些 API/服务来存储和缓存数据或通过互联网进行通信。
其中一些服务由 Google Cloud Platform 作为独立产品提供,并且可以无缝集成到 App Engine 中,而另一些服务则仅可在运行时环境中使用。
可用服务的列表经常变化,因为 Google 发布了新的 API 和工具;以下是我们将在本书后面部分使用的一些工具的子集,除了我们之前介绍的数据存储、Google Cloud Endpoints、Google Cloud SQL 和 Google Cloud Storage 服务外:
-
通道:此 API 允许应用程序与客户端建立持久连接,并通过这些连接实时推送数据,而不是使用轮询策略。客户端必须使用一些 JavaScript 代码与服务器交互。我们将在第六章 使用通道实现实时应用 中学习如何使用通道。
-
数据存储备份/还原:在任何给定时间,都可以对 Datastore 中的实体执行备份或从以前的备份中还原它们;管理操作非常简单,因为它们可以从管理控制台交互式执行。我们将在第四章 提高应用性能 中详细了解备份和还原程序。
-
图像:此 API 允许开发者访问和操作由应用程序提供或从 Google Cloud Storage 加载的图像数据。我们可以获取有关格式、大小和颜色的信息,并执行诸如调整大小、旋转和裁剪等操作,我们还可以在 API 提供的不同格式之间转换图像并应用一些基本过滤器。我们将在第三章 存储和处理用户数据 中使用 Images API 提供的一些功能。
-
邮件: 此服务允许应用程序代表管理员或使用 Google 账户登录的用户发送电子邮件,并接收发送到某些地址并路由到应用程序的电子邮件消息。我们将在第三章存储和处理用户数据中使用服务提供的这两个功能。
-
Memcache: 这是一个通用、分布式内存缓存系统,可用于显著提高应用程序性能,以比访问数据库或 API 更快的方式提供频繁访问的数据。我们将在第四章提高应用程序性能中看到如何使用 Memcache。
-
模块: 这些用于将应用程序拆分为可以相互通信并共享其状态的逻辑组件。它们非常有用,因为每个模块都可以有不同的版本和性能以及扩展设置,这为开发者在调整应用程序时提供了极大的灵活性。我们将在第四章提高应用程序性能中看到如何使用模块。
-
计划任务: 这就是 App Engine 实现 cron 作业的方式。开发者可以安排一个作业在指定的日期或定期执行。计划以类似英语的格式定义:例如,
每周五 20:00是一个有效的计划,我们可以用它向用户发送周报。我们将在第三章存储和处理用户数据中看到如何使用计划任务。 -
任务队列: 如前所述,运行在 App Engine 上的应用程序的整个请求/响应周期最长为 60 秒,这使得执行长时间操作成为不可能。这就是为什么存在 Task Queue API 的原因——它可以在用户请求之外执行工作,这样长时间的操作就可以在后台稍后执行,并在 10 分钟内完成。我们将在第三章存储和处理用户数据中看到如何使用任务队列。
-
URL Fetch: 正如我们所知,运行时环境阻止我们的应用程序执行任何类型的网络连接,但通过 HTTP 请求访问外部资源是 Web 应用程序的一个常见需求。这种限制可以通过使用 URL Fetch API 来发出 HTTP 或 HTTPS 请求,并以可扩展和高效的方式检索响应来克服。
-
用户:我们可以在应用程序中使用 Google 帐户、Google Apps 域中的帐户或通过 OpenID 标识来验证用户。使用 Users API,我们的应用程序可以确定用户是否已登录,并将他们重定向到登录页面或访问他们的电子邮件。使用此 API,开发者可以将创建帐户和验证用户数据的责任委托给 Google 或 OpenID 提供商。
有关我们可以在 App Engine 环境中使用并由 Google 提供的工具和服务的更多信息,请参阅 developers.google.com/appengine/features/。
制作我们的第一个 Python 应用程序
我们现在已经了解了 Google Cloud Platform 可以为我们提供哪些功能,并且我们准备将 App Engine 付诸实践,但在我们开始编写代码之前,我们需要设置我们的工作站。
下载和安装
要开始,我们需要为我们的选择平台安装 Google App Engine SDK for Python。SDK 包含开发应用程序所需的所有库,以及一套在本地环境中运行和测试应用程序以及将其部署到生产服务器的工具。在某些平台上,可以通过图形用户界面(Google App Engine Launcher)执行管理任务,在其他平台上,我们可以使用一套全面的命令行工具。我们将在本章后面详细讨论 Google App Engine Launcher。
在安装 SDK 之前,我们必须检查我们的系统上是否有可用的 Python 2.7(在撰写本书时,2.7.8 是最新版本)的安装;我们需要这个特定的 Python 版本,因为 2.5 已经弃用,它是 App Engine 平台唯一支持的版本。如果我们使用 Linux 或 Mac OS X,我们可以从发出命令的终端中检查 Python 版本(注意大写字母 V):
python -V
输出结果应如下所示:
Python 2.7.8
如果我们在 Windows 上,我们只需确保在 控制面板 的 程序 部分列出了正确的 Python 版本即可。
官方的 App Engine 下载页面包含所有可用 SDK 的链接。以下链接直接指向 Python 版本:developers.google.com/appengine/downloads#Google_App_Engine_SDK_for_Python。
我们必须为我们的平台选择正确的软件包,下载安装程序,然后继续安装。
在 Windows 上安装
要在 Windows 上安装 SDK,我们必须从 App Engine 下载页面下载 .msi 文件,双击它以启动安装向导,并遵循屏幕上的说明。安装完成后,Google App Engine 启动器的快捷方式将被放置在桌面上,以及 开始 菜单中的一个项目。SDK 的 Windows 版本不提供任何命令行工具,因此我们将始终使用启动器来管理我们的应用程序。
在 Mac OS X 上安装
要在 Mac OS X 上安装 SDK,我们必须从 App Engine 下载页面下载 .dmg 文件,双击它以打开磁盘映像,然后将 App Engine 图标拖到 Applications 文件夹中。在 Dock 中保留启动器的快捷方式很方便;为此,我们只需再次将 App Engine 图标从 Applications 文件夹拖到 Dock 中。命令行工具也将被安装,并在启动器首次执行时,会弹出一个对话框询问我们是否想要创建必要的符号链接,以便使工具在系统范围内可用,这样就可以在任何终端窗口中执行,而无需进一步配置。
在 Linux 上安装
要在 Linux 和更广泛的 POSIX 兼容系统上安装 SDK,我们必须从 App Engine 下载页面下载 .zip 文件,并将其内容提取到我们选择的目录中。该存档包含一个名为 google_appengine 的文件夹,其中包含运行时和命令行工具,我们必须将其添加到我们的 shell 的 PATH 环境变量中,以便在任何终端中都可以使用这些工具。SDK 的 Linux 版本不包括启动器。
App Engine 启动器
SDK 的 Windows 和 OS X 版本附带了一个名为启动器的图形用户界面工具,我们可以使用它来执行创建和管理多个应用程序等管理任务。
注意
启动器是一个非常方便的工具,但请记住,虽然我们可以通过启动器完成的每个任务都可以通过命令行工具执行,但反之则不然。有些任务只能通过使用适当的工具从命令行执行,正如我们将在本书后面看到的那样。
以下截图显示了 OS X 中的启动器窗口:

在以下截图中,我们可以看到 Windows 中的启动器:

在开始使用启动器之前,检查它是否使用正确的 Python 版本非常重要。如果我们系统中安装了多个 Python,这一点尤为重要。要检查启动器使用的 Python 版本并更改它,我们可以根据我们的平台点击相应的菜单打开 首选项... 对话框,并设置 Python 路径值。在同一个对话框中,我们可以指定当需要编辑应用程序文件时,启动器将默认打开哪个文本编辑器。
要创建一个新应用程序,我们可以在文件菜单中点击新建应用程序或点击启动器窗口左下角的带有加号图标按钮。启动器将提示输入应用程序名称和包含所有项目文件的文件夹路径;一旦创建,应用程序将列在启动器的主窗口中。
我们可以通过在启动器工具栏上点击运行按钮或在控制菜单中点击运行来启动本地开发服务器。一旦服务器启动,我们可以通过点击停止按钮或在控制菜单中的停止选项来停止它。点击浏览按钮或在控制菜单中的浏览选项将在默认浏览器中打开所选应用程序的主页。要浏览开发服务器生成的日志,我们可以通过点击工具栏上的日志按钮或在控制菜单中的日志选项来打开日志控制台窗口。工具栏上的SDK 控制台按钮和控制菜单中的SDK 控制台操作将在默认浏览器中打开服务于开发者控制台的 URL,这是一个与本地开发服务器交互的内置应用程序,我们将在本章后面详细探讨。
编辑按钮将在外部文本编辑器中打开所选应用程序的配置文件,可能是我们在首选项...对话框中指定的那个;当我们点击编辑菜单中的在外部编辑器中打开操作时,也会发生同样的事情。
要部署和上传所选应用程序到 App Engine,我们可以在工具栏上点击部署按钮或在控制菜单中点击部署操作。工具栏上的仪表板按钮和控制菜单中的仪表板操作将在默认浏览器中打开 App Engine 管理控制台的 URL。
使用启动器,我们可以为本地开发服务器设置额外的标志并自定义一些参数,例如监听的 TCP 端口号。为此,我们必须点击编辑菜单中的应用程序设置...选项并在设置对话框中进行所需的调整。
启动器还可以处理从头开始通过命令行创建或从外部仓库签出的现有应用程序。要将现有应用程序添加到启动器,我们可以在文件菜单中点击添加现有应用程序...选项并指定应用程序路径。
创建应用程序
创建应用程序的第一步是为它选择一个名称。根据传统,我们将编写一个打印"Hello, World!"的应用程序,因此我们可以选择helloword作为应用程序名称。我们已经知道如何从启动器创建应用程序,另一种选择是手动从命令行进行操作。
在最简单的情况下,一个工作的 Python 应用程序由一个名为应用程序根的文件夹组成,该文件夹包含一个 app.yaml 配置文件和一个包含处理 HTTP 请求所需代码的 Python 模块。当我们通过 Launcher 创建应用程序时,它会为我们生成这些文件和 root 文件夹,但让我们看看我们如何可以从命令行完成相同的结果。
app.yaml 配置文件
当我们开始创建 root 文件夹时,我们如何命名并不重要,但为了与 Launcher 保持一致,我们可以使用应用程序的名称:
mkdir helloworld && cd helloworld
然后,我们创建一个 app.yaml 文件,其中包含以下 YAML 代码:
application: helloworld
version: 1
runtime: python27
api_version: 1
threadsafe: yes
handlers:
- url: .*
script: main.app
libraries:
- name: webapp2
version: "2.5.2"
注意
YAML(YAML Ain't Markup Language 的递归缩写)是一种适合于既需要用户访问又需要程序访问和操作配置文件的易于阅读的序列化格式。
上一段代码的第一个部分定义了一些应用程序的设置参数:
-
应用程序参数:这是应用程序名称;在本书的后续部分,我们将看到它有多么重要。
-
版本参数:这是一个指定应用程序版本的字符串。App Engine 保留每个部署版本的副本,并且我们可以选择性地运行它们,这是一个在公开之前测试应用程序的非常有用的功能。
-
运行时参数:在撰写本书时,Python 2.7 是为新创建的应用程序提供的唯一运行时,因为 Python 2.5 已被弃用。
-
API 版本参数:这是当前运行时环境的 API 版本。在撰写本书时,1 是 Python 2.7 运行时唯一可用的 API 版本。
-
线程安全参数:这指定了我们的应用程序是否可以处理并发请求的单独线程。
app.yaml 文件的下一部分列出了我们想要匹配的 URL,形式为正则表达式;script 属性指定了每个 URL 的处理器。处理器是 App Engine 调用以在应用程序收到请求时提供响应的程序。有两种类型的处理器:
-
脚本处理器:这些处理器运行应用程序提供的 Python 代码
-
静态文件处理器:这些处理器返回静态资源的内容,例如图像或包含 JavaScript 代码的文件
在这种情况下,我们使用了一个 script 处理器,一个使用点符号导入的 Python 可调用对象 string:App Engine 将匹配任何 URL 并调用 main 模块中包含的 app 对象。
最后一个部分列出了我们想要从 App Engine 应用程序中使用的第三方模块的名称和版本,在这种情况下,我们只需要 webapp2 网络框架的最新版本。我们可能会想知道为什么我们需要像网络框架这样复杂的东西来简单地打印一个"Hello, World!"消息,但正如我们已经知道的,我们的处理程序必须实现一个符合 WSGI 接口,这正是 webapp2 提供的一个功能。我们将在下一节中看到如何使用它。
main.py应用程序脚本
现在应用程序已经配置好了,我们需要提供逻辑,因此我们在应用程序根目录中创建一个名为main.py的文件,该文件将包含以下内容:
import webapp2
class MainHandler(webapp2.RequestHandler):
def get(self):
self.response.write('Hello world!')
app = webapp2.WSGIApplication([
('/', MainHandler)
], debug=True)
在上一段代码的第一行中,我们将webapp2包导入到我们的代码中,然后继续定义一个名为MainHandler的类,该类是从框架提供的RequestHandler类派生出来的。基类实现了一种行为,使得实现 HTTP 请求的处理程序变得非常容易;我们只需要定义一个以我们想要处理的 HTTP 操作命名的函数。在这种情况下,我们实现了get()方法,该方法将在应用程序接收到类型为GET的请求时自动调用。RequestHandler类还提供了一个self.response属性,我们可以使用它来访问将返回给应用程序服务器的响应对象。这个属性是一个类似文件的对象,它支持一个write()方法,我们可以使用它向 HTTP 响应体中添加内容;在这种情况下,我们在响应体中写入一个字符串,使用默认的内容类型text/html,以便在浏览器中显示。
在MainHandler类定义之后,我们创建了app对象,这是一个由 webapp2 提供的WSGIApplication类的实例,该类实现了我们在app.yaml中指定的符合 WSGI 的调用入口。我们向类构造函数传递两个参数,一个是 URL 模式列表,另一个是一个布尔标志,表示应用程序是否应该以调试模式运行。URL 模式是包含两个元素的元组:一个匹配请求 URL 的正则表达式和一个从webapp2.RequestHandler类派生出来的类对象,该对象将被实例化以处理请求。URL 模式按列表中的顺序逐个处理,直到匹配并调用相应的处理程序。
如我们所注意到的,URL 映射发生了两次——首先是在app.yaml文件中,将一个 URL 路由到我们代码中的符合 WSGI 的应用程序,然后在WSGIApplication类实例中,将一个 URL 路由到请求处理程序对象。我们可以自由选择如何使用这些映射,即要么将app.yaml文件中的所有 URL 路由到单个 webapp2 应用程序,在那里它们被分发到处理程序,要么将不同的 URL 路由到不同的、更小的 webapp2 应用程序。
运行开发服务器
App Engine SDK 提供了一个极其有用的工具,称为开发服务器,它在我们本地系统上运行,模拟我们在生产环境中找到的运行环境。这样,我们可以在编写应用程序的同时在本地测试它们。我们已经知道如何从启动器启动开发服务器。要从命令行启动它,我们运行dev_appserver.py命令工具,并将我们想要执行的应用程序根目录作为参数传递。例如,如果我们已经位于helloworld应用程序的根目录中,要启动服务器,我们可以运行以下命令:
dev_appserver.py .
开发服务器将在 shell 上打印一些状态信息,然后开始在本地的默认 TCP 端口 8000 和 8080 上监听,分别服务于管理控制台和应用程序。
当服务器正在运行时,我们可以打开浏览器,将其指向http://localhost:8080,并看到我们的第一个 Web 应用程序正在提供内容。
以下截图显示了输出:

如果我们使用启动器,我们可以简单地按下浏览按钮,浏览器会自动在正确的 URL 打开。
开发服务器会在检测到应用程序根目录中的某些内容发生变化时自动重启应用程序实例。例如,当服务器正在运行时,我们可以尝试更改 Python 代码,该代码会改变我们写入响应体的字符串:
import webapp2
class MainHandler(webapp2.RequestHandler):
def get(self):
self.response.write('<H1>Hello world!</H1>')
self.response.write("<p>I'm using App Engine!</p>")
app = webapp2.WSGIApplication([
('/', MainHandler)
], debug=True)
保存文件后,我们可以刷新浏览器,立即看到更改,而无需重新加载服务器,如下面的截图所示:

我们现在可以将应用程序移动到 App Engine 的生产服务器上,并通过互联网使其可用。
上传应用程序到 App Engine
在 App Engine 上运行的应用程序通过其在 Google Cloud Platform 中的名称唯一标识。这就是为什么有时我们在文档和工具中看到部分内容将其称为应用程序 ID。当在本地系统上工作时,我们可以安全地为应用程序选择任何我们想要的名称,因为本地服务器不对应用程序 ID 施加任何控制;但是,如果我们想在生产中部署应用程序,应用程序 ID 必须通过 App Engine 管理控制台进行验证和注册。
管理控制台可以通过 appengine.google.com/ 访问,并使用有效的 Google 用户账户或 Google apps 账户(用于自定义域名)登录。如果我们使用应用程序启动器,点击仪表板按钮将为我们打开正确的浏览器地址。一旦登录,我们可以点击创建应用程序按钮以访问应用程序创建页面。我们必须提供一个应用程序 ID(控制台将告诉我们它是否有效且可用),以及应用程序的标题,然后我们就完成了。目前,我们可以接受剩余选项的默认值;再次点击创建应用程序按钮将最终为我们注册应用程序的 ID。
现在,我们必须将为我们应用程序提供的虚拟应用程序 ID 更改为在 App Engine 上注册的 ID。打开 app.yaml 配置文件并相应地更改 application 属性:
application: the_registered_application_ID
version: 1
runtime: python27
api_version: 1
threadsafe: yes
handlers:
- url: .*
script: main.app
libraries:
- name: webapp2
version: "2.5.2"
我们现在已准备好在 App Engine 上部署应用程序。如果我们使用应用程序启动器,我们只需在工具栏中点击部署按钮。启动器将要求我们提供 Google 凭据,然后日志窗口将打开,显示部署状态。如果一切顺利,最后显示的行应该是这样的:
*** appcfg.py has finished with exit code 0 ***
从命令行部署同样简单;从应用程序根目录,我们发出以下命令:
appcfg.py update .
我们将被提示输入我们的 Google 账户凭据,然后部署将自动进行。
每个在生产中运行的 App Engine 应用程序都可以通过 http://the_registered_application_ID.appspot.com/ 访问,因此我们可以通过从浏览器访问此 URL 并检查输出是否与本地开发服务器产生的输出相同来判断应用程序是否实际工作。
Google App Engine 允许我们在 HTTPS(HTTP Secure)协议之上通过 Secure Sockets Layer(SSL)协议提供内容服务,这意味着从服务器传输到服务器和从服务器传输到服务器的数据是加密的。当使用 appspot.com 域名时,此选项免费。为了在客户端和 App Engine 服务器之间启用安全连接,我们只需将 secure 选项添加到 app.yaml 文件中列出的 URL:
handlers:
- url: .*
script: main.app
secure: always
在本地开发服务器上,我们仍然会使用常规的 HTTP 连接,但在生产环境中,我们将通过 HTTPS 连接以安全的方式访问 https://the_registered_application_ID.appspot.com/。
如果我们想通过自定义域名(如 example.com)而不是 HTTPS 访问应用程序,我们必须按照 cloud.google.com/appengine/docs/ssl 中的说明配置 App Engine,以便平台可以使用我们的证书。此服务需要付费,并且我们将按月收费。
Google 开发者控制台
在 Google Cloud Platform 发布之前,管理员控制台是开发者可用的唯一工具,用于在 App Engine 应用程序上执行管理和监控任务。管理员控制台提供了许多功能,并且仍然足够强大,可以管理任何规模的 App Engine 应用程序。然而,如果我们广泛使用 Google Cloud Platform 提供的新服务系列,特别是如果我们存储数据在 Google Cloud Storage 或我们的数据库服务器是 Google Cloud SQL 的情况下,它就不是合适的工具;在这种情况下,为了收集诸如账单数据和使用历史等信息,我们必须与其他工具交互。
最近,Google 发布了开发者控制台,这是一个综合性的工具,用于管理和监控 Google Cloud Platform 的服务、资源、身份验证和账单信息,包括 App Engine 应用程序。我们可以在console.developers.google.com/访问开发者控制台,并使用有效的 Google 用户账户或自定义域的 Google apps 账户登录。
为了强调开发者可以将来自 Google 云基础设施的各个部分组合起来构建复杂应用程序的概念,开发者控制台引入了云项目的概念。项目是一组功能分组云产品,它们共享相同的团队和账单信息。项目的核心总是一个 App Engine 应用程序:每次我们创建一个项目时,一个 App Engine 应用程序就会在管理员控制台中弹出。同时,当我们向管理员控制台注册应用程序时,一个相应的项目就会被创建并在开发者控制台中列出。每个项目都有一个描述性的名称,这是一个唯一的标识符,称为项目 ID,它也是相关 App Engine 应用程序的 ID,以及另一个自动生成的唯一标识符,称为项目编号。
除了创建和删除项目之外,开发者控制台还允许我们执行以下操作:
-
管理项目成员:当我们创建一个项目时,我们成为该项目的所有者。作为所有者,我们可以添加或删除成员并设置他们的权限。
-
管理 API:我们可以添加或删除由 Google Cloud Platform 提供的 API 服务,设置账单,并监控数据。
-
管理应用程序身份:我们可以将请求与特定项目关联起来,这样我们就可以监控特定的流量和账单,并在需要时实施配额。
-
管理应用程序安全:我们可以为我们的应用程序设置 OAuth2 或提供 API 密钥以授权请求。
-
过滤和限制服务:我们可以允许只有来自授权主机或 IP 地址的请求,并限制每个用户每秒或每天允许的请求数量。
对于 Google Cloud Platform 的每一项服务,开发者控制台都为我们提供了通过网页界面执行维护操作的便捷工具。例如,我们可以添加或删除 Google Cloud SQL 实例,对 Google Cloud Datastore 进行查询,浏览和操作 Google Cloud Storage 的内容,以及管理在 Google Compute Engine 上运行的虚拟机。本书后面我们将使用开发者控制台的一些部分。
开发者控制台
当我们在本地开发服务器上时,我们仍然可以访问一个工具来浏览和管理 Datastore、任务队列、cron 作业以及本地运行的 App Engine 模拟组件。这个工具被称为开发者控制台,当本地服务器处于活动状态时,可以通过http://localhost:8000访问。
概述
在本章中,我们学习了 Google Cloud Platform 是什么,它提供哪些工具和服务,以及我们如何使用它们来开发和运行用 Python 编写的快速和可扩展的 Web 应用程序。
我们探讨了开始使用 Python 为 App Engine 平台开发所需的工具,如何使用开发服务器本地运行应用程序,以及将其上传到生产服务器是多么快速和简单,使其准备好通过互联网提供服务。
本章中我们使用的简单示例,虽然是一个功能齐全的 App Engine 应用程序,但相当简单,除了运行环境之外,没有利用平台提供的任何其他功能。在下一章中,我们将从头开始,使用一个新的、更有用的应用程序,探索 webapp2 框架,并利用 Cloud Datastore。
第二章.一个更复杂的应用程序
网络应用通常提供一系列功能,如用户身份验证和数据存储。正如我们从上一章所知,App Engine 提供了实现这些功能所需的服务和工具,而学习如何使用它们的最佳方式是通过编写一个网络应用并看到平台在实际中的应用。
在本章中,我们将涵盖以下主题:
-
webapp2 框架的更多细节
-
如何验证用户身份
-
在 Google Cloud Datastore 上存储数据
-
使用模板构建 HTML 页面
-
提供静态文件
在笔记应用上进行实验
为了更好地探索 App Engine 和云平台的功能,我们需要一个真实世界的应用来进行实验;一个不是那么容易编写,但有一份合理的需求列表,以便它能够适应这本书的内容。一个好的候选者是笔记应用;我们将称之为 Notes。
Notes 允许用户添加、删除和修改笔记列表;一个笔记有一个标题和正文文本。用户只能看到他们个人的笔记,因此在使用应用程序之前必须进行身份验证。
应用程序的主页将显示登录用户的笔记列表以及添加新笔记的表单。
上一章中helloworld示例中的代码是一个好的起点。我们可以简单地更改根文件夹的名称和app.yaml文件中的application字段,以匹配我们为应用选择的新名称,或者我们可以从头开始一个新的项目,命名为notes。
验证用户身份
我们 Notes 应用的第一个需求是只向已登录用户显示主页,并将其他人重定向到登录表单;App Engine 提供的用户服务正是我们所需要的,将其添加到我们的MainHandler类中相当简单:
import webapp2
from google.appengine.api import users
class MainHandler(webapp2.RequestHandler):
def get(self):
user = users.get_current_user()
if user is not None:
self.response.write('Hello Notes!')
else:
login_url = users.create_login_url(self.request.uri)
self.redirect(login_url)
app = webapp2.WSGIApplication([
('/', MainHandler)
], debug=True)
在上一段代码的第二行中我们导入的user包提供了访问用户服务功能的方法。在MainHandler类的get()方法内部,我们首先检查访问页面的用户是否已登录。如果他们已登录,get_current_user()方法将返回一个由 App Engine 提供的user类的实例,代表一个已验证的用户;否则,它返回None作为输出。如果用户有效,我们提供与之前相同的响应;否则,我们将他们重定向到 Google 登录表单。登录表单的 URL 是通过create_login_url()方法返回的,我们调用它,将作为参数传递的 URL 作为成功身份验证后要重定向用户的 URL。在这种情况下,我们希望将用户重定向到他们正在访问的相同 URL,由 webapp2 的self.request.uri属性提供。webapp2 框架还提供了带有redirect()方法的处理器,我们可以使用它方便地设置响应对象的正确状态和位置属性,以便客户端浏览器将被重定向到登录页面。
使用 Jinja2 的 HTML 模板
Web 应用程序提供了丰富和复杂的 HTML 用户界面,Notes 也不例外,但到目前为止,我们应用程序中的响应对象只包含少量文本。我们可以在 Python 模块中将 HTML 标签作为字符串包含在内,并在响应体中写入它们,但我们可以想象这会变得多么混乱,难以维护代码。我们需要完全将 Python 代码与 HTML 页面分开,这正是模板引擎所做的。模板是一段存在于其自身文件中的 HTML 代码,可能包含额外的特殊标签;借助模板引擎,我们可以从 Python 脚本中加载此文件,正确解析特殊标签(如果有的话),并在响应体中返回有效的 HTML 代码。App Engine 在 Python 运行时包含了一个知名的模板引擎:Jinja2 库。
要使 Jinja2 库对我们的应用程序可用,我们需要在 app.yaml 文件中的 libraries 部分添加此代码:
libraries:
- name: webapp2
version: "2.5.2"
- name: jinja2
version: latest
我们可以将主页的 HTML 代码放在名为 main.html 的文件中,该文件位于应用程序根目录内。我们从一个非常简单的页面开始:
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title>Notes</title>
</head>
<body>
<div class="container">
<h1>Welcome to Notes!</h1>
<p>
Hello, <b>{{user}}</b> - <a href="{{logout_url}}">Logout</a>
</p>
</div>
</body>
</html>
大部分内容是静态的,这意味着它将以我们看到的标准 HTML 格式渲染,但有一部分是动态的,其内容取决于在运行时传递给渲染过程的哪些数据。这些数据通常被称为模板上下文。
需要动态的是当前用户的用户名和用于从应用程序注销的链接。HTML 代码包含两个使用 Jinja2 模板语法编写的特殊元素,{{user}} 和 {{logout_url}},它们将在最终输出之前被替换。
回到 Python 脚本;我们需要在 MainHandler 类定义之前添加初始化模板引擎的代码:
import os
import jinja2
jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(os.path.dirname(__file__)))
环境实例存储引擎配置和全局对象,并用于加载模板实例;在我们的例子中,实例是从与 Python 脚本相同的目录中的 HTML 文件加载的。
要加载和渲染我们的模板,我们需要将以下代码添加到 MainHandler.get() 方法中:
class MainHandler(webapp2.RequestHandler):
def get(self):
user = users.get_current_user()
if user is not None:
logout_url = users.create_logout_url(self.request.uri)
template_context = {
'user': user.nickname(),
'logout_url': logout_url,
}
template = jinja_env.get_template('main.html')
self.response.out.write(
template.render(template_context))
else:
login_url = users.create_login_url(self.request.uri)
self.redirect(login_url)
与我们获取登录 URL 的方式类似,用户服务提供的 create_logout_url() 方法返回指向注销程序的绝对 URI,我们将它分配给 logout_url 变量。
然后,我们创建 template_context 字典,该字典包含我们想要传递给模板引擎进行渲染过程的环境值。我们将当前用户的昵称分配给字典中的 user 键,并将注销 URL 字符串分配给 logout_url 键。
jinja_env实例的get_template()方法接受包含 HTML 代码的文件名,并返回一个 Jinja2 模板对象。为了获得最终输出,我们在template对象上调用render()方法,传入包含将被访问的值的template_context字典,并在 HTML 文件中使用模板语法元素{{user}}和{{logout_url}}指定它们各自的键。
这是模板渲染的结果:

处理表单
应用程序的主页应该列出属于当前用户的所有笔记,但目前还没有创建此类笔记的方法。我们需要在主页上显示一个网页表单,以便用户可以提交详细信息并创建笔记。
要显示一个用于收集数据和创建笔记的表单,我们在main.html模板文件中用户名和注销链接下方放置以下 HTML 代码:
{% if note_title %}
<p>Title: {{note_title}}</p>
<p>Content: {{note_content}}</p>
{% endif %}
<h4>Add a new note</h4>
<form action="" method="post">
<div class="form-group">
<label for="title">Title:</label>
<input type="text" id="title" name="title" />
</div>
<div class="form-group">
<label for="content">Content:</label>
<textarea id="content" name="content"></textarea>
</div>
<div class="form-group">
<button type="submit">Save note</button>
</div>
</form>
在显示表单之前,只有当模板上下文中包含名为note_title的变量时,才会显示一条消息。为此,我们使用一个if语句,在{% if note_title %}和{% endif %}分隔符之间执行;类似的分隔符用于在模板内部执行for循环或分配值。
form标签的action属性为空;这意味着在表单提交时,浏览器将向相同的 URL 执行一个POST请求,在这个例子中是主页 URL。由于我们的 WSGI 应用程序将主页映射到MainHandler类,我们需要向这个类添加一个方法,以便它可以处理POST请求:
class MainHandler(webapp2.RequestHandler):
def get(self):
user = users.get_current_user()
if user is not None:
logout_url = users.create_logout_url(self.request.uri)
template_context = {
'user': user.nickname(),
'logout_url': logout_url,
}
template = jinja_env.get_template('main.html')
self.response.out.write(
template.render(template_context))
else:
login_url = users.create_login_url(self.request.uri)
self.redirect(login_url)
def post(self):
user = users.get_current_user()
if user is None:
self.error(401)
logout_url = users.create_logout_url(self.request.uri)
template_context = {
'user': user.nickname(),
'logout_url': logout_url,
'note_title': self.request.get('title'),
'note_content': self.request.get('content'),
}
template = jinja_env.get_template('main.html')
self.response.out.write(
template.render(template_context))
当表单提交时,处理程序被调用,并调用post()方法。我们首先检查是否有有效的用户登录;如果没有,我们不在响应体中提供任何内容,直接抛出HTTP 401: 未授权错误。由于 HTML 模板与get()方法提供的相同,我们仍然需要将注销 URL 和用户名添加到上下文中。在这种情况下,我们还将从 HTML 表单中存储的数据添加到上下文中。要访问表单数据,我们调用self.request对象的get()方法。最后三行是加载和渲染主页模板的样板代码。我们可以将此代码移动到单独的方法中,以避免重复:
def _render_template(self, template_name, context=None):
if context is None:
context = {}
template = jinja_env.get_template(template_name)
return template.render(context)
在处理类中,我们将使用类似以下内容来输出模板渲染结果:
self.response.out.write(
self._render_template('main.html', template_context))
我们可以尝试提交表单,并检查笔记标题和内容是否实际上显示在表单上方。
在 Datastore 中持久化数据
即使用户可以登录并提交笔记,但如果没有将笔记存储在某个地方,我们的应用程序也不会很有用。Google Cloud Datastore 是存储我们笔记的完美场所。作为 App Engine 基础设施的一部分,它负责数据分布和复制,所以我们只需要定义存储和检索我们的实体,使用 Python NDB (Next DB) Datastore API。
笔记
目前在 Python 运行时有两个 API 可用于与 Datastore 交互:DB Datastore API,也称为 ext.db,以及 NDB Datastore API。即使这两个 API 在 Datastore 中存储的数据完全相同,在这本书中,我们只会使用 NDB;它更新,提供更多功能,并且其 API 稍微更健壮。
一个实体有一个或多个属性,这些属性依次具有名称和类型;每个实体都有一个唯一键来标识它,并且与关系数据库不同,Datastore 中的每个实体都按类型分类。在 Python 世界中,类型由其模型类确定,我们需要在我们的应用程序中定义它。
定义模型
要表示一种类型,Datastore 模型必须从 NDB API 提供的 ndb.Model 类派生。我们在名为 models.py 的 Python 模块中定义我们的模型,该模块包含以下代码:
from google.appengine.ext import ndb
class Note(ndb.Model):
title = ndb.StringProperty()
content = ndb.TextProperty(required=True)
date_created = ndb.DateTimeProperty(auto_now_add=True)
Note 类有一个名为 title 的属性,其中包含少量文本(最多 500 个字符),另一个名为 content 的属性,其中包含无限长度的文本,还有一个名为 date_created 的属性,其中包含日期和时间。此类实体必须至少包含 user 和 content 属性的值,如果没有提供,则 date_created 属性的值将存储实体创建时的日期和时间。现在,当用户在笔记应用的首页提交表单时,我们可以向 Datastore 添加新的 Note 类实体。在 main.py 模块中,我们首先需要从 models 模块导入 Note 类:
from models import Note
然后,我们按如下方式修改 post() 方法:
def post(self):
user = users.get_current_user()
if user is None:
self.error(401)
note = Note(title=self.request.get('title'),
content=self.request.get('content'))
note.put()
logout_url = users.create_logout_url(self.request.uri)
template_context = {
'user': user.nickname(),
'logout_url': logout_url,
}
self.response.out.write(
self._render_template('main.html', template_context))
从现在起,每次用户在首页提交表单时,都会创建一个 Note 类的实例,并在调用 put() 方法后立即将实体持久化到 Datastore 中。由于我们没有修改 template_context 字典,存储过程看起来不会做任何事情。为了验证数据实际上已存储,我们可以使用本地开发控制台,通过在浏览器中打开 http://localhost:8000 并检查 Datastore 观察器来验证。
基本查询
一个实体可以可选地指定另一个实体作为其 父实体,没有父实体的实体是 根实体;Datastore 中的实体形成一个类似于文件系统目录结构的分层结构空间。一个实体及其所有后代形成一个 实体组,共同祖先的键被定义为 父键。
由于 Datastore 的内在分布式特性,理解实体之间的关系非常重要。不深入细节,我们需要知道的是,跨多个实体组的查询不能保证一致的结果,并且此类查询的结果有时可能无法反映数据最近的变化。
我们有一个替代方案;为了获得强一致性结果,我们可以执行所谓的祖先查询,这是一种将结果限制在特定实体组的查询。要在我们的代码中使用祖先查询,首先的事情是在创建模型实例时为我们的笔记实体添加一个父实体:
note = Note(parent=ndb.Key("User", user.nickname()),
title=self.request.get('title'),
content=self.request.get('content'))
note.put()
由于每个笔记都属于创建它的用户,我们可以使用相同的逻辑来结构我们的数据;我们使用当前登录的用户作为包含该用户所有笔记的实体组的父键。这就是为什么我们在上一段代码中调用Note构造函数时指定了parent关键字。为了获取当前登录用户的键,我们使用ndb.Key类构造函数,传入类型和对应实体的标识符。
现在我们需要从 Datastore 检索我们的笔记并向用户展示它们。由于我们将使用祖先查询,在继续之前,我们在Note模型类中添加一个实用方法:
class Note(ndb.Model):
title = ndb.StringProperty()
content = ndb.TextProperty(required=True)
date_created = ndb.DateTimeProperty(auto_now_add=True)
@classmethod
def owner_query(cls, parent_key):
return cls.query(ancestor=parent_key).order(
-cls.date_created)
owner_query()方法返回一个已经过滤并包含由parent_key函数参数指定的父键的组实体的查询对象。
要加载属于当前用户的全部笔记,我们接下来编写以下代码:
user = users.get_current_user()
ancestor_key = ndb.Key("User", user.nickname())
qry = Note.owner_query(ancestor_key)
notes = qry.fetch()
由于我们希望在GET和POST请求的情况下在主页上显示笔记,我们可以在_render_template()方法中加载实体,该方法在两种情况下都由处理程序调用:
def _render_template(self, template_name, context=None):
if context is None:
context = {}
user = users.get_current_user()
ancestor_key = ndb.Key("User", user.nickname())
qry = Note.owner_query(ancestor_key)
context['notes'] = qry.fetch()
template = jinja_env.get_template(template_name)
return template.render(context)
我们将笔记列表作为context字典中notes键的值添加,这样我们就可以通过在表单下方编写以下内容在 HTML 模板中使用它们:
{% for note in notes %}
<div class="note">
<h4>{{ note.title }}</h4>
<p class="note-content">{{ note.content }}</p>
</div>
{% endfor %}
对于查询结果中的每个笔记,将打印出一个div元素,如果查询返回了一个空列表,则不会打印任何内容。即使对于笔记类型的实体,title属性是可选的,我们也可以安全地访问它。如果它不存在,将返回一个空字符串。
事务
对于 Web 应用程序来说,定义并使用相互依赖的 Datastore 模型是很常见的,这样当我们更新一个实体时,我们很可能还需要更新依赖的实体。然而,如果在一系列 Datastore 操作过程中,其中一些操作失败了怎么办?在这种情况下,我们可以将这些操作封装在一个事务中,这样要么所有操作都成功,要么所有操作都失败。
为了展示事务的使用案例,我们在Note模型中添加了一个小功能:清单。清单是一系列项目,它们具有一个布尔属性,用于确定其选中状态。我们首先需要定义一个用于单个清单项目的 Datastore 模型:
class CheckListItem(ndb.Model):
title = ndb.StringProperty()
checked = ndb.BooleanProperty(default=False)
实体有两个属性,title属性用于显示的字符串,以及checked属性用于存储项目是否被选中。
然后我们在Node模型类中添加一个属性,以引用项目实体:
class Note(ndb.Model):
title = ndb.StringProperty()
content = ndb.TextProperty(required=True)
date_created = ndb.DateTimeProperty(auto_now_add=True)
checklist_items = ndb.KeyProperty("CheckListItem",
repeated=True)
@classmethod
def owner_query(cls, parent_key):
return cls.query(ancestor=parent_key).order(
-cls.date_created)
checklist_items属性存储CheckListItem类型的键值;需要repeated=True参数来定义该属性可以存储多个值。
用户可以通过在创建表单中填写逗号分隔的值列表来为笔记创建清单项目,因此我们在 HTML 模板中添加以下内容:
<form action="" method="post">
<div class="form-group">
<label for="title">Title:</label>
<input type="text" id="title" name="title"/>
</div>
<div class="form-group">
<label for="content">Content:</label>
<textarea id="content" name="content"></textarea>
</div>
<div class="form-group">
<label for="checklist_items">Checklist items:</label>
<input type="text" id="checklist_items" name="checklist_items" placeholder="comma,separated,values"/>
</div>
<div class="form-group">
<button type="submit">Save note</button>
</div>
</form>
现在,我们必须处理MainHandler类中的逗号分隔列表:
note = Note(parent=ndb.Key("User", user.nickname()),
title=self.request.get('title'),
content=self.request.get('content'))
note.put()
item_titles = self.request.get('checklist_items').split(',')
for item_title in item_titles:
item = CheckListItem(parent=note.key, title=item_title)
item.put()
note.checklist_items.append(item.key)
note.put()
我们首先从请求中检索表示清单项目的逗号分隔值。然后,对于每一个值,我们创建一个CheckListItem实例。直到模型实例被持久化,Datastore 不会为其分配任何键。因此,在访问key属性和检索该实体的Key实例之前,我们需要首先通过调用put()方法存储每个项目。一旦我们有一个有效的键,我们就将其追加到Note实例的项目列表中。我们将笔记的键作为项目的父级,这样所有这些实体都将成为同一实体组的一部分。最后一步是调用put()方法并更新节点实体以及存储checklist_items属性的新数据。
现在如果note.put()方法失败会发生什么?我们将有一系列未与任何笔记关联的CheckListItem类型的实体,这是一个一致性问题的例子。事务可以帮助我们重构笔记的创建,使其要么成功,要么失败,而不会留下任何悬空数据。我们在处理类中封装了笔记对象的创建,创建了一个单独的_create_node()方法:
@ndb.transactional
def _create_note(self, user):
note = Note(parent=ndb.Key("User", user.nickname()),
title=self.request.get('title'),
content=self.request.get('content'))
note.put()
item_titles = self.request.get('checklist_items').split(',')
for item_title in item_titles:
item = CheckListItem(parent=note.key, title=item_title)
item.put()
note.checklist_items.append(item.key)
note.put()
@ndb.transactional装饰器是我们需要的所有 Python 代码。然后 Datastore 将确保装饰的方法中的任何操作都在事务中发生。这样,要么我们创建一个笔记实体以及所有清单项目实体,要么在没有接触底层数据的情况下得到一个错误。为了完成代码,我们必须在post()方法中调用_create_node()方法:
def post(self):
user = users.get_current_user()
if user is None:
self.error(401)
self._create_note(user)
logout_url = users.create_logout_url(self.request.uri)
template_context = {
'user': user.nickname(),
'logout_url': logout_url,
}
self.response.out.write(
self._render_template('main.html', template_context))
为了显示笔记清单中的项目列表,我们必须在 HTML 模板中添加所需的代码:
{% for note in notes %}
<div class="note">
<h4>{{ note.title }}</h4>
<p class="note-content">{{ note.content }}</p>
{% if note.checklist_items %}
<ul>
{% for item in note.checklist_items %}
<li class="{%if item.get().checked%}checked{%endif%}">{{item.get().title}}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
如果checklist_items属性不为空,我们添加一个无序列表。然后我们遍历项目列表,当项目的checked属性设置为true值时,添加一个包含checked参数的class属性:在本章的后面,我们将学习如何添加一个CSS(层叠样式表)规则,以便当这个类存在时,项目将以水平线穿过其中心显示。
使用静态文件
通常,Web 应用程序会使用 CSS 和 JavaScript 资源来提供更好的用户体验。出于效率的考虑,这种内容不是由 WSGI 应用程序动态提供,而是由 App Engine 作为静态文件提供。
从前一章我们知道 App Engine 提供了两种类型的处理程序,脚本处理程序和静态文件处理程序。我们像这样在我们的app.yaml配置文件中添加一个静态文件处理程序:
handlers:
- url: /static
static_dir: static
- url: .*
script: main.app
语法几乎与脚本处理器相同。我们指定一个作为正则表达式的 URL 来映射,但不是提供 Python 脚本来处理请求,而是指定一个相对于应用根目录的文件系统路径,其中包含需要作为静态资源提供的服务文件和目录。
注意
现在我们将通过手动编写一些 CSS 规则来为我们的 HTML 页面提供一个最小样式。虽然对于本书的范围来说,学习如何从头开始构建自定义设计是可接受的,但在现实世界中,我们可能更愿意使用如 Bootstrap (getbootstrap.com/) 或 Foundation (foundation.zurb.com/) 这样的前端框架,以轻松提供最先进的美学、跨浏览器功能和针对移动设备的响应式布局。
要为我们应用提供 CSS,我们然后在应用根目录中创建 static/css 文件夹:
mkdir -p static/css
此文件夹应包含一个名为 notes.css 的文件,该文件将包含我们应用的样式表:
body {
font-family: "helvetica", sans-serif;
background: #e8e8e8;
color: rgba(39,65,90,.9);
text-align: center;
}
div.container {
width: 600px;
display: inline-block;
}
第一部分是用于全局布局元素;我们将把表单和笔记放在一个居中的容器中,一个在另一个下方。然后我们样式化表单:
form {
background: white;
padding-bottom: 0.5em;
margin-bottom: 30px;
}
h4,legend {
margin-bottom: 10px;
font-size: 21px;
font-weight: 400;
}
表单将被包含在一个白色框中,图例将看起来像笔记标题。表单元素将被如下样式化:
div.form-group {
margin-bottom: 1em;
}
label {
display: inline-block;
width: 120px;
text-align: right;
padding-right: 15px;
}
input, textarea {
width: 250px;
height: 35px;
-moz-box-sizing: border-box;
box-sizing: border-box;
border: 1px solid #999;
font-size: 14px;
border-radius: 4px;
padding: 6px;
}
textarea {
vertical-align: top;
height: 5em;
resize: vertical;
}
然后我们继续样式化包含笔记数据的白色框:
div.note {
background: white;
vertical-align: baseline;
display: block;
margin: 0 auto 30px auto;
}
legend, div.note > h4 {
padding: 18px 0 15px;
margin: 0 0 10px;
background: #00a1ff;
color: white;
}
样式表的最后一部分是专门用于笔记清单的。我们为包含在具有 note 类的 div 元素中的无序列表提供了一种样式,并为处于选中状态的列表项提供了一种样式:
div.note > ul {
margin: 0;
padding: 0;
list-style: none;
border-top: 2px solid #e7f2f0;
}
div.note > ul > li {
font-size: 21px;
padding: 18px 0 18px 18px;
border-bottom: 2px solid #e7f2f0;
text-align: left;
}
div.note-content {
text-align: left;
padding: 0.5em;
}
.checked {
text-decoration: line-through;
}
要使用样式表,我们在 HTML 模板中添加以下内容,在 <meta> 标签内:
<link rel="stylesheet" type="text/css" href="static/css/notes.css">
这就是应用应用样式表后的外观:

摘要
多亏了 App Engine,我们到目前为止已经以相对较小的努力实现了一套丰富的功能。
在本章中,我们发现了关于 webapp2 框架及其功能的一些更多细节,实现了一个非平凡的请求处理器。我们学习了如何使用 App Engine 用户服务来提供用户认证。我们深入了解了 Datastore 的一些基本细节,现在我们知道如何以分组实体结构化数据,以及如何通过祖先查询有效地检索数据。此外,我们还借助 Jinja2 模板库创建了一个 HTML 用户界面,学习了如何提供静态内容,如 CSS 文件。
在下一章中,我们将继续为笔记应用添加更多功能,学习如何将上传的文件存储在 Google Cloud Storage 上,处理图像,以及处理长时间操作和计划任务。我们还将使应用能够发送和接收电子邮件。
第三章:存储和处理用户数据
有一些数据需要持久化,并且不太适合 Datastore 或类似存储系统,例如图像和媒体文件;这些通常很大,其大小会影响应用程序成本以及它们应该如何上传、存储和当请求时提供。此外,有时我们需要在服务器端修改这些内容,并且操作可能需要很长时间。
我们将在 Notes 应用程序中添加一些将引发这些问题的功能,我们将看到 App Engine 如何提供我们面对这些问题的所有所需。
在本章中,我们将涵盖以下主题:
-
在我们的应用程序中添加表单以允许用户上传图像
-
将上传的文件返回给客户端
-
使用 Images 服务转换图像
-
使用任务队列执行长时间作业
-
调度任务
-
处理来自我们应用程序的电子邮件消息
将文件上传到 Google Cloud Storage
对于一个网络应用程序来说,处理图像文件或 PDF 文档是非常常见的,Notes 也不例外。对于用户来说,除了标题和描述文本外,将图像或文档附加到一个或多个笔记中可能非常有用。
在 Datastore 中存储大量二进制数据将是不高效的,并且相当昂贵,因此我们需要使用不同的、专门的系统:Google Cloud Storage。Cloud Storage 允许我们在称为桶的位置存储大文件。一个应用程序可以从多个桶中读取和写入,并且我们可以设置访问控制列表(ACL)来决定谁可以访问特定的桶以及具有什么权限。每个 App Engine 应用程序都有一个默认的桶与之关联,但我们可以通过开发者控制台创建、管理和浏览任意数量的桶。
安装 Cloud Storage 客户端库
为了更好地与 Cloud Storage 交互,我们需要一个外部软件,它不包括在 App Engine 运行时环境中,这就是GCS 客户端库。这个 Python 库实现了读取和写入桶内文件的功能,并处理错误和重试。以下这些函数的详细列表:
-
open()方法:这允许我们在桶内容上操作类似文件缓冲区
-
listbucket()方法:这检索桶的内容
-
stat()方法:这为桶中的文件获取元数据
-
delete()方法:这将从桶中删除文件
要安装 GCS 客户端库,我们可以使用 pip:
pip install GoogleAppEngineCloudStorageClient -t <app_root>
使用-t选项指定包的目标目录非常重要,因为这是在生产服务器上安装由 App Engine 不提供的第三方包的唯一方式。当我们部署应用程序时,应用程序根目录中的所有内容都将复制到远程服务器上,包括cloudstorage包。
如果我们系统上安装了svn仓库,我们还可以克隆Subversion(SVN)可执行文件并检出源代码的最新版本:
svn checkout http://appengine-gcs- client.googlecode.com/svn/trunk/python gcs-client
要检查库是否正常工作,我们可以在命令行中发出以下命令,并验证没有错误打印出来:
python -c"import cloudstorage"
注意
与 Google Cloud Storage 交互的另一种方式是Blobstore API,它是 App Engine 环境捆绑的一部分。Blobstore 是第一个提供大文件便宜且有效存储的 App Engine 服务,尽管云存储更新且更活跃地开发,但它仍然可用。即使我们不在 Blobstore 中存储任何数据,我们也会在本章的后面使用 Blobstore API 与云存储一起使用。
添加表单上传图片
我们开始在用于创建笔记的 HTML 表单中添加一个字段,以便用户可以指定要上传的文件。在提交按钮之前,我们插入一个输入标签:
<div class="form-group">
<label for="uploaded_file">Attached file:</label>
<input type="file" id="uploaded_file" name="uploaded_file">
</div>
我们将把每个用户的所有文件存储在默认存储桶下的一个以用户 ID 命名的文件夹中;如果未更改默认访问控制列表,我们的应用程序是访问该文件的唯一方式,因此我们可以在应用程序级别强制执行安全和隐私。为了从webapp2请求对象中访问上传的文件,我们需要重写MainHandler类的post方法,但首先,我们需要在main.py模块的顶部添加以下导入语句:
from google.appengine.api import app_identity
import cloudstorage
import mimetypes
我们很快就会看到这些模块的用途;这是将被添加到MainHandler类中的代码:
def post(self):
user = users.get_current_user()
if user is None:
self.error(401)
bucket_name = app_identity.get_default_gcs_bucket_name()
uploaded_file = self.request.POST.get('uploaded_file')
file_name = getattr(uploaded_file, 'filename', None)
file_content = getattr(uploaded_file, 'file', None)
real_path = ''
if file_name and file_content:
content_t = mimetypes.guess_type(file_name)[0]
real_path = os.path.join('/', bucket_name, user.user_id(),
file_name)
with cloudstorage.open(real_path, 'w',
content_type=content_t) as f:
f.write(file_content.read())
self._create_note(user, file_name)
logout_url = users.create_logout_url(self.request.uri)
template_context = {
'user': user.nickname(),
'logout_url': logout_url,
}
self.response.out.write(
self._render_template('main.html', template_context))
我们首先通过调用app_identity服务的get_default_gcs_bucket_name()方法来检索我们应用程序的默认存储桶名称。然后,我们访问request对象以获取uploaded_file字段的值。当用户指定要上传的文件时,self.request.POST.get('uploaded_file')返回 Python 标准库中cgi模块定义的FileStorage类的实例。FieldStorage对象有两个字段,filename和file,分别包含上传文件的名称和内容。如果用户没有指定要上传的文件,则uploaded_file字段的值变为空字符串。
在处理上传的文件时,我们尝试使用 Python 标准库中的mimetypes模块来猜测其类型,然后根据/<bucket_name>/<user_id>/<filename>方案构建文件的完整路径。最后一部分涉及到 GCS 客户端库;实际上,它允许我们像在常规文件系统上一样在云存储上打开一个文件进行写入。我们通过在file_name对象上调用read方法来写入上传文件的正文。最后,我们调用_create_note方法,同时传递文件名,这样它就会被存储在Note实例内部。
注意
如果用户上传的文件与云存储中已存在的文件同名,后者将被新数据覆盖。如果我们想处理这个问题,需要添加一些逻辑,例如重命名新文件或询问用户如何操作。
在重构_create_note()方法以接受和处理附加到笔记的文件名之前,我们需要向我们的Note模型类添加一个属性来存储附加文件的名称。模型变为以下内容:
class Note(ndb.Model):
title = ndb.StringProperty()
content = ndb.TextProperty(required=True)
date_created = ndb.DateTimeProperty(auto_now_add=True)
checklist_items = ndb.KeyProperty("CheckListItem",
repeated=True)
files = ndb.StringProperty(repeated=True)
@classmethod
def owner_query(cls, parent_key):
return cls.query(ancestor=parent_key).order(
-cls.date_created)
即使我们在创建笔记时只支持添加单个文件,我们也存储一个文件名列表,以便我们已经在单个笔记中提供了对多个附件的支持。
在main.py模块中,我们将_create_note()方法重构如下:
@ndb.transactional
def _create_note(self, user, file_name):
note = Note(parent=ndb.Key("User", user.nickname()),
title=self.request.get('title'),
content=self.request.get('content'))
note.put()
item_titles = self.request.get('checklist_items').split(',')
for item_title in item_titles:
item = CheckListItem(parent=note.key, title=item_title)
item.put()
note.checklist_items.append(item.key)
if file_name:
note.files.append(file_name)
note.put()
当file_name参数未设置为None值时,我们添加文件名并更新Note实体。现在我们可以运行代码,在创建笔记时尝试上传文件。我们编写的代码到目前为止只存储上传的文件而没有任何反馈,所以为了检查一切是否正常工作,我们需要在本地开发控制台中使用 Blobstore 查看器。如果我们正在生产服务器上运行应用程序,我们可以使用 Google 开发者控制台上的云存储界面来列出默认存储桶的内容。
注意
在编写此内容时,本地开发服务器以与模拟 Blobstore 相同的方式模拟云存储,这就是为什么我们只会在开发控制台中找到 Blobstore 查看器。
从云存储中提供文件
由于我们没有为默认存储桶指定访问控制列表,因此未经身份验证或通过笔记应用程序,它只能从开发者控制台访问。只要我们希望将文件保留给执行上传的用户,这就可以了,但我们需要提供一个应用程序的 URL,以便可以检索这些文件。例如,如果用户想要检索名为example.png的文件,URL 可以是/media/example.png。我们需要为这样的 URL 提供请求处理器,检查当前登录用户是否上传了请求的文件,并相应地提供响应。在main.py模块中,我们添加以下类:
class MediaHandler(webapp2.RequestHandler):
def get(self, file_name):
user = users.get_current_user()
bucket_name = app_identity.get_default_gcs_bucket_name()
content_t = mimetypes.guess_type(file_name)[0]
real_path = os.path.join('/', bucket_name, user.user_id(),
file_name)
try:
with cloudstorage.open(real_path, 'r') as f:
self.response.headers.add_header('Content-Type',
content_t)
self.response.out.write(f.read())
except cloudstorage.errors.NotFoundError:
self.abort(404)
确定当前登录用户后,我们使用与存储 <bucket_name>/<user_id>/<filename> 文件相同的方案构建请求文件的完整路径。如果文件不存在,GCS 客户端库会引发 NotFoundError 错误,我们使用请求处理器的 abort() 方法提供 404:未找到 的礼貌页面。如果文件实际上在云存储中,我们使用 GCS 客户端库提供的常规文件接口打开它进行读取,并在设置正确的 Content-Type HTTP 头部后将其内容写入响应体。这样,即使我们知道文件名,我们也无法访问其他用户上传的任何文件,因为我们的用户 ID 将用于确定文件的完整路径。
要使用 MediaHandler 类,我们在 WSGIApplication 构造函数中添加一个元组:
app = webapp2.WSGIApplication([
(r'/', MainHandler),
(r'/media/(?P<file_name>[\w.]{0,256})', MediaHandler),
], debug=True)
正则表达式试图匹配以 /media/ 路径开始的任何 URL,后面跟一个文件名。在匹配时,名为 file_name 的正则表达式组被传递到 MediaHandler 类的 get() 方法作为参数。
最后一步是在主页中为每个附加到笔记的文件添加一个链接,以便用户可以下载它们。我们只需在 main.html 模板的清单项迭代之前添加一个 for 迭代即可:
{% if note.files %}
<ul>
{% for file in note.files %}
<li class="file"><a href="/media/{{ file }}">{{ file }}</a></li>
{% endfor %}
</ul>
{% endif %}
我们最终将 CSS 的 file 类添加到 li 元素上,以区分文件和清单项;我们将相应的样式添加到 note.css 文件中:
div.note > ul > li.file {
border: 0;
background: #0070B3;
}
li.file > a {
color: white;
text-decoration: none;
}
使用这个更新的样式表,文件项的背景颜色与清单项不同,链接文本颜色为白色。
通过 Google 的内容分发网络提供文件服务
我们目前通过 MediaHandler 请求处理类使用我们的 WSGI 应用程序提供附加到笔记的文件服务,这非常方便,因为我们可以在执行安全检查的同时确保用户只能获取他们之前更新过的文件。尽管如此,这种方法有几个缺点:与常规 Web 服务器相比,应用程序效率较低,我们消耗了诸如内存和带宽之类的资源,这可能会给我们带来大量的费用。
然而,有一个替代方案;如果我们放宽 Notes 应用程序的要求,允许内容公开访问,我们可以从高度优化且无 cookie 的基础设施中低延迟地提供此类文件:Google 内容分发网络 (CDN)。如何实现这取决于我们必须提供哪种类型的文件:图片或其他任何 MIME 类型。
服务器端图片
如果我们处理的是图像文件,我们可以使用图像服务生成一个 URL,该 URL 是公开的但不可猜测的,可以访问存储在云存储中的内容。首先,我们需要计算一个编码密钥,代表我们想要在云存储中提供的服务文件;为此,我们使用 Blobstore API 提供的create_gs_key()方法。然后,我们使用图像服务提供的get_serving_url()方法为编码密钥生成一个托管 URL。如果我们需要以不同的尺寸提供相同的图像——例如,提供缩略图——就没有必要多次存储相同的文件;实际上,我们可以指定我们想要提供的图像的大小,CDN 将负责处理。我们需要在main.py模块的顶部导入所需的包:
from google.appengine.api import images
from google.appengine.ext import blobstore
为了方便,我们在MainHandler类中添加了一个_get_urls_for()方法,我们可以在需要获取云存储中文件的托管 URL 时调用此方法:
def _get_urls_for(self, file_name):
user = users.get_current_user()
if user is None:
return
bucket_name = app_identity.get_default_gcs_bucket_name()
path = os.path.join('/', bucket_name, user.user_id(),
file_name)
real_path = '/gs' + path
key = blobstore.create_gs_key(real_path)
url = images.get_serving_url(key, size=0)
thumbnail_url = images.get_serving_url(key, size=150,
crop=True)
return url, thumbnail_url
此方法接受文件名作为参数,并使用略有不同的/gs/<bucket_name>/<user_id>/<filename>方案(注意只有在生成编码密钥时需要添加前缀的/gs字符串)构建云存储的完整路径。然后,将文件的真正路径传递给create_gs_key()函数,该函数生成一个编码密钥,然后我们调用两次get_serving_url()方法:一次生成全尺寸图像的 URL,然后生成一个 150 像素大小的裁剪缩略图的 URL。最后,返回这两个 URL。除非我们从图像服务中调用delete_serving_url()方法并传递相同的密钥,否则这些 URL 将永久可用。如果我们没有指定size参数,CDN 将默认提供优化后的图像版本,其大小更小;通过将size=0参数显式传递给get_serving_url()函数的第一次调用,将使 CDN 提供原始图像。
我们可以通过提供一个描述附加到笔记的文件的新类型来改进数据模型。在models.py模块中,我们添加以下内容:
class NoteFile(ndb.Model):
name = ndb.StringProperty()
url = ndb.StringProperty()
thumbnail_url = ndb.StringProperty()
full_path = ndb.StringProperty()
我们为每个文件存储名称、两个 URL 和云存储中的完整路径。然后,我们从Note模型中引用NoteFile实例而不是纯文件名:
class Note(ndb.Model):
title = ndb.StringProperty()
content = ndb.TextProperty(required=True)
date_created = ndb.DateTimeProperty(auto_now_add=True)
checklist_items = ndb.KeyProperty("CheckListItem",
repeated=True)
files = ndb.KeyProperty("NoteFile",
repeated=True)
@classmethod
def owner_query(cls, parent_key):
return cls.query(ancestor=parent_key).order(
-cls.date_created)
为了根据新的模型存储数据,我们重构了_create_note()方法:
@ndb.transactional
def _create_note(self, user, file_name, file_path):
note = Note(parent=ndb.Key("User", user.nickname()),
title=self.request.get('title'),
content=self.request.get('content'))
note.put()
item_titles = self.request.get('checklist_items').split(',')
for item_title in item_titles:
item = CheckListItem(parent=note.key, title=item_title)
item.put()
note.checklist_items.append(item.key)
if file_name and file_path:
url, thumbnail_url = self._get_urls_for(file_name)
f = NoteFile(parent=note.key, name=file_name,
url=url, thumbnail_url=thumbnail_url,
full_path=file_path)
f.put()
note.files.append(f.key)
note.put()
我们生成 URL 并创建NoteFile实例,将其添加到Note实体组中。在MainHandler类的post()方法中,我们现在按照以下方式调用_create_note()方法:
self._create_note(user, file_name, real_path)
在 HTML 模板中,我们添加以下代码:
{% if note.files %}
<ul>
{% for file in note.files %}
<li class="file">
<a href="{{ file.get().url }}">
<img src="img/{{ file.get().thumbnail_url }}">
</a>
</li>
{% endfor %}
</ul>
{% endif %}
我们不是显示文件的名称,而是在指向图像全尺寸版本的链接中显示缩略图。
提供其他类型的文件服务
我们不能在非图像文件上使用图像服务,因此在这种情况下我们需要遵循不同的策略。公开可访问的存储在云存储中的文件可以通过组合 Google CDN 的 URL 和它们的完整路径来访问。
那么,首先要做的事情是,在 MainHandler 类的 post() 方法中保存文件时更改默认的 ACL:
with cloudstorage.open(real_path, 'w', content_type=content_t,
options={'x-goog-acl': 'public-read'}) as f:
f.write(file_content.read())
GCS 客户端库的 open() 方法的 options 参数让我们可以指定一个包含要传递给 Cloud Storage 服务的额外头部的字符串字典:在这种情况下,我们将 x-goog-acl 头设置为 public-read 值,以便文件将公开可用。从现在起,我们可以通过 http://storage.googleapis.com/<bucket_name>/<file_path> 类型的 URL 来访问该文件,因此让我们添加代码来组合和存储此类 URL,用于不是图像的文件。
在 _get_urls_for() 方法中,我们捕获 TransformationError 或 NotImageError 类型的错误,假设如果 Images 服务未能处理某个文件,那么该文件不是图像:
def _get_urls_for(self, file_name):
user = users.get_current_user()
if user is None:
return
bucket_name = app_identity.get_default_gcs_bucket_name()
path = os.path.join('/', bucket_name, user.user_id(),
file_name)
real_path = '/gs' + path
key = blobstore.create_gs_key(real_path)
try:
url = images.get_serving_url(key, size=0)
thumbnail_url = images.get_serving_url(key, size=150,
crop=True)
except images.TransformationError, images.NotImageError:
url = "http://storage.googleapis.com{}".format(path)
thumbnail_url = None
return url, thumbnail_url
如果文件类型不受 Images 服务的支持,我们将按照之前所述的方式组合 url 参数,并将 thumbnail_url 变量设置为 None 值。
在 HTML 模板中,对于不是图像的文件,我们将显示文件名而不是缩略图:
{% if note.files %}
<ul>
{% for file in note.files %}
{% if file.get().thumbnail_url %}
<li class="file">
<a href="{{ file.get().url }}">
<img src="img/{{ file.get().thumbnail_url }}">
</a>
</li>
{% else %}
<li class="file">
<a href="{{ file.get().url }}">{{ file.get().name }}</a>
</li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
使用 Images 服务转换图像
我们已经使用了 App Engine Images 服务通过 Google 的 CDN 来提供图像服务,但它可以做更多的事情。它可以调整图像大小、旋转、翻转、裁剪图像,并将多个图像组合成一个文件。它可以使用预定义的算法增强图片。它可以转换图像的格式。该服务还可以提供有关图像的信息,例如其格式、宽度、高度以及颜色值的直方图。
注意
要在本地开发服务器上使用 Images 服务,我们需要下载并安装 Python Imaging Library (PIL) 包,或者,作为替代方案,安装 pillow 包。
我们可以直接从我们的应用程序传递图像数据到服务,或者指定存储在 Cloud Storage 中的资源。为了了解这是如何工作的,我们在 Notes 应用程序中添加了一个函数,用户可以触发该函数以缩小任何笔记中附加的所有图像,以便在 Cloud Storage 中节省空间。为此,我们在 main.py 模块中添加了一个专用的请求处理器,当用户点击 /shrink URL 时将被调用:
class ShrinkHandler(webapp2.RequestHandler):
def _shrink_note(self, note):
for file_key in note.files:
file = file_key.get()
try:
with cloudstorage.open(file.full_path) as f:
image = images.Image(f.read())
image.resize(640)
new_image_data = image.execute_transforms()
content_t = images_formats.get(str(image.format))
with cloudstorage.open(file.full_path, 'w',
content_type=content_t) as f:
f.write(new_image_data)
except images.NotImageError:
pass
def get(self):
user = users.get_current_user()
if user is None:
login_url = users.create_login_url(self.request.uri)
return self.redirect(login_url)
ancestor_key = ndb.Key("User", user.nickname())
notes = Note.owner_query(ancestor_key).fetch()
for note in notes:
self._shrink_note(note)
self.response.write('Done.')
在get()方法中,我们从 Datastore 加载属于当前登录用户的全部笔记,然后对每个笔记调用_shrink_note()方法。对于每个附加到笔记的文件,我们检查它是否是图片;如果不是,我们捕获错误并传递给下一个。如果文件实际上是图片,我们使用 GCS 客户端库打开文件并将图像数据传递给Image类构造函数。图像对象封装图像数据并提供了一个用于操作和获取封装图像信息的接口。变换不会立即应用;它们被添加到一个队列中,当我们对Image实例调用execute_transforms()方法时进行处理。在我们的情况下,我们只应用一个变换,即将图像宽度调整为 640 像素。execute_transforms()方法返回我们用来覆盖原始文件的变换后的图像数据。在将新的图像数据写入云存储时,我们需要再次指定文件的内容类型:我们从image对象的format属性中推导出正确的内容类型。这个值是一个整数,必须映射到一个内容类型字符串;我们通过在main.py模块顶部添加此字典来完成此操作:
images_formats = {
'0': 'image/png',
'1': 'image/jpeg',
'2': 'image/webp',
'-1': 'image/bmp',
'-2': 'image/gif',
'-3': 'image/ico',
'-4': 'image/tiff',
}
我们将image.format值转换为字符串并访问正确的字符串,将其传递给 GCS 客户端库的open()方法。
我们在main.py模块中添加了/shrink URL 的映射:
app = webapp2.WSGIApplication([
(r'/', MainHandler),
(r'/media/(?P<file_name>[\w.]{0,256})', MediaHandler),
(r'/shrink', ShrinkHandler),
], debug=True)
为了让用户访问此功能,我们在主页上添加了一个超链接。我们借此机会为我们的应用程序提供一个主菜单,如下修改main.html模板:
<h1>Welcome to Notes!</h1>
<ul class="menu">
<li>Hello, <b>{{ user }}</b></li>
<li><a href="{{ logout_url }}">Logout</a></li>
<li><a href="/shrink">Shrink images</a></li>
</ul>
<form action="" method="post" enctype="multipart/form-data">
为了使菜单水平布局,我们在notes.css文件中添加了以下行:
ul.menu > li {
display: inline;
padding: 5px;
border-left: 1px solid;
}
ul.menu > li > a {
text-decoration: none;
}
用户现在可以通过点击主页菜单中的相应操作来缩小其笔记中附加的图片所占的空间。
使用任务队列处理长时间作业
App Engine 提供了一种称为请求计时器的机制,以确保客户端请求有一个有限的生命周期,避免无限循环并防止应用程序过度使用资源。特别是,当请求完成超过 60 秒时,请求计时器会引发一个DeadlineExceededError错误。如果我们的应用程序提供涉及复杂查询、I/O 操作或图像处理的功能,我们必须考虑这一点。上一段中的ShrinkHandler类就是这样一种情况:要加载的笔记数量和要处理的附加图片可能足够多,使得请求持续超过 60 秒。在这种情况下,我们可以使用任务队列,这是 App Engine 提供的一项服务,允许我们在请求/响应周期之外执行操作,具有更宽的时间限制,即 10 分钟。
有两种类型的任务队列:推送队列,用于由 App Engine 基础设施自动处理的任务,以及拉取队列,允许开发者使用另一个 App Engine 应用程序或从另一个基础设施外部构建自己的任务消费策略。我们将使用推送队列,以便我们有来自 App Engine 的现成解决方案,无需担心外部组件的设置和可伸缩性。
我们将在任务队列内部运行缩放图像功能,为此,我们需要重构 ShrinkHandler 类:在 get() 方法中,我们将启动任务,将查询执行和图像处理移动到 post() 方法。post() 方法将由任务队列消费者基础设施调用以处理任务。
我们首先需要导入 taskqueue 包以使用任务队列 Python API:
from google.appengine.api import taskqueue
然后,我们将 post() 方法添加到 ShrinkHandler 类:
def post(self):
if not 'X-AppEngine-TaskName' in self.request.headers:
self.error(403)
user_email = self.request.get('user_email')
user = users.User(user_email)
ancestor_key = ndb.Key("User", user.nickname())
notes = Note.owner_query(ancestor_key).fetch()
for note in notes:
self._shrink_note(note)
为了确保我们已经收到任务队列请求,我们检查 X-AppEngine-TaskName HTTP 头是否已设置;如果请求来自平台外部,App Engine 会删除这些类型的头,因此我们可以信任客户端。如果此头缺失,我们设置 HTTP 403: Forbidden 响应代码。
请求包含一个 user_email 参数,该参数包含添加此任务到队列的用户电子邮件地址(我们将在稍后看到这个参数需要在何处设置);我们通过传递电子邮件地址来实例化一个 User 对象,以匹配有效用户,并继续进行图像处理。
ShrinkHandler 类的 get() 方法需要按照以下方式进行重构:
def get(self):
user = users.get_current_user()
if user is None:
login_url = users.create_login_url(self.request.uri)
return self.redirect(login_url)
taskqueue.add(url='/shrink',
params={'user_email': user.email()})
self.response.write('Task successfully added to the queue.')
在检查用户是否登录后,我们使用任务队列 API 向队列中添加一个任务。我们将映射到执行作业的处理器的 URL 作为参数传递,以及包含我们想要传递给处理器的参数的字典。在这种情况下,我们将 post() 方法中使用的 user_email 参数设置为加载有效的 User 实例。任务添加到队列后,将立即返回响应,实际缩放操作可能持续长达 10 分钟。
使用 Cron 调度任务
我们将缩放操作设计为用户触发的可选功能,但我们可以为每个用户在确定的时间间隔内运行它,以降低云存储的成本。App Engine 支持使用 Cron 服务调度作业的执行;每个应用程序都有一定数量的 Cron 作业可用,这取决于我们的计费计划。Cron 作业具有与任务队列中的任务相同的限制,因此请求可以持续长达 10 分钟。
我们首先准备一个实现作业的请求处理器:
class ShrinkCronJob(ShrinkHandler):
def post(self):
self.abort(405, headers=[('Allow', 'GET')])
def get(self):
if 'X-AppEngine-Cron' not in self.request.headers:
self.error(403)
notes = Note.query().fetch()
for note in notes:
self._shrink_note(note)
我们从ShrinkHandler类派生出ShrinkCronJob类以继承_shrink_note()方法。Cron 服务执行一个类型为GET的 HTTP 请求,因此我们应该重写post()方法,简单地返回一个HTTP 405: 方法不允许错误,从而避免有人用 HTTP POST请求击中我们的处理程序。所有逻辑都在处理程序类的get()方法中实现。为了确保处理程序是由 Cron 服务触发的,而不是由外部客户端触发的,我们首先检查请求是否包含通常由 App Engine 移除的X-AppEngine-Cron头;如果不是这种情况,我们返回一个HTTP 403: 未授权错误。然后,我们加载所有笔记实体并调用每个实体上的_shrink_note()方法。
然后,我们将ShrinkCronJob处理程序映射到/shrink_all URL:
app = webapp2.WSGIApplication([
(r'/', MainHandler),
(r'/media/(?P<file_name>[\w.]{0,256})', MediaHandler),
(r'/shrink', ShrinkHandler),
(r'/shrink_all', ShrinkCronJob),
], debug=True)
Cron 作业以YAML文件的形式列在应用程序根目录中,因此我们创建了一个包含以下内容的cron.yaml文件:
cron:
- description: shrink images in the GCS
url: /shrink_all
schedule: every day 00:00
该文件包含一系列带有一些属性的作业定义:对于每个作业,我们必须指定 URL 和schedule属性,分别包含映射到实现作业的处理程序的 URL 和作业执行的时时间间隔,即每天午夜。我们还添加了一个可选的description属性,其中包含一个字符串来详细说明作业。
每次我们部署应用程序时,都会更新计划中的 Cron 作业列表;我们可以通过访问开发者控制台或本地开发控制台来检查作业的详细信息和工作状态。
发送通知电子邮件
对于 Web 应用程序来说,向用户发送通知是非常常见的,电子邮件是一种既便宜又有效的传递渠道。笔记应用程序也可以从通知系统中受益:在本章早期,我们修改了shrink图像函数,使其在任务队列中运行。用户会立即收到响应,但实际上作业被放入队列,他们不知道缩放操作何时成功完成。
由于我们可以代表管理员或具有 Google 账户的用户从 App Engine 应用程序发送电子邮件消息,因此当缩放操作完成后,我们立即向用户发送消息。
我们首先在main.py模块中导入邮件包:
from google.appengine.api import mail
然后,我们将以下代码追加到ShrinkHandler类中的post()方法末尾:
sender_address = "Notes Team <notes@example.com>"
subject = "Shrink complete!"
body = "We shrunk all the images attached to your notes!"
mail.send_mail(sender_address, user_email, subject, body)
我们只需调用send_mail()方法,传入发件人地址、目的地地址、电子邮件主题和消息正文。
如果我们在生产服务器上运行应用程序,则sender_address参数必须包含 App Engine 上一位管理员的注册地址,否则消息将无法送达。
如果应用程序在本地开发服务器上运行,App Engine 将不会发送真实的电子邮件,而是在控制台上显示一条详细的消息。
接收用户数据作为电子邮件消息
对于一个 Web 应用程序来说,一个不太常见但很有用的功能是能够接收来自其用户的电子邮件消息:例如,一个客户关系管理(CRM)应用程序在收到用户发送到特定地址(例如,support@example.com)的电子邮件后,可以打开一个支持工单。
为了展示这在 App Engine 上的工作原理,我们添加了用户通过向笔记应用程序发送电子邮件消息来创建笔记的能力:电子邮件的主题将被用作标题,消息正文用作笔记内容,并且每封电子邮件消息中附加的每个文件都将存储在云存储上,并将其附加到笔记中。
App Engine 应用程序可以在任何<string>@<appid>.appspotmail.com格式的地址接收电子邮件消息;然后,消息被转换成对/_ah/mail/<address> URL 的 HTTP 请求,在那里一个请求处理器将处理数据。
在我们开始之前,我们需要启用默认禁用的入站电子邮件服务,因此我们将在我们的app.yaml文件中添加以下内容:
inbound_services:
- mail
然后,我们需要实现一个用于电子邮件消息的处理程序,它从 App Engine 提供的专门InboundMailHandler请求处理程序类中派生。我们的子类必须重写接受一个包含InboundEmailMessage类实例的参数的receive()方法,我们可以使用这个实例来访问我们从收到的电子邮件中获取的所有详细信息。我们将这个新的处理程序添加到main.py模块中,但在继续之前,我们需要导入所需的模块和包:
from google.appengine.ext.webapp import mail_handlers
import re
然后,我们开始实现我们的CreateNoteHandler类;这是代码的第一部分:
class CreateNoteHandler(mail_handlers.InboundMailHandler):
def receive(self, mail_message):
email_pattern = re.compile(
r'([\w\-\.]+@(\w[\w\-]+\.)+[\w\-]+)')
match = email_pattern.findall(mail_message.sender)
email_addr = match[0][0] if match else ''
try:
user = users.User(email_addr)
user = self._reload_user(user)
except users.UserNotFoundError:
return self.error(403)
title = mail_message.subject
content = ''
for content_t, body in mail_message.bodies('text/plain'):
content += body.decode()
attachments = getattr(mail_message, 'attachments', None)
self._create_note(user, title, content, attachments)
代码的第一部分实现了一个简单的安全检查:我们实际上只为来自用户注册相同地址的电子邮件消息创建特定用户的笔记。我们首先使用正则表达式从包含在mail_message参数中的InboundEmailMessage实例的sender字段中提取电子邮件地址。然后,我们实例化一个代表发送该消息的电子邮件地址所有者的User对象。如果发送者不对应于已注册用户,App Engine 将引发UserNotFoundError错误,我们返回一个403: Forbidden HTTP 响应代码,否则我们调用_reload_user()方法。
如果用户想要将文件附加到他们的笔记中,笔记应用程序需要知道笔记所有者的用户 ID 来构建在云存储上存储文件时的路径;问题是当我们没有从users API 调用get_current_user()方法来实例化User类时,该实例的user_id()方法总是返回None值。在撰写本文时,App Engine 没有提供一种干净的方法来确定User类实例的用户 ID,因此我们通过以下步骤实现了一个解决方案:
-
将
User实例分配给 Datastore 实体的一个字段,该字段称为UserLoader实体。 -
将
UserLoader实体存储在 Datastore 中。 -
立即再次加载实体。
这样,我们强制Users服务填写所有用户数据;通过访问UserLoader实体中包含User实例的字段,我们将获得所有用户属性,包括id属性。我们在处理器类的实用方法中执行此操作:
def _reload_user(self, user_instance):
key = UserLoader(user=user_instance).put()
key.delete(use_datastore=False)
u_loader = UserLoader.query(
UserLoader.user == user_instance).get()
return UserLoader.user
要强制从 Datastore 重新加载实体,我们首先需要清除 NDB 缓存,这是通过在传递use_datastore=False参数的键上调用delete()方法来实现的。然后我们从 Datastore 重新加载实体并返回user属性,现在它包含我们所需的所有数据。我们将UserLoader模型类添加到我们的models.py模块中:
class UserLoader(ndb.Model):
user = ndb.UserProperty()
在receive()方法中,我们在重新加载User实例后继续从电子邮件消息中提取所需的所有数据;为了提取所有数据,我们需要创建一个笔记:消息主题是一个简单的字符串,我们将用它作为笔记标题。访问正文稍微复杂一些,因为电子邮件消息可能有多个正文,内容类型不同,通常是纯文本或 HTML;在这种情况下,我们只提取纯文本正文并将其用作笔记内容。
在这种情况下,电子邮件消息有附件,并且mail_message实例提供了attachments属性:我们将其作为参数传递给专门用于创建笔记的方法,即_create_note()方法。_create_note()方法在事务中运行并封装了创建Note实体所需的所有逻辑:
@ndb.transactional
def _create_note(self, user, title, content, attachments):
note = Note(parent=ndb.Key("User", user.nickname()),
title=title,
content=content)
note.put()
if attachments:
bucket_name = app_identity.get_default_gcs_bucket_name()
for file_name, file_content in attachments:
content_t = mimetypes.guess_type(file_name)[0]
real_path = os.path.join('/', bucket_name,
user.user_id(), file_name)
with cloudstorage.open(real_path, 'w',
content_type=content_t,
options={'x-goog-acl': 'public-read'}) as f:
f.write(file_content.decode())
key = blobstore.create_gs_key('/gs' + real_path)
try:
url = images.get_serving_url(key, size=0)
thumbnail_url = images.get_serving_url(key,
size=150, crop=True)
except images.TransformationError,
images.NotImageError:
url = "http://storage.googleapis.com{}".format(
real_path)
thumbnail_url = None
f = NoteFile(parent=note.key, name=file_name,
url=url, thumbnail_url=thumbnail_url,
full_path=real_path)
f.put()
note.files.append(f.key)
note.put()
该方法与MainHandler类中同名的方法非常相似;主要区别在于我们从电子邮件消息中附加的文件中访问数据的方式。attachments参数是一个包含两个元素的元组列表:一个是包含文件名的字符串,另一个是包含消息有效载荷的包装器类的实例。我们使用文件名来构建云存储中文件的完整路径,并使用decode()方法来访问有效载荷数据并将其存储在文件中。
最后,我们将 URL 映射到处理器:
app = webapp2.WSGIApplication([
(r'/', MainHandler),
(r'/media/(?P<file_name>[\w.]{0,256})', MediaHandler),
(r'/shrink', ShrinkHandler),
(r'/shrink_all', ShrinkCronJob),
(r'/_ah/mail/<appid>\.appspotmail\.com', CreateNoteHandler),
], debug=True)
在本地开发服务器上测试应用程序时,我们可以使用开发控制台从 Web 界面模拟发送电子邮件;此功能可通过点击左侧栏上的入站邮件菜单项获得。
摘要
在本章中,我们在 Notes 应用程序中添加了许多功能,现在我们应该能够利用云存储并将其用于存储和从我们的应用程序中提供静态内容。我们看到了 Images API 的实际应用,现在我们应该知道如何处理耗时较长的请求,我们还学习了如何安排重复任务。在最后一部分,我们深入探讨了 Mail API 的功能,并了解了 App Engine 如何提供发送和接收电子邮件消息的现成解决方案。
在下一章中,我们将审视我们应用程序的性能,并探讨我们可以在哪些方面以及如何进行改进,这包括利用我们已使用的组件的高级功能,以及 App Engine 提供的更多服务。
第四章:提高应用程序性能
即使我们的笔记应用程序缺少许多细节,到目前为止,我们已经在使用云平台的一些关键组件,因此它可以被认为是一个完整的网络应用程序。这是一个很好的机会停止添加主要功能,并尝试深入了解涉及 Datastore、Memcache 和模块服务的实现细节,以优化应用程序性能。
在阅读本章内容时,我们必须考虑到如何优化在按使用付费的服务(如 App Engine)上运行的网络应用程序,这对于最大化性能和降低成本至关重要。
在本章中,我们将涵盖以下主题:
-
深入了解 Datastore:属性、查询、缓存、索引和管理
-
如何将临时数据存储到 Memcache 中
-
如何在模块服务的帮助下构建我们的应用程序
Datastore 的高级使用
到目前为止,我们已经对 Datastore 学习了很多,包括如何使用模型类定义实体类型、属性概念以及如何进行简单查询。
我们可以使用 NDB Python API 做很多事情来优化应用程序,正如我们很快就会看到的。
更多关于属性的内容——使用 StructuredProperty 安排复合数据
在我们的笔记应用程序中,我们定义了 CheckListItem 模型类来表示可勾选项,然后我们在 Note 模型中添加了一个名为 checklist_items 的属性,该属性引用了该种实体的列表。这通常被称为笔记和清单项之间的一对多关系,这是构建应用程序数据的一种常见方式。然而,按照这种策略,每次我们向笔记添加一个项目时,我们都必须在 Datastore 中创建并存储一个新的实体。这根本不是什么坏习惯,但我们必须考虑到我们根据所进行的操作数量来支付 Datastore 的使用费用;因此,如果我们有大量数据,保持低写入操作率可以潜在地节省很多钱。
Python NDB API 提供了一种名为 StructuredProperty 的属性类型,我们可以使用它将一种模型包含在另一种模型中;而不是在 Note 模型的 KeyProperty 类型的属性中引用 CheckListItem 模型,我们将其存储在 StructuredProperty 类型的属性中。在我们的 models.py 模块中,我们按照以下方式更改 Note 模型:
class Note(ndb.Model):
title = ndb.StringProperty()
content = ndb.TextProperty(required=True)
date_created = ndb.DateTimeProperty(auto_now_add=True)
checklist_items = ndb.StructuredProperty(CheckListItem,
repeated=True)
files = ndb.KeyProperty("NoteFile", repeated=True)
在 main.py 模块中,当我们创建新笔记时需要调整代码以存储清单项,因此我们这样重构了 create_note 方法:
@ndb.transactional
def _create_note(self, user, file_name, file_path):
note = Note(parent=ndb.Key("User", user.nickname()),
title=self.request.get('title'),
content=self.request.get('content'))
item_titles = self.request.get('checklist_items').split(',')
for item_title in item_titles:
if not item_title:
continue
item = CheckListItem(title=item_title)
note.checklist_items.append(item)
note.put()
if file_name and file_path:
url, thumbnail_url = self._get_urls_for(file_name)
f = NoteFile(parent=note.key, name=file_name,
url=url, thumbnail_url=thumbnail_url,
full_path=file_path)
f.put()
note.files.append(f.key)
note.put()
首先,我们将对 note.put() 方法的调用移至笔记创建下方;我们不需要在 CheckListItem 构造函数的 parent 参数中提供一个有效的键,因此我们可以在方法末尾持久化 Note 实例。然后,我们为每个想要添加到笔记中的项目实例化一个 CheckListItem 对象,就像之前一样,但实际上并不在 Datastore 中创建任何实体;这些对象将由 NDB API 在 Note 实体内部透明地序列化。
我们还需要调整 HTML 模板,因为笔记实体中的 checklist_items 属性不再包含键的列表;它包含 CheckListItem 对象的列表。在 main.html 文件中,我们相应地更改代码,删除了 get() 方法的调用:
{% if note.checklist_items %}
<ul>
{% for item in note.checklist_items %}
<li class="{%if item.checked%}checked{%endif%}">
{{item.title}}
</li>
{% endfor %}
</ul>
{% endif %}
为了看到与结构化属性一起工作是多么容易,我们在应用程序中添加了一个非常小的功能:一个切换清单中项目选中状态的链接。为了切换项目的状态,我们必须向请求处理程序提供包含项目的笔记的键以及 checklist_items 列表中项目的索引,因此我们构建了一个具有方案 /toggle/<note_key>/<item_index> 的 URL。在 main.html 文件中,我们添加了以下内容:
{% if note.checklist_items %}
<ul>
{% for item in note.checklist_items %}
<li class="{%if item.checked%}checked{%endif%}">
<a href="/toggle/{{note.key.urlsafe()}}/{{ loop.index }}">
{{item.title}}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
Key 类的实例有一个 urlsafe() 方法,可以将键对象序列化为一个字符串,该字符串可以安全地用作 URL 的一部分。为了在循环中检索当前索引,我们使用 Jinja2 提供的 loop.index 表达式。我们还可以向 notes.css 文件中添加一个简单的 CSS 规则,使项目看起来更好一些:
div.note > ul > li > a {
text-decoration: none;
color: inherit;
}
为了实现切换逻辑,我们在 main.py 模块中添加了 ToggleHandler 类:
class ToggleHandler(webapp2.RequestHandler):
def get(self, note_key, item_index):
item_index = int(item_index) - 1
note = ndb.Key(urlsafe=note_key).get()
item = note.checklist_items[item_index]
item.checked = not item.checked
note.put()
self.redirect('/')
我们将项目索引标准化,使其从零开始,然后使用其键从 Datastore 加载一个笔记实体。我们通过将使用 urlsafe() 方法生成的字符串传递给构造函数并使用 urlsafe 关键字参数来实例化一个 Key 对象,然后使用 get() 方法检索实体。在切换请求索引处的项目状态后,我们通过调用 put() 方法更新 Datastore 中的笔记内容。最后,我们将用户重定向到应用程序的主页。
最终,我们将 URL 映射添加到应用程序构造函数中,使用正则表达式匹配我们的 URL 方案,/toggle/<note_key>/<item_index>:
app = webapp2.WSGIApplication([
(r'/', MainHandler),
(r'/media/(?P<file_name>[\w.]{0,256})', MediaHandler),
(r'/shrink', ShrinkHandler),
(r'/shrink_all', ShrinkCronJob),
(r'/toggle/(?P<note_key>[\w\-]+)/(?P<item_index>\d+)', ToggleHandler),
(r'/_ah/mail/create@book-123456\.appspotmail\.com', CreateNoteHandler),
], debug=True)
与结构化属性一起工作很简单;我们只需像访问实际实体一样访问 checklist_items 属性中包含的对象的属性和字段。
这种方法的唯一缺点是CheckListItem实体实际上并没有存储在 Datastore 中;它们没有键,我们不能独立于它们所属的Note实体来加载它们,但这对我们的用例来说完全没问题。我们不是加载我们想要更新的CheckListItem实体,而是加载Note实体,并使用索引来访问项目。作为交换,在笔记创建期间,我们为笔记保存一个put()方法调用,并为清单中的每个项目保存一个put()方法调用;在检索笔记时,我们为清单中的每个项目保存一个get()方法调用。不言而喻,这种优化可以有利于应用程序的成本。
更多关于查询的内容 - 使用投影来节省空间,并使用映射来优化迭代
查询在应用程序中用于搜索 Datastore 中的实体,这些实体符合我们可以通过过滤器定义的搜索标准。我们已经使用 Datastore 查询通过过滤器检索实体;例如,每次我们执行祖先查询时,我们实际上是在过滤掉那些具有与提供给 NDB API query()函数不同的父实体的这些实体。
尽管如此,我们还可以使用查询过滤器做更多的事情,在本节中,我们将详细探讨 NDB API 提供的两个可以用来优化应用程序性能的功能:投影查询和映射。
投影查询
当我们使用查询检索一个实体时,我们会得到该实体的所有属性和数据,这是预期的;但有时,在检索一个实体之后,我们只使用其数据的一个小子集。例如,看看我们ShrinkHandler类中的post()方法;我们执行一个祖先查询来检索属于当前登录用户的笔记,然后对每个笔记调用_shrink_note()方法。_shrink_note()方法只访问笔记实体的files属性,所以即使我们只需要它的一小部分,我们仍然在内存中保留并传递一个相当大的对象。
使用 NDB API,我们可以向fetch()方法传递一个投影参数,该参数包含我们想要为检索到的实体设置的属性列表。例如,在ShrinkHandler类的post()方法中,我们可以这样修改代码:
notes = Note.owner_query(ancestor_key).fetch(
projection=[Note.files])
这是一种所谓的投影查询,以这种方式检索到的实体将只设置files属性。这种检索方式效率更高,因为它检索和序列化的数据更少,实体在内存中占用的空间也更少。如果我们尝试访问这些实体上的任何其他属性而不是files,将会抛出UnprojectedPropertyError错误。
投影有一些我们必须注意的限制。首先,正如我们所预期的,使用投影获取的实体不能在 Datastore 上保存,因为它们只是部分填充的。另一个限制是关于索引的;实际上,我们只能在投影中指定索引属性,这使得无法投影具有未索引类型(如 TextProperty 类型)的属性。
映射
有时,我们需要在查询返回的一组实体上调用相同的函数。例如,在 ShrinkHandler 类的 post() 方法中,我们需要对当前用户的每个笔记实体调用 _shrink_note() 方法:
ancestor_key = ndb.Key("User", user.nickname())
notes = Note.owner_query(ancestor_key).fetch()
for note in notes:
self._shrink_note(note)
我们首先获取与笔记列表中查询匹配的所有实体,然后对列表中的每个项目调用相同的函数。我们可以通过用 NDB API 提供的 map() 方法的一个单独调用替换 for 迭代来重写那段代码:
ancestor_key = ndb.Key("User", user.nickname())
Note.owner_query(ancestor_key).map(self._shrink_note)
我们通过传递我们希望在查询的每个结果上调用的回调函数来调用 map() 方法;回调函数接收一个类型为 Note 的实体对象作为其唯一参数,除非我们使用 keys_only=True 参数调用 map() 方法。在这种情况下,当被调用时,回调将接收一个 Key 实例。
由于 map() 方法接受标准查询选项集(这就是为什么我们可以传递 keys_only 参数),我们也可以对投影查询执行映射:
Note.owner_query(ancestor_key).map(
self._shrink_note, projection=[Note.files])
除了投影之外,这个版本的代码稍微更有效率,因为 Datastore 可以在加载实体时应用一些并发性,并且结果是以批量的形式检索的,而不是在内存中检索整个数据集。如果我们想在回调函数内部获取有关当前批次的详细信息,我们需要在调用 map() 方法时传递 pass_batch_into_callback=True 参数。在这种情况下,回调将接收三个参数:一个由 App Engine 提供的 Batch 对象,它封装了大量有关当前批次的详细信息,当前批次中当前项目的索引,以及从 Datastore 获取的实体对象(或如果使用了 keys_only 参数,则为实体键)。
NDB 异步操作
正如我们所预期的,Datastore 是考虑应用程序性能时的一个关键组件;调整查询和使用正确的惯用表达式可以显著提高效率并降低成本,但还有更多。多亏了 NDB API,我们可以在与其他作业并行执行 Datastore 操作或同时执行多个 Datastore 操作来加速我们的应用程序。
NDB API 提供的几个函数都有一个 _async 对应版本,它们接受完全相同的参数,例如 put 和 put_async 函数。每个异步函数都返回一个 future,这是一个表示已启动但可能尚未完成的操作的对象。我们可以通过调用 get_result() 方法从 future 本身获取异步操作的结果。
在我们的笔记应用程序中,我们可以在MainHandler类的_render_template()方法中使用异步操作:
def _render_template(self, template_name, context=None):
if context is None:
context = {}
user = users.get_current_user()
ancestor_key = ndb.Key("User", user.nickname())
qry = Note.owner_query(ancestor_key)
context['notes'] = qry.fetch()
template = jinja_env.get_template(template_name)
return template.render(context)
目前,我们在加载模板之前等待获取笔记,但我们可以同时在数据存储工作时加载模板:
def _render_template(self, template_name, context=None):
if context is None:
context = {}
user = users.get_current_user()
ancestor_key = ndb.Key("User", user.nickname())
qry = Note.owner_query(ancestor_key)
future = qry.fetch_async()
template = jinja_env.get_template(template_name)
context['notes'] = future.get_result()
return template.render(context)
以这种方式,应用程序在获取数据时不会阻塞,因为fetch_async()方法会立即返回;我们随后继续加载模板,同时数据存储正在工作。当需要填充上下文变量时,我们在未来对象上调用get_result()方法。在这个时候,要么结果可用,我们继续进行渲染操作,要么get_result()方法会阻塞,等待数据存储准备好。在这两种情况下,我们都成功地并行执行了两个任务,从而提高了性能。
使用 NDB API,我们还可以实现称为任务函数的异步任务,在执行其他工作的同时返回一个未来。例如,在本章的早期,我们在ShrinkHandler类中使用map()方法对从数据存储检索的一组实体调用相同的函数。我们知道这段代码比显式for循环版本稍微高效一些,但实际上并没有快多少;回调函数在同步的get()方法上阻塞,因此映射的每一步都要等待前一步完成。
如果我们将回调函数转换为任务函数,App Engine 可以并行运行映射,从而显著提高应用程序性能。由于 NDB API,编写任务函数很简单;例如,ShrinkHandler类的_shrink_note()方法只需两行代码就可以转换为任务函数,如下所示:
@ndb.tasklet
def _shrink_note(self, note):
for file_key in note.files:
file = yield file_key.get_async()
try:
with cloudstorage.open(file.full_path) as f:
image = images.Image(f.read())
image.resize(640)
new_image_data = image.execute_transforms()
content_t = images_formats.get(str(image.format))
with cloudstorage.open(file.full_path, 'w',
content_type=content_t) as f:
f.write(new_image_data)
except images.NotImageError:
pass
我们首先将ndb.tasklet装饰器应用于我们想要转换为任务函数的函数;装饰器提供了所有逻辑来支持带有get_result()方法的前置机制。然后我们使用yield语句告诉 App Engine,我们将在执行的那个点暂停,等待get_async()方法的结果。在我们暂停期间,map()方法可以执行另一个具有不同实体的任务函数,而不是等待我们完成。
缓存
缓存是像 App Engine 这样的系统中的关键组件,因为它影响应用程序性能和数据存储往返,从而影响应用程序成本。NDB API 自动为我们管理缓存,并提供了一套配置缓存系统的工具。如果我们想利用这些功能,理解 NDB 缓存的工作方式非常重要。
NDB 使用两个缓存级别:运行在进程内存中的 in-context 缓存和连接到 App Engine Memcache 服务的网关。in-context 缓存仅在单个 HTTP 请求的持续时间内存储数据,并且仅对处理请求的代码本地,因此它非常快。当我们使用 NDB 函数在 Datastore 上写入数据时,它首先填充 in-context 缓存。对称地,当我们使用 NDB 函数从 Datastore 获取实体时,它首先在 in-context 缓存中搜索,甚至在最佳情况下都不需要访问 Datastore。
Memcache 的速度比 in-context cache 慢,但仍然比 Datastore 快得多。默认情况下,所有在事务外执行的 Datastore 操作都会在 Memcache 上进行缓存,并且 App Engine 确保数据位于同一服务器上以最大化性能。当 NDB 在事务内操作时忽略 Memcache,但在提交事务时,它会尝试从 Memcache 中删除所有涉及的实体,我们必须考虑到其中一些删除可能会失败。
这两个缓存都由一个所谓的上下文管理,该上下文由 App Engine 提供的 Context 类的实例表示。每个传入的 HTTP 请求和每个事务都在一个新的上下文中执行,我们可以使用 NDB API 提供的 get_context() 方法访问当前上下文。
在我们的 Notes 应用程序中,我们已经经历过这些罕见的情况之一,即 NDB 自动缓存实际上是一个问题;在 CreateNoteHandler 类的 _reload_user() 方法中,我们必须强制从 Datastore 重新加载 UserLoader 实体,作为填充 User 对象的解决方案。在 UserLoader 实体的 put() 方法和 get() 方法之间,我们编写了这个指令来从除 Datastore 之外的所有位置删除实体:
UserLoader(user=user_instance).put()
key.delete(use_datastore=False)
u_loader = UserLoader.query(
UserLoader.user == user_instance).get()
没有这个指令,NDB 缓存系统就不会从头开始从 Datastore 获取实体,正如我们所需要的。现在我们知道了 NDB 缓存的工作方式,我们可以以等效的方式重写那个方法,从而更明确地管理缓存,使用 Context 实例:
ctx = ndb.get_context()
ctx.set_cache_policy(lambda key: key.kind() != 'UserLoader')
UserLoader(user=user_instance).put()
u_loader = UserLoader.query(
UserLoader.user == user_instance).get()
由上下文对象公开的 set_cache_policy() 方法接受一个键对象并返回一个布尔结果。当该方法返回 False 参数时,由该键标识的实体不会被保存到任何缓存中;在我们的情况下,我们仅在实体为 UserLoader 类型时返回 False 参数。
备份和恢复功能
为了使用 App Engine 为 Datastore 提供的备份和恢复功能,我们首先需要启用 Datastore Admin,默认情况下它是禁用的。Datastore Admin 是一个提供一组非常有助于管理任务的 Web 应用程序。在撰写本文时,启用和访问 Datastore Admin 的唯一方法是使用位于 appengine.google.com 的旧版 Admin Console。
我们访问我们的项目控制台,然后我们必须执行以下步骤:
-
点击页面左侧数据部分下的数据存储管理员菜单。
-
点击按钮以启用管理员。
-
选择一个或多个我们想要备份或恢复的实体类型。
要执行完整备份,我们首先必须将我们的应用程序置于只读模式。从控制台,我们需要执行以下步骤:
-
点击左侧管理菜单下的应用程序设置。
-
在页面底部,点击禁用写入...按钮,位于禁用数据存储写入选项下。
-
返回到数据存储管理员部分,并选择我们想要备份的所有实体类型。
-
点击备份实体按钮。
-
选择备份的目标位置,并在blobstore和云存储之间进行选择。指定备份文件的名称。
-
点击备份实体按钮。
-
备份在后台运行;一旦完成,它就会在数据存储管理员中列出。
-
重新启用我们应用程序的写入权限。
从数据存储管理员界面,我们可以选择一个备份并执行恢复操作。在开始恢复操作后,数据存储管理员会询问我们想要恢复哪些实体类型,然后它将在后台进行。
索引
索引是按索引的某些属性和可选的实体祖先顺序排列的数据存储实体的表。每次我们对数据存储进行写入时,索引都会更新以反映它们各自实体的变化;当我们从数据存储读取时,结果通过访问索引来获取。这基本上是为什么从数据存储读取比写入快得多的原因。
我们的 Notes 应用程序执行多个查询,这意味着必须存在某些索引,但我们从未直接管理或创建索引。这是因为两个原因。第一个原因是当我们运行本地开发服务器时,它会扫描我们的源代码,寻找查询并自动生成创建所有所需索引的代码。另一个原因是数据存储为每个类型的每个属性自动生成基本索引,称为预定义索引,这对于简单的查询是有用的。
索引在应用程序根目录的index.yaml文件中声明,其语法如下:
- kind: Note
ancestor: yes
properties:
- name: date_created
direction: desc
- name: NoteFile
这些是需要定义和创建索引的属性,以便我们对属于当前登录用户的 Note 实体进行查询,并按日期逆序排序。当我们部署应用程序时,index.yaml文件会被上传,并且 App Engine 开始构建索引。
如果我们的应用程序执行了每一种可能的查询,包括每一种排序组合,那么开发服务器生成的条目将代表一个完整的索引集。这就是为什么在绝大多数情况下,我们不需要声明索引或自定义现有的索引,除非我们有一个非常特殊的案例需要处理。无论如何,为了优化我们的应用程序,我们可以禁用那些我们知道永远不会进行查询的属性上的索引。预定义的索引没有列在index.yaml文件中,但我们可以使用models.py模块中的属性构造函数来禁用它们。例如,如果我们事先知道我们永远不会直接通过查询搜索NoteFile实体,我们可以禁用所有其属性的索引:
class NoteFile(ndb.Model):
name = ndb.StringProperty(indexed=False)
url = ndb.StringProperty(indexed=False)
thumbnail_url = ndb.StringProperty(indexed=False)
full_path = ndb.StringProperty(indexed=False)
通过将indexed=False参数传递给构造函数,我们避免了 App Engine 为这些属性创建索引,这样每次我们存储一个NoteFile实体时,将会有更少的索引需要更新,从而加快写入操作。NoteFile实体仍然可以通过Note实体中的files属性检索,因为 App Engine 会继续创建预定义的索引来按类型和键检索实体。
使用 Memcache
我们已经知道 Memcache 是 App Engine 提供的分布式内存数据缓存。一个典型的用法是将其用作从持久存储(如 Datastore)快速检索数据的缓存,但我们已经知道 NDB API 会为我们做这件事,所以没有必要显式地缓存实体。
存储在 Memcache 中的数据可以随时被清除,因此我们只应该缓存那些即使丢失也不会影响完整性的数据。例如,在我们的笔记应用中,我们可以缓存每个用户全局存储的笔记总数,并在主页上显示这种类型的指标。每次用户访问主页时,我们都可以执行一个查询来计算Note实体,但这会很麻烦,可能会抵消我们迄今为止所做的所有优化。更好的策略是在 Memcache 中保持一个计数器,并在应用中创建笔记时增加该计数器;如果 Memcache 数据过期,我们将再次执行计数查询而不会丢失任何数据,并重新开始增加内存中的计数器。
我们实现两个函数来封装 Memcache 操作:一个用于获取计数器的值,另一个用于增加它。我们首先在utils.py文件中创建一个新的 Python 模块,该模块包含以下代码:
from google.appengine.api import memcache
from models import Note
def get_note_counter():
data = memcache.get('note_count')
if data is None:
data = Note.query().count()
memcache.set('note_count', data)
return data
我们首先尝试从 Memcache 中调用get()方法访问计数器的值,请求note_count键。如果返回值是None,我们假设键不在缓存中,然后我们继续查询 Datastore。然后我们将查询的结果存储在 Memcache 中,并返回该值。
我们想在主页上显示计数器,所以我们在MainHandler类的_render_template()方法中将它添加到模板上下文中:
def _render_template(self, template_name, context=None):
if context is None:
context = {}
user = users.get_current_user()
ancestor_key = ndb.Key("User", user.nickname())
qry = Note.owner_query(ancestor_key)
future = qry.fetch_async()
template = jinja_env.get_template(template_name)
context['notes'] = future.get_result()
context['note_count'] = get_note_counter()
return template.render(context)
在使用函数获取计数器之前,我们需要从 main 模块中导入它:
from utils import get_note_counter
我们还需要修改 HTML 模板:
<body>
<div class="container">
<h1>Welcome to Notes!</h1>
<h5>{{ note_count }} notes stored so far!</h5>
然后,我们可以刷新笔记应用程序的主页,以查看计数器的实际效果。现在,我们需要编写增加计数器的代码,但在继续之前,有一些事情我们应该注意。
多个请求可能会并发尝试在 Memcache 中增加值,这可能导致竞争条件。为了避免这种情况,Memcache 提供了两个函数,incr() 和 decr(),它们可以原子性地增加和减少 64 位整数值。这些将非常适合我们的计数器,但我们可以提供一个更通用的解决方案,该解决方案也适用于非整数的缓存值,使用 App Engine Python API 的 比较 和 设置 功能。
在 utils.py 模块中,我们添加了以下函数:
def inc_note_counter():
client = memcache.Client()
retry = 0
while retry < 10:
data = client.gets('note_count')
if client.cas('note_count', data+1):
break
retry += 1
我们使用 Client 类的一个实例,因为 memcache 模块中没有提供比较和设置功能作为函数。在获取到 Client 实例后,我们进入所谓的 retry 循环,如果检测到罕见条件,我们将重复迭代最多 10 次。然后我们尝试使用客户端的 gets 方法获取 note_count 键的值。此方法会改变客户端的内部状态,存储由 Memcache 服务提供的戳记值。然后我们尝试通过在客户端对象上调用 cas() 方法来增加与同一键对应的值;该方法将键的新值传输到 Memcache,以及之前提到的戳记。如果戳记匹配,则值被更新,cas() 方法返回 True 参数,导致 retry 循环退出;否则,它返回 False 参数,我们再次尝试。
在 main 模块中导入 inc_note_counter() 函数后,我们可以在创建新笔记的地方调用它来增加计数器:在 MainHandler 类的 _create_note 中,以及在 CreateNoteHandler 类的 _create_note 方法中。
将我们的应用程序拆分为模块
目前,我们的笔记应用程序提供了一些前端功能,例如服务主页,以及一些后端功能,例如处理定时任务。这对于大多数用例来说是可以的,但如果应用程序架构复杂且流量很大,那么有多个后端任务占用资源,这并不总是可以接受的。为了应对这类问题,App Engine 提供了一种极其灵活的方式来使用 模块 来布局 Web 应用程序。
每个 App Engine 应用程序至少由一个模块组成;即使我们之前不知道,到目前为止,我们已经处理了 Notes 应用程序的默认模块。模块通过名称标识,由源代码和配置文件组成,可以位于应用程序根目录或子目录中。每个模块都有一个版本,我们可以部署同一模块的多个版本;每个版本将根据我们如何配置其扩展性而生成一个或多个 App Engine 实例。能够部署同一模块的多个版本,特别是对于测试新组件或部署渐进式升级非常有用。属于同一应用程序的模块共享服务,如 Memcache、Datastore 和任务队列,并且可以使用 Python API 模块以安全的方式通信。
要深入了解一些其他细节,我们可以通过添加一个专门处理 cron 作业的新模块来重构我们的 Notes 应用程序。我们不需要添加任何功能;我们只是分解和重构现有代码。由于我们的应用程序架构非常简单,我们可以直接在应用程序根目录中添加该模块。首先,我们需要配置这个新模块,我们将在一个新文件backend.yaml中将其命名为backend,该文件包含以下内容:
application: notes
module: backend
version: 1
runtime: python27
api_version: 1
threadsafe: yes
handlers:
- url: .*
script: backend.app
这与任何应用程序配置文件非常相似,但主要区别是包含模块名称的module属性。当此属性不在配置文件中,或其值为default字符串时,App Engine 假定这是应用程序的默认模块。然后我们告诉 App Engine,我们希望backend_main文件中的 Python 模块处理该模块将接收到的每个请求。当我们不在配置文件中指定任何扩展选项时,将假定自动扩展。
我们在backend_main.py文件中编写了一个全新的 Python 模块,其中包含一个专用的 WSGI 兼容应用程序:
app = webapp2.WSGIApplication([
(r'/shrink_all', ShrinkCronJob),
], debug=True)
如我们从映射中看到的那样,此应用程序将仅处理 shrink cron 作业的请求。我们从主模块中获取处理程序代码,为了避免依赖它,我们重写了ShrinkCronJob类,使其不再需要从ShrinkHandler类派生。同样,在backend_main.py模块中,我们添加以下内容:
class ShrinkCronJob(webapp2.RequestHandler):
@ndb.tasklet
def _shrink_note(self, note):
for file_key in note.files:
file = yield file_key.get_async()
try:
with cloudstorage.open(file.full_path) as f:
image = images.Image(f.read())
image.resize(640)
new_image_data = image.execute_transforms()
content_t = images_formats.get(str(image.format))
with cloudstorage.open(file.full_path, 'w',
content_type=content_t) as f:
f.write(new_image_data)
except images.NotImageError:
pass
def get(self):
if 'X-AppEngine-Cron' not in self.request.headers:
self.error(403)
notes = Note.query().fetch()
for note in notes:
self._shrink_note(note)
为了方便起见,我们可以将image_formats字典移动到utils.py模块中,这样我们就可以在这里以及main.py模块中重用它。
现在我们有两个模块,我们需要将进入我们应用程序的请求路由到正确的模块,我们可以通过在应用程序根目录中创建一个名为dispatch.yaml的文件来实现,该文件包含以下内容:
dispatch:
- url: "*/shrink_all"
module: backend
- url: "*/*"
module: default
基本上,这是我们可以在 App Engine 上拥有的最高级别的 URL 映射。我们可以使用通配符而不是正则表达式来将传入请求的 URL 路由到正确的模块;在这种情况下,我们将请求路由到/shrink_all URL,将所有其他请求留给默认模块。
注意
理想情况下,我们可以将实现通过电子邮件创建笔记的代码也移动到后端模块,但不幸的是,App Engine 只允许在默认模块上使用入站服务。
在本地开发环境和生产环境中使用模块会增加一些复杂性,因为我们不能使用 App Engine Launcher 图形界面来启动和停止开发服务器或部署应用程序;我们必须使用命令行工具。
例如,我们可以检查模块在本地环境中的工作方式,但我们必须通过传递每个模块的YAML文件以及dispatch.yaml文件作为参数来启动开发服务器。在我们的情况下,我们在命令行上执行以下操作:
dev_appserver.py app.yaml backend.yaml dispatch.yaml
要在 App Engine 上部署应用程序,我们使用appcfg命令行工具传递我们想要部署的模块的YAML文件,确保在第一次部署时,默认模块的配置文件是列表中的第一个,例如,我们可以使用以下YAML文件:
appcfg.py update app.yaml backend.yaml
当应用程序重新启动时,我们应该能够在开发控制台或管理控制台中看到为额外的后端模块运行的实例。
注意
由于在像笔记这样的小型应用程序上使用模块不太实用,并且对本书的目的没有提供任何好处,我们可以切换回只有一个模块的布局。
摘要
在本章中,我们深入探讨了迄今为止我们使用的云平台组件的许多细节。正如之前提到的,当使用按使用付费的服务,如云平台时,掌握细节和最佳实践对性能和成本都有益。本章的大部分内容都致力于云数据存储,确认这是几乎所有 Web 应用程序的关键组件;了解如何布局数据或执行查询可以决定我们应用程序的成功。
我们还学习了如何从 Python 应用程序安全地使用 Memcache,避免竞争条件和难以调试的奇怪行为。在章节的最后部分,我们涵盖了 App Engine 的模块功能;即使我们必须处理复杂的应用程序以完全欣赏模块化架构的好处,了解模块是什么以及它们能为我们做什么也是重要信息,如果我们想在 App Engine 上部署我们的应用程序。
下一章完全致力于 Google Cloud SQL 服务。我们将学习如何创建和管理数据库实例,以及如何建立连接和执行查询。
第五章。在 Google Cloud SQL 中存储数据
Google Cloud SQL 是一个位于 Google 云基础设施中的 MySQL 数据库服务器实例;它可以在不运行在 App Engine 平台的应用程序中从 Google Cloud Platform 外部使用。我们将学习两种使用方法:通过向我们的笔记应用程序添加代码以及创建一个在我们工作站上运行的独立脚本。
Google 为 Cloud SQL 提供两种计费计划,套餐和按使用量计费,不提供任何免费层。这意味着我们必须为执行本章中的代码付费,尽管选择按使用量计费计划并且仅为了通过本章而运行实例应该非常便宜。
在本章中,我们将涵盖以下主题:
-
如何创建、配置和运行一个 Cloud SQL 实例
-
如何管理正在运行的实例
-
如何从 App Engine 使用 Cloud SQL
-
如何从 App Engine 外部使用 Cloud SQL
创建一个 Cloud SQL 实例
我们将在本章中大量使用开发者控制台,我们首先创建一个 Cloud SQL 数据库的实例。正如我们从第一章,“入门”中已经知道的那样,即使我们从 App Engine 管理员控制台创建了我们的笔记应用程序,我们也应该在开发者控制台中有一个对应的项目。
注意
到目前为止,我们必须为我们的项目启用计费功能,以便从开发者控制台中访问所有与 Cloud-SQL 相关的功能。
从开发者控制台,一旦我们选择了项目,我们必须执行以下操作:
-
点击左侧菜单中的存储部分下的Cloud SQL项。
-
点击创建实例按钮。
-
为数据库实例提供一个名称,例如,myfirst;实例的名称必须在项目内是唯一的,并且将始终与项目名称结合使用。
-
选择区域,与 App Engine 应用程序的位置相同(最可能是美国)。
-
为实例选择一个层级;为了本章的目的,我们可以安全地使用最便宜的层级,标记为D0。
-
点击保存按钮。
以下截图显示了开发者控制台:

我们 Cloud SQL 实例的创建过程将立即开始。几分钟内,实例的状态将变为可运行状态,这意味着我们可以随时启动实例。在实例处于可运行状态时,我们不收取任何费用。
配置访问
在使用我们的数据库实例之前,我们应该配置访问权限和凭证,以控制谁可以执行到数据库的连接以及如何进行。存在两个级别的访问控制,一个在云平台级别,另一个在数据库级别。第一级授权客户端应用程序访问云 SQL 实例,无论是通过检查应用程序 ID 从 App Engine 基础设施,还是通过检查源 IP 地址从互联网上的远程节点。第二级是 MySQL 权限系统,负责用户的身份验证并将他们与数据库的权限相关联,例如执行SELECT、INSERT、UPDATE或DELETE操作。
如果我们在开发者控制台的项目设置中创建了云 SQL 实例,我们的 App Engine 应用程序已经授权连接到数据库。为了双重检查,在开发者控制台中我们必须:
-
点击云 SQL菜单项。
-
点击实例 ID。
-
打开访问控制选项卡。
在授权 App Engine 应用程序标签下,我们可以查看我们的应用程序 ID 是否已列出。
当我们在该页面上时,我们可以为我们的本地机器设置访问权限;这是执行添加用户和数据库等管理任务所需的,可以使用任何 MySQL 客户端。我们首先需要为我们的实例分配一个 IP 地址,以便我们可以从云平台基础设施外部访问它;点击添加新链接,位于IP 地址标签旁边,并等待地址分配给我们的实例。
注意
当我们请求云 SQL 实例的 IP 地址时,我们应该意识到,在实例未运行时使用此地址的时间将会产生费用。为了降低成本,我们可以在不需要时立即释放 IP 地址。
当我们从我们的本地机器连接到云 SQL 实例时,显然我们处于 App Engine 基础设施之外,因此我们必须将我们的公网 IP 地址添加到允许从互联网访问的主机列表中。为此,我们需要执行以下操作:
-
获取我们的公网 IP 地址;我们可以通过访问这个
www.google.com/#q=my-ipURL 来使用 Google 进行此操作。 -
点击授权网络标签旁边的添加新链接。
-
使用我们的公网 IP 地址填写表格。
-
点击添加按钮。
以下截图显示了开发者控制台:

从现在起,我们可以使用 MySQL 命令行客户端连接到我们的云 SQL 实例,例如从我们的笔记本电脑。对于访问控制系统的第一级,目前这已经足够了;我们可以继续配置第二级。
设置 root 密码
要完全控制我们的云 SQL 实例,第一步是为 MySQL 的root用户设置密码;为此,请执行以下操作:
-
在开发者控制台中,我们转到访问控制选项卡。
-
在设置 Root 密码部分下填写所需的密码。
-
点击设置按钮。
在下一段中,我们将看到如何以root用户身份连接到实例,并执行在使用我们的 Notes 应用程序内部使用实例之前需要完成的行政任务。
使用 MySQL 控制台连接到实例
要与我们的 Cloud SQL 实例交互,我们将使用 MySQL 命令行客户端,该客户端适用于 App Engine 支持的所有平台,即使我们可以使用任何我们更熟悉的客户端。客户端通常与大多数 MySQL 服务器安装包一起提供;除了安装 MySQL 客户端工具外,建议安装 MySQL 并运行本地服务器,这样我们就可以在开发应用程序时使用它而不是生产实例。我们将在本章中很快回到这一点。
创建 notes 数据库
我们需要执行的第一项任务是创建 Cloud SQL 实例上的新数据库;我们将使用它来存储我们的 Notes 应用程序中的数据。要连接到实例,我们从命令行发出以下指令:
mysql –host=<your instance IP> --user=root –password
在插入root用户的密码后,我们应该进入 MySQL 监控器,并看到以下类似的输出:
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 1
Server version: 5.5.38 (Google)
Copyright (c) 2000, 2014, Oracle and/or its affiliates. All rights reserved.
Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql>
如果我们成功管理到提示符,我们可以通过以下指令创建一个名为notes的数据库:
mysql> CREATE DATABASE notes;
Query OK, 1 row affected (1.62 sec)
如果成功,命令的输出应该与前一个命令非常相似;我们现在可以继续创建一个专门的数据库用户,我们将使用它从我们的 Notes 应用程序进行连接。
创建专用用户
MySQL 安装中的root用户具有无限权限,避免使用superuser凭据连接到服务器是一种良好的安全实践。因此,我们创建一个专用用户,我们将使用它从我们的 Notes 应用程序进行连接,并且能够专门在notes数据库上操作。在继续之前,我们删除 Cloud SQL 实例默认提供的匿名 localhost 访问;这是一种良好的安全实践,避免了当 MySQL 检查用户权限时匿名用户会掩盖普通用户。从客户端,我们发出以下语句:
mysql> DROP USER ''@localhost;
Query OK, 0 rows affected (0.17 sec)
然后我们继续创建一个普通用户:
mysql> CREATE USER 'notes'@'%' IDENTIFIED BY 'notes_password';
Query OK, 0 rows affected (1.47 sec)
当然,我们应该选择一个更强的密码;无论如何,我们刚刚创建了一个名为notes的新用户,该用户将能够从任何主机进行连接(注意%字符是一个通配符,匹配任何主机)。为了方便,我们授予notes用户对notes数据库的任何权限:
mysql> GRANT ALL PRIVILEGES ON notes.* TO 'notes'@'%';
Query OK, 0 rows affected (0.49 sec)
我们最终使用以下语句让 MySQL 服务器重新加载所有更新的权限:
mysql> FLUSH PRIVILEGES;
Query OK, 0 rows affected (0.17 sec)
我们现在可以断开与服务器的连接,使用\q命令结束当前会话,并尝试使用notes用户重新连接:
mysql> \q
Bye
mysql –host=<your instance IP> --user=notes –password
我们应该无错误地与 MySQL 监控器建立连接,然后我们可以检查我们是否真的可以访问notes数据库:
mysql> use notes;
Database changed
我们现在可以继续创建用于存储 Notes 应用程序数据的表。
创建表
假设我们想要记录用户活动并将这些信息存储在数据库中,以便我们可以在以后用于,比如说,商业智能分析。使用 Datastore 来实现这个目的至少有两个原因是不好的:
-
我们可能会写入大量数据,因此我们无法使用太多索引,我们可能不得不避免使用分组实体。
-
我们将需要一个额外的 App Engine 应用程序来检索和分析数据,因为我们无法从平台外部访问 Datastore。
Cloud SQL 可以分别解决上述两个问题:
-
Cloud SQL 的写入限制比 Datastore 更宽松。
-
我们可以从外部应用程序连接到 Cloud SQL 实例并访问数据。
我们现在可以开始定义我们想要记录的数据;对于简单的使用分析,我们可以保存用户标识符、执行的操作类型以及此类操作的日期和时间。一旦通过 MySQL 客户端连接到服务器,我们就可以发出 CREATE 语句:
CREATE TABLE 'notes'.'ops'
(
'id' INT NOT NULL auto_increment,
'user_id' VARCHAR(128) NOT NULL,
'operation' VARCHAR(16) NOT NULL,
'date' DATETIME NOT NULL,
PRIMARY KEY ('id')
);
如果查询成功,我们应该看到类似以下输出:
Query OK, 0 rows affected (0.55 sec)
SQL 语句在 notes 数据库内部创建了一个名为 ops 的关系或表。该表有 4 个列:
-
id列 This 包含自动递增的整数值,每次插入新行时都会增加;这是主键。 -
user_id列:这包含 App Engine 提供的用户标识符,通常长度为 56 个字符;我们将其设置为 128 个字符的长度,以便在长度增加时有空间。 -
operation列:这是为了存储记录的操作类型;16 个字符应该足够了。 -
date列:这包含记录操作时的日期和时间。
从我们的应用程序连接到实例
要从我们的 Python 代码连接到 Cloud SQL 实例,我们使用 MySQLdb 包,这是一个实现了 Python 数据库 API 的 MySQL 驱动程序,如 PEP 249 文档所述。要安装该包,我们可以使用 pip;从命令行,我们发出以下命令:
pip install MySQL-python
我们没有指定 -t 选项,就像在第三章中安装 GCS 客户端库时做的那样,存储和处理用户数据,因为 MySQLdb 包已包含在生产服务器的 App Engine Python 运行时环境中,我们不需要在部署期间上传它。相反,我们在 app.yaml 文件的 libraries 部分列出该包:
libraries:
- name: webapp2
version: "2.5.2"
- name: jinja2
version: latest
- name: MySQLdb
version: latest
一个简单的测试来检查数据库连接是否正确工作,包括检索和记录 Cloud SQL 版本号。我们在 utils.py 模块中添加了一个函数来获取数据库连接。我们首先需要在 utils.py 模块的顶部导入 MySQLdb 包以及 os 模块:
import os
import MySQLdb
然后,我们添加以下函数:
def get_cloudsql_db():
db_ip = os.getenv('CLOUD_SQL_IP')
db_user = os.getenv('CLOUD_SQL_USER')
db_pass = os.getenv('CLOUD_SQL_PASS')
return MySQLdb.connect(host=db_ip, db='notes',
user=db_user, passwd=db_pass)
该函数返回数据库连接。我们通过访问一些环境变量来检索所有信息以执行连接,这样它们就可以从我们的代码库中的任何位置轻松访问。要定义环境变量,我们只需在app.yaml文件的底部添加以下内容:
env_variables:
CLOUD_SQL_IP: '<your_instance_ip>'
CLOUD_SQL_USER: 'notes'
CLOUD_SQL_PASS: 'notes_password'
我们可以使用数据库连接来获取main.py模块中MainHandler类的get()方法中的 MySQL 版本。我们首先导入get_cloudsql_db()方法和logging模块:
from utils import get_cloudsql_db
import logging
我们按照以下方式修改get()方法:
def get(self):
user = users.get_current_user()
if user is not None:
db = get_cloudsql_db()
ver = db.get_server_info()
logging.info("Cloud SQL version: {}".format(ver))
logout_url = users.create_logout_url(self.request.uri)
template_context = {
'user': user.nickname(),
'logout_url': logout_url,
}
self.response.out.write(
self._render_template('main.html', template_context))
else:
login_url = users.create_login_url(self.request.uri)
self.redirect(login_url)
我们可以使用本地开发服务器运行 Notes 应用程序,并用我们的浏览器访问主页;如果一切正常,我们应该在日志控制台(或者如果你从那里启动了dev_appserver.py服务器,在你的 shell 中)看到类似以下的消息:
INFO 2014-09-28 12:40:41,796 main.py:109] Cloud SQL version: 5.5.38
到目前为止一切顺利,但如果我们尝试在 App Engine 上部署应用程序,结果将是一个包含以下错误信息的错误页面:
OperationalError: (2004, "Can't create TCP/IP socket (-1)")
这是因为我们正在尝试使用 TCP/IP 套接字访问 Cloud SQL 实例,如果我们从 App Engine 外部连接,这是完全正常的;但由于运行时环境网络限制,如果我们从 App Engine 应用程序连接,我们必须使用 Unix 套接字。
我们可以按照以下方式更改utils.py模块中的连接字符串:
def get_cloudsql_db():
db_user = os.getenv('CLOUD_SQL_USER')
db_pass = os.getenv('CLOUD_SQL_PASS')
instance_id = os.getenv('CLOUD_SQL_INSTANCE_ID')
unix_socket = '/cloudsql/{}'.format(instance_id)
return MySQLdb.connect(unix_socket=unix_socket, db='notes',
user=db_user, passwd=db_pass)
我们需要在app.yaml文件中定义一个名为CLOUD_SQL_INSTANCE_ID的附加环境变量:
env_variables:
CLOUD_SQL_IP: '<your_instance_ip>'
CLOUD_SQL_USER: 'notes'
CLOUD_SQL_PASS: 'notes_password'
CLOUD_SQL_INSTANCE_ID: '<your_instance_id>'
如果我们尝试部署这个应用程序版本,我们会注意到它实际上在 App Engine 上运行正常,但在本地环境服务器上不再工作。为了避免每次从开发模式切换到生产模式时都修改get_cloudsql_db()函数中的代码,我们可以提供一个自动检测应用程序是本地运行还是运行在 App Engine 服务器上的方法。在utils.py模块中,我们添加以下代码:
def on_appengine():
return os.getenv('SERVER_SOFTWARE', '').startswith('Google App Engine')
这个函数简单地返回如果应用程序在 App Engine 上运行则返回True参数,否则返回False参数。我们可以以这种方式在get_cloudsql_db()函数中使用该函数:
def get_cloudsql_db():
db_user = os.getenv('CLOUD_SQL_USER')
db_pass = os.getenv('CLOUD_SQL_PASS')
if on_appengine():
instance_id = os.getenv('CLOUD_SQL_INSTANCE_ID')
sock = '/cloudsql/{}'.format(instance_id)
return MySQLdb.connect(unix_socket=sock, db='notes',
user=db_user, passwd=db_pass)
else:
db_ip = os.getenv('CLOUD_SQL_IP')
return MySQLdb.connect(host=db_ip, db='notes',
user=db_user, passwd=db_pass)
该函数将始终返回应用程序运行的环境的正确数据库连接。
加载数据和保存数据
现在我们已经知道如何从我们的 App Engine 应用程序连接到 Cloud SQL 实例,是时候学习如何从数据库中写入和读取数据了。我们已创建了一个名为ops的表,我们将使用它来存储有关用户操作的信息。我们将记录以下事件:
-
有用户创建了一个笔记
-
有用户添加了一个文件
-
有用户执行了收缩操作
我们必须为想要记录的操作类型分配一个文本代码。为此,我们可以使用一个简单的 Python 类,它作为一个枚举。在utils.py模块中,我们添加以下代码:
class OpTypes(object):
NOTE_CREATED = 'NCREATED'
FILE_ADDED = 'FADDED'
SHRINK_PERFORMED = 'SHRINKED'
我们稍后将看到如何使用它。现在,我们在utils.py模块中提供了一个log_operation()方法,我们将使用它来记录 Cloud SQL 数据库中的操作。我们将在 Notes 代码中调用此函数,传递实际执行操作的用户的用户名、适当的操作类型以及操作日期和时间。代码如下:
def log_operation(user, operation_type, opdate):
db = get_cloudsql_db()
cursor = db.cursor()
cursor.execute('INSERT INTO ops (user_id, operation, date)'
' VALUES (%s, %s, %s)',
(user.user_id(), operation_type, opdate))
db.commit()
db.close()
我们首先检索一个有效的数据库连接,然后通过在连接对象上调用cursor()方法来获取一个游标对象。通过在游标对象上调用execute()方法,我们可以发出我们传递为参数的字符串中包含的 SQL 语句。在这种情况下,我们在ops表中插入一个新行,持久化用户标识符、对应操作类型的字符串以及操作执行的日期和时间。最后,我们提交事务并关闭连接。
我们可以在main.py模块中的代码的多个位置调用log_operation()方法:
-
在
MainHandler类的post()方法中:if file_name and file_content: content_t = mimetypes.guess_type(file_name)[0] real_path = os.path.join('/', bucket_name, user.user_id(), file_name) with cloudstorage.open(real_path, 'w', content_type=content_t, options={'x-goog-acl': 'public-read'}) as f: f.write(file_content.read()) log_operation(user, OpTypes.FILE_ADDED, datetime.datetime.now()) self._create_note(user, file_name, real_path) log_operation(user, OpTypes.NOTE_CREATED, datetime.datetime.now()) -
在
ShrinkHandler类的get()方法中:taskqueue.add(url='/shrink', params={'user_email': user.email()}) log_operation(user, OpTypes.SHRINK_PERFORMED, datetime.datetime.now()) self.response.write('Task added to the queue.') -
在
CreateNoteHandler类的receive()方法中:attachments = getattr(mail_message, 'attachments', None) self._create_note(user, title, content, attachments) log_operation(user, OpTypes.NOTE_CREATED, datetime.datetime.now())
注意,通过将日期和时间传递给log_operation()方法,我们可以记录用户执行操作的实际时间,而不是函数代码执行的时间;如果我们需要准时但函数被添加到任务队列并在稍后执行,这可能很有用。
从现在起,当有人使用我们的 Notes 应用程序时,我们将收集有关该用户的用法信息。我们可以从 Notes 应用程序本身或另一个有权访问同一 Cloud SQL 实例的应用程序中访问这些信息;否则,我们可以使用一个在我们在工作站或另一个远程服务器上运行的纯 Python 应用程序来访问和处理所需的数据。例如,我们在 App Engine 项目root之外创建一个名为analyze.py的应用程序(这样我们就可以在部署时避免上传文件)。代码如下:
# -*- coding: utf-8 -*-
import sys
import MySQLdb
CLOUD_SQL_IP = '<your_instance_ip>'
CLOUD_SQL_USER = 'notes'
CLOUD_SQL_PASS = 'notes_password'
def main():
db = MySQLdb.connect(host=CLOUD_SQL_IP, db='notes',
user=CLOUD_SQL_USER,
passwd=CLOUD_SQL_PASS)
cursor = db.cursor()
cursor.execute('SELECT COUNT(DISTINCT user_id) FROM ops '
'WHERE date > (DATE_SUB(CURDATE(), '
'INTERVAL 1 MONTH));')
users = cursor.fetchone()[0]
sys.stdout.write("Active users: {}\n".format(users))
cursor.execute('SELECT COUNT(*) FROM ops WHERE date > '
'(DATE_SUB(CURDATE(), INTERVAL 1 HOUR))')
ops = cursor.fetchone()[0]
sys.stdout.write("Ops in the last hour: {}\n".format(ops))
cursor.execute('SELECT COUNT(*) FROM ops WHERE '
'operation = "SHRINKED"')
ops = cursor.fetchone()[0]
sys.stdout.write("Total shrinking ops: {}\n".format(ops))
return 0
if __name__ == '__main__':
sys.exit(main())
我们可以使用以下命令行在任何时候从命令行运行脚本:
python analyze.py
回到代码;在main()方法中,我们首先通过实例的公网 IP 使用 TCP/IP 套接字连接到数据库。然后,我们获取一个游标对象并执行第一个查询,该查询统计我们认为活跃的用户数量,即在过去一个月内至少执行过一种操作的用户。由于这是一个计数查询,我们期望只有一个结果行。在这种情况下,我们可以调用游标对象的fetchone()方法;该方法返回一个包含一个值的元组,我们通过索引获取该值并将其存储在users变量中,我们在标准输出上打印这个变量。使用相同的策略,我们从标准输出检索并打印过去一小时全局执行的操作数量以及总压缩操作数量。
这只是一个简单的例子,但它展示了如何容易地从 Cloud SQL 实例中提取数据来为我们的 Web 应用程序获取使用度量,使用运行在 App Engine 外部的任何 Python 程序。
使用本地 MySQL 安装进行开发
有几个原因说明为什么我们不想在本地开发服务器上运行应用程序时与 Cloud SQL 实例一起工作。我们可能会注意到严重的减速,因为每次我们连接到 Cloud SQL 实例时,我们都会与一个可能非常遥远的远程主机进行套接字连接。此外,无论我们选择哪个 Cloud SQL 层,我们都会为使用该服务付费,而我们可能不想在本地开发服务器上进行实验时使用它。
幸运的是,当我们的代码与之交互时,Cloud SQL 实例最终不过是一个 MySQL 数据库。因此,我们可以安装一个本地的 MySQL 服务器实例并与之工作。
我们安装并启动本地服务器,并执行我们在 Cloud SQL 实例上所做的相同操作:
-
使用 MySQL 客户端进行连接。
-
创建
notes数据库。 -
创建
notes用户并授予他们在notes数据库上的权限。 -
重新加载数据库权限。
-
创建
ops表。
到目前为止,我们只需更改我们的app.yaml文件中的CLOUD_SQL_IP环境变量,使其指向localhost变量:
env_variables:
CLOUD_SQL_IP: 'localhost'
CLOUD_SQL_USER: 'notes'
CLOUD_SQL_PASS: 'notes_password'
我们现在可以开始使用本地实例,避免网络延迟和成本。
摘要
在本章中,我们将使用由 Google Cloud Platform 提供的可扩展数据库服务 Cloud SQL。Cloud SQL 不仅仅是一个 MySQL 实例;它是一个灵活且可扩展的关系型数据库服务器,我们可以用它来存储和检索来自我们的 App Engine 应用程序以及外部服务和应用程序的数据。
即使当我们在高流量 Web 应用程序中处理大量数据时,Cloud Datastore 是首选解决方案,但在本章中,你了解到拥有一个关系型数据库来存储一些数据是多么方便,而无需触及 Datastore 对写操作施加的限制。能够从 App Engine 外部访问这些数据是一个很大的优势,我们已经看到了一个简单而有效的用例,而这使用 Datastore 是无法实现的。
在下一章中,我们将向我们的笔记应用程序添加新功能;我们将使用 Channel API 使应用程序实时,将数据从服务器推送到连接的客户端。
第六章。使用频道实现实时应用程序
网络应用程序使用请求/响应消息交换模式与服务器通信。通信流程始终从客户端(通常是网页浏览器)开始,发起请求,服务器提供响应并在之后立即关闭连接。这意味着如果我们需要从服务器获取信息,而这些信息一旦可用,我们的客户端就必须主动且反复地使用轮询策略请求它们,这虽然是一个简单但通常效果不佳的解决方案。实际上,如果轮询间隔短,我们需要执行大量的请求,这会消耗时间和带宽,并使服务器过载;另一方面,如果轮询间隔长,我们不能再将信息的传递视为实时了。
客户端和服务器之间的实时交互是许多网络应用程序(如协作编辑器、在线多人游戏或即时通讯软件)的要求。一般来说,每当客户端需要获取非系统化或不可预测的信息时,类似于与人类用户交互时的情况,我们最好采用实时方式。
如果我们的应用程序运行在 App Engine 上,我们可以使用Channel API 在访问应用程序的浏览器和 Google 服务器之间创建一个看似持久的连接;这个连接可以在任何时间用来向连接的客户端发送几乎实时的消息,而无需关心底层的通信机制。
在本章中,我们将涵盖以下主题:
-
Channel API 背后的技术
-
如何实现实时应用程序的服务器部分
-
如何使用 JavaScript 实现实时应用程序的客户端部分
-
如何处理客户端的断开连接
理解 Channel API 的工作原理
Channel API 基本上由以下元素组成:
-
频道:这是服务器和 JavaScript 客户端之间的一条单向通信路径。每个客户端恰好有一个频道,服务器使用它来派发消息。
-
客户端 ID:这是一个字符串,用于在服务器上标识单个 JavaScript 客户端。我们可以指定任何字符串作为客户端 ID,例如,当前用户的标识符。
-
JavaScript 客户端:客户端负责连接到特定的频道,监听频道本身的更新,并通过 HTTP 请求向服务器发送消息。
-
服务器:服务器负责为每个连接的 JavaScript 客户端创建频道,提供访问令牌以验证连接,通过 HTTP 请求接收来自客户端的消息,并通过频道发送更新。
使用 Channel API 的第一步是将 JavaScript 客户端交付给我们的用户,并将代码集成到应用程序提供的网页中。在浏览器接收并执行客户端代码后,以下情况会发生:
-
JavaScript 客户端通过 HTTP 请求向服务器请求一个 token 来打开一个提供其自己的 Client ID 的通道。
-
服务器创建一个通道并为其分配一个 token;token 被发送回客户端。
-
JavaScript 客户端使用 token 连接到通道。
一旦客户端连接到通道,服务器就可以通过通道推送消息,JavaScript 客户端可以实时处理这些消息,如下面的截图所示:

在设计一个利用 Channel API 的应用程序时,我们必须记住两个重要的限制:
-
同时只能有一个客户端使用给定的 Client ID 连接到一个通道;我们不能在多个客户端之间共享同一个通道。
-
JavaScript 客户端每个页面只能连接到一个通道;如果我们想从服务器发送和接收多种类型的数据(例如,关于页面不同部分的数据),我们需要将它们多路复用,以便所有信息都能通过同一个通道流动。
使我们的应用程序实时化
为了展示如何使用 Channel API,我们将在我们的笔记应用程序中添加一个小的功能。如果我们在一个浏览器中打开主页面,除非我们刷新页面,否则我们无法意识到已经创建了一个新的笔记。由于笔记可以通过入站电子邮件服务创建,所以立即在我们的浏览器中看到变化会很好。
我们将使用 Channel API 实现这个功能:当我们访问主页面时,我们的应用程序将打开一个通道,该通道将等待创建新的笔记。为了本书的范围,以及避免编写过多的 JavaScript 代码,我们不会修改页面的文档对象模型(DOM);我们只会显示一个对话框,建议在添加新笔记后立即刷新页面以查看新内容。
实现服务器
我们将首先添加在服务器端处理通道所需的 Python 代码。我们预计 JavaScript 客户端将发出 HTTP GET请求来请求一个通道,因此我们添加了一个请求处理器,该处理器将创建一个通道并返回一个 JSON 格式的 token 以访问它。我们首先在main.py模块的顶部导入所需的模块:
from google.appengine.api import channel
from utils import get_notification_client_id
import json
然后,我们添加请求处理器的代码:
class GetTokenHandler(webapp2.RequestHandler):
def get(self):
user = users.get_current_user()
if user is None:
self.abort(401)
client_id = get_notification_client_id(user)
token = channel.create_channel(client_id, 60)
self.response.headers['Content-Type'] = 'application/json'
self.response.write(json.dumps({'token': token}))
我们首先检查用户是否已登录,如果没有,则返回一个HTTP 401:未经授权错误页面。然后,我们使用get_notification_client_id()方法为当前的 JavaScript 客户端创建一个 Client ID,该方法生成一个字符串,该字符串由我们传递给它的user实例的标识符和任意前缀组成:
def get_notification_client_id(user):
return 'notify-' + user.user_id()
我们可以将前面的代码添加到utils.py模块中以便于使用。
回到GetTokenHandler代码;在为客户获取到 Client ID 之后,我们可以通过调用create_channel()方法并传递标识符作为第一个参数来创建通道。我们传递给函数的第二个参数是通道的超时时间,以分钟为单位;当通道过期时,会向 JavaScript 客户端抛出错误,并关闭通道。如果我们没有指定该参数,默认值为 2 小时,之后客户端可以请求新的通道。然后我们设置响应的Content-Type头为application/json参数,并在响应体中写入令牌。
最后,我们在main.py模块中将GetTokenHandler处理器映射到/notify_token URL。
app = webapp2.WSGIApplication([
(r'/', MainHandler),
(r'/media/(?P<file_name>[\w.]{0,256})', MediaHandler),
(r'/shrink', ShrinkHandler),
(r'/shrink_all', ShrinkCronJob),
(r'/toggle/(?P<note_key>[\w\-]+)/(?P<item_index>\d+)', ToggleHandler),
(r'/_ah/mail/create@book-123456\.appspotmail\.com', CreateNoteHandler),
(r'/notify_token', GetTokenHandler),
], debug=True)
我们可以通过访问运行中的本地开发服务器上的http://localhost:8080/notify_token URL 来检查端点是否正常工作。在浏览器窗口中,我们应该看到如下内容:

我们在服务器端需要做的最后一部分工作实际上是使用我们创建的通道向我们的用户发送消息。特别是,我们希望在创建新的笔记时立即通过入站电子邮件服务通知用户。因此,我们将在CreateNoteHandler处理器中添加一些代码,修改receive()方法的代码如下:
def receive(self, mail_message):
email_pattern = re.compile(r'([\w\-\.]+@(\w[\w\-]+\.)+[\w\-]+)')
match = email_pattern.findall(mail_message.sender)
email_addr = match[0][0] if match else ''
try:
user = users.User(email_addr)
user = self._reload_user(user)
except users.UserNotFoundError:
return self.error(403)
title = mail_message.subject
content = ''
for content_t, body in mail_message.bodies('text/plain'):
content += body.decode()
attachments = getattr(mail_message, 'attachments', None)
self._create_note(user, title, content, attachments)
channel.send_message(get_notification_client_id(user),
json.dumps("A new note was created! "
"Refresh the page to see it."))
实际创建笔记后,我们使用通道模块中的send_message()方法向特定客户端发送消息。为了获取接收者的 Client ID,我们使用与创建通道时相同的get_notification_client_id()方法。传递给send_message()方法的第二个参数是代表我们想要发送给客户端的消息的字符串;在这种情况下,一旦消息送达,我们将在浏览器对话框上显示一些简单的文本。在实际场景中,我们会使用比纯字符串更复杂的消息,添加更多数据以让 JavaScript 客户端识别消息的类型和目的地;如果我们需要为不同的消费者传输不同的信息,这非常有用。
现在我们已经完成了服务器端的所有必要工作,因此我们可以转向客户端,并编写我们需要与 Channel API 交互的 JavaScript 代码。
客户端的 JavaScript 代码
App Engine 提供了一个小的 JavaScript 库,它简化了管理通道的套接字连接所需的某些操作,因此在我们继续之前,我们需要在 HTML 页面中包含此代码。JavaScript 代码必须包含在<body></body>标签内,我们将将其放在关闭标签之前,以避免其执行减慢页面渲染过程。
在我们的main.html模板文件中,我们添加以下内容:
<!-- Javascript here -->
<script type="text/javascript" src="img/jsapi"></script>
</body>
</html>
JavaScript 代码将由 App Engine 在本地开发环境和生产环境中,在/_ah/channel/jsapi URL 上提供。
为 JavaScript 客户端提供逻辑所需的代码将被添加到一个名为client.js的文件中,我们将将其存储在相对于应用程序根文件夹的static/js/路径中。这样,在部署过程中,该文件将与其他静态资源一起上传到 App Engine 服务器。
注意
我们将在一种称为立即执行函数表达式(IIFE)的闭包中编写我们的 JavaScript 代码,这实际上是一个在window参数的上下文中自我调用的匿名函数,如下所示:
(function(window){
"use strict";
var a = 'foo';
function private(){
// do something
}
})(this);
这是一个在尝试保留全局命名空间时非常有用的常见 JavaScript 表达式;实际上,在函数体内部声明的任何变量都将属于闭包,但仍然会在整个运行时中存在。
一旦我们创建了client.js文件,我们需要将其包含在我们的笔记应用程序提供的 HTML 页面中。在main.html文件中,我们添加以下内容:
<!-- Javascript here -->
<script type="text/javascript" src="img/jsapi"></script>
<script type="text/javascript" src="img/client.js"></script>
</body>
</html>
<script>标签的顺序很重要,因为 JavaScript 客户端必须在执行我们的代码之前可用。
多亏了 JavaScript 客户端库提供的功能,我们不需要编写很多代码。首先,我们需要从我们的后端获取频道令牌,因此我们在client.js文件中添加以下内容:
(function (window) {
"use strict";
// get channel token from the backend and connect
var init = function() {
var tokenReq = new XMLHttpRequest();
tokenReq.onload = function () {
var token = JSON.parse(this.responseText).token;
console.log(token);
};
tokenReq.open("get", "/notify_token", true);
tokenReq.send();
};
init();
}(this));
在这里,我们声明了一个名为init的函数,该函数将执行一个XMLHttpRequest(XHR)请求到我们的后端以获取令牌,然后将在 JavaScript 控制台上打印其值。
注意
在 JavaScript 控制台上的日志信息是非标准的,并且不会对每个用户都有效;这很大程度上取决于所使用的浏览器。例如,为了在 Google Chrome 上启用 JavaScript 控制台,我们需要执行以下步骤:
-
前往视图菜单。
-
选择开发者。
-
点击JavaScript 控制台。
函数体中的第一条指令创建了一个XMLHttpRequest对象,我们将使用它来对我们的后端执行 HTTP GET请求。在发送请求之前,我们将onload回调设置为匿名函数,该函数将在从服务器正确检索响应且没有错误时执行。回调函数将响应体中的文本解析为json对象,并在 JavaScript 控制台上立即记录。在定义回调后,我们初始化请求,该请求在XMLHttpRequest对象上调用open()方法,并指定我们想要使用的 HTTP 方法、我们想要到达的 URL 以及一个表示我们是否想要异步执行请求的布尔标志。稍后,我们实际上执行了调用send()方法的请求。然后我们调用init()函数本身,以确保它在第一次访问页面和脚本加载时执行。
为了检查一切是否正常工作,我们可以在浏览器中启用 JavaScript 控制台后,启动本地开发服务器并将浏览器指向主页面。如果请求成功完成,我们应该在控制台中看到包含令牌的日志消息,如下面的截图所示:

我们现在可以使用从后端检索到的令牌打开一个通道。在client.js文件中,我们修改代码如下:
(function (window) {
"use strict";
// create a channel and connect the socket
var setupChannel = function(token) {
var channel = new goog.appengine.Channel(token);
var socket = channel.open();
socket.onopen = function() {
console.log('Channel opened!');
};
socket.onclose = function() {
console.log('goodbye');
};
};
// get channel token from the backend and connect
var init = function() {
var tokenReq = new XMLHttpRequest();
tokenReq.onload = function () {
var token = JSON.parse(this.responseText).token;
setupChannel(token);
};
tokenReq.open("get", "/notify_token", true);
tokenReq.send();
};
init();
}(this));
我们首先添加一个名为setupChannel()的函数,它只接受一个有效的令牌作为其唯一参数。使用 App Engine 的 JavaScript 客户端代码,我们创建一个goog.appengine.Channel对象,并将令牌传递给构造函数。然后我们调用open方法,该方法返回一个用于该通道的goog.appengine.Socket对象。该 socket 对象跟踪连接状态,并公开几个回调函数,我们可以通过这些回调函数对通道活动执行操作。目前,我们只为onopen和onclosesocket 事件提供回调,在 JavaScript 控制台中记录一条消息。请注意,我们已经修改了init()函数,使其现在调用setupChannel()函数而不是简单地将在 JavaScript 控制台中记录一条消息。
为了测试回调是否正常工作,我们可以在后端创建通道时为通道设置一个非常短的超时,这样我们就可以在合理的时间内看到通道过期时会发生什么。在main.py模块中,我们以这种方式更改对create_channel()函数的调用,在GetTokenHandler类的get()方法中:
token = channel.create_channel(client_id, 1)
现在,如果我们用 JavaScript 控制台打开浏览器中的笔记应用主页面,1 分钟后我们应该看到以下截图类似的内容:

如我们所见,通道已打开,1 分钟后过期,导致 JavaScript 客户端出现错误,并最终调用我们为 socket 对象的onclose事件设置的回调。
为了处理即将过期的通道,我们可以在 socket 对象的onerror事件中添加一个回调。在我们的client.js文件中,我们添加以下内容:
socket.onopen = function() {
console.log('Channel opened!');
};
socket.onerror = function(err) {
// reconnect on timeout
if (err.code == 401) {
init();
}
};
socket.onclose = function() {
console.log('goodbye');
};
当通道管理中出现错误时,我们添加的回调会被执行。该回调接收一个参数为对象的参数,其中包含错误消息和错误代码。如果我们收到一个HTTP 401 错误页面,我们假设通道已过期,并调用init函数来创建和设置一个新的通道。这次,如果我们点击主页面并等待 1 分钟,我们可以看到如下截图所示的内容:

如我们所见,通道过期后,会立即创建一个新的通道;根据我们如何使用通道,这可能会对我们的用户完全透明。
现在,我们可以继续添加处理服务器通过通道推送的消息的代码。我们必须为 goog.appengine.Socket 类的 onmessage 事件提供一个回调。当套接字收到消息时,回调被调用,并传递一个参数:消息对象。该对象的 data 字段包含传递给服务器上 send_message() 方法的字符串。然后我们在 client.js 文件中添加以下代码:
socket.onopen = function() {
console.log('Channel opened!');
};
socket.onmessage = function (msg) {
window.alert(msg.data);
};
socket.onerror = function(err) {
// reconnect on timeout
if (err.code == 401) {
init();
}
};
一旦收到消息,我们就使用 window 对象的 alert() 方法在浏览器上打开一个对话框。对话框显示消息对象的 data 字段中包含的字符串,指出已创建新笔记,我们应该刷新页面以查看更新后的列表。
要查看代码的实际效果,我们可以将浏览器指向笔记应用的主页;然后,使用本地开发控制台,我们可以模拟入站电子邮件,就像我们在第三章中做的那样,存储和处理用户数据。
一旦收到电子邮件并创建新笔记,我们应该在我们的浏览器中看到如下内容:

我们假设通过通道到达的消息仅涉及新笔记的创建,但我们可以从服务器发送更多结构化数据;回调函数可以实施更复杂的逻辑来区分消息内容,并根据此执行不同的操作。
跟踪连接和断开连接
App Engine 应用负责创建通道和传输令牌,但它不知道 JavaScript 客户端是否已连接。例如,我们的笔记应用通过入站电子邮件服务在新笔记创建时发送消息,但在另一端,JavaScript 客户端可能收到也可能收不到。在某些情况下,这并不是一个问题,但有几个用例中,App Engine 应用需要知道何时客户端连接或断开连接。
要启用通道通知,我们首先需要启用入站通道存在服务。为此,我们必须更改我们的 app.yaml 配置文件,添加以下代码:
inbound_services:
- mail
- channel_presence
现在,presence 服务已启用,我们的笔记应用将接收以下 URL 的 HTTP POST 请求:
-
_/_ah/channel/connected/URL:当 JavaScript 客户端连接到通道并可以接收消息时 -
_/_ah/channel/disconnected/URL:当客户端从通道断开连接时
要查看服务的工作方式,我们可以在 main.py 模块中添加两个处理器:
class ClientConnectedHandler(webapp2.RequestHandler):
def post(self):
logging.info('{} has connected'.format(self.request.get('from')))
class ClientDisconnectedHandler(webapp2.RequestHandler):
def post(self):
logging.info('{} has disconnected'.format(self.request.get('from')))
每个处理器都会接收到 POST 请求体中的 from 字段。该字段包含连接或断开连接的客户端的 Client ID。我们可以查看应用程序日志以了解何时发生通知。
摘要
在本章中,我们学习了使用标准请求/响应交换模式从服务器获取数据的应用程序与实时应用程序之间的区别,在实时应用程序中,客户端持续连接到服务器,并在数据可用时立即接收数据。使用 Channel API,我们看到了当它在 App Engine 上运行时,实现实时网络应用是多么容易。
通过为我们的笔记应用添加新功能,我们现在应该对 Channel API 提供的功能以及我们可以如何充分利用其组件有所了解。
我们首先实现了服务器部分,管理通道创建和消息发送。然后,我们转向客户端,我们设法通过仅编写几行 JavaScript 代码就实现了与通道交互所需的逻辑。
现在笔记应用几乎完成了,我们对谷歌云平台已经足够熟悉,以至于我们可以拆分它并重新开始,使用另一个 Python 网络框架而不是 webapp2。在下一章中,我们将使用 Django 重新实现笔记应用。
第七章. 使用 Django 构建应用程序
Django是一个用 Python 编写的开源 Web 应用程序框架,最初由 Adrian Holovaty 和 Simon Willison 于 2003 年编写,旨在快速解决为在线报纸提供基于 Web、数据库驱动的应用程序内容的需求。Django 于 2005 年作为开源项目向公众发布,并迅速获得了强大的支持。凭借来自世界各地成千上万的用户和贡献者,Django 现在是 Python 社区中最受欢迎的 Web 框架之一,由一个独立的非营利性基金会支持,该基金会推广项目并保护其知识产权。
对 Django 成功贡献最大的组件之一是其对象关系映射(ORM),这是一个数据访问层,它将底层的数据库与用 Python 编写的某些面向对象代码映射起来。起初,被认为是框架的强点的实际上在 App Engine 环境中变成了弱点。事实上,Django 仅支持关系数据库,因此排除了 Datastore 选项。
然而,在 Google Cloud SQL 服务发布之后,事情发生了深刻的变化,现在我们可以在 Google Cloud Platform 上使用 Django 及其 ORM 与关系数据库。在本章中,我们将重新实现原始 Notes 应用程序的几个功能,从头开始使用 Django 而不是 webapp2 框架,展示 App Engine 平台如何成为部署和运行 Django 应用程序的可行解决方案。
在本章中,我们将涵盖以下主题:
-
配置开发环境
-
通过使用内置的认证系统使用 ORM 与 Cloud SQL
-
在 Google Cloud Storage 上上传文件
设置本地环境
在撰写本书时,App Engine 为 Python 2.7 运行环境提供了一个第三方库,提供了 Django 版本 1.4 和 1.5。尽管它相当古老(Django 1.4 于 2012 年 3 月发布,1.5 于 2013 年 2 月发布),但 1.4 版本目前是长期支持发行框架,保证直到 2015 年 3 月提供安全补丁和数据丢失修复,而 1.5 版本(因此在 App Engine 上标记为实验性)与 1.4 版本相比,包含了许多新功能和改进。因此,我们可以安全地使用 App Engine 提供的 Django 包之一来构建我们的应用程序,而不用担心产生遗留代码的风险。
然而,如果我们能够放弃 Google 为 Django 1.4 和 1.5 提供的官方支持,我们可以使用目前可用的最新 Django 版本 1.7,唯一的区别是我们将不得不自己处理包的部署,因为我们不会在生产服务器上找到它。
由于使用 Django 1.4 和 1.5 编写的应用程序的部署在官方文档中得到了很好的覆盖,并且我们正在构建一个原型,其唯一目的是学习如何充分利用 Google App Engine,因此我们将使用 Django 1.7 开发我们的 Django Notes 应用程序;让我们看看如何操作。
配置虚拟环境
当我们需要使用与操作系统提供的包管理器中提供的版本不同的特定 Python 包版本时,最好使用像 virtualenv 这样的工具在单独的环境中隔离此类软件的安装,以避免冲突。
假设我们使用的是 Python 2.7,我们可以使用 pip 软件包管理器来安装 virtualenv:
pip install virtualenv
现在,我们可以像在 第一章,入门 中所做的那样,简单地创建应用程序根文件夹来启动一个新的 App Engine 应用程序:
mkdir django_notes && cd django_notes
现在,我们可以在应用程序文件夹内设置一个虚拟环境:
virtualenv .
每次我们想在虚拟环境中工作之前,都需要激活它,这样我们就可以透明地使用 Python 和 pip 可执行文件来运行代码和安装包。对于 Linux 和 Mac OS X,我们可以这样激活虚拟环境:
source ./bin/activate
对于 Windows,我们可以在 Scripts 文件夹中简单地调用激活脚本:
Scripts\activate
要取消虚拟环境并停止引用隔离的 Python 安装,我们可以为每个支持的操作系统发出以下命令:
deactivate
我们现在需要让本地的 App Engine Python 运行时对虚拟环境可用。如果我们遵循了 第一章,入门 中的说明,那么现在我们应该已经将 App Engine 安装在文件系统上的某个路径上,具体取决于我们运行的操作系统。注意这个路径;例如,在 Mac OS X 上,App Engine SDK 被符号链接到 /usr/local/google_appengine URL。然后我们创建一个名为 gae.pth 的文件,并将其放入虚拟环境的 site-package 目录中,路径为 $VIRTUAL_ENV/lib/python2.7/site-packages/。
$VIRTUAL_ENV 变量是一个环境变量,在虚拟环境激活期间可用,它指向我们本地文件系统上的虚拟环境安装。.pth 文件必须包含以下行:
/path/to/appengine/sdk # /usr/local/google_appengine on Mac OS X
import dev_appserver; dev_appserver.fix_sys_path()
为了检查一切是否正常工作,我们可以激活环境并尝试导入 App Engine 包。例如,在 Linux 和 Mac OS X 上,我们可以这样做:
source bin/activate
python -c"import google"
安装依赖项
现在我们已经为我们的应用程序设置了一个虚拟环境,我们可以开始安装运行 Django Notes 应用程序所需的依赖项。当然,我们需要安装的第一个包是 Django:
pip install django -t <app_root>
正如我们在第三章“存储和处理用户数据”中所学,我们需要使用-t选项安装包,以便在部署过程中将其上传到生产服务器。
由于 Django 也由 App Engine Python SDK 提供,我们需要确保当我们导入import django包时,Python 实际上是指向应用程序根文件夹中的 1.7 包。有许多方法可以实现这一点,但我们将以下内容添加到gae.pth文件中:
/path/to/appengine/sdk # /usr/local/google_appengine on Mac OS X
import dev_appserver; dev_appserver.fix_sys_path()
import sys; sys.path.insert(1, '/path/to/application/root')
由于fix_sys_path()函数将所有 App Engine 包和模块添加到 Python 路径的前面,我们需要在添加其他任何内容之前插入 Django 1.7 所在的位置。这就是为什么我们在这里使用sys.path.insert()函数。为了确保我们使用的是正确的 Django 版本,一旦虚拟环境工具激活,我们可以在命令行中写下以下内容:
python -c"import django; print django.get_version()"
输出应该是类似1.7.1的。
只要我们需要,我们就会继续添加包,但我们必须记住每次想要在本地运行项目或部署应用程序,以及每次安装新包时,都要激活虚拟环境,最重要的是每次安装新包时。
使用 Django 1.7 重写我们的应用程序
我们已经创建了应用程序根文件夹,也就是我们安装虚拟环境的同一个文件夹。Django 提供了一个名为project的脚本,用于构建标准应用程序布局,同时也为配置文件提供了一些默认内容。要在应用程序根目录内启动一个新的项目,我们在命令行中输入以下内容:
django/bin/django-admin.py startproject notes
现在,我们应该在我们的应用程序根目录中有一个名为notes的文件夹,其中包含一个名为wsgi.py的 Python 模块,我们需要注意它,因为我们将在app.yaml文件中使用它。
如我们所知,要创建一个新的 App Engine 应用程序,我们需要提供一个app.yaml文件。我们可以从之前的章节中选择任何一个app.yaml文件作为基础,然后按照以下方式重写它:
application: the_registered_application_ID
version: 2
runtime: python27
api_version: 1
threadsafe: yes
handlers:
- url: /static
static_dir: static
- url: /.*
script: notes.wsgi.application
我们更改了版本号,这样我们就可以轻松管理在任何时候应该运行在生产服务器上的应用程序:是使用 webapp2 框架构建的旧版本,还是使用 Django 构建的新版本。我们只定义了一个处理器,它将匹配任何 URL 的请求,并使用wsgi.py模块中的应用程序实例来提供服务,该模块是由项目文件夹内的django_admin.py脚本生成的。
现在,我们可以运行开发服务器,并将浏览器指向http://localhost:8080 URL。如果 Django 运行正常,我们应该看到如下信息:

正如网页本身所述,我们已经使用 Django 网络框架在 App Engine 上创建了我们的第一个应用程序。现在我们可以继续前进,让我们的应用程序做些更有用的事情。
使用 Google Cloud SQL 作为数据库后端
我们已经提到,我们将使用 Google Cloud SQL 作为关系型数据库后端,这样我们就可以运行 Django 框架的每个组件,而无需求助于额外的包或衍生项目。
配置关系型数据库层以使 ORM 工作是我们开发 Django 应用程序时必须采取的第一步之一。实际上,几个关键组件,如用户认证机制,都依赖于一个正常工作的数据库。
Django ORM 默认完全支持 MySQL 数据库,因此我们使用 Google Cloud SQL 所需的额外软件仅仅是 MySQLdb Python 包,我们将使用 pip 软件包管理器来安装它,就像我们在第五章中做的那样,在 Cloud SQL 中存储数据。以下命令用于安装该包:
pip install MySQL-python
要在生产服务器上使用该包,我们必须将以下内容添加到我们的 app.yaml 文件中:
libraries:
- name: MySQLdb
version: "latest"
我们已经知道如何配置 Google Cloud SQL,因此我们假设此时我们已经有一个正在运行的实例。我们可以从本地开发环境和 App Engine 应用程序中访问它,并且我们已经为该项目创建了一个数据库。
如果我们打开 Django 项目文件夹内的 settings.py 模块,我们会看到它包含以下内容:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
Django 可以从单个应用程序同时使用和连接到多个关系型数据库,DATABASES 字典包含一个 Python 字典,其中包含每个数据库的配置。对于像我们的笔记这样的小型应用程序,我们可以只使用一个数据库——标记为 default 的数据库。当从我们的本地开发环境连接到 Cloud SQL 时以及当应用程序在 App Engine 生产服务器上运行时所需的参数略有不同,因此如果我们只想保留一个设置模块的版本,我们需要添加一些逻辑。
首先,我们需要在 <app_root>/notes/notes 路径下创建一个 utils.py 模块,其中包含来自第五章的 on_appengine() 函数,以确定我们的应用程序是否在 App Engine 上运行:
import os
def on_appengine():
return os.getenv('SERVER_SOFTWARE', '').startswith('Google App Engine')
然后,我们编辑 settings.py 模块,并使用以下代码更改 DATABASES 字典:
# Database
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases
from .utils import on_appengine
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'notes',
'USER': 'notes',
'PASSWORD': 'notes_password',
}
}
if on_appengine():
DATABASES['default']['HOST'] = '/cloudsql/my-project-id:myfirst'
else:
DATABASES['default']['HOST'] = '<instance_ip>'
当我们从本地开发环境和 App Engine 生产服务器连接时,我们使用相同的 Python 数据库驱动程序。数据库名称和用户凭据也是相同的,但我们需要根据应用程序运行的位置指定不同的 HOST 参数,因为在 App Engine 上,连接是通过 Unix 套接字执行的,而在本地连接中,我们使用 TCP 套接字。如果我们想使用本地的 MySQL 安装,我们可以相应地更改 NAME、USER、PASSWORD 和 HOST 参数。
在进行配置关系型数据库的最终步骤之前,我们需要介绍迁移的概念,这是 Django 1.7 的新特性。由于 ORM 将 Python 对象映射到数据库模式,因此它可能需要根据我们对 Python 代码所做的更改相应地更改模式。Django 将这些更改写入一个或多个位于项目源树中的migration文件夹中的迁移文件。我们将在本章后面看到如何处理迁移。目前,我们只需要调用一个名为migrate的命令来创建数据库模式的第一版。
注意
要调用 Django 命令,我们使用由django_admin.py脚本在首次创建项目时生成的manage.py脚本。在项目文件夹中,我们可以这样启动命令:
python manage.py <command>
要查看可用命令的列表,我们可以不带参数调用manage.py脚本:
python manage.py
要启动migrate命令,我们在命令行中输入以下内容:
python manage.py migrate
如果 Cloud SQL 实例配置良好,我们应该看到以下输出:
Operations to perform:
Apply all migrations: admin, contenttypes, auth, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
Applying sessions.0001_initial... OK
由于用户身份验证系统默认可用,我们可以使用以下命令向系统中添加一个superuser用户:
python manage.py createsuperuser
命令将提示用户名、电子邮件地址和密码。我们可以提供我们选择的凭据。
在 Django 中创建可重用应用程序
当我们提到由django_admin.py脚本生成的文件系统布局时,我们已经使用了project这个术语。它包含运行我们的名为 Notes 的 Web 应用程序所需的所有代码和资源。Django 项目的核心是其设置文件,它定义了全局环境和配置,我们已经在设置关系型数据库层时看到了如何使用它。
现在是时候介绍“应用程序”这个术语了。在 Django 术语中,一个application是一个 Python 包,它提供了一组定义良好的功能,并且可以在不同的 Django 项目中重用。我们不应该混淆 Django 中定义的“应用程序”术语和更通用的“Web 应用程序”术语。尽管 Notes 在广义上实际上是一个应用程序,但它被开发为一个 Django 项目,并包含一些称为 Django 应用程序的功能块。
Django 应用程序通常包含 ORM 模型类、视图函数和类、HTML 模板以及静态资源。可以通过pip包管理器安装application包,或者与project包一起提供。我们需要知道,Django 项目只有在settings.py模块中的INSTALLED_APPS设置值中列出时,才会使用应用程序。
我们将创建一个名为core的 Django 应用程序来实现 Notes 的核心功能。为了在我们的项目中创建一个空的应用程序,我们可以使用startapp命令并传递应用程序的名称:
python manage.py startapp core
我们可以看到命令如何在我们的项目文件夹中创建了一个名为core的 Python 包,正如我们请求的那样。该包包含了一组我们将要实现的标准模块,正如我们稍后将看到的。
如前所述,我们需要在我们的INSTALLED_APPS设置中列出我们新创建的应用程序,以便告诉 Django 必须使用它:
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'core',
)
Django 1.7 提供了一个由django.apps包提供的名为apps的注册表,它为每个已安装的应用程序存储一个AppConfig对象。我们可以使用AppConfig对象来检查应用程序的元数据或更改特定应用程序的配置。要查看apps注册表的实际操作,我们可以像这样访问 Django shell:
python manage.py shell
然后,我们可以测试以下 Python 代码:
>>> from django.apps import apps
>>> apps.get_app_config('core').verbose_name
'Core'
>>> apps.get_app_config('core').path
u'/opt/projects/django_notes/notes/core'
视图和模板
现在数据后端已经功能正常,我们可以开始实现第一个构建块——为我们的 Notes 网络应用程序提供显示主页的视图。在 Django 的世界里,视图不过是一个接收 HTTP 请求并返回 HTTP 响应的 Python 函数或类,它实现了构建最终发送给客户端的内容所需的任何逻辑。我们将把构建主页的视图实现代码添加到我们在core应用程序中创建的views.py模块中:
from django.shortcuts import render
from django import get_version
def home(request):
context = {'django_version': get_version()}
return render(request, 'core/main.html', context)
被称为home的视图参数在 webapp2 版本的 Notes 中与MainHandler类的get()方法执行非常相似的操作。我们创建一个context字典,该字典将在渲染过程中传递给模板。然后我们调用render()方法,该方法传递我们作为参数接收的相同的request对象——一个包含 HTML 模板路径的字符串。它将被用于页面和context字典。
在 webapp2 版本的 Notes 中,我们使用了 Jinja2 来渲染我们的页面,但 Django 已经在其框架中集成了自己的模板系统。我们在 HTML 文件中使用的语言与 Jinja2 非常相似,但仍然存在一些主要差异,因此我们必须重写我们的模板。我们在project文件夹相对路径的core/templates/core/main.html处创建了一个新的 HTML 文件,包含以下代码:
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title>Notes</title>
<link rel="stylesheet" type="text/css" href="/static/css/notes.css">
</head>
<body>
<div class="container">
<h1>Welcome to Notes!</h1>
<h5>Built with Django {{ django_version }}.</h5>
<ul class="menu">
<li>Hello, <b>{{ user }}</b></li>
</ul>
<form action="" method="post" enctype="multipart/form-data">
<legend>Add a new note</legend>
<div class="form-group">
<label>Title: <input type="text" id="title" name="title"/>
</label>
</div>
<div class="form-group">
<label for="content">Content:</label>
<textarea id="content" name="content"></textarea>
</div>
<div class="form-group">
<label for="checklist_items">Checklist items:</label>
<input type="text" id="checklist_items" name="checklist_items"
placeholder="comma,separated,values"/>
</div>
<div class="form-group">
<label for="uploaded_file">Attached file:</label>
<input type="file" id="uploaded_file" name="uploaded_file">
</div>
<div class="form-group">
<button type="submit">Save note</button>
</div>
</form>
</div>
</body>
</html>
注意在模板中我们是如何使用{{ django_version }}元素,它输出我们在context字典中放置的变量,以及{{ user }}元素,这是 Django 认证系统默认提供的。由于我们没有执行登录,当前用户被设置为称为匿名用户的特殊实体。
现在我们有一个提供 HTTP 响应和用于渲染 HTML 页面的模板的视图函数,我们需要将我们选择的 URL 映射到该视图,就像我们在 webapp2 中做的那样。Django 有一个名为urls.py的 URL 配置器模块(也称为URLconf模块),它包含纯 Python 代码,并定义了使用正则表达式描述的 URL 与视图函数或类之间的映射。django_admin.py脚本生成一个我们可以用作起点的urls.py模块,但映射首页视图的最终版本应该是以下内容:
from django.conf.urls import patterns, include, url
urlpatterns = patterns('',
url(r'^$', 'core.views.home', name='home'),
)
一个URLconf模块必须定义一个名为urlpatterns的变量,并包含一个django.conf.urls.url实例的列表,这些实例将被 Django 按顺序迭代,直到其中一个与请求的 URL 匹配。当发生正则表达式匹配时,Django 停止迭代,并可能执行以下两个操作:
-
导入并调用作为参数传递的
view。 -
处理一个
include语句,该语句从另一个模块加载一个urlpattern对象。
在我们的情况下,我们匹配域的根 URL,并导入我们在views.py模块中先前定义的home函数视图。
最后,我们将与 webapp2 版本 Notes 相同的 CSS 文件放在相对于 App Engine 应用程序根文件夹的static/css/notes.css路径下,我们应该得到以下截图所示的首页结果:

使用 Django 验证用户
为了验证我们的用户,我们不会使用 App Engine 用户服务,而是完全依赖 Django。Django 提供了一个内置的用户认证系统,它还提供了授权检查。我们可以验证用户是否是他们所声称的人,并确定他们被允许做什么。认证系统作为 Django 应用程序实现,我们必须确保在尝试使用它之前,它已列在INSTALLED_APPS设置中,如下所示:
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'core',
)
认证系统还负责将user变量添加到模板上下文中,这样我们就可以在我们的 HTML 模板中写入{{ user }}。
由于我们不会使用 App Engine 用户服务,我们必须自己实现登录和注销页面,而 Django 通过提供两个现成的视图来帮助我们,这些视图分别作为登录和注销页面。首先,我们需要在URLconf模块中将登录和注销 URL 映射到这些视图,因此我们在urls.py模块中添加以下内容:
from django.contrib.auth import views as auth_views
urlpatterns = patterns('',
url(r'^$', 'notes.views.home', name='home'),
url(r'^accounts/login/$', auth_views.login),
url(r'^accounts/logout/$', auth_views.logout),
)
即使登录用户的逻辑是免费的,我们也需要提供一个登录页面的 HTML 模板。我们在core应用的template文件夹内添加一个名为registration的文件夹,并在其中创建一个名为login.html的文件,包含以下代码:
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title>Notes</title>
<link rel="stylesheet" type="text/css" href="/static/css/notes.css">
</head>
<body>
<div class="container">
<form action="{% url 'django.contrib.auth.views.login' %}" method="post">
{% csrf_token %}
<legend>You must login to use Notes</legend>
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" name="username"/>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" name="password"/>
</div>
<div class="form-group">
<button type="submit">Login</button>
</div>
<input type="hidden" name="next" value="{{ next }}" />
</form>
</div>
</body>
</html>
我们使用与主页相同的样式表和页面结构来显示登录表单。为了填充表单的 action 属性,我们使用 url 模板标签,它检索具有给定名称的视图的 URL。在这种情况下,该属性将包含映射到 django.contrib.auth.views.login 视图的 URL。然后我们使用 {% csrf_token %} 模板标签在表单内创建一个 Django 需要的字段,以防止 跨站请求伪造 (CSRF) 攻击。我们还添加了一个包含我们想要在成功登录后重定向用户的 URL 的隐藏字段。此 URL 由 Django 处理,认证系统负责在模板上下文中填充 next 值。
当我们的用户尝试访问受登录保护的 URL 时,他们将被自动重定向到登录页面。为了看到认证系统的工作情况,我们告诉 Django 通过在 core 应用程序内的 views.py 模块中添加以下代码来保护 home 视图:
from django.contrib.auth.decorators import login_required
@login_required()
def home(request):
context = {'django_version': get_version()}
return render(request, 'core/main.html', context)
现在我们已经将 login_required() 函数装饰器添加到了 home 视图中,只有登录用户才能看到页面内容,其他人将被重定向到登录页面。如果我们尝试访问 http://localhost:8080 URL,我们应该看到以下内容:

我们可以登录使用 superuser 用户,使用我们在本章前面 createsuperuser 命令执行期间提供的相同凭据。
最后,我们必须提供一个指向注销视图的链接,用户可以通过它来终止他们的认证会话。在 main.html 模板文件中,我们添加以下代码:
<ul class="menu">
<li>Hello, <b>{{ user }}</b></li>
<li>
<a href="{% url 'django.contrib.auth.views.logout' %}">Logout</a>
</li>
</ul>
我们只需检索映射到注销视图的 URL,Django 就会完成剩下的工作,执行所有必要的操作以使用户注销。
使用 ORM 和迁移系统
我们已经熟悉 webapp2 提供的模型类,因为在 第二章 更复杂的应用 中,我们使用它们将 Python 对象映射到数据存储实体。Django 使用几乎相同的方法;我们定义从 django.db.models.Model 包派生的 Python 类,ORM 组件负责将这些类的实例映射到底层关系数据库中的行和表中。为了看到 ORM 的工作情况,我们在 core 应用程序内的 models.py 模块中添加以下模型:
from django.db import models
from django.contrib.auth.models import User
class Note(models.Model):
title = models.CharField(max_length=10)
content = models.TextField(blank=False)
date_created = models.DateTimeField(auto_now_add=True)
owner = models.ForeignKey(User)
class CheckListItem(models.Model):
title = models.CharField(max_length=100)
checked = models.BooleanField(default=False)
note = models.ForeignKey('Note', related_name='checklist_items')
我们定义了一个 Note 模型类,它包含一个 title 属性,包含少量文本(最多十个字符),一个 content 属性,包含任意长度的文本,一个 date_created 属性,包含日期和时间,以及一个 owner 属性,它是一个外键,引用 Django 认证系统中的 User 模型的一个实例。由于我们向 TextField 模型字段构造函数传递了 blank=False 参数,因此 content 属性是必需的。
我们随后定义了一个CheckListItem模型类,它包含一个title属性,该属性包含少量文本(最多一百个字符),一个checked属性,该属性包含一个默认为False参数的布尔值,如果不指定,以及一个note属性,它是一个外键,指向Note模型。我们传递给ForeignKey模型字段构造函数的related_name='checklist_items'参数意味着我们将能够通过在实例本身上访问一个名为checklist_items的属性来访问与Note实例相关联的清单项集合。
为了将我们的模型转换为映射到关系数据库所需的 SQL 代码,我们需要执行迁移,更确切地说,是初始迁移,因为这是我们第一次为核心应用程序执行此操作:
python manage.py makemigrations core
迁移的输出应该是以下内容:
Migrations for 'core':
0001_initial.py:
- Create model CheckListItem
- Create model Note
- Add field note to checklistitem
makemigrations命令在core应用程序路径内创建一个名为migration的文件夹,并包含一个名为0001_initial.py的迁移文件。迁移文件包含 ORM 需要执行的操作列表,以便将当前的 Python 代码映射到数据库模式,相应地创建或更改表。
备注
迁移文件是代码库的一部分,应该像任何其他 Python 模块一样置于版本控制之下。
要将更改应用到数据库,我们需要使用以下命令执行迁移:
python manage.py migrate core
对数据库应用更改的输出应该是以下内容:
Operations to perform:
Apply all migrations: core
Running migrations:
Applying core.0001_initial... OK
在这一点上,我们已经为我们的核心应用程序创建了数据库模式。如果我们想确认迁移系统产生的确切 SQL 代码,我们可以发出以下命令:
python manage.py sqlmigrate core 0001_initial
此命令将在命令行上打印出为我们初始迁移产生的 SQL 代码。
在这一点上,我们可能会注意到我们的Note模型有一个title字段,它实际上太小,无法包含描述性标题,因此我们在models.py中更改它:
class Note(models.Model):
title = models.CharField(max_length=100)
content = models.TextField(blank=False)
date_created = models.DateTimeField(auto_now_add=True)
当然,这个更改将改变数据库模式,因此我们需要执行迁移:
python manage.py makemigrations core
这次,输出将是以下内容:
Migrations for 'core':
0002_auto_20141101_1128.py:
- Alter field title on note
在迁移文件夹中添加了一个名为0002_auto_20141101_1128.py的文件,其中包含更改数据库模式以反映我们新的 Python 代码所需的 SQL 指令。我们需要采取的最后一步是应用迁移:
python manage.py migrate core
使用表单 API 处理表单
现在我们已经准备好存储我们的数据,我们可以实现处理主页上显示的表单以创建新笔记所需的代码,Django 的表单 API 将简化并自动化这一工作的很大一部分。特别是,我们将让 Django 负责以下工作:
-
自动从我们的
Note模型类的内容创建 HTML 表单 -
处理和验证提交的数据
-
提供 CSRF 安全检查
首先,我们实现一个从django.forms.ModelForm类派生的类,这将使我们能够定义和处理我们的表单。在core应用程序内部,我们创建了一个名为forms.py的新 Python 模块,包含以下行:
from django import forms
from .models import Note
class NoteForm(forms.ModelForm):
class Meta:
model = Note
exclude = ['id', 'date_created', 'owner']
我们定义了一个 Python 类,实现了一个所谓的 model 表单,这是一个 Django form 类,它定义并验证来自 Django 模型的数据。我们在 NoteForm 类内部定义了另一个名为 Meta 的类。它包含表单的元数据,主要是它将要工作的模型名称以及我们不想在 HTML 表单中显示的模型字段列表。
我们将在 home 视图中使用 NoteForm 类,因此我们在 views.py 模块中添加以下内容:
from django.http import HttpResponseRedirect
from django.core.urlresolvers import reverse
from .forms import NoteForm
@login_required()
def home(request):
user = request.user
if request.method == "POST":
f = NoteForm(request.POST)
if f.is_valid():
note = f.save(commit=False)
note.owner = user
note.save()
return HttpResponseRedirect(reverse('home'))
else:
f = NoteForm()
context = {
'django_version': get_version(),
'form': f,
'notes': Note.objects.filter(owner=user).order_by('-id'),
}
return render(request, 'core/main.html', context)
我们最初分配一个包含当前登录用户实例的 user 变量。然后我们检查视图是否正在服务一个 HTTP POST 请求。如果是这种情况,我们实例化我们的模型表单类,将请求本身传递给构造函数。表单将从请求体中提取所需的数据。然后我们调用 is_valid() 方法来检查是否所有需要的字段都已填写了正确的数据,并调用 save() 方法传递 commit=False 参数,这将创建一个新的 Note 实例而不将其保存到数据库中。我们将 owner 字段分配给当前登录用户并保存 Note 实例,这次使其持久化在数据库中。最后,我们将用户重定向到主页 URL。我们调用 reverse() 方法并传递视图名称作为参数。如果请求是 GET 类型,我们实例化一个空模型表单。NoteForm 实例被添加到上下文中,以便模板可以使用它来渲染 HTML 表单。在这里,我们使用 Django ORM 执行我们的第一次数据库查询。Note.objects.filter(owner=user).order_by('-id') 查询返回一个按 id 参数逆序排序的笔记对象列表,该列表按当前登录用户作为 owner 过滤(注意字段名前的 - 字符)。列表被添加到上下文中,以便模板可以渲染它。
我们需要采取的最后一步是修改 main.html 模板,以便它可以正确渲染我们刚刚添加到上下文中的新内容。让我们从表单开始,我们可以很容易地使用模型表单实例 form 来定义它,而不需要编写太多的 HTML 代码:
<form method="post" action="" enctype="multipart/form-data">
<legend>Add a new note</legend>
{# Show visible fields #}
{% for field in form.visible_fields %}
<div class="form-group">
{{ field.errors }}
{{ field.label_tag }}
{{ field }}
</div>
{% endfor %}
{# Include hidden fields #}
{% for hidden in form.hidden_fields %}
{{ hidden }}
{% endfor %}
{% csrf_token %}
<div class="form-group">
<button type="submit">Save note</button>
</div>
</form>
我们首先迭代表单的可见字段。请注意,Django 将负责打印正确的标签和标签。在迭代完可见字段后,我们打印隐藏字段,然后打印 CSRF 令牌(就像我们在登录表单中所做的那样),最后提供 submit 按钮。在表单定义之后,我们可以添加循环,生成显示当前用户笔记的 HTML 代码:
{% for note in notes %}
<div class="note">
<h4>{{ note.title }}</h4>
<p class="note-content">{{ note.content }}</p>
{% if note.checklist_items %}
<ul>
{% for item in note.checklist_items.all %}
<li class="{%if item.checked%}checked{%endif%}">
<a href="#">{{item.title}}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
如我们所见,添加checklist_items参数的input控件缺失。这是因为我们希望提供与在 Notes 的 webapp2 版本中相同的列表——一个以逗号分隔的列表。由于 Note 模型没有提供字段来存储这个列表,模型表单不会显示任何提供此类数据的内容。无论如何,我们可以独立于关联模型的字段手动向模型表单添加任意字段。在我们的forms.py模块中,我们在NoteForm类中添加以下内容:
class NoteForm(forms.ModelForm):
cl_items = forms.CharField(required=False,
label="Checklist Items",
widget=forms.TextInput(attrs={
'placeholder': 'comma,separated,values'
}))
class Meta:
model = Note
exclude = ['id', 'date_created', 'owner']
我们添加了一个名为ci_items的新字段。它不是必需的,并使用forms.TextInput小部件渲染。我们在这里不使用默认小部件,因为我们想为相应的 HTML 标签提供placeholder属性。我们可以刷新主页以查看新字段的出现,而无需修改 HTML 模板文件。现在我们需要处理这个新字段,我们在home视图中这样做:
@login_required()
def home(request):
user = request.user
if request.method == "POST":
f = NoteForm(request.POST)
if f.is_valid():
note = f.save(commit=False)
note.owner = user
note.save()
for item in f.cleaned_data['cl_items'].split(','):
CheckListItem.objects.create(title=item,
note=note)
return HttpResponseRedirect(reverse('home'))
在保存笔记实例后,我们访问 Django 在表单处理完毕后填充的cleaned_data字典中的cl_items值。我们使用逗号作为分隔符拆分字符串,为用户通过表单字段传递的每个项目创建一个新的CheckListItem实例,并使该实例持久化。
上传文件到 Google Cloud Storage
默认情况下,当用户上传文件时,Django 将内容本地存储在服务器上。正如我们所知,App Engine 应用程序在沙盒环境中运行,无法访问服务器文件系统,因此这种方法根本无法工作。无论如何,Django 提供了一个抽象层,即文件存储系统。我们可以使用这个层来自定义上传文件存储的位置和方式。我们将利用这个功能,实现自己的存储后端类,该类将上传的文件存储在 Google Cloud Storage 上。
在开始之前,我们需要安装 GCS 客户端库,就像我们在第三章“存储和处理用户数据”中做的那样,以便轻松与存储后端交互。然后我们在core应用程序中创建一个storage.py模块,其中包含存储后端类的定义,如下面的代码所示:
class GoogleCloudStorage(Storage):
def __init__(self):
try:
cloudstorage.validate_bucket_name(
settings.BUCKET_NAME)
except ValueError:
raise ImproperlyConfigured(
"Please specify a valid value for BUCKET_NAME")
self._bucket = '/' + settings.BUCKET_NAME
构造函数必须不带参数调用,因此我们需要从存储后端获取的所有内容都必须从 Django 设置中检索。在这种情况下,我们期望BUCKET_NAME设置值中指定了存储桶名称,我们使用 GCS 客户端库的validate_bucket_name参数确保它是一个有效的名称。然后我们在类中添加我们必须提供的方法以满足自定义存储后端的要求:
def _open(self, name, mode='rb'):
return cloudstorage.open(self.path(name), 'r')
def _save(self, name, content):
realname = self.path(name)
content_t = mimetypes.guess_type(realname)[0]
with cloudstorage.open(realname, 'w',
content_type=content_t,
options={
'x-goog-acl': 'public-read'
}) as f:
f.write(content.read())
return os.path.join(self._bucket, realname)
def delete(self, name):
try:
cloudstorage.delete(self.path(name))
except cloudstorage.NotFoundError:
pass
def exists(self, name):
try:
cloudstorage.stat(self.path(name))
return True
except cloudstorage.NotFoundError:
return False
def listdir(self, name):
return [], [obj.filename for obj in
cloudstorage.listbucket(self.path(name))]
def size(self, name):
filestat = cloudstorage.stat(self.path(name))
return filestat.st_size
def url(self, name):
key = blobstore.create_gs_key('/gs' + name)
return images.get_serving_url(key)
代码基本上与我们在第三章中看到的一样,存储和处理用户数据,所有类方法都与 GCS 客户端库中的对应方法匹配,因此非常紧凑。注意在url参数中,我们告诉 Django 使用 Google CDN 从我们的存储中提供文件。然后我们添加以下可选方法来完成我们的存储后端:
def path(self, name):
if not name:
raise SuspiciousOperation(
"Attempted access to '%s' denied." % name)
return os.path.join(self._bucket, os.path.normpath(name))
def created_time(self, name):
filestat = cloudstorage.stat(self.path(name))
creation_date = timezone.datetime.fromtimestamp(
filestat.st_ctime)
return timezone.make_aware(creation_date,
timezone.get_current_timezone())
path()方法返回文件的完整路径,包括前面的斜杠和存储桶名称。不允许访问bucket根目录,在这种情况下我们将引发异常。
现在自定义存储后端已经完成,我们告诉 Django 使用它,因此我们在settings.py模块中写下以下代码:
DEFAULT_FILE_STORAGE = 'core.storage.GoogleCloudStorage'
BUCKET_NAME = '<your_bucket_name>'
要查看自定义文件存储的实际效果,我们将稍微改变我们笔记应用的某些要求。为了简化,我们将只支持每个笔记附加一个文件,这样我们就可以简单地在我们models.py模块中的Note模型类中添加几个字段:
class Note(models.Model):
title = models.CharField(max_length=100)
content = models.TextField(blank=False)
date_created = models.DateTimeField(auto_now_add=True)
owner = models.ForeignKey(User)
attach = models.FileField(blank=True, null=True)
thumbnail_url = models.CharField(max_length=255, blank=True, null=True)
attach字段是FileField类型。这意味着 Django 将为我们处理上传和存储过程,使用我们的文件存储。thumbnail_url字段将包含一个字符串,其中包含检索附件裁剪版本的 URL,如果它是图像,就像我们在第三章中看到的那样,存储和处理用户数据。重要的是要记住,在此更改后,我们必须对我们的core应用执行迁移。我们不希望在 HTML 表单中显示thumbnail_url字段,因此我们相应地更改NoteForm文件中的Meta类:
class Meta:
model = Note
exclude = ['id', 'date_created', 'owner', 'thumbnail_url']
到目前为止,HTML 表单将显示文件输入字段,但我们需要在我们的home视图中处理上传:
from .storage import GoogleCloudStorage
from google.appengine.api import images
from google.appengine.ext import blobstore
@login_required()
def home(request):
user = request.user
if request.method == "POST":
f = NoteForm(request.POST, request.FILES)
if f.is_valid():
note = f.save(commit=False)
note.owner = user
if f.cleaned_data['attach']:
try:
s = GoogleCloudStorage()
path = '/gs' + s.path(f.cleaned_data['attach'].name)
key = blobstore.create_gs_key(path)
note.thumbnail_url = images.get_serving_url(key, size=150, crop=True)
except images.TransformationError, images.NotImageError:
pass
note.save()
for item in f.cleaned_data['cl_items'].split(','):
CheckListItem.objects.create(title=item, note=note)
return HttpResponseRedirect(reverse('home'))
首先,我们将包含上传数据的request.FILES字典传递给表单构造函数,以便它可以处理和验证我们的attach字段。然后,如果该字段存在,我们将在可能的情况下生成缩略图 URL,并相应地更新我们的note模型实例。在这里,我们使用我们的自定义存储类从云存储中检索文件的路径。通常情况下,自定义存储后端类不应该被开发者直接使用,但在这个案例中,我们可以睁一只眼闭一只眼,避免代码重复。程序的最后一个步骤是在笔记主页面中显示附件,所以我们这样修改main.html模板:
<h4>{{ note.title }}</h4>
<p class="note-content">{{ note.content }}</p>
{% if note.attach %}
<ul>
{% if note.thumbnail_url %}
<li class="file">
<a href="{{ note.attach.url }}">
<img src="img/{{ note.thumbnail_url }}">
</a>
</li>
{% else %}
<li class="file">
<a href="{{ note.attach.url }}">{{ note.attach.name }}</a>
</li>
{% endif %}
</ul>
{% endif %}
即使这个版本的笔记只支持每个笔记一个附件,我们仍然保持与 webapp2 版本相同的 HTML 结构,以避免重写 CSS 规则。如果附件是图像,我们将看到一个缩略图,否则显示文件名。
摘要
这是一次穿越 Django 领地的漫长旅程,即使我们没有 Notes 的 webapp2 版本的所有功能,但在这个阶段,我们确实有一个稳固的起点来添加所有缺失的部分。我们已经知道如何处理云平台服务,并且我们可以将迁移作为练习来完成,以提升 Django 编程技能,并对 App Engine 背后的所有技术更加自信。
在本章中,我们学习了如何启动 Django 项目,框架背后的基本概念,以及如何将其顺利集成到 App Engine 应用程序中。使用 Django 1.7 版本,我们还有机会处理全新的迁移系统,并充分利用 Cloud SQL 数据库服务。到目前为止,我们知道如何处理表单,一个简单的例子展示了在 Django 这样的框架帮助下,生活可以多么轻松,它为我们节省了大量重复性工作。我们朝着 Django 和 App Engine 完美融合迈出的另一个重要步骤是集成 Google Cloud Storage 服务,这是一个用于存储用户上传到我们 Notes 应用程序的文件的优秀后端。
在下一章中,我们将回到使用 Notes 的 webapp2 版本,通过 Google Cloud Endpoints 技术实现 REST API。
第八章. 使用 Google Cloud Endpoints 暴露 REST API
在 第一章,入门中,我们提供了网络应用程序的定义,然后一章又一章,你学习了如何使用 App Engine 实现应用程序。到目前为止,我们对这类软件的结构有了足够的了解,可以理解网络应用程序的后端和前端组件之间的区别:前者提供逻辑、定义和数据访问,而后者提供用户界面。
在前面的章节中,我们没有在这两个组件之间做出明确的区分,我们编写的代码到目前为止提供了前端和后端组件,而没有太多的分离。在本章中,我们将拆解我们的 Notes 应用程序的前端组件,实现一个独立的后端服务器,准备与不同的客户端交换数据,从移动应用程序到在浏览器中运行的富 JavaScript 客户端。
再次强调,为了实现我们的应用程序,我们将利用 Google Cloud Platform 提供的一些工具和服务,称为 Google Cloud Endpoints。
在本章中,我们将涵盖以下主题:
-
什么是 REST,以及为 Notes 应用设计 API
-
使用 Cloud Endpoints 实现 REST API
-
API 探索工具
-
使用 OAuth2 保护 API
使用 REST API 的原因
表征状态转移(REST)是一种简单的无状态架构风格,通常在 HTTP 协议上运行。REST 背后的理念是将系统的状态作为我们可以操作的资源集合暴露出来,通过名称或 ID 来寻址。后端服务负责使资源的数据持久化,通常通过使用数据库服务器来实现。客户端通过向服务器发送 HTTP 请求来检索资源的状态。资源可以通过 HTTP 请求进行操作并返回给服务器。资源可以用几种格式表示,但我们将使用 JSON,这是一种轻量级、可读性强且广泛使用的交换格式。我们可以将资源的状态操作看作是一个 创建、检索、更新、删除(CRUD)系统。我们将要做的是将这些操作映射到特定的 HTTP 动词。我们将通过发送 HTTP POST 请求来创建新的资源,通过发送 GET 请求来检索现有的资源,通过发送 PUT 请求来更新其状态,以及通过发送 DELETE 请求来从系统中删除它。
这些天,REST 被广泛采用,主要是因为它允许客户端和服务器之间有很强的解耦,易于在 HTTP 上实现,性能非常好,可以缓存,并且总的来说,可以很好地扩展。公开 REST API 使得提供移动客户端、浏览器扩展或任何需要访问和处理应用程序数据的软件变得极其容易;出于这些原因,我们将为 Notes 提供 REST API。使用 Cloud Endpoints,我们可以在 Notes 的 webapp2 版本现有代码库中添加 REST API,而无需触及数据模型或应用程序的整体架构。
设计和构建 API
在编写代码之前,我们需要在心中有一个整洁的想法,即通过 API 提供的资源、我们将提供用于操作这些资源的方法以及我们将向客户端提供的响应代码。设计完 API 后,我们可以开始编写一些代码来实现资源表示。
资源、URL、HTTP 动词和响应代码
定义资源与在 ORM 系统中定义模型类非常相似,并且它们通常是一致的,就像在我们的案例中一样。实际上,我们将提供以下资源:
-
Note
-
NoteFile
-
ChecklistItem
每个资源都将由一个 URL 来标识。我们在这里省略了主机名以保持清晰:
-
The
/notesURL: 这标识了一个类型为 Note 的资源集合 -
The
/notes/:idURL: 这使用其 ID 作为区分符来标识类型为 Note 的单个资源 -
The
/notefilesURL: 这标识了一个类型为 NoteFile 的资源集合 -
The
/notefiles/:idURL: 这标识了一个类型为 NoteFile 的单个资源
我们不会通过 API 公开CheckListItem资源,因为在底层数据模型中,我们将条目定义为Note模型的StructuredProperty字段。由于相应的实体在 Datastore 中不存在,我们不能在不更改 Note 状态的情况下更改ChecklistItem资源的状态。因此,公开两个不同的资源没有太多意义。
客户端在联系后端服务器时,在请求头中指定一个特定的 HTTP 动词或方法,HTTP 动词告诉服务器如何处理由 URL 标识的数据。我们需要知道,根据 URL 是否代表单个资源或集合,动词可能具有不同的含义。对于我们的 REST API 公开的 URL,我们将支持以下动词:
-
The
GETrequest-
On a collection: 这将检索资源表示列表
-
On a single resource: 这将检索资源表示
-
-
The
POSTrequest-
On a collection: 这将创建一个新的资源并返回其表示
-
On a single resource: 这不适用并返回错误
-
-
The
PUTrequest-
On a collection: 这批量更新资源列表并返回无负载
-
在单个资源上:这更新单个资源并返回更新后的表示
-
-
DELETE 请求
-
在集合上:这不适用,并返回错误
-
单个资源:这将删除资源并返回无负载
-
每当服务器响应用户的请求时,都会传输一个 HTTP 状态码,以及可能的负载。我们的 API 将提供以下状态码:
-
200 OK:这表示请求成功。
-
204 无内容:这表示请求成功,但响应不包含数据,通常在
DELETE请求之后返回。 -
400 错误请求:这意味着请求格式不正确;例如,数据未通过验证或格式不正确。
-
404 未找到:这表示请求的资源无法找到。
-
401 未授权:这表示在访问资源之前我们需要执行身份验证。
-
405 方法不允许:这意味着用于此资源的 HTTP 方法不受支持。
-
409 冲突:这表示在更新系统状态时发生了冲突,例如当我们尝试插入重复项时。
-
503 服务不可用:这表示服务器暂时不可用。特别是,当我们的 Cloud Endpoints 应用程序抛出一个未捕获的异常时,这种情况会发生。
-
500 内部服务器错误:当其他所有操作都失败时发生。
现在 API 的设计已经完成,是我们开始编写代码的时候了。
定义资源表示
我们已经提到,请求和响应可能包含一个或多个资源的表示,我们也已经声明我们将使用 JSON 格式来实现这样的表示。现在我们需要在我们的代码中定义一个资源,Cloud Endpoints 将负责将我们的资源在 JSON 格式之间转换。这个操作被称为序列化。
在我们开始编码之前,我们需要花一些时间在 Cloud Endpoints 架构上,这样会更容易理解为什么我们使用某些 Python 包或数据结构。
Cloud Endpoints 建立在 Google Protocol RPC 库之上,这是一个在 HTTP 协议上实现远程过程调用(RPC)服务的框架。一个服务是一组可以通过常规 HTTP 请求调用的方法。一个方法接收请求中的消息类型对象,并返回另一个消息类型作为响应。消息类型是派生自protorpc.messages.Message类的常规 Python 类,而服务是派生自protorpc.remote.Service类的 Python 类的方法。由于 Cloud Endpoints 实际上是一个底层的 RPC 服务,因此我们 REST 资源的表示将作为 RPC 消息实现。
我们在应用程序根目录下创建了一个名为resources.py的新模块,包含以下代码:
from protorpc import messages
from protorpc import message_types
class CheckListItemRepr(messages.Message):
title = messages.StringField(1)
checked = messages.BooleanField(2)
class NoteRepr(messages.Message):
key = messages.StringField(1)
title = messages.StringField(2)
content = messages.StringField(3)
date_created = message_types.DateTimeField(4)
checklist_items = messages.MessageField(CheckListItemRepr,
5, repeated=True)
files = messages.StringField(6, repeated=True)
class NoteCollection(messages.Message):
items = messages.MessageField(NoteRepr, 1, repeated=True)
定义消息类有点像在 ORM 中定义模型类;我们指定与我们要用来表示资源的每个字段相对应的类属性。字段有一个类型,并且它们的构造函数接受一个整数参数作为标识符,该标识符必须在消息类中是唯一的。CheckListItemRepr类将用于序列化附加到笔记上的可检查项。NoteRepr表示笔记资源,并且是我们 API 的核心。
我们需要一个key字段,以便客户端在需要获取详细信息或修改资源时有一个参考。checklist_items字段引用CheckListItemRepr类,它将嵌套到笔记表示中。我们将与笔记关联的文件表示为一个名为files的字符串列表,其中包含models.NoteFile实例的键。最后,我们定义了一个名为NoteCollection的笔记集合表示。它只有一个字段,即items,包含单个笔记表示。
一旦序列化,笔记的 JSON 表示应该看起来像这样:
{
"checklist_items": [
{
"checked": false,
"title": "one"
},
{
"checked": true,
"title": "two"
},
{
"checked": false,
"title": "three"
}
],
"content": "Some example contents",
"date_created": "2014-11-08T15:49:07.696869",
"files": [
"ag9kZXZ-Ym9vay0xMjM0NTZyQAsSBFVzZXIiE"
],
"key": "ag9kZXZ-Ym9vay0xMjM0NTZyKwsSBFVz",
"title": "Example Note"
}
如我们所见,JSON 表示非常易于阅读。
现在我们已经有了我们 REST 资源的表示,我们可以开始实现 REST API 的端点。
实现 API 端点
正如我们之前提到的,我们的 REST API 将与现有的 App Engine 应用程序集成,而不会改变其行为,因此我们需要指定一个新的 WSGI 应用程序来处理我们映射到 API 端点的 URL。让我们从app.yaml文件开始,其中我们添加以下代码:
handlers:
- url: /static
static_dir: static
- url: /_ah/spi/.*
script: notes_api.app
- url: .*
script: main.app
libraries:
- name: webapp2
version: "2.5.2"
- name: jinja2
version: latest
- name: endpoints
version: 1.0
匹配 API URL 的正则表达式实际上是/_ah/spi/.*。即使我们向类似于https://example.com/_ah/api/v1/an-endpoint的 URL 发出请求,Cloud Endpoints 也会负责适当的重定向。API URL 的处理脚本指向notes_api模块中的app变量,我们尚未创建。在一个名为notes_api.py的新文件中,我们添加以下代码:
import endpoints
app = endpoints.api_server([])
这是我们的 REST API 的非常基本的框架。现在我们需要定义端点作为从protorpc.remote.Service类派生的 Python 类的成员方法,并将此类附加到传递给api_server()函数的参数列表中。
在notes_api.py模块中,我们添加了NotesApi类,它将包含所有用于检索和操作笔记资源的端点。让我们看看如何实现操作笔记集合的端点,一次一个,从支持GET请求的端点开始:
from protorpc import message_types
from protorpc import remote
from google.appengine.ext import ndb
import models
import resources
@endpoints.api(name='notes', version='v1')
class NotesApi(remote.Service):
@endpoints.method(message_types.VoidMessage,
resources.NoteCollection,
path='notes',
http_method='GET',
name='notes.notesList')
def note_list(self, unused_request_msg):
items = []
for note in models.Note.query().fetch():
checkl_items = []
for i in note.checklist_items:
checkl_items.append(
resources.CheckListItemRepr(title=i.title,
checked=i.checked))
files = [f.urlsafe() for f in note.files]
r = resources.NoteRepr(key=note.key.urlsafe(),
title=note.title,
content=note.content,
date_created=note.date_created,
checklist_items=checkl_items,
files=files)
items.append(r)
return resources.NoteCollection(items=items)
app = endpoints.api_server([NotesApi])
我们应用到NotesApi类的装饰器,即@endpoints.api装饰器,告诉 Cloud Endpoints 这个类是名为notes的 API 的一部分,版本为 v1。note_list()方法被@endpoints.method装饰器装饰,并且这个方法期望以下参数,按照给出的顺序:
-
用于请求的消息类。在这种情况下,我们不期望任何输入,所以我们使用
message_types.VoidMessage,这是 Cloud Endpoints 提供的一个特殊消息类。 -
我们将在响应中返回的消息类,在这种情况下是我们的
resources.NoteCollection类。 -
端点的 URL 或路径。
-
端点支持的 HTTP 方法或动词。
-
表示端点名的一个字符串。
端点的逻辑很简单——我们从 Datastore 加载所有笔记实例,并为每个实例构建一个 NoteRepr 对象。然后使用 NoteCollection 类将这些表示添加到集合中,并将其返回给客户端。
现在我们添加支持 POST 类型请求的端点:
@endpoints.method(resources.NoteRepr,
resources.NoteRepr,
path='notes',
http_method='POST',
name='notes.notesCreate')
def note_create(self, new_resource):
user = endpoints.get_current_user()
if user is None:
raise endpoints.UnauthorizedException()
note = models.Note(parent=ndb.Key("User",
user.nickname()),
title=new_resource.title,
content=new_resource.content)
note.put()
new_resource.key = note.key.urlsafe()
new_resource.date_created = note.date_created
return new_resource
我们将方法命名为 note_create() 以更好地描述其语义。它期望请求中包含创建新资源信息的 NoteRepr 消息,并将返回一个包含已创建资源的 NoteRepr 消息。new_resource 参数包含请求中到达的 NoteRepr 实例,并用于在 Datastore 中构建一个新的 Note 实体。我们需要传递一个用户作为笔记的所有者,因此我们调用 endpoints 包中的 get_current_user 方法。我们将在本章的后面部分看到用户如何通过我们的 API 进行身份验证。在调用 PUT 类型后,我们可以访问新创建实体的键,因此我们更新 new_resource 消息字段并将其返回给客户端。
下面是支持 PUT 类型请求的端点的代码:
@endpoints.method(resources.NoteCollection,
message_types.VoidMessage,
path='notes',
http_method='PUT',
name='notes.notesBatchUpdate')
def note_batch_update(self, collection):
for note_repr in collection.items:
note = ndb.Key(urlsafe=note_repr.key).get()
note.title = note_repr.title
note.content = note_repr.content
checklist_items = []
for item in note_repr.checklist_items:
checklist_items.append(
models.CheckListItem(title=item.title, checked=item.checked))
note.checklist_items = checklist_items
files = []
for file_id in note_repr.files:
files.append(ndb.Key(urlsafe=file_id).get())
note.files = files
note.put()
return message_types.VoidMessage()
该方法被命名为 note_batch_update(),因为它应该对请求中接收到的资源集合执行更新,不向客户端返回任何有效载荷。它期望输入中有一个 NoteCollection 消息类,并在执行所有必要的更新后返回一个 VoidMessage 实例。
在笔记集合上操作的最后一个端点实际上是一个错误条件的处理器。实际上,在集合上执行 DELETE 请求应该导致一个 HTTP 错误 405:不允许的方法 消息。为了响应带有错误代码的 API 调用,我们只需在实现端点的 Python 方法中抛出一个适当的异常:
@endpoints.method(message_types.VoidMessage,
message_types.VoidMessage,
path='notes',
http_method='DELETE',
name='notes.notesBatchDelete')
def note_list_delete(self, request):
raise errors.MethodNotAllowed()
note_list_delete() 方法只是抛出一个我们仍然需要定义的异常。在我们的应用程序中,我们添加一个新的 errors.py 模块,并添加以下内容:
import endpoints
import httplib
class MethodNotAllowed(endpoints.ServiceException):
http_status = httplib.METHOD_NOT_ALLOWED
我们需要定义自己的 MethodNotAllowed 异常,因为 Cloud Endpoints 只提供了最常见的 HTTP 错误代码的异常类:400、401、403、404 和 500。
在note类型资源集合上操作的 REST API 部分现在已经完成,因此我们可以继续并开始实现操作单个笔记的端点。单个资源的路径包含一个参数,即资源标识符。在这种情况下,以及当需要传递查询字符串参数时,我们不能使用简单的Message类来处理请求,而必须使用一个特殊的容器,该容器定义在endpoints.ResourceContainer参数中,它将消息和路径中的参数以及查询字符串中的参数包装在一起。在我们的例子中,由于我们将多次使用该容器,我们可以将其定义为NotesApi类的字段:
from protorpc import messages
@endpoints.api(name='notes', version='v1')
class NotesApi(remote.Service):
NoteRequestContainer = endpoints.ResourceContainer(
resources.NoteRepr, key=messages.StringField(1))
我们将想要包装的消息及其通过请求路径或查询字符串接收的参数传递给构造函数。每个参数都必须定义为具有唯一标识符的消息字段。
我们接下来定义处理单个资源GET请求的端点:
@endpoints.method(NoteRequestContainer,
resources.NoteRepr,
path='notes/{key}',
http_method='GET',
name='notes.notesDetail')
def note_get(self, request):
note = ndb.Key(urlsafe=request.key).get()
checklist_items = []
for i in note.checklist_items:
checklist_items.append(
resources.CheckListItemRepr(title=i.title,
checked=i.checked))
files = [f.urlsafe() for f in note.files]
return resources.NoteRepr(key=request.key,
title=note.title,
content=note.content,
date_created=note.date_created,
checklist_items=checklist_items,
files=files)
我们期望在端点note_get()的输入中有一个NoteRequestContainer参数,它将返回一个NoteRepr消息。路径包含{key}参数,每当请求的 URL 匹配时,Cloud Endpoint 将填充NoteRequestContainer实例中的相应key字段,并解析其值。然后我们使用资源的键从 Datastore 检索相应的实体,并最终填充并返回一个NoteRepr消息对象。
当客户端对单个资源发出POST类型请求时,我们抛出一个错误,因此端点实现如下:
@endpoints.method(NoteRequestContainer,
message_types.VoidMessage,
path='notes/{key}',
http_method='POST',
name='notes.notesDetailPost')
def note_get_post(self, request):
raise errors.MethodNotAllowed()
This is the code for requests of type PUT instead:
@endpoints.method(NoteRequestContainer,
resources.NoteRepr,
path='notes/{key}',
http_method='PUT',
name='notes.notesUpdate')
def note_update(self, request):
note = ndb.Key(urlsafe=request.key).get()
note.title = request.title
note.content = request.content
checklist_items = []
for item in request.checklist_items:
checklist_items.append(
models.CheckListItem(title=item.title,
checked=item.checked))
note.checklist_items = checklist_items
files = []
for file_id in request.files:
files.append(ndb.Key(urlsafe=file_id).get())
note.files = files
note.put()
return resources.NoteRepr(key=request.key,
title=request.title,
content=request.content,
date_created=request.date_created,
checklist_items=request.checklist_items,
files=request.files)
note_update()方法从 Datastore 检索note实体,并根据请求的内容相应地更新其字段。最后,该方法返回更新资源的表示。
对于单个资源,我们需要支持的最后一个方法是DELETE:
@endpoints.method(NoteRequestContainer,
message_types.VoidMessage,
path='notes/{key}',
http_method='DELETE',
name='notes.notesDelete')
def note_delete(self, request):
ndb.Key(urlsafe=request.key).delete()
return message_types.VoidMessage()
端点接收一个请求容器,删除相应的 Datastore 实体,如果一切正常,则返回一个空的有效载荷。
我们最终拥有一个完整的 REST API 来处理笔记实体。现在是时候玩玩它并检查结果是否符合我们的预期。
使用 API Explorer 测试 API
我们可以通过运行dev_appserver.py脚本或在 App Engine 上部署应用程序来在本地开发环境中测试我们的 REST API。在这两种情况下,Cloud Endpoints 提供了一个工具,允许我们探索我们的 API;让我们看看如何。
当本地开发服务器正在运行时,我们将浏览器指向http://localhost:8080/_ah/api/explorer URL,我们立即被重定向到 API Explorer,在那里我们可以看到我们的 API 被列出,如下面的截图所示:

当我们点击我们的 API 名称时,探索器列出了通过 Cloud Endpoints 服务公开的所有端点。在我们开始测试之前,我们应该确保 Datastore 中存在一些笔记。我们可以使用 Notes 网络应用程序来插入它们。
通过点击笔记列表条目,我们可以访问端点的详细信息页面,在那里我们可以点击执行按钮来执行GET请求并检索在响应部分可见的笔记集合,以 JSON 格式表示。我们还可以复制集合中笔记的键字段,并访问notesDetail端点的详细信息页面。在这里,我们在表单的键字段中粘贴键,然后按执行按钮。这次,响应应该包含资源表示。
要查看如何更新此资源,我们访问notesUpdate端点的详细信息页面。在这里,我们可以再次粘贴我们想要更新的资源的键,并使用请求体编辑器构建请求体,这是一个非常强大的工具,它允许我们通过仅指向和点击一些 HTML 控件来组合复杂的 JSON 对象。

。
API 探索器在开发 API 时非常有帮助,可以立即看到对端点的调用结果,测试具有特定有效负载的端点,并检查同一 API 不同版本的行为。我们也可以使用其他客户端来测试我们的 API,例如命令行中的curl程序,但 API 探索器提供的交互性是一个巨大的增值。
在下一段中,我们将看到 API 探索器的另一个功能,这将使我们的生活变得更加容易——有机会使用通过 OAuth2 认证的客户端测试我们的 API。
使用 OAuth2 保护端点
即使我们的 REST API 看起来相当完整,但在我们的实现中仍缺少一个关键组件:实际上任何客户端目前都能在不提供身份验证的情况下检索存储在 Datastore 中的所有笔记,无论这些笔记的所有者是否是客户端。此外,直到我们为我们的 REST API 提供身份验证之前,创建笔记将是不可能的,因为我们需要在NotesApi类的note_create()方法中需要一个经过身份验证的用户来创建实体。我们可以很容易地填补这个需求缺口,因为 Cloud Endpoints 提供了使用 OAuth2 授权框架保护我们 API 全部或部分的支持。
为我们的 API 提供保护的第一步是指定允许访问 API 的客户端。在这里,我们使用“客户端”一词实际上来识别一种客户端类型,例如在浏览器中运行的 JavaScript 应用程序、在 Android 或 iOS 上运行的移动应用程序等等。每个客户端都有一个称为客户端 ID 的字符串来标识,我们必须使用开发者控制台生成它:
-
在左侧菜单中,选择APIs & auth。
-
选择credentials。
-
点击创建新的 Client ID按钮。
然后,将启动一个引导程序,我们只需遵循屏幕上的说明即可生成新的客户端 ID。
然后,我们使用NotesApi类的@endpoints.api装饰器指定授权的客户端 ID 列表,如下所示:
JS_CLIENT_ID = '8nej3vl.apps.googleusercontent.com'
IOS_CLIENT_ID = 'm6gikl14bncbqks.apps.googleusercontent.com'
@endpoints.api(name='notes', version='v1',
allowed_client_ids=[
endpoints.API_EXPLORER_CLIENT_ID,
JS_CLIENT_ID,
IOS_CLIENT_ID
])
class NotesApi(remote.Service):
要从探索器访问 API,我们也列出其客户端 ID,该 ID 由 endpoints 包提供。由于客户端 ID 列在 Python 源代码中,我们必须记住,每次我们更改 allowed_client_ids 列表时,都需要重新部署应用程序。
如果我们将 Android 应用程序添加到允许的客户端 ID 列表中,我们必须也在 @endpoints.api 装饰器中指定受众参数。此参数的值与客户端 ID 相同:
JS_CLIENT_ID = '8nej3vl.apps.googleusercontent.com'
IOS_CLIENT_ID = 'm6gikl14bncbqks.apps.googleusercontent.com'
ANDROID_CLIENT_ID = '1djhfk8ne.apps.googleusercontent.com'
@endpoints.api(name='notes', version='v1',
allowed_client_ids=[
endpoints.API_EXPLORER_CLIENT_ID,
JS_CLIENT_ID,
IOS_CLIENT_ID,
ANDROID_CLIENT_ID,
],
audiences=[ANDROID_CLIENT_ID])
class NotesApi(remote.Service):
最后的配置步骤是声明我们希望客户端提供的 OAuth2 范围,以便访问我们的 API。对于我们的 Notes API,我们只需要 endpoints.EMAIL_SCOPE 类,这是 Cloud Endpoints 提供 OAuth2 认证和授权所需的最小范围。我们将以下内容添加到传递给 @endpoints.api 装饰器的参数列表中:
@endpoints.api(name='notes', version='v1',
allowed_client_ids=[
endpoints.API_EXPLORER_CLIENT_ID,
JS_CLIENT_ID,
ANDROID_CLIENT_ID
],
audiences=[ANDROID_CLIENT_ID],
scopes=[endpoints.EMAIL_SCOPE])
class NotesApi(remote.Service):
从现在起,Cloud Endpoints 框架将自动验证用户并强制执行允许的客户端列表,如果认证过程成功,将为我们的应用程序提供一个有效的 User 实例。要检索已认证的用户,我们以与在 create_note() 端点方法中相同的方式调用 endpoints.get_current_user() 函数。如果认证过程失败,get_current_user() 函数返回 None 参数。检查当前用户是否有效是我们代码的责任,在我们要保护的各个方法中。
例如,我们可以在 NotesApi 类的 note_list() 方法开头添加以下安全检查:
def note_list(self, request):
if endpoints.get_current_user() is None:
raise endpoints.UnauthorizedException()
现在,如果我们打开 API 探索器并尝试对 notesList 端点执行 GET 请求,我们将得到以下响应:
401 Unauthorized
{
"error": {
"code": 401,
"errors": [
{
"domain": "global",
"message": "Unauthorized",
"reason": "required"
}
],
"message": "Unauthorized"
}
}
多亏了 API 探索器,我们可以使用 OAuth2 进行认证并尝试访问相同的端点,以检查我们这次是否被允许。停留在我们之前执行失败请求的页面上,我们可以在 API 探索器界面的右上角看到一个标签为 使用 OAuth 2.0 授权请求 的开关。如果我们点击它,探索器将使用我们的一个 Google 账户启动 OAuth2 授权过程,一旦完成,我们就可以再次执行请求而不会出现认证错误。
除了设置认证之外,现在我们还可以使用用户实例过滤 Datastore 查询,这样每个用户就只能访问他们自己的数据。
摘要
在本章的最后,我们深入探讨了 Cloud Endpoints 框架,你现在拥有了完成 REST API 以及可能支持各种客户端所需的技能:有人可以编写 Notes 的 Android 版本,其他人可能提供 iOS 的移植。我们可以编写一个 JavaScript 客户端,并通过它们各自的市场交付为 Chrome 或 Firefox 应用程序。
您简要了解了 REST,以及为什么您应该在众多解决方案中选择它来与各种客户端通信。我们精心设计了我们的 API,提供了一套全面的端点来检索和操作我们应用程序中的资源。我们最终实现了代码,并使用 API Explorer(一个能够执行 API 方法、显示请求和响应数据以及验证客户端的交互式探索工具)来玩转 API。
REST 是一种在互联网上许多地方使用的语言,多亏了 Cloud Endpoints,我们有机会为在 App Engine 上运行的每个 Web 应用程序轻松提供现代且强大的 API。
我希望您像我写作这本书一样喜欢它,无论您的下一个 Python 应用程序是否将在 Google App Engine 上运行,我都希望这本书能帮助您做出那个决定。


浙公网安备 33010602011771号