CherryPy-精要-全-

CherryPy 精要(全)

原文:zh.annas-archive.org/md5/794dac6c51b55cec1e952aeff4414f21

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在过去的几年里,随着互联网突破带来的繁荣,几乎每一种编程语言或平台都欢迎 Web 开发工具包、库和框架的兴起。

虽然 Python 编程语言已经形成了一个相当大的这些环境列表,但除了 Zope 和 Twisted 等少数几个之外,大多数都有相当小的社区。正是在这种背景下,CherryPy 诞生了,当其创造者 Rémi Delon 决定他需要一个能够满足他个人项目需求的工具时。然后,他发布了 CherryPy,并采用免费软件许可,以便任何人都可以使用、分发和为该项目做出贡献。

CherryPy 是一个实现 HTTP 协议的 Python 库,HTTP 协议是网络的基石,它使用常见的 Python 习惯用法。在此基础上,CherryPy 提供了自己对如何帮助开发者构建 Web 应用程序的观点和概念,同时通过其简单直接的 API 最大限度地减少对开发者的干扰。

本书将引导您了解 CherryPy 库,目的是帮助您在自己的 Web 应用程序中充分利用它。

前四章专门介绍 CherryPy,内容从其历史到其关键特性的深入讲解。接下来的部分将带您进入一个照片博客应用程序的开发。每一章都试图提供足够的背景知识,让您思考每个决策的原因和方式。确实,编写软件应用程序不是一门精确的科学,为了更好的结果,需要做出妥协。然而,事实是编写软件通常不会完全按照计划进行。我写这本书的目的是希望您最终能学到比使用 Python 库更多的东西。

本书涵盖的内容

第一章 介绍了 CherryPy 的故事以及对该项目的概述。

第二章 指导您通过使用 distutils、setuptools 或 subversion 等常见策略来安装和部署 CherryPy。

第三章 对 CherryPy 的主要和最常见方面进行了概述,这将帮助您了解这个库能做什么。

第四章 对库的主要方面进行了深入审查,如对 HTTP 协议或 WSGI 接口的支持。它还广泛讨论了 CherryPy API 的工具功能。

第五章介绍了应用,这将是本书余下部分的主题统一。本章回顾了应用将操作的基本实体,然后解释了我们将如何将它们映射到关系数据库中。这将使我们能够解释 ORM 的概念,并对 SQLAlchemy、SQLObject 和 Dejavu 进行快速比较。

第六章通过回顾 REST 和 Atom 发布协议来介绍 Web 服务背后的理念。

第七章描述了如何使用模板引擎(如 Kid)动态生成网页。本章还介绍了 Mochikit,这是一个用于客户端开发的 JavaScript 工具包。

第八章通过深入 Ajax 的世界来扩展第七章,这提醒 Web 开发者,他们可以通过简单地使用浏览器功能、JavaScript 语言和 HTTP 协议来创建极其强大的应用。

第九章强调任何应用都应该进行合理的测试,并介绍了单元测试、功能测试和负载测试等测试策略。

第十章通过回顾在 Apache 和 lighttpd 等常见 Web 服务器前端下部署 CherryPy 应用的一些方法来结束本书。本章还解释了如何从 CherryPy 应用中启用 SSL。

你需要这本书的哪些东西

在本书中,我们将假设你已经安装并可以使用以下包。

  • Python 2.4 或更高版本

  • CherryPy 3.0

你需要具备 Python 语言的基本知识。

这本书是为谁写的

本书主要面向希望了解 Python 编程语言如何满足其需求的 Web 开发者。尽管 CherryPy 工具包是本书的核心,但为了使本书面向更广泛的读者,介绍了许多常见库。

习惯用法

在本书中,你会找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。

代码有三种风格。文本中的代码词如下所示:“部署包的一种较新且更常见的方式是使用easy_install命令安装 egg。”

代码块将如下设置:

body
{
background-color: #663;
color: #fff;
}
p
{
text-align: center;
}

任何命令行输入和输出都如下所示:

python ez_setup.py 

新术语重要词汇以粗体字形式引入。屏幕上看到的词,例如在菜单或对话框中,在我们的文本中如下所示:“下一步是点击全部按钮来运行这些测试。”

注意

警告或重要注意事项以如下框中的形式出现。

注意

技巧和窍门如下所示。

读者反馈

我们欢迎读者的反馈。请告诉我们您对这本书的看法,您喜欢什么或可能不喜欢什么。读者的反馈对我们开发您真正能从中获得最大收益的标题非常重要。

要发送一般性反馈,请简单地将电子邮件发送到 feedback@packtpub.com,确保在邮件主题中提及书名。

如果有您需要的书籍并且希望我们看到出版,请通过www.packtpub.com上的建议一个标题表单或通过 suggest@packtpub.com 发送邮件给我们。

如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们关于www.packtpub.com/authors的作者指南。

客户支持

现在您已经是 Packt 图书的骄傲拥有者了,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载本书的示例代码

访问www.packtpub.com/support,从标题列表中选择此书,以下载任何示例代码或额外的资源。可下载的文件将随后显示。

可下载的文件包含如何使用它们的说明。

错误清单

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并有助于改进本书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/support,选择您的书,点击提交错误清单链接,并输入您的错误清单详情。一旦您的错误清单得到验证,您的提交将被接受,并将错误清单添加到现有错误清单中。现有的错误清单可以通过从www.packtpub.com/support中选择您的标题来查看。

问题

如果您在书中遇到任何问题,可以通过 questions@packtpub.com 联系我们,我们将尽力解决。

第一章. CherryPy 简介

互联网的使用呈指数级增长,已成为我们今天生活方式的关键组成部分。从开发者的角度来看,网络提供了巨大的机会和乐趣。然而,面向网络的技术的数量越来越多,令人难以抉择。本书的目标是介绍其中之一,CherryPy,一个 Python 网络应用库。

本章将介绍 CherryPy 的功能和优势,从 CherryPy 的历史概述开始,然后探讨其友好的社区,这个社区长期以来一直是项目成功的重要部分,最后回顾 CherryPy 发展背后的关键原则。

概述

CherryPy 是一个为 Python 开发者提供友好 HTTP 协议接口的 Python 库。HTTP 是万维网的骨架。在过去的几年里,网络应用呈指数级增长。这种爆炸性增长随后是各种编程语言中发布的大量工具包、库和框架,以帮助网络开发者完成任务。最终,所有这些目标都是为了使网络开发者的生活更加轻松。在这种情况下,CherryPy 开始利用 Python 作为动态语言的优势,将 HTTP 协议建模并绑定到一个遵循 Python 习惯的 API 中。

几年来,Python 社区已经积累了大量的网络库和框架,以至于这已经成为一种既令人担忧又有些好笑的现象。尽管只有少数几个吸引了大多数社区的关注(如 TurboGears、Django 或 Zope),但每个现有的库或框架都通过提供自己对如何将 Python 与 HTTP 和网络接口的看法,保持了其特定的市场影响力。CherryPy 的诞生是因为当时它的创造者 Remi Delon 找不到他想要的东西。多年来,CherryPy 的设计由喜欢其优势并加入的新开发者进行了调整。如今,该项目拥有一个强大的社区基础,它在许多不同的环境中每天都会使用它。

CherryPy 的历史

Remi Delon 在 2002 年 6 月晚些时候发布了 CherryPy 的第一个版本。这是成功 Python 网络库的起点。Remi 是一位法国黑客,他信任 Python,认为它是开发网络应用的最佳替代品之一。

该项目吸引了一些对 Remi 所采取的方法感兴趣的开发者:

  • CherryPy 类是对 Python 的扩展,以支持数据与展示之间的关注点分离。它接近模型-视图-控制器模式。

  • CherryPy 类必须由 CherryPy 引擎处理和编译,以生成一个包含完整应用程序以及其内置网络服务器的自包含 Python 模块。

CherryPy 会将 URL 和其查询字符串映射到一个 Python 方法调用,例如:somehost.net/echo?message=hello 会映射到 echo(message='hello')

在接下来的两年里,项目得到了社区的支持,Remi 发布了几个改进版本。

2004 年 6 月,关于项目未来的讨论开始了,讨论的主题是是否应该继续使用相同的架构。主要担忧之一是编译步骤,这对于 Python 开发者来说并不自然。随后,几个项目常客的头脑风暴和讨论导致了对象发布引擎和过滤器的概念,这很快成为了 CherryPy 2 的核心组成部分。

最终,在 2004 年 10 月,CherryPy 2 的第一个 alpha 版本作为这些核心思想的证明被发布。随后是六个月的紧张工作,以发布一个稳定版本(2005 年 4 月晚些时候)。很快,其他开发者加入了项目,以改进它。CherryPy 2.0 是一个真正的成功;然而,人们认识到其设计仍有改进的空间,需要进行重构。

在进一步的社区反馈/讨论之后,CherryPy 的 API 被进一步修改,以提高其优雅性,导致 CherryPy 2.1.0 版本于 2005 年 10 月发布。这个版本由流行的 TurboGears 项目提供支持——它本身是一个项目堆栈,旨在生产一个网络超级框架。该团队于 2006 年 4 月发布了 CherryPy 2.2.0 版本。

CherryPy 作为 TurboGears 堆栈中越来越广泛采用的核心理念之一,自然意味着越来越多的关于 CherryPy 一些方面的讨论和问题被提出。例如,其 WSGI 支持、缺乏最新的文档,或者其仅平均的性能。很明显,为了满足这些真实且重要的需求,在不破坏向后兼容性约束的情况下,扩展 CherryPy 2 将会非常困难。因此,最终决定转向 CherryPy 3,该版本于 2006 年底发布。

CherryPy 历史

社区

如果没有过去几年建立起来的社区,CherryPy 不可能达到现在的地位。Remi 总是明确表示,他并不希望 CherryPy 成为他的个人项目,而更希望它成为一个社区项目。

CherryPy 一直有其追随者,但 CherryPy 社区实际上是从产品的 2.0 版本开始的。2004 年 11 月,在 OpenFree Technology CommunityOFTC)网络上注册了一个 IRC 频道,以便开发者和用户可以快速交换想法或报告缺陷。频道逐渐吸引了越来越多的常客,并被普遍认为是一个非常友好的地方。除了 IRC 频道外,还创建了针对开发者和用户的邮件列表。最终,一个常规 CherryPy 用户博客条目的聚合源被发布,并从那时起在 planet.cherrypy.org 上提供。

社区

CherryPy 项目优势

  • 简单性:主要目标之一始终是尽可能保持 CherryPy 的简单性,目的是避免库过度工程化项目。由于库覆盖的范围较窄,开发者能够专注于 API 和社区反馈。

  • 自包含:从一开始,Remi 就决定 CherryPy 的核心不需要任何第三方 Python 包即可工作,并且将完全依赖于 Python 标准库。

  • 不侵入性:开发团队长期关注的另一个关键方面是确保 CherryPy 尽可能不干扰其用户。想法是为任何开发者提供一套工具,不对他们选择使用它们的方式做出任何假设。

  • 开放讨论:开发团队始终倾听社区给出的反馈。这并不意味着每个请求都被采纳,但几乎所有的请求都经过了讨论和审查。

  • 有趣:在参与开源项目时,贡献者不应感到这仅仅是他们的日常工作;相反,真正享受他们所做的事情具有很大的价值。同样,对于 CherryPy 用户来说,乐趣元素也是一个重要的部分,我们观察到这使我们都成为了更好的、更有创造力的开发者。

CherryPy 之外

在其早期,CherryPy 吸引了一小群用户,但其设计阻止了它成长为更大的项目或被更广泛地使用。此外,当时 Python 网络开发领域主要被 Zope 平台占据。当 CherryPy 2 发布时,其概念受到了社区的广泛欢迎,并最终吸引了更多用户开始将其用于应用程序,以及基于其构建自己的包。

事实上,在 2005 年 9 月晚些时候,Kevin Dangoor 发布了 TurboGears——一个作为现有开源产品堆栈构建的 Web 开发框架。Kevin 选择了 CherryPy 来处理其框架的 HTTP 层,SQLObject 将对象映射到数据库,Kid 用于 XHTML 模板,MochiKit 用于客户端处理。这次发布发生在另一个 Python Web 框架 Django 向社区开放后的几个月。这两个项目很快在 Python 社区中获得了巨大的流行度,并且由于它们之间的一点点竞争,它们以非常快的速度增长。TurboGears 的繁荣提升了 CherryPy 的知名度,并吸引了一大批新用户。

这些新开发者的浪潮增加了 CherryPy 的功能请求以及修复的缺陷数量,最终导致了 CherryPy 3 的诞生,这是库最稳定的版本,也是本书的写作。

CherryPy 的未来清晰且光明;罗伯特·布鲁尔所做的出色工作使得这个库达到了巡航速度。TurboGears 的未来版本肯定会迁移到 CherryPy 3,这将引发一系列新问题,需要开发团队提出,并将推动 CherryPy 迈出下一个重大步伐。

通过本书

本书旨在以一个能够让你对自己在个人 Web 应用中使用 CherryPy 充满信心的水平介绍 CherryPy 库。此外,我们还将尝试在撰写本书时对 Web 应用设计和领域视角进行讨论。简而言之,本书将解释如何在 Python 社区中通过多种常见方式获取和安装 CherryPy,例如使用 setup 工具和 easy_install。它还将概述 CherryPy 的主要和最常见方面。这将让你逐渐了解这个库能做什么。然后,它将深入探讨库的功能,如其 HTTP 能力、替代 URI 分发器、扩展库以及其 WSGI 支持。

这将帮助你牢固地理解 CherryPy、其设计和如何在你的应用中最好地利用它。本书随后通过介绍对象关系映射器、Web 服务和 Ajax 等技术工具,通过开发一个简单的博客应用来分解 Web 开发的层次。

它介绍了博客应用的目标和边界,回顾了 Python 中数据库处理的状态,然后解释了对象关系映射。它广泛介绍了 Python 中的一个 ORM,称为 Dejavu。它还讨论了 REST 和 Atom 发布协议,它们都提供了一种设计可以扩展 Web 应用超 HTML 页面简单服务能力的服务的方式。然后,它介绍了博客应用的表示层,包括对名为 Kid 的模板引擎和名为 MochiKit 的 JavaScript 库的回顾。本书讨论了 Ajax 以及你的应用如何从中受益。然后,我们将看到你的应用如何调用 Web 服务。接着,本书广泛检查了 Web 应用测试的领域。这从单元测试到通过其功能测试方面的负载测试。本书最后通过展示将 Web 应用作为独立应用或通过 Apache 和 lighttpd 等知名 Web 服务器部署的不同方式来结束。

虽然有些章节没有广泛讨论 CherryPy 本身,但它们都将汇聚到对 Web 应用开发某些方面的理解。希望这本书能让你了解 CherryPy,并也会给你提供知识和欲望去了解更多它所涵盖的主题。

摘要

阅读这一介绍后,你应该对这本书将引导你走向何方有必要的背景理解。CherryPy 是一个简单而强大的 Python 库,它将成为那些希望找到一个能够隐藏 HTTP 协议的困难同时保持其优势的包的网页开发者的绝佳伴侣。CherryPy 社区在过去几年里一直在努力工作,以使这样的产品成为可能;希望这本书能为你提供正确的方向,让你充分利用它。

第二章:下载和安装 CherryPy

与大多数开源项目一样,CherryPy 可以通过多种方式下载和安装。在这里,我们将讨论以下三种方法:

  • 使用 tarball

  • 使用 easy_install

  • 使用 Subversion 获取最新版本的源代码

每一种方法都为项目的用户提供不同的价值,了解每个的贡献是很重要的。

一旦你阅读了这一章,你应该能够检索和部署 CherryPy,以及了解如何为你的软件使用每种技术。

要求

在本书的整个过程中,我们将假设你已经安装并可以使用以下包。

  • Python 2.4 或更高版本

  • CherryPy 3.0

我们还将假设你对 Python 本身有所了解,因为我们不会涵盖该语言。

概述

安装 Python 模块或包通常是一个简单的过程。首先,让我们讨论最常见的方法来构建和安装一个新的包,这得益于 Python 2.0 伴随出现的标准模块,distutils。

此模块提供了一个干净的接口来指定包的结构,所需的依赖项,以及包的构建规则。对于用户来说,通常意味着输入以下命令:

python setup.py build
python setup.py install

第一个命令将简单地根据开发者定义的规则构建包,报告错误,以便最终用户知道缺少依赖项等。第二个命令将把包安装到 Python 用来存储第三方包或模块的默认目录。请注意,后一个命令将默认调用前一个命令,以快速检查自上次运行以来是否有任何变化。

存储包和模块的默认目录是:

  • 在 UNIX 或 Linux 上 /usr/local/lib/python2.4/site-packages/usr/lib/python2.4/site-packages

  • 在 Microsoft Windows 上 C:\Python 或 C:\Python2x

  • 在 MacOS 上 Python:Lib:site-packages

在 UNIX 或 Linux 上,这取决于你的 Python 安装方式,但上述目录是最常见的。当导入一个模块时,Python 会查找一系列目录,包括一些默认目录和用户提供的目录,直到找到匹配的模块,否则会引发异常。搜索列表可以通过定义 PYTHONPATH 环境变量或从代码本身进行修改,如下所示:

import sys
sys.path.append(path)

注意

PYTHONPATH 环境变量是 Python 引擎启动时读取的变量之一。它包含附加到第三方模块和包搜索列表的路径。

另一种方法是设置一个以包命名的文件,并带有 .pth 扩展名。此文件应包含包的完整路径。

尽管这个算法很简单,但它有其局限性。由于 sys.path 列表是有序的,您必须确保如果两个路径包含具有不同版本的相同模块,则您的应用程序导入的是第一个到达的模块。这导致我们面临以下软件包版本问题。

假设您在全局安装的 Python 中安装了 CherryPy 2.2.1;它将在 /usr/local/lib/site-packages/cherrypy 目录下可用。然而,路径中不包含软件包的版本信息。因此,如果您必须安装 CherryPy 3.0.0,您必须覆盖现有的安装。

幸运的是,Python 社区已经找到了这个问题的解决方案——egg。一个 egg 是一个包含软件包所有文件和子目录的压缩文件夹,其名称中包含软件包的版本详细信息。

注意

一个 egg 是一个可分发捆绑包,默认情况下是压缩的,包含一个 Python 软件包或模块,包括作者和软件包版本等信息。

例如,由 Python 2.4 构建的 CherryPy 2.2.1 将看起来像以下这样:Cherrypy-2.2.1-py2.4.egg。egg 本身并不是非常有用;它的部署需要 easy_install,这是一个包含处理 egg 逻辑的 Python 模块。这意味着您可以在同一目录下部署多个版本,并让 easy_install 决定加载哪一个。

在接下来的章节中,我们将详细说明如何使用最常见的情况安装 CherryPy。

从 Tarball 安装

一个 tarball 是文件或目录的压缩存档。这个名字来源于 UNIX 和相关操作系统上发现的 tar 工具的使用。

注意

历史上使用的压缩格式通常是 gzip,而 tarball 的扩展名可以是 .tar.gz.tgz

CherryPy 为每个发布版本提供 tarball,无论是 alpha、beta、候选发布还是稳定版本。它们都可以从 download.cherrypy.org/ 获取。

CherryPy tarballs 包含库的完整源代码。

从 Tarball 安装

要从 tarball 安装 CherryPy,您需要经过以下步骤:

    1. download.cherrypy.org/ 下载您感兴趣版本。
    1. 前往已下载 tarball 的目录,并解压缩它:
    • 如果您正在使用 Linux,请输入以下命令:

      tar zxvf cherrypy-x.y.z.tgz 
      
      

    在给定的命令中,x.y.z 是您获取的版本。

    • 如果您正在运行 Microsoft Windows,您可以使用 7-Zip 等实用程序通过图形界面解压缩存档。
    1. 移动到新创建的目录,并输入以下命令,这将构建 CherryPy:
    python setup.py build 
    
    
    1. 最后,为了进行全局安装,您必须发出以下命令(您很可能需要管理员权限):
python setup.py install 

注意

注意,这些命令必须从命令行发出。在 Microsoft Windows 下,你将从一个 DOS 命令提示符运行这些命令。

以上步骤将在你的系统上为默认 Python 环境全局安装 CherryPy。有些情况下这可能不适合或不可行。例如,你可能只想为 Python 的特定版本安装 CherryPy;在这种情况下,你将不得不指定正确的 Python 二进制文件,例如在前面提到的第 3 和第 4 步中指定python2.4

也可能发生的情况是你不想进行全局安装,在这种情况下,在 UNIX 和 Linux 下最快的方法是将前面提到的第 4 步替换为:

python setup.py install --home=~ 

这将把文件放在$HOME/lib/python中,其中$HOME代表你的家目录。

在没有了解HOME的 Microsoft Windows 下,你会这样做:

python setup.py install --prefix=c:\some\path 

你选择的路径本身并不重要,你可以使用适合你环境的任何路径。

然后你必须确保当你需要导入模块时 Python 会通过那个目录。最简单的方法是将PYTHONPATH环境变量设置为以下内容:

  • 在 Linux 中使用 bash shell

    export PYTHONPATH=~/lib/python
    
    
  • 在 Microsoft Windows 中使用命令提示符

    set PYTHONPATH=/some/path/
    
    

    注意

    注意,这只会持续到命令窗口打开时,一旦你关闭它,这些更改就会被丢弃。为了使更改永久生效,你应该通过系统属性 | 高级 | 环境变量设置全局PYTHONPATH变量。

  • 在 MacOS 中使用csh shell

    setenv PYTHONPATH "/some/path/" 
    
    

PYTHONPATH环境变量将在启动时被 Python 解释器读取,并将其追加到其内部系统路径。

通过 Easy Install 安装

Easy_install是一个可以在Python Enterprise Application KitPEAK)网站上找到的 Python 模块,用于简化 Python 包和模块的部署。从开发者的角度来看,它提供了一个简单的 API 来导入 Python 模块,无论是特定版本还是一系列版本。例如,以下是你如何加载在环境中找到的第一个大于 2.2 的 CherryPy 版本的操作:

>>> from pkg_resources import require
>>> require("cherrypy>=2.2")
[CherryPy 2.2.1 (/home/sylvain/lib/python/
CherryPy-2.2.1-py2.4.egg)]

从用户的角度来看,它简化了下载、构建和部署 Python 产品的过程。

在安装 CherryPy 之前,我们必须安装easy_install本身。从peak.telecommunity.com/dist/ez_setup.py下载ez_setup.py模块,并以具有计算机管理员权限的用户身份运行,如下所示:

python ez_setup.py 

如果你没有管理员权限,你可以使用-install-dir (-d)选项,如下所示:

python ez_setup.py -install-dir=/some/path 

确保/some/path是 Python 系统路径的一部分。例如,你可以将PYTHONPATH设置为那个目录。

这将设置你的环境以支持 easy_install。然后,为了安装支持 easy_install 的 Python 产品,你应该发出以下命令:

easy_install product_name 

easy_install 将会搜索Python 包索引PyPI)以找到给定产品。PyPI 是关于 Python 产品的信息集中存储库。

通过 Easy Install 安装

为了部署 CherryPy 的最新可用版本,你应该发出以下命令:

easy_install cherrypy 

然后,easy_install 将会下载 CherryPy,构建并全局安装到你的 Python 环境中。如果你希望将其安装到特定位置,你需要输入以下命令:

easy_install --install-dir=~ cherrypy 

安装完成后,你将有一个名为cherrypy.x.y.z-py2.4.egg的文件,这取决于 CherryPy 的最新版本。

从 Subversion 安装

Subversion 是一个优秀的开源版本控制系统,允许开发者以受控和并发的方式执行项目。

这种系统的基本原理是注册一个资源,并跟踪对其所做的每个更改,以便任何开发者都可以检索任何以前的版本,比较两个版本,甚至跟踪该资源的演变过程。资源可以是源代码文件、二进制文件、图像、文档或任何可以用机器可读形式表达的东西。

Subversion 是集中的,因此项目由 Subversion 服务器管理,每个客户端都有一个副本。开发者在这个副本上工作,并将所做的任何更改提交回去。当出现冲突时,例如,如果另一个开发者已经修改了相同的文件并提交了它,服务器会通知你,并禁止你提交,直到你解决问题。

Subversion 是原子的,这意味着如果一个提交在某个文件上失败,整个提交都会失败。另一方面,如果它成功了,整个项目的修订版本将会增加,而不仅仅是涉及的文件。

注意

Subversion 通常被视为 CVS 的后继者,并且被认为更加友好。然而,也存在其他版本控制系统,如 Monotone 或 Darcs。

在 Linux 下,你可以从源代码安装 Subversion,或者使用包管理器。让我们描述一下源代码的安装过程。

    1. subversion.tigris.org/获取最新的 tarball。
    1. 然后在命令控制台中输入以下命令:
tar zxvf subversion-x.y.z.tar.gz 

    1. 进入新创建的目录,并输入:./configure
    1. 然后为了构建软件包本身,输入:make
    1. 你可能还需要 Subversion 的 Python 绑定:
     make swig-py
    
    
    1. 要全局安装 Subversion,你需要是管理员,然后输入:make install; make install-swig-py

在 Linux 或 UNIX 下,大多数时候,通过命令行使用 Subversion 更容易。然而,如果你更喜欢使用图形界面,我建议你安装一个肥客户端应用程序,如 eSvn 或 kdesvn。

从 Subversion 安装 CherryPy,使用 easy_install 安装

在 Microsoft Windows 下,直接使用图形应用程序(如 TortoiseSVN)会更容易,它将安装 Subversion 客户端。

在以下情况下建议使用 Subversion 获取 CherryPy:

  • 存在了一个功能或修复了一个错误,并且这些功能或错误仅在开发中的代码中可用。

  • 你决定专注于 CherryPy 本身的工作。

  • 你需要从主 trunk 分支出来,以便 尝试并查看 一个功能、一个新的设计,或者简单地回滚到之前版本中的错误修复。

为了使用项目的最新版本,你首先需要从 Subversion 仓库中找到的 trunk 文件夹检出。从 shell 中输入以下命令:

svn co http://svn.cherrypy.org/trunk cherrypy 

注意

在 Microsoft Windows 下,你可以从命令行操作,或者简单地使用 TortoiseSVN。请参阅其文档以获取更多信息。

这将创建一个 cherrypy 目录并将完整的源代码下载到其中。由于通常不建议部署开发中的版本,因此你会输入以下命令将 CherryPy 安装到本地目录:

  • 在 Linux 和相关系统使用控制台的情况下:
python setup.py install --home=~ 

  • 在 Microsoft Windows 使用命令提示符:
python setup.py install --prefix=c:\some\path 

然后将 PYTHONPATH 环境变量指向所选目录。

注意,只要这个目录可以通过 PYTHONPATH 或标准 sys 模块被 Python 进程访问,它就无关紧要。

测试你的安装

无论你决定以何种方式在你的环境中安装和部署 CherryPy,你都必须能够从 Python shell 中导入它,如下所示:

>>> import cherrypy
>>> cherrypy.__version__
'3.0.0'

如果你没有将 CherryPy 全局安装到 Python 环境中,不要忘记设置 PYTHONPATH 环境变量,否则你将得到以下错误:

>>> import cherrypy
Traceback (most recent call last):
File "<stdin>", line 1, in ?
ImportError: No module named cherrypy

保持 CherryPy 更新

更新或升级 CherryPy 将取决于你安装它所采取的方法。

  • 使用 tarball 安装

    通常,确保更新顺利进行的最佳方式是首先从 sys.path 中的位置删除包含包的目录,然后遵循之前描述的步骤安装库。

  • 使用 easy_install 安装

    更新是 easy_install 提供的关键功能之一。

easy_install -U cherrypy

  • 由于包含库的 eggs 以其服务的版本命名,你可以简单地遵循上一节中定义的步骤,而无需删除现有的 egg。但请注意,这仅在应用程序运行时明确指定所需版本的情况下才成立。

  • 使用 Subversion 安装

    这种方法的有趣之处在于你可以几乎连续地更新库。要更新你的安装,你需要从包含源代码的顶级目录中输入 svn update 命令,然后发出 python setup.py install 命令。

注意

像往常一样,在更新之前请务必备份你的文件。

摘要

在本章中,我们讨论了通过三种技术将 CherryPy 安装到您环境中的不同方法。传统的方法是使用包含 Python 包所有文件的存档来安装和使用该存档内的 setup.py 模块。一种较新且更常见的方式是使用 easy_install 命令来安装 eggs。最后,如果您希望与 CherryPy 的最新开发同步,您可以从其 Subversion 仓库获取该包。无论您采用哪种方法,它们都将使 CherryPy 可用您的系统上。

第三章:概述 CherryPy

在第一章中,我们简要回顾了 CherryPy 的一些方面;现在是时候深入挖掘,看看这个项目是如何设计和构建的。我们首先将通过一个基本的 CherryPy 示例。然后我们将探讨 CherryPy 的核心、发布对象引擎,以及它是如何将 HTTP 协议封装在一个面向对象的库中的。我们的下一步将是探索挂钩到核心、CherryPy 库和工具机制的概念。然后我们将回顾 CherryPy 如何处理错误和异常,以及你如何从中受益。

到本章结束时,你将对 CherryPy 库有一个很好的概述;然而,你很可能会在本书的其余部分回到这一章,以便完全理解它。

词汇表

为了避免误解,我们需要定义一些将在整本书中使用的关键词。

关键词 定义
Web 服务器 Web 服务器是处理 HTTP 协议的接口。其目标是将传入的 HTTP 请求转换为实体,然后传递给应用服务器,并将应用服务器中的信息转换回 HTTP 响应。
应用 应用是一段软件,它接收一个信息单元,对其应用业务逻辑,并返回一个处理过的信息单元。
应用服务器 应用服务器是托管一个或多个应用的组件。
Web 应用服务器 Web 应用服务器简单地将 Web 服务器和应用服务器合并为一个组件。

CherryPy 是一个 Web 应用服务器。

基本示例

为了说明 CherryPy 库,我们将通过一个非常基本的 Web 应用,允许用户通过 HTML 表单在主页上留下笔记。笔记将按创建日期的逆序堆叠并渲染。我们将使用会话对象来存储笔记作者的姓名。

基本示例

每个笔记都将附有一个 URI,形式为/note/id

基本示例

创建一个名为note.py的空白文件,并复制以下源代码。

#!/usr/bin/python
# -*- coding: utf-8 -*
# Python standard library imports
import os.path
import time
###############################################################
CherryPylibrary, working of#The unique module to be imported to use cherrypy
###############################################################
import cherrypy
# CherryPy needs an absolute path when dealing with static data
_curdir = os.path.join(os.getcwd(), os.path.dirname(__file__))
###############################################################
# We will keep our notes into a global list
# Please not that it is hazardous to use a simple list here
# since we will run the application in a multi-threaded environment
# which will not protect the access to this list
# In a more realistic application we would need either to use a
# thread safe object or to manually protect from concurrent access
# to this list
###############################################################
_notes = []
###############################################################
# A few HTML templates
###############################################################
_header = """
<html>
<head>
<title>Random notes</<title>
<link rel="stylesheet" type="text/css" href="/style.css"></link>
</head>
<body>
<div class="container">"""
_footer = """
</div>
</body>
</html>"""
_note_form = """
<div class="form">
<form method="post" action="post" class="form">
<input type="text" value="Your note here..." name="text"
size="60"></input>
<input type="submit" value="Add"></input>
</form>
</div>"""
_author_form = """
CherryPylibrary, working of<div class="form">
<form method="post" action="set">
<input type="text" name="name"></input>
<input type="submit" value="Switch"></input>
</form>
</div>"""
_note_view = """
<br />
<div>
%s
<div class="info">%s - %s <a href="/note/%d">(%d)</a></div>
</div>"""
###############################################################
# Our only domain object (sometimes referred as to a Model)
###############################################################
class Note(object):
def __init__(self, author, note):
self.id = None
self.author = author
self.note = note
self.timestamp = time.gmtime(time.time())
def __str__(self):
return self.note
###############################################################
# The main entry point of the Note application
###############################################################
class NoteApp:
"""
The base application which will be hosted by CherryPy
"""
# Here we tell CherryPy we will enable the session
# from this level of the tree of published objects
# as well as its sub-levels
_cp_config = { 'tools.sessions.on': True }
def _render_note(self, note):
"""Helper to render a note into HTML"""
return _note_view % (note, note.author,
time.strftime("%a, %d %b %Y %H:%M:%S",
note.timestamp),
note.id, note.id)
@cherrypy.expose
def index(self):
# Retrieve the author stored in the current session
# None if not defined
author = cherrypy.session.get('author', None)
page = [_header]
if author:
page.append("""
<div><span>Hello %s, please leave us a note.
<a href="author">Switch identity</a>.</span></div>"""
%(author,))
page.append(_note_form)
else:
page.append("""<div><a href="author">Set your
identity</a></span></div>""")
notes = _notes[:]
CherryPylibrary, working ofnotes.reverse()
for note in notes:
page.append(self._render_note(note))
page.append(_footer)
# Returns to the CherryPy server the page to render
return page
@cherrypy.expose
def note(self, id):
# Retrieve the note attached to the given id
try:
note = _notes[int(id)]
except:
# If the ID was not valid, let's tell the
# client we did not find it
raise cherrypy.NotFound
return [_header, self._render_note(note), _footer]
@cherrypy.expose
def post(self, text):
author = cherrypy.session.get('author', None)
# Here if the author was not in the session
# we redirect the client to the author form
if not author:
raise cherrypy.HTTPRedirect('/author')
note = Note(author, text)
_notes.append(note)
note.id = _notes.index(note)
raise cherrypy.HTTPRedirect('/')
class Author(object):
@cherrypy.expose
def index(self):
return [_header, _author_form, _footer]
@cherrypy.expose
def set(self, name):
cherrypy.session['author'] = name
return [_header, """
Hi %s. You can now leave <a href="/" title="Home">notes</a>.
""" % (name,), _footer]
if __name__ == '__main__':
# Define the global configuration settings of CherryPy
global_conf = {
'global': { 'engine.autoreload.on': False,
'server.socket_host': 'localhost',
'server.socket_port': 8080,
}}
application_conf = {
'/style.css': {
'tools.staticfile.on': True,
'tools.staticfile.filename': os.path.join(_curdir,
'style.css'),
}
}
# Update the global CherryPy configuration
CherryPylibrary, working ofcherrypy.config.update(global_conf)
# Create an instance of the application
note_app = NoteApp()
# attach an instance of the Author class to the main application
note_app.author = Author()
# mount the application on the '/' base path
cherrypy.tree.mount(note_app, '/', config = application_conf)
# Start the CherryPy HTTP server
cherrypy.server.quickstart()
# Start the CherryPy engine
cherrypy.engine.start()

以下是在名为style.css的文件中保存的 CSS,该文件应存储在与note.py相同的目录中。

html, body {
background-color: #DEDEDE;
padding: 0px;
marging: 0px;
height: 100%;
}
.container {
border-color: #A1A1A1;
border-style: solid;
border-width: 1px;
background-color: #FFF;
margin: 10px 150px 10px 150px;
height: 100%;
}
a:link {
text-decoration: none;
color: #A1A1A1;
}
a:visited {
text-decoration: none;
color: #A1A1A1;
}
a:hover {
text-decoration: underline;
}
input {
CherryPylibrary, working ofborder: 1px solid #A1A1A1;
}
.form {
margin: 5px 5px 5px 5px;
}
.info {
font-size: 70%;
color: #A1A1A1;
}

在本章的其余部分,我们将通过应用来解释 CherryPy 的设计。

内置 HTTP 服务器

CherryPy 自带其自己的 Web(HTTP)服务器。做出这个决定的目标是使 CherryPy 成为一个自包含的系统,并允许用户在获得库后几分钟内运行 CherryPy 应用。正如其名所示,Web 服务器是 CherryPy 应用的入口,所有 HTTP 请求和响应都必须通过它。因此,该层负责处理客户端和服务器之间传递信息的低级 TCP 套接字。

虽然使用内置服务器不是强制性的,但如果有需要,CherryPy 完全能够与其它 Web 服务器接口。然而,在这本书中,我们只会使用默认的内置 Web 服务器。

要启动 Web 服务器,您必须执行以下调用:

cherrypy.server.quickstart()

内部引擎

CherryPy 引擎是负责以下内容的层:

  • 创建和管理请求和响应对象

    • 请求负责检索和调用与 Request-URI 匹配的页面处理程序。

    • 响应对象在将响应返回给底层服务器之前构建和验证响应。

  • 控制、管理和监控 CherryPy 进程

要启动引擎,您必须发出以下调用:

cherrypy.engine.start()

配置

CherryPy 自带配置系统,允许您参数化 HTTP 服务器以及 CherryPy 引擎在处理 Request-URI 时的行为。

设置可以存储在接近INI格式的文本文件中,或者存储在纯 Python 字典中。选择两者之一将取决于个人喜好,因为它们都携带相同的信息。

CherryPy 提供了两个入口点来传递配置值——通过cherrypy.config.update()方法全局传递给服务器实例,以及通过cherrypy.tree.mount()方法按应用程序传递。此外,还有一个第三个作用域,可以在其中应用配置设置:按路径。

要配置 CherryPy 服务器实例本身,您需要使用设置的global部分。

note应用程序中,我们定义了以下设置:

global_conf = {
'global': {
'server.socket_host': 'localhost',
'server.socket_port': 8080,
},
}
application_conf = {
'/style.css': {
'tools.staticfile.on': True,
'tools.staticfile.filename': os.path.join(_curdir,
'style.css'),
}
}

这可以在如下文件中表示:

[global]
server.socket_host="localhost"
CherryPyconfiguringserver.socket_port=8080
[/style.css]
tools.staticfile.on=True
tools.staticfile.filename="/full/path/to.style.css"

注意

当使用文件存储设置时,您必须使用有效的 Python 对象(字符串、整数、布尔值等)。

我们定义了服务器将监听传入连接的主机和端口。

然后我们指示 CherryPy 引擎,/style.css文件将由staticfile工具处理,并也指出了要服务的物理文件的绝对路径。我们将在以下章节中详细解释这些工具是什么,但到目前为止,请想象它们是扩展 CherryPy 内部功能和增强其可能性的方式。

为了通知 CherryPy 我们的全局设置,我们需要执行以下调用:

  • 使用字典
cherrypy.config.update(conf)

  • 使用文件
cherrypy.config.update('/path/to/the/config/file')

我们还必须按照以下方式将配置值传递给挂载的应用程序:

  • 使用字典
cherrypy.tree.mount(application_instance, script_name, config=conf)

  • 使用文件
cherrypy.tree.mount(application_instance, script_name,
config='/path/to/config/file')

虽然在大多数情况下,在字典和文件之间进行选择将取决于个人喜好,但在某些情况下,一种方式可能比另一种方式更好。例如,您可能需要将复杂的数据或对象传递给配置中的一个键,而这无法通过文本文件实现。另一方面,如果设置需要由应用程序管理员修改,使用 INI 文件可能会简化这项任务。

注意

记住,如果您像我们在 Note 应用程序中那样配置应用程序的某些部分(如服务样式表),您必须调用cherrypy.tree.mount()

配置应用程序的最后一种方式是在页面处理程序或包含页面处理程序的类的_cp_config属性上使用,在这种情况下,配置将适用于所有页面处理程序。

在下面的代码示例中,我们表明Root类的所有页面处理程序都将使用gzip压缩,除了hello页面处理程序。

import cherrypy
class Root:
_cp_config = {'tools.gzip.on': True}
@cherrypy.expose
CherryPyconfiguringdef index(self):
return "welcome"
@cherrypy.expose
def default(self, *args, **kwargs):
return "oops"
@cherrypy.expose
# this next line is useless because we have set the class
# attribute _cp_config but shows you how to configure a tool
# using its decorator. We will explain more in the next
# chapters.
@cherrypy.tools.gzip()
def echo(self, msg):
return msg
@cherrypy.expose
def hello(self):
return "there"
hello._cp_config = {'tools.gzip.on': False}
if __name__ == '__main__':
cherrypy.quickstart(Root(), '/')

上面的quickstart调用是一个快捷方式:

cherrypy.tree.mount(Root(), '/')
cherrypy.server.quickstart()
cherrypy.engine.start()

您可以在任何时候使用这个调用,只要您只在 CherryPy 服务器上挂载一个单一的应用程序。

最后一个重要点是,配置设置与应用程序挂载的前缀无关。因此,在上面的例子中,即使应用程序可以挂载在/myapp而不是/上,设置也不会不同。它们不会包含前缀。因此,请考虑配置设置相对于应用程序,但与挂载应用程序使用的前缀无关。

注意

应用程序挂载的地址称为script_name

对象发布器引擎

HTTP 服务器,如 Apache 或 lighttpd,将请求 URI 映射到文件系统上的路径,这使得它们在处理主要由静态内容(如图片)组成的网站时非常高效。

CherryPy 选择了完全不同的方法,并使用其自己的内部查找算法来检索由请求 URI 引用的处理程序。CherryPy 2.0 做出的决定是,这样的处理程序将是一个附加到已发布对象树上的 Python 可调用对象。这就是我们说对象发布是因为请求 URI 映射到一个 Python 对象的原因。

CherryPy 定义了两个重要的概念:

  • 已发布:当一个 Python 对象附加到一个对象树,并且这个树的根通过cherrypy.tree.mount调用挂载到 CherryPy 引擎服务器时,我们说这个 Python 对象被发布了。

    例如:

root = Blog()
root.admin = Admin()
cherrypy.tree.mount(root, '/blog')

在上面的例子中,根对象被称为已发布。通过扩展,作为已发布对象属性的 admin 对象也是已发布的。

  • 公开:当一个已发布对象有一个名为exposed的属性设置为True时,我们说这个对象被公开了。一个公开的对象必须是 Python 可调用的。

    对于一个对象来说,仅仅被发布是不够的,CherryPy 将其视为 URI 的潜在处理程序。一个已发布对象必须被公开,以便它对 CherryPy 引擎可见。例如:

class Root:
@cherrypy.expose
def index(self):
return self.dosome()
def dosome(self):
return "hello there"
cherrypy.tree.mount(Root(), '/')

  • 在这个例子中,对/dosome的请求将返回一个未找到错误,因为即使该方法属于一个已发布对象,它也没有被公开。原因是dosome可调用对象没有被公开给内部引擎作为 URI 的潜在匹配项。

您可以通过手动设置或使用 CherryPy 提供的expose装饰器来设置exposed属性,正如我们将在本书中做的那样。

注意

CherryPy 社区通常将暴露的对象称为页面处理程序。本书中我们将使用这个术语。

例如,在Note应用程序中,发布的对象是note_appauthor。树的根是note_app,并挂载在'/'前缀上。因此,当接收到以'/'开头的任何路径的请求时,CherryPy 将使用该对象树。如果我们使用前缀如/postit,则只有在接收到以该前缀开始的请求时,Note应用程序才会由 CherryPy 提供服务。

因此,可以通过不同的前缀挂载多个应用程序。CherryPy 将根据请求 URI 调用正确的一个。(正如我们将在本书后面解释的,通过cherrypy.tree.mount()挂载的两个应用程序彼此之间是不知道的。CherryPy 确保它们不会泄露。)

下表显示了请求 URI 与 CherryPy 找到的 URI 路径匹配的页面处理程序之间的关系。

请求 URI 路径 发布的对象 页面处理程序
/ note_app index
/author/ note_app.author index
/author/set note_app.author set
/note/1 note_app note

index()default()方法是 CherryPy 的特殊页面处理程序。前者与以斜杠结尾的请求 URI 匹配,类似于 Apache 服务器上的index.html文件。后者在 CherryPy 找不到显式页面处理程序的请求 URI 时使用。我们的Note应用程序没有定义一个,但default页面处理程序通常用于捕获不规则 URI。

你还可以注意到,/note/1 URI 实际上与note(id)匹配;这是因为 CherryPy 支持位置参数。总之,CherryPy 将调用第一个签名与请求 URI 匹配的页面处理程序。

注意

只要 CherryPy 找到一个具有以下签名的页面处理程序:note(id),CherryPy 就会以相同的方式处理/note/1/note?id=1

下图是 HTTP 请求到达 CherryPy 服务器时遵循的流程的全球概述。

对象发布引擎

图书馆

CherryPy 附带了一套模块,用于构建 Web 应用程序时的常见任务,例如会话管理、静态资源服务、编码处理或基本缓存。

自动重载功能

CherryPy 是一个长期运行的 Python 进程,这意味着如果我们修改应用程序的 Python 模块,它将不会在现有进程中传播。由于手动停止和重新启动服务器可能是一项繁琐的任务,CherryPy 团队包含了一个自动重载模块,该模块在检测到应用程序导入的 Python 模块的修改时立即重新启动进程。此功能通过配置设置处理。

如果您需要在生产环境中启用自动重新加载模块,您将按照以下方式设置它。注意 engine.autoreload_frequency 选项,它设置自动重新加载引擎在检查新更改之前必须等待的秒数。如果不存在,默认为 1 秒。

[global]
server.environment = "production"
engine.autoreload_on = True
engine.autoreload_frequency = 5

自动重新加载不是一个真正的模块,但我们在这里提到它,因为它是由库提供的常见功能。

缓存模块

缓存是任何 Web 应用程序的重要方面,因为它减少了不同服务器(HTTP、应用程序和数据库服务器)的负载和压力。尽管它与应用程序本身高度相关,但此模块提供的通用缓存工具等可以帮助在应用程序性能上实现相当大的改进。

CherryPy 缓存模块在 HTTP 服务器级别工作,这意味着它会缓存要发送给用户代理的生成输出,并根据预定义的键检索缓存资源,默认为指向该资源的完整 URL。缓存存储在服务器内存中,因此当停止服务时将丢失。请注意,您还可以传递自己的缓存类来以不同的方式处理底层过程,同时保持相同的高级接口。

覆盖率模块

在构建应用程序时,了解应用程序根据其处理的输入所采取的路径通常是有益的。这有助于确定潜在的瓶颈,并查看应用程序是否按预期运行。CherryPy 提供的覆盖率模块就是这样做的,并提供了一个友好的可浏览输出,显示了运行期间执行的代码行。该模块是少数几个依赖第三方包来运行的模块之一。

编码/解码模块

通过 Web 发布意味着处理现有的众多字符编码。在一端,您可能只使用 US-ASCII 发布自己的内容,而不需要征求读者的反馈;在另一端,您可能发布一个如公告板之类的应用程序,它可以处理任何类型的 charset。为了帮助完成这项任务,CherryPy 提供了一个编码/解码模块,该模块根据服务器或用户代理设置过滤输入和输出内容。

HTTP 模块

此模块提供了一套类和函数来处理 HTTP 头和实体。

例如,为了解析 HTTP 请求行和查询字符串:

s = 'GET /note/1 HTTP/1.1' # no query string
r = http.parse_request_line(s) # r is now ('GET', '/note/1', '',
'HTTP/1.1')
s = 'GET /note?id=1 HTTP/1.1' # query string is id=1
r = http.parse_request_line(s) # r is now ('GET', '/note', 'id=1',
'HTTP/1.1')
http.parseQueryString(r[2]) # returns {'id': '1'}
Provide a clean interface to HTTP headers:
For example, say you have the following Accept header value:
accept_value = "text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5"
values = http.header_elements('accept', accept_value)
print values[0].value, values[0].qvalue # will print text/html 1.0

Httpauth 模块

此模块提供了 RFC 2617 中定义的基本和摘要认证算法的实现。

分析器模块

此模块提供了一个用于对应用程序进行性能检查的接口。

会话模块

互联网建立在无状态协议 HTTP 之上,这意味着请求是相互独立的。尽管如此,用户在浏览电子商务网站时会有一种感觉,即应用程序或多或少地遵循他或她打电话给商店下订单的方式。因此,会话机制被引入互联网,以便服务器能够跟踪用户信息。

CherryPy 的会话模块为应用程序开发者提供了一个直观的接口,用于存储、检索、修改和删除会话对象中的数据块。CherryPy 内置了三种不同的会话对象后端存储:

后端类型 优点 缺点
RAM 高效接受任何类型的对象无需配置 服务器关闭时信息丢失内存消耗可能快速增长
文件系统 信息持久化简单设置 文件系统锁定可能效率低下只能存储可序列化(通过 pickle 模块)的对象
关系型数据库(内置 PostgreSQL 支持) 信息持久化健壮可扩展可进行负载均衡 只能存储可序列化的对象设置不太直观

优点是,你的应用程序将使用一个与底层后端无关的高级接口。因此,在早期开发中,你可能使用 RAM 会话,但如果你以后需要,可以轻松切换到 PostgreSQL 后端,而无需修改你的应用程序。显然,CherryPy 允许你在需要时插入并使用自己的后端。

静态模块

即使是最动态的应用程序也需要提供静态资源,如图像或 CSS。CherryPy 提供了一个模块,用于简化提供这些资源或提供完整目录结构的流程。它将处理底层的 HTTP 交换,例如使用 If-Modified-Since 头部,该头部检查资源自给定日期以来是否已更改,从而避免不必要的再次处理。

Tidy 模块

尽管作为网络应用程序开发者,你应该确保你应用程序生成的内容是干净且符合标准的,但可能发生你必须提供你无法完全控制的内容。在这种情况下,CherryPy 提供了一种简单的方法,通过使用 nsgmltidy 等工具来过滤输出内容。

Wsgiapp 模块

此模块允许你将任何 WSGI 应用程序包装为 CherryPy 应用程序。有关 WSGI 的更多信息,请参阅第四章。

XML-RPC 模块

XML-RPC 是一种使用 XML 格式消息的远程过程调用协议,通过 HTTP 在 XML-RPC 客户端和 XML-RPC 服务器之间传输。基本上,客户端创建一个包含要调用的远程方法名称和要传递的值的 XML 文档,然后通过 HTTP POST 消息请求服务器。返回的 HTTP 响应包含一个作为字符串的 XML 文档,由客户端进行处理。

CherryPy 的 xmlrpc 模块允许您将发布的对象转换为 XML-RPC 服务。CherryPy 将从传入的 XML 文档中提取方法名称以及值,并将应用与常规 URI 调用相同的逻辑,因此寻找匹配的页面处理器。然后当页面处理器返回时,CherryPy 将内容包装成有效的 XML-RPC 响应并发送回客户端。

以下代码示例定义了一个由 CherryPy 提供的 XML-RPC 服务。

import cherrypy
from cherrypy import _cptools
class Root(_cptools.XMLRPCController):
@cherrypy.expose
def echo(self, message):
return message
if __name__ == '__main__':
cherrypy.quickstart(Root(), '/xmlrpc')

您的 XML-RPC 客户端可能看起来像这样:

import xmlrpclib
proxy = xmlrpclib.ServerProxy('http://localhost:8080/xmlrpc/')
proxy.echo('hello') # will return 'hello'

工具

在前面的章节中,我们介绍了内置模块。CherryPy 提供了一个统一接口,称为工具接口,用于调用这些模块或构建并调用您自己的模块。

工具可以从三个不同的上下文中设置:

  • 配置文件或字典
conf = {'/': {
'tools.encode.on': True,
'tools.encode.encoding': 'ISO-8859-1'
}
}
cherrypy.tree.mount(Root(), '/', config=conf)

  • 附属于特定的页面处理器

    决定向匹配 URI 的对象路径添加额外处理并不罕见。在这种情况下,您可能想在页面处理器周围使用 Python 装饰器。

@cherrypy.expose
@cherrypy.tools.encode(encoding='ISO 8859-1')
def index(self)
return "Et voilà"

  • 使用高级接口进行库调用

    工具可以作为常规 Python 可调用对象应用。

def index(self):
cherrypy.tools.accept.callable(media='text/html')

上面的行显示了如何调用 accept 工具,该工具在请求的 Accept HTTP 标头中查找提供的媒体类型。

多亏了统一接口,可以修改工具的底层代码,而无需修改应用程序本身。

注意

工具是通过将第三方组件插入 CherryPy 引擎来扩展 CherryPy 的接口。

错误和异常处理

CherryPy 尽力帮助开发者将网络应用程序视为与丰富应用程序尽可能接近。这意味着您可以从页面处理器中引发 Python 错误或异常,就像在其他任何 Python 应用程序中一样。CherryPy 会捕获这些错误并将它们转换为根据错误类型生成的 HTTP 消息。

注意

注意,当异常被引发且未被应用程序的其他部分捕获时,CherryPy 将返回相应的 HTTP 500 错误代码。

例如,以下示例将展示 CherryPy 的默认行为。

import cherrypy
class Root:
@cherrypy.expose
def index(self):
raise NotImplementedError, "This is an error..."
if __name__ == '__main__':
cherrypy.quickstart(Root(), '/')

错误和异常处理

如您所见,CherryPy 显示了 Python 错误的完整跟踪信息。虽然这在开发应用程序时很有用,但在生产模式下可能并不相关。在这种情况下,CherryPy 仅返回默认消息。

错误和异常处理

注意

在开发模式下,您可以通过在配置设置的“全局”部分中使用 request.show_tracebacks 键来隐藏错误时的跟踪信息。

当 CherryPy 捕获到应用程序开发者未处理的错误时,它会返回 HTTP 错误代码 500。HTTP 规范定义了两套错误代码,4xx 范围内的客户端错误和 5xx 范围内的服务器错误。客户端错误表示用户代理发送了无效的请求(例如,缺少身份验证凭据、请求的资源未找到或已删除等)。服务器错误通知用户代理发生了事件,阻止服务器完成请求处理。

CherryPy 提供了一个简单的接口,允许应用程序开发者发送正确的错误代码:

cherrypy.HTTPError(error_code, [error_message])

注意

HTTPError 错误将被 CherryPy 引擎捕获,然后它会使用错误代码和错误消息作为要发送的 HTTP 响应的状态和主体。

当引发该错误时,CherryPy 将 HTTP 响应主体设置为提供的信息,并将 HTTP 头部设置为与定义的错误代码匹配。

import cherrypy
class Root:
@cherrypy.expose
def index(self):
raise cherrypy.HTTPError(401, 'You are not authorized to \
access this resource')
if __name__ == '__main__':
CherryPyerror handlingcherrypy.quickstart(Root(), '/')

返回的 HTTP 响应将是:

HTTP/1.x 401 Unauthorized
Date: Wed, 14 Feb 2007 11:41:55 GMT
Content-Length: 744
Content-Type: text/html
Server: CherryPy/3.0.1alpha

错误和异常处理

import cherrypy
class Root:
CherryPyerror handling@cherrypy.expose
def index(self):
# shortcut to cherrypy.HTTPError(404)
raise cherrypy.NotFound
if __name__ == '__main__':
CherryPyexception handlingconf = {'global':{'request.show_tracebacks':False}}
cherrypy.config.update(conf)
cherrypy.quickstart(Root(), '/')

错误和异常处理

你可能会想知道如何更改 CherryPy 返回的错误页面布局,以将其集成到自己的应用程序中。实现这一目标的方法是使用配置系统。

import cherrypy
class Root:
# Uncomment this line to use this template for this level of the
# tree as well as its sub-levels
#_cp_config = {'error_page.404': 'notfound.html'}
@cherrypy.expose
def index(self):
CherryPyexception handlingraise cherrypy.NotFound
# Uncomment this line to tell CherryPy to use that html page only
CherryPyerror handling# for this page handler. The other page handlers will use
# the default CherryPy layout
# index._cp_config = {'error_page.404': 'notfound.html'}
if __name__ == '__main__':
# Globally set the new layout for an HTTP 404 error code
cherrypy.config.update({'global':{'error_page.404': 'notfound.html' }})
cherrypy.quickstart(Root(), '/')

notfound.html 页面:

<html>
<head><title>Clearly not around here</title></head>
<body>
<p>Well sorry but couldn't find the requested resource.</p>
</body>
</html>

错误和异常处理

当捕获到 HTTPError 错误时,CherryPy 会查找该页面处理器的配置中的 error_page.xxx(其中 xxx 是使用的 HTTP 错误代码)条目,并使用它而不是默认模板。

如您所见,CherryPy 提供了一种非常灵活且有效的使用自己的页面模板来显示更友好的错误消息的方法。

到目前为止,我们已经讨论了 CherryPy 中错误的高级处理。然而,我们可以通过钩子 API 修改内部处理,正如我们将在下一章中看到的。

摘要

本章应该已经向您介绍了 CherryPy、HTTP 和服务器引擎的一些核心原则,以及其配置系统。我们还简要讨论了对象发布引擎,它允许将 URI 透明映射到公开的 Python 对象。最后,我们简要回顾了 CherryPy 库的核心模块,这些模块增强了其功能,以及 CherryPy 让您处理错误的方式。下一章将深入探讨 CherryPy 的内部组件和功能,并更详细地介绍一些已经讨论过的主题。

第四章。CherryPy 深入

第三章介绍了 CherryPy 的常见方面,而没有过多深入细节。在本章中,我们将通过解释关键特性,如如何运行多个 HTTP 服务器、使用额外的 URI 分派器、使用内置工具并开发新的工具、提供静态内容以及最后如何 CherryPy 和 WSGI 交互,深入探讨使 CherryPy 成为网络开发者如此强大的库的原因。本章内容密集,但将为您提供一个良好的基础,让您在使用产品时更加轻松高效。

HTTP 兼容性

CherryPy 正在稳步发展,尽可能地遵守 HTTP 规范——首先支持旧的 HTTP/1.0,然后逐渐过渡到完全支持 RFC 2616 中定义的 HTTP/1.1。据说 CherryPy 对 HTTP/1.1 的兼容性是有条件的,因为它实现了规范中的所有 必须要求 级别,但没有实现所有 应该 级别。因此,CherryPy 支持以下 HTTP/1.1 的特性:

  • 如果客户端声称支持 HTTP/1.1,则必须在任何使用该协议版本的请求中发送 Host 头字段。如果没有这样做,CherryPy 将立即停止请求处理,并返回 400 错误代码消息(RFC 2616 的第 14.23 节)。

  • CherryPy 在所有配置中生成 Date 头字段(RFC 2616 的第 14.18 节)。

  • CherryPy 可以处理客户端支持的 Continue 响应状态码(100)。

  • CherryPy 内置的 HTTP 服务器支持 HTTP/1.1 中的默认持久连接,通过使用 Connection: Keep-Alive 头部。请注意,如果选择的 HTTP 服务器不支持此功能,更改 HTTP 服务器(更多详情请参阅第十章
    cherrypy.server.quickstart()


如您所见,我们调用了服务器对象的 `quickstart()` 方法,这将实例化内置 HTTP 服务器并在其自己的线程中启动它。

现在想象一下,我们有一个希望在多个网络接口上运行的应用程序;我们应该这样做:

```py
from cherrypy import _cpwsgi
# Create a server on interface 1102.168.0.12 port 100100
s1 = _cpwsgi.CPWSGIServer()
s1.bind_addr = ('1102.168.0.12', 100100)
# Create a server on interface 1102.168.0.27 port 4700
s2 = _cpwsgi.CPWSGIServer()
s2.bind_addr = ('1102.168.0.27', 4700)
# Inform CherryPy which servers to start and use
cherrypy.server.httpservers = {s1: ('1102.168.0.12', 100100),
s2: ('1102.168.0.27', 4700)}
cherrypy.server.start()

如您所见,我们首先创建了内置 HTTP 服务器的两个实例,并为每个实例设置了套接字应该监听传入请求的绑定地址。

然后,我们将这些服务器附加到 CherryPy 的 HTTP 服务器池中,并调用 start() 方法,这将使每个服务器在其接口上启动。

注意,我们并没有调用 cherrypy.config.update,因为这将会更新所有服务器共享的全局配置设置。然而,这实际上并不是一个问题,因为内置服务器的每个实例都有与配置键匹配的属性。因此:

s1.socket_port = 100100
s1.socket_host = '1102.168.0.12'
s1.socket_file = ''
s1.socket_queue_size = 5
s1.socket_timeout = 10
s1.protocol_version = 'HTTP/1.1'
s1.reverse_dns = False
s1.thread_pool = 10
s1.max_request_header_size = 500 * 1024
s1.max_request_body_size = 100 * 1024 * 1024
s1.ssl_certificate = None
s1.ssl_private_key = None

如您所见,您可以直接设置服务器实例的设置,避免使用全局配置。这种技术还允许应用程序同时通过 HTTP 和 HTTPS 提供服务,正如我们将在第十章(Chapter 10 中看到的,默认情况下,CherryPy 将 URI 映射到具有exposed属性设置为True的 Python 可调用对象。随着时间的推移,CherryPy 社区希望更加灵活,并且会欣赏其他调度器的解决方案。这就是为什么 CherryPy 3 提供了另外三个内置调度器,并提供了编写和使用您自己的调度器的一种简单方法。

  • 其中一个是设置为允许按 HTTP 方法开发应用程序。(GET、POST、PUT 等。)

  • 第二个是基于一个流行的第三方包 Routes,由 Ben Bangert 从 Ruby on Rails 的原始 Ruby 实现中开发而来。

  • 第三个调度器是一个虚拟主机调度器,它允许根据请求的域名而不是 URI 路径进行调度。

HTTP 方法调度器

在某些应用中,URI 与服务器在资源上执行的操作是独立的。例如,看看下面的 URI:

http://somehost.com/album/delete/12

如你所见,URI 包含了客户端希望执行的操作。使用默认的 CherryPy 调度器,这会映射到类似以下的内容:

album.delete(12)

虽然这样做是可以的,但你可能希望从 URI 本身中移除该操作,使其更加独立,这样它看起来就会像:

http://somehost.com/album/12

你可能会立即想知道服务器应该如何知道要执行哪个操作。这个信息由 HTTP 请求本身携带,多亏了 HTTP 方法:

DELETE /album/12 HTTP/1.1

处理此类请求的页面处理器看起来如下:

class Album:
exposed = True
def GET(self, id):
....
def POST(self, title, description):
....
def PUT(self, id, title, description):
....
def DELETE(self, id):
....

当使用 HTTP 方法分配器时,被调用的页面处理器将是album.DELETE(12)

如果你查看之前的类定义,你会看到方法没有携带exposed属性,而是类本身设置了该属性。这个原因来自于分配器实现的方式。

当一个请求到达服务器时,CherryPy 会寻找最佳匹配的页面处理器。当使用 HTTP 方法分配器时,处理器实际上是 URI 所指向的资源的概念性表示,在我们的例子中是album类的实例。然后分配器检查该类是否有与请求使用的 HTTP 方法名称匹配的方法。如果有,分配器会使用剩余的参数调用它。否则,它会立即发送 HTTP 错误代码405 方法不允许来通知客户端它不能使用 HTTP 方法,因此不能在该特定资源上执行该操作。

例如,如果我们没有在Album类中对DELETE进行定义,那么在之前使用的请求中会返回这样的错误代码。

然而,无论如何,CherryPy 都会自动将Allow HTTP 头添加到响应中,以通知客户端它可以对资源使用哪些方法。

注意

注意,在这种情况下,CherryPy 不会像使用 URI 到对象分配器那样寻找indexdefault页面处理器。这来自于仅基于 URI 分配与 URI+HTTP 方法分配之间的基本区别。第六章将更详细地讨论这一点。

要启用 HTTP 方法分配器,你必须将request.dispatch键设置为针对目标路径的该分配器的实例。

例如,如果我们整个应用程序都是使用那种技术构建的,我们会使用:

{'/' : {'request.dispatch': cherrypy.dispatch.MethodDispatcher()}}

HTTP 方法分配器通常用于遵循 REST 原则的应用程序中,我们将在第六章中看到。

Routes 分配器

无论是在 URI 到对象或 HTTP 方法分配器中,我们都没有明确声明与页面处理器关联的 URI;相反,我们将找到最佳对应的责任留给了 CherryPy 引擎。许多开发者更喜欢明确的方法,并决定 URI 应该如何映射到页面处理器。

因此,当使用 Routes 分配器时,你必须连接一个匹配 URI 并关联特定页面处理器的模式。

让我们回顾一个例子:

import cherrypy
class Root:
def index(self):
return "Not much to say"
def hello(self, name):
return "Hello %s" % name
if __name__ == '__main__':
root = Root()
# Create an instance of the dispatcher
d = cherrypy.dispatch.RoutesDispatcher()
# connect a route that will be handled by the 'index' handler
d.connect('default_route', '', controller=root)
# connect a route to the 'hello' handler
# this will match URIs such as '/say/hello/there'
# but not '/hello/there'
d.connect('some_other', 'say/:action/:name',
controller=root, action='hello')
# set the dispatcher
conf = {'/': {'request.dispatch': d}}
cherrypy.quickstart(root, '/', config=conf)

注意

当使用 Routes 分配器处理器时,你不需要有exposed属性。

路由分配器的connect方法定义为:

connect(name, route, controller, **kwargs)

下面是connect方法的参数:

  • name 参数是连接到路由的唯一名称。

  • route 是匹配 URI 的模式。

  • controller 是包含页面处理程序的实例。

  • **kwargs 允许你为路由传递额外的有效参数。

请参阅官方 Routes 文档以了解该包的工作方式。

默认情况下,CherryPy 路由调度器不会将 Routes 映射返回的 actioncontroller 值传递给与任何路由匹配的 URI。这些在 CherryPy 应用程序中不一定有用。然而,如果你需要它们,你可以将 Routes 调度器构造函数的 fetch_result 参数设置为 True。然后这两个值都将传递给页面处理程序,但在此情况下,你必须将 controlleraction 参数添加到所有页面处理程序中。

虚拟主机调度器

可能会发生这样的情况,你需要在单个 CherryPy 服务器内托管不同的网络应用程序,每个应用程序服务一个特定的域名。CherryPy 提供了一种简单的方法来实现这一点,如下面的示例所示:

import cherrypy
class Site:
def index(self):
return "Hello, world"
index.exposed = True
class Forum:
def __init__(self, name):
self.name = name
def index(self):
return "Welcome on the %s forum" % self.name
index.exposed = True
if __name__ == '__main__':
site = Site()
site.cars = Forum('Cars')
site.music = Forum('My Music')
hostmap = {'www.ilovecars.com': '/cars',
'www.mymusic.com': '/music',}
cherrypy.config.update({'server.socket_port': 80})
conf = {'/': {'request.dispatch': cherrypy.dispatch.VirtualHost(**hostmap)}}
cherrypy.tree.mount(site, config=conf)
cherrypy.server.quickstart()
cherrypy.engine.start()

首先,正如你所看到的,我们只是创建了一个应用程序树。接下来,我们定义 hostmap 字典,它将通知 VirtualHost 调度器如何根据域名来服务请求。因此,来自 www.mymusic.com 的请求将由位于 /music 前缀的应用程序提供服务。接下来,我们告诉 CherryPy 我们将使用 VirtualHost 调度器,我们最后像往常一样挂载网站应用程序并启动服务器。

注意,此示例需要你编辑你机器上的 hosts 文件以添加以下两个域名:

127.0.0.1 www.ilovecars.com
127.0.0.1 www.mymusic.com

它将自动将请求重定向到这些域名,而不是在互联网上查找它们。完成此示例后,你应该从 hosts 文件中删除这些行。

将钩子插入到 CherryPy 的核心引擎

CherryPy 最强大的方面之一是其核心如何以非常精细的粒度让你修改其正常行为。实际上,CherryPy 提供了一种称为钩子的机制来定制核心引擎。

钩子是 Python 可调用项在请求处理过程中的特定点应用的入口点。CherryPy 提供以下入口点:

插入点 描述
on_start_resource 在进程开始时调用。
before_request_body 在 CherryPy 尝试读取请求体之前调用。它允许一个工具通过在工具中将 process_request_body 属性设置为 False 来通知 CherryPy 是否应该执行此操作。
before_handler 在页面处理程序被调用之前调用。例如,一个工具可以将处理程序设置为 None 来通知 CherryPy 它不应该处理页面处理程序。
before_finalize 无论页面处理程序是否被调用,在 CherryPy 开始处理响应之前调用。
on_end_resource 当资源处理结束时调用。
before_error_response after_error_response 当 CherryPy 引擎捕获到错误时调用,以便应用程序恢复并决定下一步操作。
on_end_request 在整体处理结束时调用,在客户端连接关闭后立即调用。这允许您释放资源。

下图显示了 CherryPy 在处理请求时遵循的全局流程。黑色线条和箭头表示正常流程,而灰色线条表示发生错误时的路径。

Hook into CherryPy's Core Engine

在这些钩子点之一附加回调是通过调用以下内容完成的:

cherrypy.request.hooks.attach(point, callback, failsafe=None,
priority=None, **kwargs)

第一个参数是钩子点的名称,如前表所示。第二个参数是将被应用的 Python 可调用对象。第三个参数指示 CherryPy,即使另一个回调在处理此钩子点时可能失败,CherryPy 也必须运行此可调用对象。最后一个参数必须是一个介于 0 到 100 之间的值,以指示每个回调的权重并提供一种对它们进行排序的方法。较低的值将首先运行。

failsafe 参数非常有用,因为它为应用程序提供了一种灵活地恢复可能发生的问题的方法。确实,一些回调可能会失败,但不会影响请求处理链的整个流程。

注意

注意,您可以在给定的钩子点上附加所需的任何数量的回调。回调可以在应用程序运行时即时附加。然而,附加的回调越多,该钩子点的处理速度就会越慢。

钩子机制相当接近 CherryPy 2 中曾经被称为过滤器的东西。然而,随着时间的推移,人们观察到它们过于底层,并且大多数时候让用户感到不舒服。这就是为什么开发者直接使用它们的频率仍然很低。相反,它们通过一个名为 tools 的更高层接口来应用。

CherryPy 工具箱

工具接口是由 Robert Brewer 在重构 CherryPy 时设计的。目标是提供一套现成的工具,通过友好且灵活的 API 实现常见任务。在 CherryPy 中,内置工具提供了一个单一接口,用于通过钩子机制调用我们在第三章中审查的 CherryPy 库。

正如我们在第三章中看到的,工具可以以三种不同的方式使用:

  • 从配置设置

  • 作为 Python 装饰器或通过页面处理器的特殊_cp_config属性

  • 作为可以在任何函数内部应用的 Python 可调用对象

由于这种灵活性,工具可以设置为全局路径及其子集,或者设置为特定的页面处理器。现在让我们回顾 CherryPy 提供的内置工具。

基本身份验证工具

目的: 此工具的目的是为您的应用程序提供基本认证(RFC 2617)。

参数:

名称 默认 描述
realm N/A (在此情况下,N/A 表示参数必须由开发者提供,因为它没有默认值。) 定义领域值的字符串。
users N/A 形式为 username:password 的字典或返回此类字典的 Python 可调用对象。
encrypt None 用于加密客户端返回的密码并与用户字典中提供的加密密码进行比较的 Python 可调用对象。如果为 None,则使用 MD5 散列。

示例:

import sha
import cherrypy
class Root:
@cherrypy.expose
def index(self):
return """<html>
<head></head>
<body>
<a href="admin">Admin area</a>
</body>
</html>
"""
class Admin:
@cherrypy.expose
def index(self):
return "This is a private area"
if __name__ == '__main__':
def get_users():
# 'test': 'test'
return {'test': 'a104a8fe5ccb110ba61c4c0873d3101e10871082fbbd3'}
def encrypt_pwd(token):
return sha.new(token).hexdigest()
conf = {'/admin': {'tools.basic_auth.on': True,
'tools.basic_auth.realm': 'Some site',
'tools.basic_auth.users': get_users,
'tools.basic_auth.encrypt': encrypt_pwd}}
root = Root()
root.admin = Admin()
cherrypy.quickstart(root, '/', config=conf)

get_users函数返回一个硬编码的字典,但它也可以从数据库或其他地方获取值。请注意,基本认证方案实际上并不安全,因为密码只是编码的,如果有人捕获它,可以即时解码。然而,由于安全套接字层加密了包含的数据,这种方案通常在 SSL 上使用,因为它是最容易实施的。

缓存工具

目的: 此工具的目的是提供 CherryPy 生成内容的内存缓存。

参数:

名称 默认 描述
invalid_methods ("POST", "PUT", "DELETE") 不应缓存的 HTTP 方法字符串元组。这些方法还将使任何缓存的资源副本失效(删除)。
cache_class MemoryCache 用于缓存的类对象。

一个全面的示例超出了本书的范围,但如果您对这个工具感兴趣,您应该首先查看 CherryPy 测试套件,并访问 CherryPy 用户邮件列表。

解码工具

目的: 此工具的目的是解码传入的请求参数。

参数:

名称 默认 描述
encoding None 应使用什么编码来解码传入的内容?如果为 None,则查找Content-Type头,如果找不到合适的字符集,则使用default_encoding
default_encoding "UTF-8" 默认编码,当未提供或找到时将使用此编码。

示例:

import cherrypy
from cherrypy import tools
class Root:
@cherrypy.expose
def index(self):
return """<html>
<head></head>
<body>
<form action="hello" method="post">
<input type="text" name="name" value="" />
</form>
</body>
</html>
"""
@cherrypy.expose
@tools.decode(encoding='ISO-88510-1')
def hello(self, name):
return "Hello %s" % (name, )
if __name__ == '__main__':
cherrypy.quickstart(Root(), '/')

在此情况下,当 HTML 表单发送到服务器时,CherryPy 会尝试使用我们设置的编码来解码传入的数据。如果您查看name参数的类型,您会看到当使用解码工具时它是Unicode,而没有工具时它是一个字符串

摘要认证工具

目的: 此工具的目的是提供 RFC 2617 中定义的摘要认证。

参数:

名称 默认 描述
realm N/A 定义领域值的字符串。
users N/A 形式为—username:password 的字典或返回此类字典的 Python 可调用对象。

示例:

import cherrypy
class Root:
@cherrypy.expose
def index(self):
return """<html>
<head></head>
<body>
<a href="admin">Admin area</a>
</body>
</html>
"""
class Admin:
@cherrypy.expose
def index(self):
return "This is a private area"
if __name__ == '__main__':
def get_users():
return {'test': 'test'}
conf = {'/admin': {'tools.digest_auth.on': True,
'tools.digest_auth.realm': 'Some site',
'tools.digest_auth.users': get_users}}
root = Root()
root.admin = Admin()
cherrypy.quickstart(root, '/', config=conf)

注意,摘要工具不提供传递加密密码的方法。这是因为摘要方案定义了不将密码以明文形式发送到网络上。它的工作方式如下:

    1. 客户端请求访问资源。服务器返回401错误代码,表示它使用摘要方案。服务器为此交换提供令牌。
    1. 客户端根据令牌、用户名和密码创建一条新消息,并通过 MD5 算法生成哈希值。
    1. 当服务器收到来自客户端的新消息时,它尝试生成相同的值。如果它们都匹配,则允许认证。

正如你所见,密码永远不会以明文形式在网络上传输。已经进行了讨论,以决定如何使摘要工具进化,以避免需要以明文形式存储密码。一种方法是将摘要令牌的中间步骤之一(步骤 1)存储起来,并将此值与客户端发送的值进行比较。这超出了本书的范围,但你可以在 CherryPy 邮件列表中获取更多信息。

编码工具

目的: 此工具的目的是以定义的编码编码响应内容。

参数:

名称 默认值 描述
encoding None 要使用什么编码来编码响应?如果为 None,它将查找Content-Type头,并在可能的情况下设置合适的字符集。
errors "strict" 定义工具在无法编码字符时必须如何反应。

示例:

import cherrypy
from cherrypy import tools
class Root:
@cherrypy.expose
def index(self):
return """<html>
<head></head>
<body>
<form action="hello" method="post">
<input type="text" name="name" value="" />
</form>
</body>
</html>
"""
@cherrypy.expose
@tools.encode(encoding='ISO-88510-15')
def hello(self, name):
return "Hello %s" % name
if __name__ == '__main__':
cherrypy.quickstart(Root(), '/')

错误重定向工具

目的: 此工具的目的是修改 CherryPy 默认错误处理器。

参数:

名称 默认值 描述
url '' 应重定向到的 URL。
internal True True时,重定向对客户端是隐藏的,并且仅在请求的上下文中发生。如果False,CherryPy 会通知客户端客户端应自行向提供的 URL 发出重定向。

Etag 工具

目的: 此工具的目的是验证用户代理发送的实体标签(Etag),并根据 RFC 2616 第 14.24 节定义生成相应的响应。Etags 是缓存 HTTP 响应并减轻任何相关方负担的一种方式。

参数:

名称 默认值 描述
autotags False True时,工具将根据响应体设置生成一个etag值。

示例:

import cherrypy
from cherrypy import tools
class Root:
@cherrypy.expose
def index(self):
return """<html>
<head></head>
<body>
<form action="hello" method="post">
<input type="text" name="name" value="" />
</form>
</body>
</html>
"""
@cherrypy.expose
def hello(self, name):
return "Hello %s" % name
if __name__ == '__main__':
conf = {'/': {'tools.etags.on': True,
'tools.etags.autotags': True}}
cherrypy.quickstart(Root(), '/', config=conf)

在上一个示例中,我们为整个应用程序设置了etags工具。在第一次请求index页面处理器时,该工具将生成一个etag值并将其插入到响应头中。在下一次请求该 URI 时,客户端将包含最后接收到的etag。工具将比较它们,如果它们匹配,则响应将为304 Not Modified,通知客户端它可以安全地使用其资源副本。

注意,如果您需要以不同的方式计算etag值,最佳做法是将autotags参数设置为False,这是默认值,然后从您的页面处理器中自行添加Etag头到响应头中。

Gzip 工具

目的: 此工具的目的是对响应体进行内容编码。

参数:

名称 默认值 描述
compress_level 10 要达到的压缩级别。越低,速度越快。
mime_types ['text/html', 'text/plain'] 可以压缩的 MIME 类型列表。

示例:

import cherrypy
from cherrypy import tools
class Root:
@cherrypy.expose
@tools.gzip()
def index(self):
return "this will be compressed"
if __name__ == '__main__':
cherrypy.quickstart(Root(), '/')

注意,当响应通过其stream属性流式传输时不应使用gzip工具。实际上,在这种情况下,CherryPy 在有任何内容要发送时就开始发送主体,例如页面处理器产生内容时,而不是返回它。

忽略头工具

目的: 此工具的目的是在 CherryPy 处理之前从 HTTP 请求中删除指定的头。

参数:

名称 默认值 描述
ignore_headers headers=('Range',) 要忽略的头名称元组。

示例:

import cherrypy
from cherrypy import tools
class Root:
@cherrypy.expose
@tools.ignore_headers(headers=('Accept-Language',))
def index(self):
return "Accept-Language: %s" \
% cherrypy.request.headers.get('Accept-Language',
'none provided')
@cherrypy.expose
def other(self):
return "Accept-Language: %s" % cherrypy.request.headers.get('Accept-Language')
if __name__ == '__main__':
cherrypy.quickstart(Root(), '/')

如果您访问localhost:8080/,无论客户端是否确实设置了该头,您都将得到以下信息:

Accept-Language: none provided

如果您导航到localhost:8080/other,您将得到以下信息:

Accept-Language: en-us,en;q=0.5

日志头工具

目的: 此工具的目的是在服务器上发生错误时将请求头输出到错误日志文件。此工具默认禁用。

参数:

示例:

import cherrypy
from cherrypy import tools
class Root:
@cherrypy.expose
def index(self):
raise StandardError, "Some sensible error message here"
if __name__ == '__main__':
cherrypy.config.update({'global': {'tools.log_headers.on':
True}})
cherrypy.quickstart(Root(), '/')

当您访问localhost:8080时,将引发错误,错误日志将显示请求头。请注意,在这种情况下,此工具是通过cherrypy.config.update()方法在 Web 服务器级别设置的,但它也可以按路径级别应用。

日志堆栈跟踪工具

目的: 此工具的目的是在发生异常时将错误的堆栈跟踪输出到错误日志文件。此工具默认启用。

参数:

示例:

import cherrypy
from cherrypy import tools
class Root:
@cherrypy.expose
def index(self):
raise StandardError, "Some sensible error message here"
if __name__ == '__main__':
# This tool is applied globally to the CherryPy process
# by using the global cherrypy.config.update method.
cherrypy.config.update({'global': {'tools.log_tracebacks.on':
False}})
cherrypy.quickstart(Root(), '/')

代理工具

目的: 此工具的目的是更改请求的基本 URL。当在 Apache 等服务器后面运行应用程序时,这特别有用。

参数:

名称 默认值 描述
base None 如果设置且local为空,这将是从cherrypy.request.base可用的新的基本 URL。
local 'X-Forwarded-Host' 查找本地主机设置的头部,例如前端 Web 服务器设置的。
remote 'X-Forwarded-For' 查找原始客户端 IP 地址的头部。
scheme 'X-Forwarded-Proto' 查找原始方案使用的头部:例如httphttps

当未设置基本 URL 时,该工具将从请求头部获取的值构建新的基本 URI,基于其他参数。

示例:

import cherrypy
from cherrypy import tools
class Root:
@cherrypy.expose
def index(self):
return "Base URL: %s %s " % (cherrypy.request.base,
cherrypy.url(''))
@cherrypy.expose
def other(self):
raise cherrypy.HTTPRedirect(cherrypy.url(''))
if __name__ == '__main__':
conf = {'global': {'tools.proxy.on': True,
'tools.proxy.base': 'http://someapp.net/blog',
'tools.proxy.local': ''}}
cherrypy.config.update(conf)
cherrypy.quickstart(Root(), '/')

当导航到localhost:8080时,你会看到以下消息:

Base URL: http://someapp.net/blog http://someapp.net/blog/

如果你导航到localhost:8080/other,你将被重定向到someapp.net/blog/,这表明代理工具以透明的方式确保 CherryPy 库的行为与您提供的设置保持一致。

在此工具后面使用另一个服务器使用示例,请参阅第十章。

Referer 工具

目的: 此工具的目的是允许根据模式过滤请求。在匹配模式后,可以拒绝或接受请求。

参数:

名称 默认值 描述
pattern N/A 正则表达式模式。
accept True 如果为True,任何匹配的引用将允许请求继续。否则,任何匹配的引用将导致请求被拒绝。
accept_missing False 是否允许没有引用的请求。
error 403 拒绝时返回给用户的 HTTP 错误代码。
message 'Forbidden Referer header.' 拒绝时返回给用户的消息。

示例:

import cherrypy
from cherrypy import tools
class Root:
@cherrypy.expose
def index(self):
return cherrypy.request.headers.get('Referer')
if __name__ == '__main__':
conf = {'/': {'tools.referer.on': True,
'tools.referer.pattern': 'http://[^/]*dodgy\.com',
'tools.referer.accept': False}}
cherrypy.quickstart(Root(), '/', config=conf)

在此示例中,我们将拒绝所有来自dodgy.com域名及其子域的请求。

响应头部工具

目的: 此工具的目的是允许一次性为所有或许多页面处理器设置一些常见的头部信息。

参数:

名称 默认值 描述
headers None 列表:元组(头部,值)

示例:

import cherrypy
from cherrypy import tools
class Root:
@cherrypy.expose
def index(self):
return "Some text"
@cherrypy.expose
def other(self):
return "Some other text"
if __name__ == '__main__':
conf = {'/': {'tools.response_headers.on': True,
'tools.response_headers.headers': [('Content-Type',
'text/plain')]}}
cherrypy.quickstart(Root(), '/', config=conf)

在此示例中,该工具为所有页面处理器设置Content-Typetext/plain

尾部斜杠工具

目的: 此工具的目的是提供一种灵活的方式来处理请求的尾部斜杠。此工具默认启用。

参数:

名称 默认值 描述
missing True 如果页面处理器是索引,如果missing参数为True,并且请求遗漏了尾部斜杠,CherryPy 将自动向带有尾部斜杠的 URI 发出重定向。
extra False 如果页面处理器不是索引,如果 extra 参数设置为 True,并且 URI 有尾部斜杠,CherryPy 将向没有尾部斜杠的 URI 发出重定向。

示例:

import cherrypy
from cherrypy import tools
class Root:
@cherrypy.expose
def index(self):
return "This should have been redirected to add the trailing
slash"
@cherrypy.expose
def nothing(self):
return "This should have NOT been redirected"
nothing._cp_config = {'tools.trailing_slash.on': False}
@cherrypy.expose
def extra(self):
return "This should have been redirected to remove the
trailing slash"
extra._cp_config = {'tools.trailing_slash.on': True,
'tools.trailing_slash.missing': False,
'tools.trailing_slash.extra': True}
if __name__ == '__main__':
cherrypy.quickstart(Root(), '/')

要了解这个工具,请导航到以下 URL:

localhost:8080

localhost:8080/nothing

localhost:8080/nothing/

localhost:8080/extra/

XML-RPC 工具

目的: 这个工具的目的是将 CherryPy 转换为 XML-RPC 服务器,并使页面处理器成为 XML-RPC 可调用对象。

参数:

示例:

import cherrypy
from cherrypy import _cptools
class Root:
@cherrypy.expose
def index(self):
return "Regular web page handler"
class XMLRPCApp(_cptools.XMLRPCController):
@cherrypy.expose
def echo(self, message):
return message
if __name__ == '__main__':
root = Root()
root.xmlrpc = XMLRPCApp()
cherrypy.quickstart(root, '/')

XMLRPCController 是一个辅助类,应该用来代替直接使用 XML-RPC 工具。

你可以按照以下方式测试你的 XML-RPC 处理器:

>>> import xmlrpclib
>>> s = xmlrpclib.ServerProxy('http://localhost:8080/xmlrpc')
>>> s.echo('test')
'test'

工具箱

CherryPy 工具必须属于一个由 CherryPy 引擎管理的工具箱。工具箱有自己的命名空间,以避免名称冲突。尽管没有阻止你使用默认的工具箱,但你也可以创建自己的工具箱,如下所示:

from cherrypy._cptools import Toolbox,
mytb = Toolbox('mytb')
mytb.xml_parse = Tool('before_handler', xmlparse)
conf = {'/': {'mytb.xml_parse.on': True,
'mytb.xml_parse.engine': 'amara'}}

创建一个工具

现在我们已经审查了 CherryPy 一起提供的工具箱,我们将解释如何编写一个工具。在决定创建一个工具之前,你应该问自己一些问题,例如:

  • 应该在 CherryPy 级别处理添加的功能吗?

  • 在请求处理的哪个级别应该应用这个功能?

  • 你是否会修改 CherryPy 的默认行为?

这些问题只是确保你想要添加的功能处于正确的级别。工具有时看起来像是一个模式,你可以在此基础上设计你的应用程序。

我们将创建一个工具,该工具将读取并解析请求体中包含的 XML,并将其解析为页面处理器参数。为此,我们将使用 ElementTree 库。(ElementTree 由 Fredrik Lundh 维护,Amara 由 Uche Ogbuji 维护。)

工具可以通过继承 Tool 类或通过该类的实例来创建,如下面的示例所示。实例化 Tool 类是最常见的情况,也是我们将要讨论的情况。

类构造函数声明如下:

Tool(point, callable, name=None, priority=50)

  • point 参数是一个字符串,指示此工具应附加到哪个钩点。

  • callable 参数是一个 Python 可调用对象,将被应用。

  • name 参数定义了工具在工具箱中的名称。如果没有提供,它将使用在工具箱中持有工具实例的属性的名称(参考我们的示例)。

  • priority 设置了当多个工具附加到相同的钩点时,工具的顺序。

一旦创建了工具的实例,你可以按照以下方式将其附加到内置工具箱:

cherrypy.tools.mytool = Tool('on_start_resource', mycallable)

这个工具将像任何其他内置工具一样,对您的应用程序可用。

在创建工具时,你可以为你的可调用对象提供两个属性,这些属性将在初始化工具时使用。它们如下所示:

  • failsafe: 如果True,则表示即使在工具轮到之前发生错误,工具也会运行。默认为False

  • priority: 此工具相对于同一钩点上的其他工具的相对顺序。默认为50

因此,你可以这样写:

def mycallable(...):
CherryPytools, creating....
mycallable.failsafe = True
mycallable.priority = 30
cherrypy.tools.mytool = Tool('on_start_resource', mycallable)

CherryPy 为将在before_handler钩点处应用的工具提供了一个快捷方式,换句话说,就是在页面处理器被调用之前。这应该是非内置工具最常见的用例之一。

cherrypy.tools.mytool = Tool('before_handler', mycallable)

这相当于以下内容:

cherrypy.tools.mytool = HandlerTool(mycallable)

HandlerTool类提供了一个额外的功能,它允许你的可调用对象通过HandlerTool类的handler(*args, **kwargs)方法本身作为一个页面处理器应用。因此:

class Root:
other = cherrypy.tools.mytool.handler()

这可以在不重复代码的情况下,为应用程序的不同区域提供相同的处理器。

现在让我们看一个更详细的示例:

import cherrypy
from cherrypy import tools
CherryPytools, creatingfrom cherrypy import Tool
from xml.parsers.expat import ExpatError
from xml.sax._exceptions import SAXParseException
def xmlparse(engine='elementtree', valid_content_types=['text/xml',
'application/xml'], param_name='doc'):
# Transform the XML document contained in the request body into
# an instance of the chosen XML engine.
# Get the mime type of the entity sent by the user-agent
ct = cherrypy.request.headers.get('Content-Type', None)
# if it is not a mime type we can handle
# then let's inform the user-agent
if ct not in valid_content_types:
raise cherrypy.HTTPError(415, 'Unsupported Media Type')
# CherryPy will set the request.body with a file object
# where to read the content from
if hasattr(cherrypy.request.body, 'read'):
content = cherrypy.request.body.read()
doc = content
try:
if engine == 'elementtree':
from elementtree import ElementTree as ETX
doc = ETX.fromstring(content)
elif engine == 'amara':
import amara
doc = amara.parse(content)
except (ExpatError, SAXParseException):
raise cherrypy.HTTPError(400, 'XML document not
well-formed')
# inject the parsed document instance into
# the request parameters as if it had been
# a regular URL encoded value
cherrypy.request.params[param_name] = doc
# Create a new Tool and attach it to the default CherryPy toolbox
tools.xml_parse = Tool('before_handler', xmlparse)
class Root:
@cherrypy.expose
@tools.xml_parse()
def echoet(self, doc):
return doc.find('.//message').text
@cherrypy.expose
@tools.xml_parse(engine='amara', param_name='d')
def echoamara(self, d):
return unicode(d.root.message)
if __name__ == '__main__':
cherrypy.quickstart(Root(), '/')

注意

为了测试这个工具,你需要 ElementTree 或 Amara,或者两者都需要。你可以通过easy_install命令安装它们。

我们的 XML 工具将读取 HTTP 正文内容,并通过指定的 XML 工具包进行解析。然后,它将解析的文档注入到请求参数中,以便新的文档实例作为常规参数传递给页面处理器。

启动前面的示例,然后在 Python 解释器中运行:

>>> s = '<root><message>Hello!<message></root>'
>>> headers = {'Content-Type': 'application/xml'}
>>> import httplib
>>> conn = httplib.HTTPConnection("localhost:8080")
>>> conn.request("POST", "/echoet", s, headers)
>>> r1 = conn.getresponse()
>>> print r1.status, r1.reason
200 OK
>>> r1.read()
'Hello!'
>>> conn.request("POST", "/echoamara", s, headers)
>>> r1 = conn.getresponse()
>>> print r1.status, r1.reason
200 OK
>>> r1.read()
'Hello!'
>>> conn.request("POST", "/echoamara", s)
>>> r1 = conn.getresponse()
>>> print r1.status, r1.reason
415 Unsupported Media Type
>>> conn.close()

如你所见,CherryPy 3 提供的工具界面功能强大、灵活,同时非常直观且易于重用。然而,在使用工具之前,始终要仔细思考你的需求。它们应该用于适合 HTTP 请求/响应模型的底层操作。

静态资源服务

CherryPy 提供了两个简单的工具来服务单个文件或整个目录。在任一情况下,CherryPy 都会通过自动检查请求中的If-Modified-SinceIf-Unmodified-Since头来处理你的静态资源的 HTTP 缓存方面,如果存在,则直接返回304 Not Modified响应。

使用 Staticfile 工具服务单个文件

staticfile工具可以用来服务单个文件。

参数:

名称 默认值 描述
filename N/A 物理文件的绝对或相对路径。
root None 如果文件名是相对的,你必须提供文件的根目录。
match "" 用于检查 URI 路径是否匹配特定模式的正则表达式。
content_types None 形如ext: mime type的字典。

示例:

为了这个目的,让我们假设我们有以下目录结构:

application \
myapp.py
design1.css

design1.css设置如下:

body {
background-color: #86da12;
}

myapp.py模块将定义如下:

import cherrypy
class MyApp:
@cherrypy.expose
def index(self):
return """<html>
<head>
<title>My application</title>
<link rel="stylesheet" href="css/style.css" type="text/css"></link>
</head>
<html>
<body>
Hello to you.
static resource servingsingle file, Staticfile tool used</body>
</html>"""
if __name__ == '__main__':
import os.path
current_dir = os.path.dirname(os.path.abspath(__file__))
cherrypy.config.update({'environment': 'production',
'log.screen': True})
conf = {'/': {'tools.staticfile.root': current_dir},
'/css/style.css': {'tools.staticfile.on': True,
'tools.staticfile.filename':
'design1.css'}}
cherrypy.quickstart(MyApp(), '/my', config=conf)

必须考虑以下几点:

  • 根目录可以全局设置整个应用程序,这样你就不必为每个 URI 路径定义它。

  • 当使用 staticfile 工具时,URI 和物理资源不需要有相同的名称。实际上,它们在命名上可以完全不相关,就像前面的示例一样。

  • 注意,尽管应用程序挂载在 /my 前缀上,这意味着对 CSS 文件的请求将是 /my/css/style.css(注意这是这种情况,因为链接元素中提供的路径是在 href 属性中的相对路径,而不是绝对路径:它不以 / 开头),我们的配置设置不包括该前缀。正如我们在第三章中看到的,这是因为配置设置与应用程序挂载的位置无关。

Using the Staticdir Tool to Serve a Complete Directory

staticdir 工具可以用来服务一个完整的目录。

参数:

Name Default 描述
dir N/A 物理目录的绝对或相对路径。
root None 如果 dir 是相对路径,你必须提供文件的根目录。
match "" 匹配文件的正则表达式模式。
content_types None 形式为 ext: mime type 的字典。
index "" 如果 URI 指向的不是文件而是目录,你可以指定要服务的物理索引文件名。

示例:

考虑新的目录布局。

application \
myapp.py
data \
design1.css
some.js
feeds \
app.rss
app.atom

通过静态目录工具处理该结构将类似于:

import cherrypy
class MyApp:
@cherrypy.expose
def index(self):
return """<html>
<head>
<title>My application</title>
<link rel="stylesheet" href="static/css/design1.css"
type="text/css"></link>
<script type="application/javascript"
src="img/some.js"></script>
</head>
<html>
<body>
<a href="feed/app.rss">RSS 2.0 feed</a>
<a href="feed/app.atom">Atom 1.0 feed</a>
</body>
</html>"""
static resource servingdirectory, Staticdir tool usedif __name__ == '__main__':
import os.path
current_dir = os.path.dirname(os.path.abspath(__file__))
cherrypy.config.update({'environment': 'production',
'log.screen': True})
conf = {'/': {'tools.staticdir.root': current_dir},
'/static/css': {'tools.gzip.on': True,
'tools.gzip.mime_types':['text/css'],
'tools.staticdir.on': True,
'tools.staticdir.dir': 'data'},
'/static/scripts': {'tools.gzip.on': True,
'tools.gzip.mime_types':
['application/javascript'],
'tools.staticdir.on': True,
'tools.staticdir.dir': 'data'},
'/feed': {'tools.staticdir.on': True,
'tools.staticdir.dir': 'feeds',
'tools.staticdir.content_types':
{'rss':'application/xml',
'atom': 'application/atom+xml'}}}
cherrypy.quickstart(MyApp(), '/', config=conf)

在这个示例中,你会注意到 CSS 和 JavaScript 文件的 URI 路径与其物理对应物完全匹配。同时仔细看看我们是如何根据文件扩展名定义资源的适当 Content-Type 的。当 CherryPy 无法自行确定要使用的正确 MIME 类型时,这很有用。最后,看看我们是如何将静态目录工具与 gzip 工具混合使用,以便在服务之前压缩我们的静态内容。

注意

你可能会觉得 CherryPy 需要绝对路径来与不同的静态工具一起工作有些限制。但考虑到 CherryPy 无法控制应用程序的部署方式和它将驻留的位置。因此,提供这些信息的责任在于部署者。然而,请记住,绝对路径可以通过 root 属性或直接在 filenamedir 中提供。

Bypassing Static Tools to Serve Static Content

有时你可能想重用 CherryPy 的内部功能来服务内容,但又不直接使用静态工具。这可以通过从你的页面处理程序中调用 serve_file 函数来实现。实际上,这个函数也是由内置工具调用的。考虑以下示例:

import os.path
import cherrypy
from cherrypy.lib.static import serve_file
class Root:
@cherrypy.expose
def feed(self, name):
accepts = cherrypy.request.headers.elements('Accept')
for accept in accepts:
if accept.value == 'application/atom+xml':
return serve_file(os.path.join(current_dir, 'feeds',
'%s.atom' % name),
content_type='application/atom+xml')
# Not Atom accepted? Well then send RSS instead...
return serve_file(os.path.join(current_dir, 'feeds',
'%s.rss' % name),
content_type='application/xml')
if __name__ == '__main__':
current_dir = os.path.dirname(os.path.abspath(__file__))
cherrypy.config.update({'environment': 'production',
'log.screen': True})
cherrypy.quickstart(Root(), '/')

在这里,我们定义了一个页面处理程序,当被调用时,将检查用户代理首选的源内容表示形式——可能是 RSS 或 Atom。

WSGI 支持

Web 服务器网关接口WSGI)由 Phillip J. Eby 编写的Python 增强提案PEP-333)定义,旨在在 Web 服务器和 Web 应用程序之间提供一个松散耦合的桥梁。

WSGI 定义了以下三个组件:

  • 服务器或网关

  • 中间件

  • 应用程序或框架

下图显示了 WSGI 及其层:

WSGI 支持

WSGI 的目标是允许组件能够以尽可能少的 API 开销随意插入和运行。这允许代码重用常见的功能,如会话、身份验证、URL 分发、记录等。事实上,由于 API 最小化和不干扰,支持 WSGI 规范的框架或库将能够处理这些组件。

直到 CherryPy 3.0,由于 CherryPy 的内部设计和认为 WSGI 不一定能提高产品的质量,CherryPy 对 WSGI 的支持并不受欢迎。当 Robert Brewer 承担项目的重构工作时,他基于 Christian Wyglendowski 所做的工作改进了 WSGI 支持,使其成为 CherryPy 中的第一公民,并因此满足了社区的需求。

注意

注意,CherryPy 工具和 WSGI 中间件在设计上不同,但在功能上没有区别。它们旨在以不同的方式提供相同的功能。CherryPy 工具主要在 CherryPy 中有意义,因此在该环境中进行了优化。CherryPy 工具和 WSGI 中间件可以在单个应用程序中共存。

在 CherryPy WSGI 服务器中托管 WSGI 应用程序

让我们看看如何在 WSGI 环境中使用 CherryPy 的例子:

import cherrypy
from paste.translogger import TransLogger
WSGIWSGI application, hostingdef application(environ, start_response):
status = '200 OK'
response_headers = [('Content-type', 'text/plain')]
start_response(status, response_headers)
return ['Hello world!\n']
if __name__ == '__main__':
cherrypy.tree.graft(TransLogger(application), script_name='/')
cherrypy.server.quickstart()
cherrypy.engine.start()

让我们解释一下我们做了什么:

    1. 首先,我们创建一个遵守 WSGI 规范的 WSGI 应用程序,因此是一个遵守 WSGI 应用程序签名的 Python 可调用对象。environ参数包含从服务器到应用程序处理过程中正交传播的值。中间件可以通过添加新值或转换现有值来修改此字典。start_response参数是由外部层(一个中间件或最终是 WSGI 服务器)提供的 Python 可调用对象,用于执行响应处理。然后,我们的 WSGI 应用程序返回一个可迭代对象,它将被外部层消费。
    1. 然后,我们将应用程序封装到 paste 包提供的中间件中。Paste 是由 Ian Bicking 创建和维护的一套常见的 WSGI 中间件。在我们的例子中,我们使用TransLogger中间件来启用对传入请求的记录。WSGI 定义了中间件,使其能够像服务器一样封装 WSGI 应用程序,并作为托管 WSGI 服务器的应用程序。
    1. 最后,我们通过cherrypy.tree.graft()方法将 WSGI 应用程序嫁接到 CherryPy 树中,并启动 CherryPy 服务器和引擎。

由于内置的 CherryPy 服务器是一个 WSGI 服务器,它可以无障碍地处理 WSGI 应用程序。然而,请注意,CherryPy 的许多方面,如工具和配置设置,将不会应用于托管 WSGI 应用程序。你需要使用中间件来执行如paste.transLogger之类的操作。或者,你可以像以下这样使用wsgiapp工具:

import cherrypy
from paste.translogger import TransLogger
def application(environ, start_response):
status = '200 OK'
response_headers = [('Content-type', 'text/plain')]
start_response(status, response_headers)
return ['Hello world!\n']
class Root:
pass
if __name__ == '__main__':
app = TransLogger(application)
conf = {'/': {'tools.wsgiapp.on': True,
'tools.wsgiapp.app': app,
'tools.gzip.on': True}}
cherrypy.tree.mount(Root(), '/', config=conf)
cherrypy.server.quickstart()
cherrypy.engine.start()

在这个例子中,我们使用wsgiapp工具封装 WSGI 应用程序。请注意,我们可以像对待常规页面处理器一样对 WSGI 应用程序应用工具。

在第三方 WSGI 服务器中托管 CherryPy WSGI 应用程序

在这个例子中,我们将像传统那样编写 CherryPy 应用程序,并在一个不同于内置的 WSGI 服务器中托管它。实际上,我们将使用wsgiref包提供的默认 WSGI 服务器。

注意

wsgiref包是一组 WSGI 辅助工具,自 Python 2.5 起已成为 Python 标准库的一部分。否则,你可以通过easy_install wsgiref来获取它。

import cherrypy
from cherrypy import tools
from wsgiref.simple_server import make_server
from flup.middleware.gzip import GzipMiddleware
class Root:
@cherrypy.expose
@tools.response_headers(headers=[('Content-Language', 'en-GB')])
def index(self):
return "Hello world!"
if __name__ == '__main__':
wsgi_app = cherrypy.Application(Root(), script_name="/")
cherrypy.engine.start(blocking=False)
httpd = make_server('localhost', 8080, GzipMiddleware(wsgi_app))
print "HTTP Serving HTTP on http://localhost:8080/"
httpd.serve_forever()

让我们解释这个例子:

    1. 首先,我们创建一个常规的 CherryPy 应用程序。注意我们在这个上下文中仍然可以安全地使用 CherryPy 工具。
    1. 然后,我们通过cherrypy.Application辅助工具从它创建一个 WSGI 应用程序。这返回一个由 CherryPy 应用程序组成的 WSGI 有效的可调用对象。
    1. 接下来,我们以非阻塞模式启动 CherryPy 引擎,因为我们仍然需要 CherryPy 来处理请求并将请求调度到正确的页面处理器。
    1. 然后,我们创建一个 WSGI 服务器实例,托管我们的 WSGI 应用程序,该应用程序被 gzip 中间件封装,该中间件压缩响应体。这个中间件由flup包提供,它是另一个 WSGI 中间件集。 (Flup 由 Allan Saddi 维护。)

总结来说,CherryPy 3 对 WSGI 的支持水平非常出色,同时足够灵活,以便在需要时你可以使用两种设计中的最佳方案。CherryPy 可以被视为一个全面且一致的 WSGI 实现。此外,CherryPy 拥有目前最全面和最快的 WSGI 服务器,如果你需要 WSGI 支持,没有理由相信你应该放弃这个库。你可以在wsgi.org获取更多关于 WSGI 的信息。

概述

在本章中,我们回顾了 CherryPy 库的关键点,希望这能打开你的思路,了解如何充分利用其功能。虽然 CherryPy 是一个小型的包,但它提供了一套扩展且一致的特性集,旨在使你的生活更轻松。然而,CherryPy 的一些方面超出了本书的范围,获取更详细信息的最佳方式是访问用户和开发者公开邮件列表。

现在你已经对库有了良好的背景知识,我们将继续通过开发一个简单的照片博客应用程序来使用它。

第五章:相册博客应用程序

在本章中,我们将解释接下来几章将要建立的内容,以开发一个相册博客应用程序。在本章的前半部分,我们将从高层次的角度回顾此应用程序的目标和功能,而不会过多地深入细节。在后半部分,我们将定义我们的应用程序将操作的对象以及介绍对象关系映射的概念,该概念旨在减少关系数据库和面向对象软件设计之间的阻抗不匹配。我们将简要介绍最常用的 Python ORM,然后基于 Dejavu ORM 开发我们的应用程序数据访问层。

相册博客应用程序

在前面的章节中,我们已经详细审查了 CherryPy 的设计和功能,但尚未在 Web 应用程序的上下文中展示其使用。接下来的几章将通过开发一个相册博客应用程序来完成这项任务。

相册博客就像一个普通的博客,只不过主要内容不是文本而是照片。选择相册博客的主要原因是因为要实现的功能范围足够小,这样我们就可以集中精力进行设计和实现。

通过这个应用程序背后的目标是以下内容:

  • 要了解如何将一个 Web 应用程序的发展切割成有意义的层次,从而展示 Web 应用程序与您桌面上的富应用程序并没有太大的不同。

  • 要展示关注点的分离也可以通过使用名为 Ajax 的原则应用于 Web 界面本身。

  • 介绍用于处理 Web 开发常见方面的常见 Python 包,例如数据库访问、HTML 模板、JavaScript 处理等。

相册博客实体

如前所述,相册博客将尽可能保持简单,以便专注于 Web 应用程序开发的其它方面。在本节中,我们将简要描述我们的相册博客将操作的对象以及它们的属性和相互关系。

简而言之,我们的相册博客应用程序将使用以下实体,并且它们将按照以下图示关联:

相册博客实体

此图并不是我们的应用程序将看起来是什么样子,但它显示了我们的应用程序将操作的对象。一个相册博客将包含多个相册,而每个相册又可以包含所需数量的影片,这些影片将携带照片。

换句话说,我们将按照以下实体结构设计我们的应用程序:

实体: 相册博客

角色: 此实体将是应用程序的根。

属性:

  • 名称: 博客的唯一标识符

  • 标题: 博客的公共标签

关系:

  • 一个相册博客可以有零个或多个相册

实体: 相册

角色: 相册作为照片讲述故事的外壳。

属性:

  • 名称: 相册的唯一标识符

  • title: 专辑的公共标签

  • author: 专辑的作者姓名

  • description: 在源中使用的专辑的简单描述

  • story: 与专辑关联的故事

  • created: 专辑创建的时间戳

  • modified: 专辑修改的时间戳

  • blog_id: 处理专辑的博客的引用

关系:

  • 一张专辑可以参考零到多部电影

实体: 电影

角色: 一部电影汇集了一组照片。

属性:

  • name: 电影的唯一标识符

  • title: 电影的公共标签

  • created: 电影创建的时间戳

  • modified: 电影修改的时间戳

  • album_id: 对专辑的引用

关系:

  • 一部电影可以参考零到多张照片

实体: 照片

角色: 我们应用程序的单位是一张照片。

属性:

  • name: 照片的唯一标识符

  • legend: 与照片关联的图例

  • filename: 硬盘上照片的基本名称

  • filesize: 照片的字节数大小

  • width: 照片的像素宽度

  • height: 照片的像素高度

  • created: 照片创建的时间戳

  • modified: 照片修改的时间戳

  • film_id: 指向携带照片的电影的引用

关系:

从功能上讲,照片博客应用程序将通过传统的CRUD接口(创建、检索、更新删除)提供 API 来操作这些实体。我们将在第六章中对此进行更详细的阐述。

现在我们简要介绍了接下来几章我们将开发的应用程序类型,我们可以继续到下一节,并开始审查处理应用程序数据库方面的选项。但在开始之前,让我们快速了解一下本章将使用的术语表。

词汇

这里是我们将要使用的术语列表:

  • 持久性:持久性是数据项在操作它们的程序执行后仍然存在的概念。简单来说,它是在持久存储介质(如磁盘)中存储数据的过程。

  • 数据库:数据库是有组织的数据集合。有不同类型的组织模型:层次型、网络型、关系型、面向对象型等。数据库持有其数据的逻辑表示。

  • 数据库管理系统(DBMS):DBMS 是一组相关的软件应用程序,用于在数据库中操作数据。DBMS 平台应在其他功能中提供以下功能:

    • 数据的持久性

    • 用于操作数据的查询语言

    • 并发控制

    • 安全控制

    • 完整性控制

    • 事务能力

我们将使用DBMSes作为 DBMS 的复数形式。

DBMS 概述

在本节中,我们将快速回顾现有的不同类型的数据库管理系统(DBMS)。目标是快速介绍它们的主要特性。

关系型数据库管理系统(RDBMS)

在所有数据库管理系统(DBMS)中,关系数据库管理系统(RDBMS)是最常见的,无论是小型应用还是跨国基础设施。RDBMS 包含基于关系模型概念的数据库,这是一个允许通过关系逻辑表示数据集合的数学模型。关系数据库应该是关系模型的具体实现。然而,现代的关系数据库只遵循该模型到一定程度。

下表展示了关系模型术语与关系数据库实现之间的关联。

关系模型 关系数据库
关系
属性
元组

关系数据库支持一组类型来定义列可以使用的域范围。然而,支持的类型数量有限,这可能会在面向对象设计中允许的复杂数据类型中成为一个问题。

结构化查询语言,更常被称为SQL,是用于定义、操作或控制关系数据库中数据的语言。

下表是 SQL 关键字及其上下文的快速总结。

上下文 关键字
数据操作 SELECT, INSERT, UPDATE, DELETE
数据定义 CREATE, DROP, ALTER
数据控制 GRANT, REVOKE
事务 START, COMMIT, ROLLBACK

这些关键字的组合称为 SQL 语句。当执行时,一个 SQL 语句返回与查询匹配的数据行集合或无结果。

关系模型代数使用关系组合来组合不同集合的操作;这在关系数据库上下文中通过连接来实现。连接表允许复杂的查询被塑形以过滤数据。

SQL 提供了以下三种类型的连接:

联合类型 描述
INNER JOIN 两个表之间的交集。
LEFT OUTER JOIN 通过左表限制结果集。因此,左表的所有结果都将与右表中的匹配结果一起返回。如果没有找到匹配的结果,它将返回一个 NULL 值。
RIGHT OUTER JOIN 与 LEFT OUTER JOIN 相同,只是表顺序相反。

没有用 Python 编写的 RDBMS,但大多数 RDBMS 可以通过相应的 Python 库访问。

面向对象数据库管理系统(OODBMS)

一个面向对象的数据库管理系统(OODBMS)使用面向对象模型来组织和存储信息。换句话说,OODBMS 允许对象被存储,而无需映射到不同的数据结构,如关系数据库。这意味着数据库持久化数据和封装它的应用层之间具有很高的一致性。事实上,持久化机制对开发者来说是不可见的。

XML 数据库管理系统(XMLDBMS)

原生 XML 数据库NXDs)使用 XML 文档作为它们存储和操作的数据单元。基于 NXDs 的 XML 数据库管理系统(XMLDBMSes)在这方面进行了优化,并提供了对标准 XML 选择和查询语言(如 XPath 和 XQuery)的原生支持。一些现代 RDBMS 通过引入 XML 数据库要求,利用 XML 和关系数据模型之间的透明转换来提供 XML 支持。

对象关系映射

在过去十五年里,软件行业已经转向在软件应用开发的各个层次上普遍使用面向对象建模范式。一直抵抗这一浪潮的最后堡垒之一是数据库领域。尽管如此,多年来已经进行了相当重要且成功的工作,以开发面向对象数据库管理系统(OODBMSes)来填补管理数据的空白。尽管如此,OODBMSes 还没有足够地起飞,以至于能够抢走 RDBMS 的风头。

这背后有几个因素:

  • 改变市场的成本。几十年来,关系型数据库管理系统(RDBMS)一直是存储和组织数据的首选数据库管理系统。大多数企业已经围绕 RDBMS 构建了他们的基础设施,改变这种状态是一项巨大的任务,而且只有少数人愿意为这种风险付费。

  • 迁移现有数据的成本。即使一家公司准备进入新项目的新方向,它也不太可能对现有基础设施进行迁移,因为迁移和集成的成本会太高。

  • 缺乏统一的查询语言。

  • 缺乏第三方软件,例如基于 OODBMS 的报告工具。

  • 缺乏专家。找到一个 RDBMS 的数据库管理员比找到一个 ODBMS 的数据库管理员容易得多。

对象关系映射器ORMs)之所以成功,是因为它们是针对一些列举问题的有效且经济的解决方案。对象关系映射背后的原则是通过最小侵入来减少两种模型之间的阻抗不匹配。ORM 允许数据库设计者和管理员保持他们钟爱的关系型数据库管理系统(RDBMS),同时在一定程度上向软件开发者提供一个面向对象的接口。ORM 是数据库和应用程序之间的一层额外层,它将对象转换为数据库行,反之亦然。

然而,重要的是要记住,ORM 只能在一定程度上缓解这个问题,并且在某些情况下,关系和对象设计之间的差异可能无法在没有双方妥协的情况下得到满足。例如,大多数 ORM 将数据库表关联到一个类中,这在实体和它们的关系保持基本水平时工作得很好。不幸的是,这种表和类之间的一对一关系在更复杂的面向对象设计中并不总是工作得很好。在这种情况下,关系和面向对象模型之间的阻抗不匹配可能迫使设计者做出让步,这可能会在长期扩展和维护软件时产生负面影响。

Python 对象关系映射器

本节将通过一个非常基本的示例介绍三个 ORM,以便提供对它们如何工作及其差异的基本理解。目的不是宣布其中一个 ORM 为胜者,而是给你一个关于它们设计和功能的想法。

我们将要介绍的三个 ORM 是:

  • SQLObject 来自伊恩·比京克

  • SQLAlchemy 来自迈克尔·贝耶

  • Dejavu 来自罗伯特·布鲁尔

尽管在这一节中已经非常小心谨慎,但当你阅读这一章时,这些产品可能已经有所变化。你将需要参考它们的官方文档。

在以下示例中,我们将映射以下实体:

  • 艺术家:艺术家由一个名称组成。艺术家可以有零个或多个专辑。

  • 专辑:专辑由标题和发行年份组成。专辑与艺术家相关联,可以包含零个或多个歌曲。

  • 歌曲:一首歌由一个名称和在专辑中的位置组成。一首歌与一个专辑相关联。

这个示例应该被视为我们在本章开头定义的 photoblog 实体集的一个简化版本,以便专注于每个 ORM 的实际功能,而不是实体本身。

第一步:映射实体

SQLObject

from sqlobject import *
class Song(SQLObject):
title = StringCol()
position = IntCol()
album = ForeignKey('Album', cascade=True)
class Album(SQLObject):
title = StringCol()
release_year = IntCol()
artist = ForeignKey('Artist', cascade=True)
songs = MultipleJoin('Song', orderBy="position")
class Artist(SQLObject):
# Using alternateID will automatically
# create a byName() method
name = StringCol(alternateID=True, unique=True)
albums = MultipleJoin('Album')

需要注意的第一点是,SQLObject 不需要单独声明在类内部进行的映射。每个类都必须继承自无侵入性的 SQLObject 类,以便由 SQLObject 管理,并且属性将由 SQLObject 透明地映射到表的列中。SQLObject 自动添加一个 id 属性来保存每个对象的唯一标识符。这意味着由 SQLObject 映射的每个表都必须有一个主键。

ForeignKeyMultipleJoin 是定义实体之间关系的一个例子。请注意,它们需要一个字符串形式的类名,而不是类对象本身。这允许在不每个类在模块作用域内先存在的情况下声明关系。换句话说,ArtistAlbum 可以在两个不同的模块中声明,而不会出现交叉导入的问题。

当在类的一个属性中将alternateID指定为参数时,SQLObject 提供了一个有用的功能。通过使用它,SQLObject 向类添加了一个新方法,形式如上例所示。注意您也可以在那个级别上指定检索行时必须如何排序。

最后请注意,默认情况下,SQLObject 会对每个修改过的属性自动提交到数据库,这可能会增加网络开销,如果在发生错误时破坏数据库完整性。为了解决这个问题,SQLObject 提供了一个在 SQLObject 对象上的set方法,它为所有修改执行一个单一的UPDATE查询,从而限制所需的带宽。此外,SQLObject 支持事务的概念,允许我们确保操作对数据库是原子的,如果发生错误,则可以回滚。请注意,事务必须由开发者显式请求。

SQLAlchemy

from sqlalchemy import *
artist_table = Table('Artist', metadata,
Column('id', Integer, primary_key=True),
Column('name', String(), unique=True))
song_table = Table('Song', metadata,
Column('id', Integer, primary_key=True),
Column('title', String()),
Column('position', Integer),
Column('album_id', Integer,
ForeignKey('Album.id')))
album_table = Table('Album', metadata,
Column('id', Integer, primary_key=True),
Column('title', String()),
Column('release_year', Integer),
Column('artist_id', Integer,
ForeignKey('Artist.id')))
class Artist(object):
def __init__(self, name):
self.id = None
self.name = name
class Album(object):
def __init__(self, title, release_year=0):
self.id = None
self.title = title
self.release_year = release_year
class Song(object):
def __init__(self, title, position=0):
self.id = None
self.title = title
self.position = position
song_mapper = mapper(Song, song_table)
album_mapper = mapper(Album, album_table,
properties = {'songs': relation(song_mapper,
cascade="all, delete-orphan")
})
artist_mapper = mapper(Artist, artist_table,
properties = {'albums': relation(album_mapper,
cascade="all, delete-orphan")
})

SQLAlchemy 使用您可以看到的声明式映射风格。第一步是将表表达为其 Python 语法的对应物。然后我们需要声明应用程序将操作的那个类。注意它们不需要从SQLAlchemy类继承,尽管它们必须从内置的 Python 对象类继承。最终,我们通过mapper函数将这两个方面映射,该函数还允许我们通知 SQLAlchemy 实体之间的关系。

您会注意到,与 SQLObject 和 Dejavu 不同,每个表的标识符都是显式声明的。同样,您在那个级别上不指定如何排序检索到的行,因为这将在查询级别指定。

Dejavu

from dejavu import Unit, UnitProperty
class Song(Unit):
title = UnitProperty(unicode)
position = UnitProperty(int)
album_id = UnitProperty(int, index=True)
class Album(Unit):
title = UnitProperty(unicode)
release_year = UnitProperty(int)
artist_id = UnitProperty(int, index=True)
def songs(self):
return self.Song()
songs = property(songs)
def artist(self):
return self.Artist()
artist = property(artist)
def on_forget(self):
for song in self.Song():
song.forget()
class Artist(Unit):
name = UnitProperty(unicode)
def albums(self):
return self.Album()
albums = property(albums)
def on_forget(self):
for album in self.Album():
album.forget()
Album.one_to_many('ID', Song, 'album_id')
Artist.one_to_many('ID', Album, 'artist_id')

与 SQLObject 类似,Dejavu 在底层做了很多工作。每个参与映射的类都必须继承自Unit。类的属性代表表的列。实体之间的关系是通过一个更声明式的接口来完成的。

Dejavu 与其他两个的区别在于它不提供级联删除功能。这意味着这必须通过定义一个on_forget()方法并在删除单元时指定应执行哪些任务来自类本身来完成。这乍一看可能像是一个缺点,但实际上提供了如何传播级联删除的良好粒度。

第 2 步:设置数据库访问

SQLObject

# Create a connection to a SQLlite 'in memory' database
sqlhub.processConnection =
connectionForURI('sqlite:/:memory:?debug=True')

SQLAlchemy

# Inform SQLAlchemy of the database we will use
# A SQLlite 'in memory' database
# Mapped into an engine object and bound to a high
# level meta data interface
engine = create_engine('sqlite:///:memory:', echo=True)
metadata = BoundMetaData(engine)

Dejavu

# Create the global arena object
arena = dejavu.Arena()
arena.logflags = dejavu.logflags.SQL + dejavu.logflags.IO
# Add a storage to the main arena object
conf = {'Database': ":memory:"}
arena.add_store("main","sqlite", conf)
# Register units the arena will be allowed to handle
# This call must happen after the declaration of the units
# and those must be part of the current namespace
arena.register_all(globals())

第 3 步:操作表

SQLObject

def create_tables():
Album.createTable()
Song.createTable()
Artist.createTable()
def drop_tables():
Song.dropTable()
Artist.dropTable()
Album.dropTable()

SQLAlchemy

def create_tables():
artist_table.create(checkfirst=True)
album_table.create(checkfirst=True)
song_table.create(checkfirst=True)
def drop_tables():
artist_table.drop(checkfirst=False)
song_table.drop(checkfirst=False)
album_table.drop(checkfirst=False)

Dejavu

def create_tables():
arena.create_storage(Song)
arena.create_storage(Album)
arena.create_storage(Artist)
def drop_tables():
arena.drop_storage(Song)
arena.drop_storage(Album)
arena.drop_storage(Artist)

第 4 步:加载数据

SQLObject

# Create an artist
jeff_buckley = Artist(name="Jeff Buckley")
# Create an album for that artist
grace = Album(title="Grace", artist=jeff_buckley, release_year=1994)
# Add songs to that album
dream_brother = Song(title="Dream Brother", position=10, album=grace)
mojo_pin = Song(title="Mojo Pin", position=1, album=grace)
lilac_wine = Song(title="Lilac Wine", position=4, album=grace)

SQLAlchemy

session = create_session(bind_to=engine)
jeff_buckley = Artist(name="Jeff Buckley")
grace = Album(title="Grace", release_year=1994)
dream_brother = Song(title="Dream Brother", position=10)
mojo_pin = Song(title="Mojo Pin", position=1)
lilac_wine = Song(title="Lilac Wine", position=4)
grace.songs.append(dream_brother)
grace.songs.append(mojo_pin)
grace.songs.append(lilac_wine)
jeff_buckley.albums.append(grace)
session.save(jeff_buckley)
session.flush()

注意到每个对象都是独立于其他对象创建的,并且它们的关系是在第二步中完成的,例如grace.songs对象的append()方法。

与上述声明式精神相同,SQLAlchemy 默认不会自动提交到数据库。相反,它将操作延迟到您flush当前工作会话时。

Dejavu

sandbox = arena.new_sandbox()
# Create an artist unit
jeff_buckley = Artist(name="Jeff Buckley")
sandbox.memorize(jeff_buckley)
grace = Album(title="Grace", release_year=1994)
sandbox.memorize(grace)
# Add the album unit to the artist unit
jeff_buckley.add(grace)
dream_brother = Song(title="Dream Brother", position=10)
sandbox.memorize(dream_brother)
mojo_pin = Song(title="Mojo Pin", position=1)
sandbox.memorize(mojo_pin)
lilac_wine = Song(title="Lilac Wine", position=4)
sandbox.memorize(lilac_wine)
# Add each song unit to the album unit
grace.add(dream_brother)
grace.add(mojo_pin)
grace.add(lilac_wine)
sandbox.flush_all()

Dejavu 提供了沙盒的概念,在其中你可以隔离你操作的实体。此外,请注意,新创建的单位在你调用 sandbox.memorize() 方法之前并不存在于它们的相对者中,这个方法将单位放入沙盒中。

与 SQLAlchemy 类似,Dejavu 会延迟提交操作,直到你显式调用 sandbox.flush_all() 方法。

第 5 步:操作数据

首先,我们定义一个函数,它将接受一个艺术家并显示专辑的歌曲。

def display_info(artist):
for album in artist.albums:
message = """
%s released %s in %d
It contains the following songs:\n""" % (artist.name,
album.title,
album.release_year)
for song in album.songs:
message = message + " %s\n" % (song.title, )
print message

SQLObject

# Retrieve an artist by his name
buckley = Artist.byName('Jeff Buckley')
display_info(buckley)
# Retrieve songs containing the word 'la' from the given artist
# The AND() function is provided by the SQLObject namespace
songs = Song.select(AND(Artist.q.name=="Jeff Buckley",
Song.q.title.contains("la")))
for song in songs:
print " %s" % (song.title,)
# Retrieve all songs but only display some of them
songs = Song.select()
print "Found %d songs, let's show only a few of them:" %
(songs.count(), )
for song in songs[1:-1]:
print " %s" % (song.title,)
# Retrieve an album by its ID
album = Album.get(1)
print album.title
# Delete the album and all its dependencies
# since we have specified cascade delete
album.destroySelf()

SQLAlchemy

session = create_session(bind_to=engine)
# Retrieve an artist by his name
buckley = session.query(Artist).get_by(name='Jeff Buckley')
display_info(buckley)
# Retrieve songs containing the word 'la' from the given artist
songs = session.query(Song).select(and_(artist_table.c.name=="Jeff
Buckley",
song_table.c.title.like
("%la%")))
for song in songs:
print " %s" % (song.title,)
# Retrieve all songs but only display some of them
# Note that we specify the order by clause at this level
songs = session.query(Song).select(order_by=[Song.c.position])
print "Found %d songs, let's show only a few of them:" % (len(songs),)
for song in songs[1:-1]:
print " %s" % (song.title,)
# Retrieve an album by its ID
album = session.query(Album).get_by(id=1)
print album.title
# Delete the album and all its dependencies
# since we have specified cascade delete
session.delete(album)
session.flush()

Dejavu

sandbox = arena.new_sandbox()
# Retrieve an artist by his name
buckley = sandbox.Artist(name="Jeff Buckley")
display_info(buckley)
# Retrieve songs containing the word 'la' from the given artist
# We will explain in more details the concepts of Expressions
f = lambda ar, al, s: ar.name == "Jeff Buckley" and "la" in s.title
# Note how we express the composition between the units
results = sandbox.recall(Artist & Album & Song, f)
for artist, album, song in results:
print " %s" % (song.title,)
# Retrieve all songs but only display some of them
songs = sandbox.recall(Song)
print "Found %d songs, let's show only a few of them:" % (len(songs),)
for song in songs[1:-1]:
print " %s" % (song.title,)
# Retrieve an album by its ID
album = sandbox.Album(ID=1)
print album.title

选择对象关系映射器是一个困难的任务,因为通常只有在使用了一段时间之后,你才能真正衡量它对开发设计和流程的影响。如前所述,承认 ORM 不能消除关系模型和面向对象模型之间的阻抗不匹配是至关重要的。

SQLObject 具有较低的学习曲线和相当大的社区,这使得它适合那些刚开始使用 ORM 的开发者。该项目正致力于其下一个版本,该版本将修复其在早期生活中做出的许多不良设计决策,同时逐渐放弃当前版本。

SQLAlchemy 基于来自 Java 世界的 Hibernate ORM 进行设计,因此避免了 SQLObject 没有避免的许多陷阱。它的声明性语法可能不会让每个 Python 程序员都满意,但它的灵活性和良好的文档使 SQLAlchemy 成为该领域一个非常严肃的候选人。

Dejavu 是一个相对不太为人所知的 ORM,因此拥有一个较小的社区。它有很好的文档,并附带相关的示例案例。它的优势在于能够通过提供一个非常高级的接口,使用常见的 Python 习惯用法,从而摆脱底层的关系数据库层。

例如,SQLObject 和 SQLAlchemy 使用诸如tablecolumnselect之类的术语,而 Dejavu 则使用storageunit,这为底层机制提供了更好的抽象。

当涉及到构建查询的过程时,这也是正确的。与提供 Python 接口到 SQL 语句并非常接近 SQL 的 SQLObject 和 SQLAlchemy 不同,Dejavu 提供了一个独立于 SQL 的接口。查看关于操作数据的部分以获取示例。

正是因为这些原因,我们的 photoblog 应用程序将使用 Dejavu 而不是 SQLObject 或 SQLAlchemy。然而,请记住,它们都是优秀且强大的 ORM。

Photoblog 应用实体建模

首先,我们定义一个我们将称之为存储模块的东西,它提供了一个简单的接口来执行一些常见操作,如连接到数据库。

import dejavu
arena = dejavu.Arena()
from model import Photoblog, Album, Film, Photo
def connect():
conf = {'Connect': "host=localhost dbname=photoblog user=test
password=test"}
arena.add_store("main", "postgres", conf)
arena.register_all(globals())

在这种情况下,我们导入dejavu模块,并创建一个全局的Arena类实例。arena将是底层存储管理器和业务逻辑层之间的接口。

connect 函数将存储管理器添加到 PostgreSQL RDBMS 的 arena 对象中,然后注册所有导入的实体,以便 arena 对象知道它将管理哪些实体。(请参阅 Dejavu 文档以获取支持的数据库管理器列表以及如何在 add_store() 方法中声明它们。)一旦我们有了这个模块,我们就可以开始映射实体。

映射实体

实体的映射是通过以下过程完成的:

  • 创建继承自 Unit 的类

  • 使用 UnitProperty 类添加属性

  • 设置单位之间的关系

实体: 照片博客

from dejavu import Unit, UnitProperty
from engine.database import arena
from album import Album
class Photoblog(Unit):
name = UnitProperty(unicode)
title = UnitProperty(unicode)
def on_forget(self):
for album in self.Album():
album.forget()
Photoblog.one_to_many('ID', Album, 'blog_id')

实体: 专辑

import datetime
from dejavu import Unit, UnitProperty
from engine.database import arena
from film import Film
class Album(Unit):
name = UnitProperty(unicode)
title = UnitProperty(unicode)
author = UnitProperty(unicode)
description = UnitProperty(unicode)
content = UnitProperty(unicode, hints={u'bytes': 0})
created = UnitProperty(datetime.datetime)
modified = UnitProperty(datetime.datetime)
blog_id = UnitProperty(int, index=True)
def on_forget(self):
for film in self.Film():
film.forget()
Album.one_to_many('ID', Film, 'album_id')

实体: 电影

import datetime
from dejavu import Unit, UnitProperty
from engine.database import arena
from photo import Photo
class Film(Unit):
name = UnitProperty(unicode)
title = UnitProperty(unicode)
created = UnitProperty(datetime.datetime)
modified = UnitProperty(datetime.datetime)
album_id = UnitProperty(int, index=True)
def on_forget(self):
for photo in self.Photo():
photo.forget()
Film.one_to_many('ID', Photo, 'film_id')

实体: 照片

import datetime
from dejavu import Unit, UnitProperty
from engine.database import arena
class Photo(Unit):
name = UnitProperty(unicode)
legend = UnitProperty(unicode)
filename = UnitProperty(unicode)
filesize = UnitProperty(int)
width = UnitProperty(int)
height = UnitProperty(int)
created = UnitProperty(datetime.datetime)
modified = UnitProperty(datetime.datetime)
film_id = UnitProperty(int, index=True)

单位和单位属性

在上一节中,我们将我们的实体映射到 Dejavu 将管理的单位。我们所有的类都继承自 Unit 基类。这个类除了自动为类添加一个 ID 属性之外,没有提供太多功能,这也是为什么我们在任何单位中都没有显式提供它的原因。尽管如此,通过继承 Unit 类,你允许 Dejavu 注册和处理你的类。

下一步显然是使用 UnitProperty 类添加属性到你的类中,该类具有以下签名:

UnitProperty(type=unicode, index=False,
hints=None, key=None, default=None)

  • type 参数是 Python 类型。Dejavu 会透明地将它转换为适当的 SQL 等价类型。

  • index 参数表示如果 RDBMS 支持,列是否应该被索引。

  • hints 参数是一个字典,用于帮助 Dejavu 存储管理器优化列的创建。Dejavu 有三个内置提示,但如果你创建了自己的存储管理器,你也可以提供自己的提示:

    • bytes: 表示用于 unicode 属性的字节数,0 表示无限。

    • scale: 数值列小数点右边的数字数量。

    • precision: 数值列中的数字总数。

  • key 参数是属性的规范名称。

  • default 参数表示要使用的默认值。

属性将映射到关系数据库表中的列。

关联单位

关联单位是赋予你的设计形状的手段。实体是砖块,关系是灰泥。

Dejavu 支持以下常见关系:

  • 一对一(1, 1)

  • 一对多(1, n)

  • 多对一(n, 1)

在每种情况下,你提供以下签名:

nearClass(nearKey, farClass, farKey)

因此,FilmPhoto 之间的关系是:

Film.one_to_many('ID', Photo, 'film_id')

nearClassFilmnearKeyIDnearClass 的属性),farClassPhotofarKeyfilm_idfarClass 的属性)。

Dejavu 不提供原生的多对多关系,但可以通过第三个单位类和一对一关系来实现。

沙盒接口

sandbox 对象以受保护的方式管理分配给单位的内存。sandbox 是单位度过其生命的地方。有两种创建 sandbox 的方法:

box = arena.create_sandbox()
box = dejavu.Sandbox(arena)

前者是最常见的版本,也是我们将在整本书中使用的版本。

让我们回顾一下sandbox接口的一些关键方法:

  • memorize: 当你创建一个单元的新实例时,它只存在于内存中,并且与存储管理器分离。你需要调用memorize方法使其成为sandbox的一部分。这将也会设置单元的ID。此外,这还会通过发出一个INSERT INTO SQL语句在底层数据库中预留一个位置。

  • forget: 为了告诉存储管理器停止管理一个单元,你必须调用forget方法。这将把它从sandbox和存储管理器中删除。

  • repress: 在某些情况下,你可能希望从sandbox中清除单元,但不是从存储管理器中。在这种情况下,你应该使用repress方法。

  • recall, xrecall: 这两个方法允许你根据过滤器检索单元(正如我们将在单元查询部分中解释的)。recallxrecall之间的区别在于后者以迭代方式产生结果,而前者一次性将所有内容加载到列表中。

  • unit: 这两个先前的方法都是强大的数据集检索方法,但当你仅仅基于属性值查找一个单元时,它们可能会很重。这就是unit方法提供的内容。

  • flush_all: 一旦你操作了你的单元,你必须调用flush_all以使这些更改应用到物理后端。

正如你所见,Sandbox类提供的接口相当简单、直接,而且非常强大,下一节将演示这一点。

单元查询

我们已经看到了如何将我们的实体映射到单元以及如何操作这些单元。本节将详细解释如何根据标准查询存储管理器中的单元。

在 Dejavu 中,查询是通过一个Expression实例完成的。Expression类是单元的过滤器。让我们通过一个例子来解释它是如何工作的。

# Search for all photographs with a width superior to 300 pixels
f = lambda x: x.width > 300
box.recall(Photo, f)

第一步是创建一个返回bool值的函数。这个函数通常是lambda,因为没有必要用无意义的名称污染 Python 命名空间。然后我们将其传递给sandbox方法之一,如recallxrecall,这将创建一个logic.Expression实例并应用它。

表达式在过滤复杂查询时显示其值,例如涉及 JOIN 的查询。例如,如果你想在不同单元之间进行连接,你会在单元本身之间使用 Python 运算符。

# Search for all photographs of width superior to 300 pixels
# within albums created by Sylvain
box.recall(Album & Photo, lambda a,
p: a.author == "Sylvain" and p.width > 300)

正如你所见,方法的第一个参数接受一个将参与连接的单元类的聚合。Dejavu 给你使用 Python 运算符来声明单元之间聚合的机会。

在单元之间组合时,构建filter函数的顺序很重要。在先前的例子中,lambda函数的参数将与组合单元的顺序相匹配。这种行为通过recall()方法返回的结果得到了反映,该方法将提供一个包含AlbumPhoto项的列表。

下面是 Dejavu 表示的 SQL JOIN。

连接类型 操作符 描述
内连接 & 或 + 将返回两个类中所有相关联的配对。
左外连接 << 将返回两个类中所有相关联的配对。此外,如果类 1 中的任何单元在类 2 中没有匹配项,我们将返回一个包含单元 1 和一个空单元(一个所有属性都是 None 的单元)的单行。
右外连接 >> 将返回两个类中所有相关联的配对。此外,如果类 2 中的任何单元在类 1 中没有匹配项,我们将返回一个包含空单元(一个所有属性都是 None 的单元)和单元 2 的单行。

Dejavu 对您构建的聚合没有限制。例如,您可以编写:

(Film << Album) & Photo

扩展数据访问层

在前面的章节中,我们已经定义了我们的实体与我们的应用程序将要操作的类之间的映射。就目前而言,这些类并不是非常有用;在本节中,我们将看到如何扩展它们以提供更多功能。为了使本节简明扼要,我们只讨论Album类。

需要添加到专辑类中的方法:

def films(self):
"""Returns all the attached films
album = Album()
...
for film in album.films:
...
"""
return self.Film()
films = property(films)
def get_all(cls):
"""Returns all the existing albums
for album in Album.albums:
...
"""
sandbox = arena.new_sandbox()
return sandbox.recall(Album)
albums = classmethod(get_all)
def fetch(cls, id):
"""Fetch one album by id"""
sandbox = arena.new_sandbox()
return sandbox.unit(Album, ID=int(id))
fetch = classmethod(fetch)
def fetch_range(cls, start, end):
"""Fetch a range of albums which ID falls into the
specified range.
# This could return up to 5 albums
albums = Album.fetch_range(4, 9)
for album in albums:
...
"""
sandbox = arena.new_sandbox()
# dejavu's views change the capacity of dejavu to
# perform operations on a Unit
# here we create a view of the Album unit so that only
# the created and ID properties appear in the
# result of the view. A view yields values
# not units unlike recall or xrecall.
v = list(sandbox.view(Album, ['created', 'ID']))
v.sort()
photoblogdata access layer, extendingsize = len(v)
if end > size and start >= size:
return None
elif end > size and start < size:
end = size
# row[0] is the 'created' property value
# row[1] is the 'ID' property value
targets = [row[1] for row in v[start:end]]
return sandbox.recall(Album, lambda x: x.ID in targets)
fetch_range = classmethod(fetch_range)
def create(self, photoblog, name, title, slug, author, description,
content):
"""Instanciates the Album,
adds it to the passed photoblog and
persists the changes into the database"""
sandbox = photoblog.sandbox
self.name = name
self.title = title
self.author = author
self.description = description
self.content = content
self.created = datetime.datetime.now().replace(microsecond=0)
self.modified = album.created
self.blog_id = photoblog.ID
sandbox.memorize(self)
photoblog.add(self)
sandbox.flush_all()
def update(self, name, title, slug, author, description, content):
"""Updates the attributes of an album and
persists the changes into the storage"""
self.title = title
self.slug = slug
self.author = author
self.description = description
self.content = content
self.modified = datetime.datetime.now().replace(microsecond=0)
self.sandbox.flush_all()
def delete(self):
"""Delete the album from the storage"""
self.sandbox.forget(album)
def to_dict(self):
"""Return an album as a Python dictionary"""
return {'id': self.ID,
'uuid': self.uuid,
'title': self.title,
'author': self.author,
'description': self.description,
'content': self.content,
'created': self.created.strftime("%d %b. %Y, %H:%M"),
'modified': self.modified.strftime("%d %b. %Y, %H:%M")}
def to_json(self):
"""JSONify an album properties"""
return simplejson.dumps(self.to_dict())

如您所见,Album类现在包含足够的方法,允许操作Album实例。其他照片博客实体采用相同的概念,并将提供类似的接口。

概述

本章通过描述我们的照片博客应用程序的实体及其在 Python 中的映射,介绍了我们应用程序的骨架。我们下一章将回顾如何从我们的 CherryPy 处理器中操作这些实体,以构建我们应用程序的外部接口。

第六章. 网络服务

在第五章中,我们定义了数据访问层和我们的应用程序将要操作的对象。在本章中,我们将解释如何通过使用网络服务作为 API 来访问和操作我们定义的对象,从而阐述我们的照片博客应用程序。我们将介绍基于 REST 原则、Atom 发布协议的网络服务概念,并解释如何使用 CherryPy 实现它们。到本章结束时,你应该了解网络服务如何增强和扩展你的网络应用程序的能力,同时为第三方应用程序提供一个简单的入口点。

传统网络开发

大多数网络应用程序使用相同的基 URI 来处理资源的提供和资源的操作。例如,以下内容很常见:

URI 请求体 HTTP 方法 操作
/album/ N/A GET 获取所有专辑
/album/?id=12 N/A GET 获取 ID 为 12 的专辑
/album/edit?id=12 N/A GET 返回一个表单以对资源执行操作
/album/create title=Friends POST 创建专辑
/album/delete id=12 POST 删除 ID 为 12 的专辑
/album/update id=12&title=Family POST 更新 ID 为 12 的专辑

在 CherryPy 托管的应用程序中,这可以翻译为:

class Album:
@cherrypy.expose
def index(self, id=None):
# returns all albums as HTML or the one
# requested by the id parameter if provided
@cherrypy.expose
def edit(self, id=None):
# returns an HTML page with a form to perform
# an action on a resource (create, update, delete)
@cherrypy.expose
def create(self, title):
# create an album with a title
# returns an HTML page stating the success
@cherrypy.expose
def update(self, id, title):
# update an album with a title
# returns an HTML page stating the success
@cherrypy.expose
def delete(self, id):
# delete the album with the given id
# returns an HTML page stating the success

虽然这种方法是有效的,但当需要向不同类型的用户代理(浏览器、机器人、服务等)开放时,它并不是最佳选择。例如,假设我们决定提供一个肥客户端应用程序来操作专辑。在这种情况下,页面处理器返回的 HTML 页面将毫无用处;XML 或 JSON 数据将更相关。我们可能还希望将我们应用程序的一部分作为服务提供给第三方应用程序。

一个显著的例子是 flickr 提供的服务,(www.flickr.com/)一个在线照片管理应用程序,它允许用户在许多上下文中查询 flickr 服务(www.flickr.com/services/api/),如获取当前照片、活动、博客文章、评论等,以不同的格式。多亏了这些网络服务,大量第三方应用程序得以扩展,从而从网络应用程序或甚至从肥客户端应用程序中扩展 flickr 用户的体验。

关注点分离

之前的设计示例的问题在于缺乏关注点分离。正如 Tim Bray 关于网络的看法(请参阅www.tbray.org/ongoing/When/200x/2006/03/26/On-REST以获取更多详细信息):

系统中有很多东西,通过 URI 来标识

系统中对资源有两种操作:那些可以改变其状态的和那些不能的

从第一个陈述中,我们给任何可以通过系统传递的事物命名;我们称之为资源。资源的例子可以是图片、诗歌、篮球比赛的结果、澳大利亚的温度等。我们还了解到,每个资源都应该以非歧义的方式被识别。从 Tim 的第二点陈述中,我们意识到在设计上应该逻辑上分离——只读操作和可以更改资源的操作。

这些区分的一个重要推论是我们希望让客户端通知服务器它希望接收的内容类型。在我们的例子中,我们的页面处理程序仅返回 HTML 页面,而检查客户端可以处理的内容并发送资源最佳表示将更加灵活。

网络应用程序开发者应考虑以下原则:

  • 任何事物都是资源。

  • 资源有一个或多个标识符,但一个标识符只能指向一个资源。

  • 资源有一个或多个客户端可以请求的表示形式。

  • 资源操作分为改变资源状态和不改变资源状态的那些。

基于这些元素,我们可以重新定义我们的设计如下:

class Album:
@cherrypy.expose
def index(self):
# returns all albums as HTML
@cherrypy.expose
def default(self, id):
# returns the album specified or raise a NotFound
@cherrypy.expose
def edit(self, id=None):
# returns an HTML page with a form to perform
# an action on a resource (create, update, delete)
class AlbumManager:
@cherrypy.expose
def create(self, title):
# create an album with a title
# returns an XML/JSon/XHTML document
# representing the resource
@cherrypy.expose
def update(self, id, title):
# update an album with a title
# returns an XML/JSon/XHTML document
# representing the resource
@cherrypy.expose
def delete(self, id):
# delete the album with the given id
# returns nothing

通过这样做,我们允许任何类型的用户代理通过请求公开的AlbumManager处理程序来操作资源。浏览器仍然会从Album页面处理程序获取专辑的 HTML 表示。你可能会争辩说,浏览器不知道如何处理从AlbumManager页面处理程序返回的 XML 或 JSON 数据。这里缺失的信息是,HTML 表单的提交及其响应的处理将由一些客户端脚本代码通过 JavaScript 执行,该代码能够相应地处理 XML 或 JSON 数据块。我们将在第七章(ch07.html "第七章。表示层")中更详细地介绍这项技术。

上述定义的原则是今天所说的网络服务的基础。网络服务是网络应用程序提供的 API,以便异构用户代理可以通过 HTML 以外的格式与应用程序交互。通过 REST、SOAP、XML-RPC、Atom 等方式可以创建不同的网络服务。为了本书的目的,我们将回顾 REST 和 Atom 发布协议作为照片博客应用程序的网络服务。

REST

表示性状态转移REST)是 Roy T. Fielding 在 2000 年他的论文《架构风格和网络软件架构设计》中描述的分布式超媒体系统的架构风格。

REST 基于以下元素:

  • 资源: 资源是任何事物的抽象概念。例如,它可以是图片、博客条目、两种货币之间的当前汇率、体育结果、数学方程式等。

  • 资源标识符: 允许分布式系统的组件以独特的方式识别资源。

  • 表示: 资源的一个表示仅仅是数据。

  • 表示元数据: 关于表示本身的信息。

  • 资源元数据: 关于资源的信息。

  • 控制数据: 系统中组件之间传递的消息信息。

REST 还建议每个流动的消息应该是无状态的,这意味着它应该包含足够的信息供系统中的下一个组件处理,因此不应依赖于之前的或后续的消息。每个消息都是自包含的。这是通过使用资源元数据和表示元数据来实现的。

这些是描述 REST 的元素,但它们并不绑定到任何底层协议。最常用的 REST 用例可以在 Web 中找到,并使用 HTTP 协议实现。尽管如此,REST 可以在其他环境和其他协议中使用。

HTTP 是实施 REST 的好候选,以下是一些原因:

  • 它是网络的基础,这是一个分布式超媒体系统。

  • 它是无状态的。

  • 每个请求都可以包含足够的信息,可以独立于系统中的其余部分进行处理。

  • HTTP 使用的Content-TypeAccept头部提供了通过不同表示形式来表示单个资源的手段。

  • URI 是强大且常见的资源标识符。

统一资源标识符

REST 是关于在网络上命名资源并提供对这些资源执行操作的统一机制。这就是为什么 REST 告诉我们资源至少由一个标识符来识别。当基于 HTTP 协议实现 REST 架构时,这些标识符被定义为统一资源标识符URI)。

URI 集合的两个常见子集是:

  • 统一资源定位符URL),例如:www.cherrypy.org/

  • 统一资源名称URN),例如:

    urn:isbn:0-201-71088-9
    urn:uuid:13e8cf26-2a25-11db-8693-000ae4ea7d46
    
    

URL 的有趣之处在于它们包含足够的信息来定位网络上的资源。因此,在给定的 URL 中,我们知道要定位资源,我们需要使用与 HTTP 方案关联的 HTTP 协议,该协议托管在主机www.cherrypy.org上的路径/。(然而,请注意,并非 Web 社区中的每个人都认为这种能力的多路复用是 URL 的积极方面,但这次讨论超出了本书的范围。)

HTTP 方法

如果 URI 提供了命名资源的方式,HTTP 方法提供了我们可以对这些资源进行操作的手段。让我们回顾 HTTP 1.1 中最常见的方法(也称为动词)。

HTTP 方法 允许幂等 操作
HEAD 获取资源元数据。响应与 GET 相同,但无主体。
GET 获取资源元数据和内容。
POST 请求服务器使用请求体中的数据创建一个新的资源。
PUT 请求服务器用请求体中包含的资源替换现有的资源。服务器不能将包含的资源应用于未由该 URI 标识的资源。
DELETE 请求服务器删除由该 URI 标识的资源。
OPTIONS 请求服务器返回有关能力的信息,无论是全局的还是特定于资源的。

表格的幂等列表示使用该特定 HTTP 方法的请求是否会有与两个连续相同调用相同的副作用。

默认情况下,CherryPy 处理程序反映请求-URI 的路径,处理程序与 URI 的一个元素匹配,但正如我们所看到的,CherryPy 的分发器可以被更改,使其不是在 URI 中查找处理程序,而是从请求元数据(如使用的 HTTP 方法)中查找。

让我们回顾一个应用于照片博客应用程序的例子:

import cherrypy
from cherrypy.lib.cptools import accept
from models import Photoblog, Album
from lib.config import conf
from lib.tools import find_acceptable_within
class AlbumRESTService(object):
exposed = True
def GET(self, album_id):
best = accept(['application/xml', 'application/atom+xml',
'text/json', 'text/x-json'])
album = Album.fetch(album_id)
if not album:
raise cherrypy.NotFound()
if best in ['application/xml','application/atom+xml']:
cherrypy.response.headers['Content-Type'] =
'application/atom+xml'
entry = album.to_atom_entry()
return entry.xml()
if best in ['application/json', 'text/x-json', 'text/json']:
cherrypy.response.headers['Content-Type'] =
'application/json'
return album.to_json()
raise cherrypy.HTTPError(400, 'Bad Request')
def POST(self, title, segment, author, description, content,
blog_id):
photoblog = Photoblog.fetch(blog_id)
if not photoblog:
raise cherrypy.NotFound()
album = Album()
album.create(photoblog, title, segment, author, description,
content)
cherrypy.response.status = '201 Created'
cherrypy.response.headers['Location'] = '%s/album/%d' %
(conf.app.base_url, album.ID)
def PUT(self, album_id, title, segment, author, description,
content):
album = Album.fetch(album_id)
if not album:
raise cherrypy.NotFound()
album.update(title, segment, author, description, content)
def DELETE(self, album_id):
album = Album.fetch(album_id)
if album:
album.delete()
cherrypy.response.status = '204 No Content'

让我们解释在这个上下文中每个 HTTP 方法的作用。

  • GET: 这返回请求资源的表示形式,取决于Accept头。我们的应用程序允许application/xml, application/atom+xml, text/jsontext/x-json。我们使用一个名为accept的函数,它返回找到的可接受头或立即引发一个cherrypy.HTTPError (406, 'Not Acceptable')错误,通知用户代理我们的应用程序无法处理其请求。然后我们验证资源是否仍然存在;如果不存在,我们引发一个cherrypy.NotFound错误,这是cherrypy.HTTPError(404, 'Not Found')的快捷方式。一旦我们检查了先决条件,我们就返回资源的请求表示。

    注意,这相当于默认分发器的index()方法。但请记住,当使用方法分发器时,没有default()方法的等效方法。

  • POST: HTTP POST 方法允许用户代理创建一个新的资源。第一步是检查将要处理该资源的照片博客是否存在。然后我们创建资源,并返回状态码201 Created以及Location头,指示检索新创建资源的 URI。

  • PUT: HTTP PUT 方法允许用户代理用请求体中提供的一个资源替换资源。这通常被认为是一个更新操作。尽管 RFC 2616 没有禁止PUT也创建一个新的资源,但我们将不会在我们的应用程序中以这种方式使用它,我们将在后面解释。

  • DELETE: DELETE 方法请求服务器删除资源。对此方法的响应可以是200 OK204 No Content。后者通知用户代理它不应更改其当前状态,因为响应没有主体。

POSTPUT之间的(缺乏)差异长期以来一直是网络开发者讨论的来源。有些人认为有两个方法是有误导性的。让我们尝试理解它们为什么是不同的,为什么我们需要两者。

POST 请求:

POST /album HTTP/1.1
Host: localhost:8080
Content-Length: 77
Content-Type: application/x-www-form-urlencoded
blog_id=1&description=Family&author=sylvain&title=My+family&content=&
segment=

POST 响应:

HTTP/1.1 201 Created
Content-Length: 0
Location: http://localhost:8080/album/12
Allow: DELETE, GET, HEAD, POST, PUT
Date: Sun, 21 Jan 2007 16:30:43 GMT
Server: CherryPy/3.0.0
Connection: close

PUT请求:

PUT /album/12 HTTP/1.1
Host: localhost:8080
Content-Length: 69
Content-Type: application/x-www-form-urlencoded
description=Family&author=sylvain&title=Your+family&content=&segment=

PUT响应:

HTTP/1.1 200 OK
Date: Sun, 21 Jan 2007 16:37:12 GMT
Content-Length: 0
Allow: DELETE, GET, HEAD, POST, PUT
Server: CherryPy/3.0.0
Connection: close

初看,两个请求似乎相当相似,但实际上它们有一个非常重要的区别,那就是请求的 URI。

可以将数据POST到 URI,其中可能或可能不会创建资源,而在PUT的情况下,URI 本身就是资源之一,发送的内容是资源的新表示。在这种情况下,如果资源在该 URI 上尚不存在,服务器可以创建它,如果它已经实现这样做的话;否则,服务器可以返回一个 HTTP 错误消息,表明它没有满足请求。简而言之,客户端将数据POST到进程,但将请求 URI 标识的资源的新表示PUT

问题的一个根本原因是许多 Web 应用程序仅依赖于POST方法来实现对资源的任何操作,无论是创建、更新还是删除。这尤其是因为这些应用程序通常只提供 HTML 表单,这些表单只支持GETPOST来执行这些操作。

考虑到越来越多的 Web 应用程序利用关注点分离并通过 JavaScript 或外部服务通过客户端代码处理提交,PUTDELETE方法的使用很可能会增加,尽管在某些环境中可能会成为问题,因为防火墙策略禁止PUTDELETE请求。

整合

我们的博客应用程序将为以下实体提供 REST 接口:专辑、电影和条目。由于它们携带的信息、它们之间的关系以及它们的设计,我们可以提供与实体本身无关的相同接口。因此,我们重构了Album类并创建了一个Resource类,该类将集中实现每个操作。每个实体服务接口只需将信息传递给Resource类,让它处理繁重的工作。因此,我们避免了代码的重复。

import cherrypy
from cherrypy.lib.cptools import accept
from models import Photoblog
from lib import conf
from lib.tools import find_acceptable_within
class Resource(object):
def handle_GET(self, obj_id):
best = accept(['application/xml', 'application/atom+xml',
'text/json', 'text/x-json',
'application/json'])
obj = self.__source_class.fetch(obj_id)
if not obj:
raise cherrypy.NotFound()
if best in ['application/xml', 'application/atom+xml']:
cherrypy.response.headers['Content-Type'] = 'application/atom+xml'
entry = obj.to_atom_entry()
return entry.xml()
if best in ['text/json', 'text/x-json', 'application/json']:
cherrypy.response.headers['Content-Type'] =
'application/json'
return obj.to_json()
raise cherrypy.HTTPError(400, 'Bad Request')
def handle_POST(self container_cls, container_id,
location_scheme, *args, **kwargs):
container = container_cls.fetch(container_id)
if not container:
raise cherrypy.NotFound()
obj = self.__source_class()
obj.create(container, *args, **kwargs)
cherrypy.response.status = '201 Created'
cherrypy.response.headers['Location'] = location_scheme %
(conf.app.base_url, obj.ID)
def handle_PUT(cls, source_cls, obj_id, *args, **kwargs):
obj = self.__source_class.fetch(obj_id)
if not obj:
raise cherrypy.NotFound()
obj.update(obj, *args, **kwargs)
def handle_DELETE(cls, source_cls, obj_id):
obj = self.__source_class.fetch(obj_id)
if obj:
obj.delete(obj)
cherrypy.response.status = '204 No Content'

然后,让我们重新定义我们的AlbumRESTService类以利用Resource类:

from models import Photoblog, Album
from _resource import Resource
class AlbumRESTService(Resource):
exposed = True
# The entity class that will be used by the Resource class
_source_class = Album
def GET(self, album_id):
return self.handle_GET(album_id)
def POST(self, title, segment, author, description, content,
blog_id):
self.handle_POST(Photoblog, blog_id, '%s/album/%d',
title, segment, author, description,content)
def PUT(self, album_id, title, segment, author, description,
content):
self.handle_PUT(album_id,
title, segment, author, description, content)
def DELETE(self, album_id):
self.handle_DELETE(album_id)

我们现在有一个将处理专辑资源的 RESTful 接口。电影和照片实体将以相同的方式进行管理。这意味着我们的应用程序现在将支持以下请求:

POST http://somehost.net/service/rest/album/
GET http://somehost.net/service/rest/album/12
PUT http://somehost.net/service/rest/album/12
DELETE http://somehost.net/service/rest/album/12

在这些调用中的每一个,URI 都是资源的唯一标识符或名称,而 HTTP 方法是执行在该资源上的操作。

通过 CherryPy 的 REST 接口

到目前为止,我们已经描述了我们的照片博客应用程序将支持的服务,但没有详细说明如何通过 CherryPy 实现。

正如我们在前面的章节中看到的,HTTP REST 依赖于 HTTP 方法来通知 Web 应用用户代理希望执行的操作类型。为了通过 CherryPy 实现我们的照片博客应用中的 REST,我们将使用 HTTP 方法分发器,正如在第四章中回顾的那样,来处理对上述服务类的传入请求,大致如下:

rest_service = Service()
rest_service.album = AlbumRESTService()
conf = {'/': {'request.dispatch': cherrypy.dispatch.MethodDispatcher()}}
cherrypy.tree.mount(rest_service, '/service/rest', config=conf)

这意味着针对 URI 路径如/service/rest/album/的请求将在 REST 精神下执行。

REST 是一个相当常见的术语,但构建真正的 RESTful 应用可能是一项艰巨的任务。困难在于定义与应用资源相关的一个合理且有意义的服务 URI 集。换句话说,困难的部分在于 API 的设计。本节应该已经向您介绍了 REST 背后的原则,但围绕 REST 开发大型系统的架构需要高度理解所处理资源、它们的命名约定以及它们之间的关系。

Atom 发布协议

在前面的章节中,我们介绍了 REST 并展示了它如何作为 Web 应用的服务使用。在本节中,我们将介绍Atom 发布协议APP),在撰写本书时,它正在成为新的 IETF 标准。这意味着本节的一些方面可能在您阅读时可能已经不再是最新的。

APP 作为一种基于 HTTP 的应用层协议,起源于 Atom 社区,允许发布和编辑 Web 资源。APP 服务器和客户端之间的消息单元基于 RFC 4287 中定义的 Atom XML 文档格式。

虽然 APP 没有指定为 REST 原则的实现,但该协议遵循相同的思想,使其具有 RESTful 特性。因此,前一部分的许多原则也适用于这里;但首先让我们概述一下 Atom XML 文档格式。

Atom XML 文档格式

Atom XML 文档格式通过两个顶级元素描述了一组信息:

  • 源:一个源由以下内容组成:

    • 元数据(有时被称为源的头)

    • 零个或多个条目

  • 条目:一个条目由以下内容组成:

    • 元数据

    • 一些内容

以下是一个符合 RFC4287 的 Atom 1.0 源文档示例:

<?xml version="1.0" encoding="utf-8"?>
<feed >
<title>Photoblog feed</title>
<published>2006-08-13T10:57:18Z</published>
<updated>2006-08-13T11:18:01Z</updated>
<link rel="self" href="http://host/blog/feed/album/" type="application/atom+xml" />
<author>
<name>Sylvain Hellegouarch</name>
</author>
<id>urn:uuid:13e8cf26-2a25-11db-8693-000ae4ea7d46</id>
<entry>
<title>This is my family album</title>
<id>urn:uuid:25cd2014-2ab3-11db-902d-000ae4ea7d46</id>
<link rel="self" href="http://host/blog/feed/album/12"
type="application/atom+xml" />
<link rel="alternate" href="http://host/blog/album/12"
type="text/html" />
<updated>2006-08-13T11:18:01Z</updated>
<content type="text">Some content</content>
</entry>
</feed>

Web 应用可以为订阅提供 Atom 文档,从而为用户代理提供一种将自己同步到应用开发者选择提供的信息的方式。

我们的摄影博客应用将提供以下实体的 Atom 源:

  • 照片博客:每个博客的条目将链接到博客的相册条目。

  • 相册:每个博客的条目将链接到相册的电影条目。

  • 电影:每个条目将关联到一张电影照片。

我们不会解释 Atom 文档的每个元素,但会回顾一些最常见的元素。

  • idtitleupdated是任何源或条目中的强制元素。

    • id必须是 RFC 3987 中定义的 IRI,作为 URI 的补充

    • updated必须遵循 RFC 3339。RFC 4287 表示,该元素只有在修改具有语义意义时才需要更新。

  • author在 Atom 源中是强制性的,无论是在feed元素、entry元素还是在两者中。然而,如果条目没有提供,则条目可以继承源author元素。

  • link不是强制的,但推荐使用,并且非常有用,可以提供以下信息:

    • 使用rel="self"指定与条目或源关联的资源 URI

    • 使用rel="alternate"指定资源替代表示的 URI,并指定资源的媒体类型

    • 使用rel="related"来指定相关资源的 URI

  • content最多只能出现一次。一个条目的内容可以是内联在条目中的文本、转义 HTML 或 XHTML,或者通过src属性引用,提供实际内容的 URI。

因此,对于电影源,我们将有:

<?xml version="1.0" encoding="UTF-8"?>
<feed >
<id>urn:uuid:8ed4ae87-2ac9-11db-b2c4-000ae4ea7d46</id>
<title>Film of my holiday</title>
<updated>2006-08-13T13:50:49Z</updated>
<author>
<name>Sylvain Hellegouarch</name>
</author>
<entry>
APPAtom XML-document<id>urn:uuid:41548439-c12d-48b5-baec-a72b1bf8576f</id>
<published>2006-08-13T13:45:38Z</published>
<updated>2006-08-13T13:50:49Z</updated>
<title>At the beach</title>
<link rel="self" href="http://host/feed/photo/at-the-beach"
type="application/atom+xml"/>
<link rel="alternate" href="http://host/photo/at-the-beach"
type="text/html" />
<content src="img/IMAGE001.png"
type="image/png" />
</entry>
</feed>

Atom 格式在博客环境中被广泛使用,以允许用户订阅它。然而,由于其灵活性和可扩展性,Atom 格式现在被用于不同的环境中,如发布、存档和导出内容。

APP 实现

在照片博客应用程序中提供Atom 发布协议(APP)实现的目的是介绍该协议,并提供两个不同的服务,以展示关注点分离的好处。由于 APP 尚未成为标准,并且由于在撰写本书时它正处于相当多的讨论中,因此有可能在我们阅读本节时,我们的实现可能不再符合标准。然而,风险最小,因为当前协议草案的版本,即 13,就其主要特性而言似乎足够稳定。

Atom 发布协议定义了一组操作,这些操作通过 HTTP 及其机制以及 Atom XML 文档格式作为消息单元,在 APP 服务和用户代理之间进行。

APP 首先定义一个服务文档,它为用户代理提供 APP 服务所提供的不同集合的 URI。其形式为:

<?xml version="1.0" encoding="UTF-8"?>
<service  xmlns:atom=
"http://www.w3.org/2005/Atom">
<workspace>
<collection href="http://host/service/atompub/album/">
<atom:title>Friends Albums</atom:title>
<categories fixed="yes">
<atom:category term="friends" />
</categories>
</collection>
<collection href="http://host/service/atompub/film/">
<atom:title>Films</atom:title>
<accept>image/png,image/jpeg</accept>
</collection>
</workspace>
</service>

一旦用户代理获取了该服务文档,它就知道有两个集合可用。第一个集合通知用户代理它将只接受具有与定义匹配类别的 Atom 文档。第二个集合将只接受image/pngimage/jpegMIME 类型的数据。

集合是 APP 所指成员的容器。创建成员的操作是在集合上完成的,而检索、更新和删除操作是在该成员本身上完成的,而不是在集合上。

集合表示为 Atom 源,其中条目被称为成员。对 Atom 条目的关键补充是使用具有rel属性设置为edit的 Atom 链接来描述成员资源。通过将此属性设置为该值,我们表明链接元素的href属性引用的是可以检索、编辑和删除的成员资源的 URL。包含此类链接元素的 Atom 条目称为集合的成员

APP 通过以下表格中描述的 HTTP 方法指定如何对集合的成员或集合本身执行基本的 CRUD 操作。

操作 HTTP 方法 状态码 返回内容
获取 GET 200 代表资源的 Atom 条目
创建 POST 201 代表资源的 Atom 条目,通过 Location 和 Content-Location 头部的 URI 表示新创建的资源
更新 PUT 200 代表资源的 Atom 条目
删除 DELETE 200

在创建或更新资源时,APP 服务器可以自由修改资源的一部分,例如其id、其updated值等。因此,用户代理不应依赖于其资源版本,而应始终与服务器同步。

尽管集合的成员是 Atom 条目,但不必通过提交 Atom 条目来创建新成员。APP 支持任何媒体类型,只要它通过app:collection元素的app:accept元素允许即可。该元素接受一个以逗号分隔的媒体类型列表,指定客户端集合将处理 POST 请求的内容类型。

如果您将 PNG 图像POST到接受它的集合,服务器将创建至少两个资源。

  • 成员资源,可以看作是图像的元数据

  • 媒体资源

记住,APP 服务器对发送的内容拥有完全控制权,因此可以想象 APP 服务器在存储之前将 PNG 内容转换为 JPEG。客户端不能假设发送的内容或资源会被复制,就像服务器所做的那样。在任何情况下,服务器在创建成功时返回成员资源(请参阅 APP 规范以获取详细示例),这正是 APP 如此强大的原因,因为无论服务器声称处理哪种类型的资源,APP 都确保会以 Atom 条目的形式生成元数据。

除了定义一个接口来操作集合内的成员外,APP 还提供了当集合变得过大时的分页支持。这允许用户代理请求集合中给定范围的成员。我们不会解释此功能,但如果您对此功能感兴趣,可以查看 APP 规范。

此外,由于照片博客应用将尽可能遵循 REST 原则来实现 APP,我们邀请您参考 REST 部分,以获取关于 APP 如何使用 REST 原则的更具体细节。

在本节中,我们简要介绍了原子发布协议(Atom Publishing Protocol),这是一种基于 Atom XML 文档格式的协议,允许发布异构数据类型。尽管它还不是官方标准,但 APP 已经引起了许多组织的兴趣,并且很可能你会在越来越多的应用中找到它。

摘要

本章向您介绍了网络服务(web services)的概念,它定义了通过常见的网络协议(如 HTTP)提供 API 的想法。通过提供这样的 API,您的网络应用变得更加灵活、强大和可扩展。尽管网络服务不是必需的功能,并不是每个网络应用都会提供它们。我们的照片博客应用,在其展示一些常见现代网络技术的精神下,将它们用作示例而不是强制性的功能。然而,通过审查我们的照片博客应用的代码,您将了解网络服务的一些有趣的好处,这可能会为您自己的应用提供灵感。

第七章。表示层

到目前为止,我们已从服务器端的角度开发我们的应用程序。在本章中,我们将开始关注照片博客的客户端。最初,我们将通过 Kid Python 引擎介绍 HTML 模板,并通过 Mochikit 库介绍 JavaScript。我们将简要介绍 Web 成功的一些重要组件,如 HTML、XHTML 和 CSS。然而,这些部分并不旨在深入解释每个部分,因为这超出了本书的范围。

HTML

尽管在我们上一章中,我们介绍了应用程序中各层之间的关注点分离,但我们需要记住,我们的主要目标是互联网浏览器,因此我们将专注于 HTML 渲染。

HTML超文本标记语言),由蒂姆·伯纳斯-李在 20 世纪 90 年代初定义,是 SGML标准通用标记语言)的轻量级版本,仅保留了 Web 有用的简单元素。由于 Web 的快速增长,HTML 进一步发展以改进它。最终,W3C 在 1997 年正式指定了 HTML 4.0,1999 年的更新导致了 HTML 4.01,至今仍然是官方版本。

HTML 4.01 文档的示例:

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<title>Hello World!</title>
</head>
<body>
<p>Not much to say really.</p>
</body>
</html>

文档的第一行声明了 DOCTYPE 声明,指定了文档遵循的格式变体。DOCTYPEs 在 DTDs(文档类型定义)中指定。

XML

在 1996 年,W3C 开始着手于 XML可扩展标记语言),这是一种从 SGML 派生出来的通用简单标记语言,它保留了 SGML 的强大功能,同时避免了其复杂性。在 Web 的背景下,XML 的目标是解决 HTML 的几个限制,例如缺乏:

  • 可扩展性:HTML 不允许向语言中添加新元素。

  • 验证:HTML 没有提供一种语言来验证文档的结构或语义。

  • 结构:HTML 不允许复杂的结构。

XHTML

由于 XML,W3C 承担了表达性和灵活性的工作,通过 XML 重新定义 HTML 4,从而在 2000 年制定了 XHTML 1.0 规范。

XHTML 1.0 具有以下特点:

  • 仅理解 HTML 4 的用户代理可以渲染文档,使其具有向后兼容性。

  • 出版商可以进入 XML 世界及其丰富性。

XHTML 1.0 文档的示例:

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html >
<head>
<title>Hello World!</title>
</head>
<body>
<p>Not much to say really.</p>
</body>
</html>

在此示例中,我们还指定了一个 DOCTYPE 声明,告知消费者我们的文档遵循 XHTML 1.0 Strict DTD。由于 XHTML 是 XML 的应用:

  • 我们在第一行提供了 XML 声明,以便给 XML 消费者处理器一些关于文档内容的提示,例如它使用 UTF-8 编码的事实。请注意,这不是强制性的。

  • 我们还明确地将该文档的匿名命名空间标记为 XHTML 命名空间。

虽然这两个文档的语法非常相似,但它们具有不同的语义,并且会被用户代理以不同的方式处理。因此,这两个文档都有不同的 MIME 格式。HTML 文档应该使用text/html MIME 内容类型提供服务,而 XHTML 文档应该通过application/xhtml+xml提供服务。然而,由于 XHTML 1.0 旨在与不理解其 MIME 内容类型的用户代理向后兼容,因此允许按照特定指南将 XHTML 1.0 文档作为text/html提供服务。然而,这并不推荐,可能会导致意外的渲染,这取决于用户代理如何处理文档的结构;这通常被称为标签汤

由于这些原因,在互联网上提供 XHTML 可能会变得繁琐,并且是极其激烈讨论的根源。因此,我们的照片博客应用将保持简单,使用 HTML。

CSS

无论您使用 HTML 还是 XHTML,这两种格式都只指定了您页面的结构和语义;它们不告诉用户代理应该如何渲染这些页面。这是通过 CSS(层叠样式表)实现的,这是一种描述应用于标记文档(如 HTML 或 XHTML)中元素的规则的编程语言。规则的结构如下:

  • 一个 选择器 指定了要应用规则的元素。选择器可以是精确的,仅针对文档上下文中的一个特定元素,也可以是通用的,适用于所有元素。

  • 一个或多个 属性 指示了元素的哪个属性被涉及。

  • 每个属性都与一个或多个 或值集相关联。

以下是一个应用于之前 HTML 示例的例子:

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<title>Hello World!</title>
<style type="text/css">
body
{
background-color: #666633;
color: #fff;
}
p
{
text-align: center;
}
</style>
</head>
<body>
<p>Not much to say really.</p>
</body>
</html>

在这个例子中:

  • body是选择器。

  • background-color是属性,其值为#666633

在上一个例子中,我们将 CSS 嵌入到 HTML 文档本身中。建议将其外部化到自己的文档中,并从 HTML 页面链接它,如下所示:

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN"
"http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<title>Hello World!</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<p>Not much to say really.</p>
</body>
</html>

CSS 文件,style.css,如下所示:

body
{
background-color: #663;
color: #fff;
}
p
{
text-align: center;
}

DHTML

当蒂姆·伯纳斯-李想象网络时,他是为了使研究人员之间交换文档成为可能。这些文档是静态的,不是由网络应用生成的。实际上,当时还没有网络应用,只有接受请求并返回内容的网络服务器。从那时起,网络的价值增长了很多,网络应用已经成为现实。尽管如此,长期以来,完成这项工作的组件一直是服务器本身,客户端只需要显示渲染的内容。然而,很快,提供更花哨的界面将使网络向前迈出一大步,以吸引更广泛的公众。网络应该、能够、并且会不仅仅是在屏幕上展示书籍或论文。

术语 DHTML(动态 HTML)是为了将一组技术组合在一起,以改善客户端内容处理。DHTML 包括:

  • 定义文档结构的 HTML

  • CSS 来样式化网页

  • JavaScript 动态修改文档对象模型(DOM)

DOM 是浏览器构建的(X)HTML 文档结构的内存表示。通过使用 JavaScript 函数,可以动态修改 DOM 树,从而从最终用户的角度改变其渲染。

然而,尽管 DHTML 背后的想法多么有趣,由于浏览器供应商之间的互操作性问题和 JavaScript 与 CSS 在导航器中的不平等实现,它从未真正起飞。这使得网络开发者很难确保他们的页面在大多数情况下能按预期工作。如今,DHTML 在领域内不是一个常见的术语,但其思想已被保留并在新技术中得到改进。这也得益于浏览器之间更好的互操作性、更好的调试工具以及专用 JavaScript 工具包或框架的出现,这些工具包或框架通过公共 API 封装浏览器差异,正如我们稍后将要看到的。

模板化

在前面的章节中,我们介绍了构成网页的基本组件——HTML 或 XHTML 用于结构,CSS 用于样式。生成网页可能就像使用你喜欢的文本编辑器并将其放置下来一样简单。然而,在动态应用程序的上下文中,内容基于给定的上下文并即时生成,你需要工具来简化这种创建。这是通过使用模板引擎来实现的。模板引擎接受页面的模型以及输入数据,然后处理两者以渲染最终的页面。

当寻找模板引擎时,你应该寻找至少提供以下一些功能的引擎:

  • 变量替换:在你的模板中,一个变量可以作为输入的占位符。

  • 条件语句:通常情况下,模板需要根据输入数据的上下文进行轻微的渲染差异。

  • 循环机制:当你的模板需要将一组数据渲染到表格中时,这显然是必须的。

  • 可扩展性:模板通常可以共享某些方面,并在某些特定上下文中有所不同,例如常见的页眉和页脚模板。

Python 世界在模板引擎方面绝不短,选择一个满足你需求的模板引擎将肯定是一个基于其功能和语法口味的个人选择。为了本书的目的,我们将使用由 Ryan Tomayko 开发的名为Kid的模板引擎。

Kid—模板引擎

现在,我们将对Kid引擎进行一些描述。

概述

让我们通过创建我们之前示例的模板来开始对Kid引擎的介绍:

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html >
<head>
<title>${title}</title>
<link rel="stylesheet" href="style.css" />
</head>
<body> <p>${message}</p>
</body>
</html>

正如你所见,模板看起来与最终期望渲染的页面非常相似。当你将这个模板保存为名为helloworld.kid的文件时,下一步就是通过以下方式通过Kid引擎处理模板:

import kid
params = {'title': 'Hello world', 'message': 'Not much to say.'}
t = kid.Template('helloworld.kid', **params)
print t.serialize(output='html')

Kid提供了一个Template函数,该函数需要处理模板的名称以及在渲染模板时传递的输入数据。当模板第一次被处理时,Kid创建一个 Python 模块,作为模板的缓存版本供以后使用。kid.Template函数返回一个Template类的实例,然后你可以使用该实例来渲染输出内容。为此,Template类提供了以下方法:

  • serialize: 这返回输出内容作为一个 Python 字符串。

  • generate: 这返回输出内容作为一个 Python 迭代器。

  • write: 这将输出内容写入文件对象。

这三个方法接受以下参数:

  • encoding: 这告诉Kid如何编码输出内容;默认为 UTF-8。

  • fragment: 这是一个布尔值,询问Kid是否在最终结果中包含或排除 XML 序言或 Doctype。

  • output: 这指定了Kid在渲染内容时应使用哪种类型的序列化。

Kid的属性

Kid的属性如下:

基于 XML 的模板语言

Kid是一种基于 XML 的语言,这意味着:

  • 一个Kid模板必须是一个有效的 XML 文档。

  • Kid使用 XML 元素内的属性来通知底层引擎在到达元素时应该执行什么操作。为了避免与 XML 文档中其他现有属性冲突,Kid自带一个命名空间(purl.org/kid/ns#),通常与py前缀相关联,例如:

<p py:if="...">...</p>

变量替换

Kid提供了一个非常简单的变量替换方案:${variable-name}

这可以用于元素的属性或作为元素的文本内容。Kid将在模板中每次遇到变量时评估该变量。

如果你需要输出一个字面字符串,例如${something},你可以通过将美元符号加倍来转义变量替换,例如$${something},这样它将被渲染为${something}

条件语句

当你在模板中需要在不同的案例之间切换时,你需要使用以下语法:

<tag py:if="expression">...</tag>

位置:

  • tag是元素的名字,例如DIVSPAN

  • expression是一个 Python 表达式。如果作为一个布尔值评估为True,则该元素将被包含在输出内容中。否则,该元素将不包含在内。

循环机制

要告诉Kid在元素上循环,你必须使用以下语法:

<tag py:for="*expression*">...</tag>

位置:

  • tag是元素的名字。

  • expression是一个 Python 表达式,例如for value in [...]

循环机制如下:

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html >
<head>
<title>${title}</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<table>
<caption>A few songs</caption>
<tr>
<th>Artist</th>
<th>Album</th>
<th>Title</th>
</tr>
<tr py:for="info in infos">
<td>${info['artist']}</td>
<td>${info['album']}</td>
<td>${info['song']}</td>
</tr>
</table>
</body>
</html>
import kid
# Fake object and method which suggests that we pull the data to be
# rendered from a database in the form of a Python dictionary.
params = discography.retrieve_songs()
t = kid.Template('songs.kid', **params)
print t.serialize(output='html')

可扩展性

使用以下语法扩展模板:

<tag py:extends="templates">...</tag>

位置:

  • tag是元素的名字。然而,在这个特定情况下,元素只能是当前模板的根元素。

  • templates是一个以逗号分隔的Kid模板文件名或实例列表。

首先,定义一个名为common.kid:Kid模板。

<html >
<head py:match="item.tag == 'this-is-ed'">
<title>${title}</title>
<link rel="stylesheet" href="style.css" />
</head>
</html>

然后,修改前一个示例的模板:

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html py:extends="'common.kid'" >
...
...
<body>
<table>
<caption>A few songs</caption>
<tr>
<th>Artist</th>
<th>Album</th>
<th>Title</th>
</tr>
<tr py:for="info in infos">
<td>${info['artist']}</td>
<td>${info['album']}</td>
<td>${info['song']}</td>
</tr>
</table>
</body>
</html>

Kid 处理该模板时,它将首先编译 common.kid 模板。当 Kid 遇到 <this-is-ed /> 元素时,它将理解这匹配 common.kid 模板的头部元素,并将替换其内容。

其他属性

Kid 除了我们之前审查的基本属性外,还提供了更多属性:

  • py:content="expression": 使用此属性的元素的子代将被替换为表达式的输出内容。

  • py:strip="expression": 如果表达式评估为 True,则包含的元素将不会出现在结果中,但其后代将存在。如果表达式评估为 False,则处理过程正常进行。

  • py:replace="expression": 这是 py:content="expression" py:strip="True" 的快捷方式。

  • py:attrs="expression": 这允许动态向元素中插入新属性。

  • py:def="template_name(args)": 这允许创建一个可以在主模板的其他地方引用的临时模板。

您可以通过访问官方的 Kid 文档来获取更多信息,文档地址为 kid-templating.org/.

照片博客设计准备

在前面的章节中,我们介绍了我们将用于创建应用程序界面的工具。在接下来的章节中,我们将创建该界面的基础。

定位用户代理

考虑到照片博客应用程序以要显示的图像为中心,我们将忽略不支持该特性的用户代理。应用程序还将大量使用客户端代码,通过 JavaScript 实现。因此,我们将仅关注支持它的现代浏览器引擎。

这里是一个我们主要目标的简要列表:

引擎 目标浏览器
Gecko Mozilla Firefox 1.5 及以上版本,Netscape 8
MSHTML Internet Explorer 6 SP1 及以上版本
KHTML(及 WebKit) Konqueror,Safari
Presto Opera 9 及以上版本

工具

对于此应用程序,您需要:

  • 文本编辑器;您喜欢的文本编辑器即可。

  • 提供开发工具的浏览器;使用以下扩展的 Mozilla Firefox 将是一个不错的选择:

    • 网页开发者或 Firebug

    • LiveHTTPHeader 或 Tamper Data。或者,CherryPy 提供了 log_headers 工具,当在 CherryPy 的全局设置中启用时,它将在服务器上记录请求头,从而允许按请求轻松调试。

    • DOM 检查器

    • JavaScript 调试器

此外,尽管我们将使用一个特定的浏览器进行大部分开发,但建议您尽可能多地使用各种浏览器定期进行测试。

全球设计目标

正如我们所说,照片博客应用程序专注于图像。考虑到这一点,我们将绘制一个全局设计的界面,如下所示:

全球设计目标

如您所见,我们的默认设计可能看起来并不华丽,但它为我们提供了一个我们寻找的博客的基本结构,以便探索网页设计。

最顶部区域将是我们的页眉。这是您放置博客吸引人的名称的地方。在其下方,我们将有一个导航菜单,包含一些链接,用于浏览博客的基本区域。然后我们将有内容区域,其中默认只显示摄影作品。这意味着默认情况下不会显示任何文本,并且需要用户交互来揭示它。这确保了焦点始终在摄影上。然而,当需要显示文本内容时,内容区域将根据请求进行扩展。最后,有一个包含有关本博客内容版权信息的页脚区域。

设计目录布局

我们将为照片博客应用程序使用的布局将位于以下目录结构中:

default\
commond.kid
index.kid
css\
style.css
images\
js\

我们将把这个设计命名为default,因为它将是与应用程序一起提供的,并在第一次访问应用程序时默认使用。

您会注意到,尽管大量使用了 JavaScript,但js目录却是空的。原因是我们将定义一个全局静态文件目录,这些文件可能被不同的模板共享,例如我们创建的所有 JavaScript 文件。

CherryPy—封装模板渲染过程

CherryPy 处理器完全可以自己调用Kid并返回模板的序列化输出,但我们不会这样做。相反,我们将把Kid封装成一个 CherryPy 工具,我们的处理器将调用它。做出这个决定有两个原因:

  • 为了让您能够从Kid切换到不同的模板引擎。想象一下,您更喜欢Cheetah模板引擎而不是Kid。您可以使用Cheetah编写模板,只需修改工具,而无需对整个应用程序进行修改。

  • 为了便于维护。如果Kid发展并改变其语法,只需更新工具而不是整个应用程序,这将更容易。

命名为Design的工具附加到默认的 CherryPy 工具箱中:

import os.path
import cherrypy
from cherrypy import Tool, tools
import kid
def transform(path=None, template=None):
params = cherrypy.response.body
if path and template and isinstance(params, dict):
path = os.path.normpath(os.path.join(path, template + '.kid'))
template = kid.Template(file=path, **params)
cherrypy.response.body = template.generate(output='html')
# Attach our Design tool to the CherryPy default toolbox
tools.design = Tool("before_finalize", transform)

然后,我们将像这样使用该工具:

@cherrypy.expose
@cherrypy.tools.design(template='index')
def index(self):
return {...}

使用该工具的页面处理器需要返回一个 Python 字典,其中包含传递给模板引擎并期望由模板接收的值。

注意,该工具期望一个path参数,该参数本身不会传递给装饰器调用。这个path代表包含设计目录的文件夹的绝对基本路径,在我们的例子中path将是已经定义的default目录。我们将在配置文件中设置这个值一次,该配置文件将附加到 CherryPy 应用程序中。我们将在第十章中看到更多关于这个的细节 Chapter 10。

注意

克里斯蒂安·维格伦多夫斯基是名为 Buffet 的项目维护者,该项目旨在提供在提到的工具中展示的核心功能。它支持许多模板语言,并提供扩展的 API。然而,它目前仅支持 CherryPy 2,因此在本章中未使用。CherryPy 3 支持计划中,并将很快可用。

照片博客设计细节

现在,我们将看看我们照片博客设计的基本结构。

基本结构

我们的第一步是定义页面的 HTML 结构:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
"http://www.w3.org/TR/html4/strict.dtd">
<html py:extends="'common.kid'" >
<head />
<body>
<!-- main container of our content -->
<div id="page">
<div id="header">
<br />
</div>
<div id="nav">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/">Albums</a></li>
<li><a href="/">Sign-In</a></li>
<li><a href="/">About</a></li>
</ul>
</div>
<!-- content area where we will display the picture
and other content such as forms -->
<div id="content-pane">
<div id="photo-pane">
<img id="photo-data" src="img/" alt="" /><br />
</div>
</div>
<div id="footer">
<br />
</div>
</div>
</body>
</html>

这个模板,我们将命名为index.kid,扩展了common.kid模板。它看起来如下:

<html >
<head py:match="item.tag == 'head'">
<title></title>
<meta http-equiv="content-type" content="text/html;
charset=iso-8859-1"> </meta>
</head>
</html>

index.kid模板的head元素将被名为common.kidKid模板的head元素替换。

我们将按以下方式处理该模板:

import cherrypy
import kid
class Root:
@cherrypy.expose
def index(self):
t = kid.Template('index.kid')
return t.generate(output='html')
if __name__ == '__main__':
import os.path
cur_dir = os.getcwd()
conf = {'/style.css': {'tools.staticfile.on': \
True,'tools.staticfile.filename': os.path.join(cur_dir, \
'style.css')}}
cherrypy.quickstart(Root(), config=conf)

现在,如果你导航到localhost:8080/,它应该看起来如下:

基本结构

下一步是添加 CSS 样式表,通过修改common.kid模板来实现:

<html >
<head py:match="item.tag == 'head'">
<title></title>
<meta http-equiv="content-type" content="text/html;
charset=iso-8859-1">
</meta>
<link rel="stylesheet" type="text/css" href="/style.css"> </link>
</head>
</html>

然后,我们定义 CSS 如下:

body
{
background-color: #ffffff;
font-family: sans-serif;
font-size: small;
line-height: 1.3em;
text-align: center;
}
#page
{
position:relative;
Photoblog designbasic structuretop: 25px;
margin: 0px auto;
text-align:left;
width: 600px;
position: left;
border: 1px #ffffff solid;
}
#header
{
height: 45px;
background-color: #71896D;
border-bottom: 2px #858A6E solid;
}
#nav
{
height: 20px;
background-color: #CED6AB;
border-bottom: 2px #858A6E solid;
font-weight: bold;
text-align: right;
}
#nav ul
{
margin: 0 0 0 20px;
padding: 0;
list-style-type: none;
}
#nav li
{
display: inline;
padding: 0 10px;
}
#nav li a
{
text-decoration: none;
color: #858A6E;
}
#nav li a:hover
{
text-decoration: none;
color: #999966;
}
#content-pane
{
background-color: #ffffff;
Photoblog designbasic structureborder-bottom: 1px #858A6E solid;
text-align: center;
padding: 50px 50px 50px 50px;
}
#photo-pane img
{
border: 1px #858A6E solid;
padding: 3px 3px 3px 3px;
}
#footer
{
height: 20px;
background-color: #CED6AB;
}

现在,如果你重新加载页面,你应该看到如下内容:

基本结构

我们现在有了我们照片博客应用的主页。以下配置将使我们了解我们将如何处理我们应用的其他页面:

  • 每个页面一个Kid模板:在这种配置中,每次跟随链接或向应用提交表单时,服务器将从其模板构建一个新的页面,并将其发送回浏览器。

    • 优点:对于网页设计师来说很容易,现在可以编辑每个页面。

    • 缺点:从最终用户的角度来看,它感觉不太动态。感觉导航是按“页面”进行的。

  • 一到两个模板和一些 JavaScript 文件:在这种情况下,只会向浏览器发送一个页面,但它将包含足够的信息,让浏览器根据上下文和最终用户的交互生成和插入内容块。

    • 优点:对于最终用户来说感觉更加动态。服务器处理的工作更少,它发送数据给用户代理进行处理。

    • 缺点:对于网页设计师来说不太直观。它不适用于不支持 JavaScript 的用户代理。

  • 每个要显示的内容块一个Kid模板:这是前两种解决方案的结合。将向用户代理发送一个页面,并在用户交互时,浏览器将去服务器从Kid模板中获取额外的内容块,这些内容块将直接插入到网页中。这种技术被称为 AHAH(异步 HTML 和 HTTP),我们将在下一章中看到。

    • 优点:对于在 HTML 代码上工作的网页开发者来说很容易,就像第一种解决方案一样。

    • 缺点:渲染工作由服务器完成,因此服务器需要做更多的工作。它不适用于不支持 JavaScript 的浏览器。

为了这个应用的目的,我们将主要使用第二种解决方案。我们将在接下来的章节中看到应用。

Mochikit

Mochikit,由 Bob Ippolito 创建和维护,是一个 JavaScript 工具包,提供了一套简化客户端网络应用开发的函数。Mochikit 提供了以下组件:

  • 异步操作:这允许浏览器进行同步或异步处理的 HTTP 请求。我们将在下一章中详细解释。

  • 基础:这是一组用于常见编程任务的函数。

  • DOM:这是一个 API,用于简化 DOM 树的操纵,并执行如插入或删除树中节点等操作。

  • 拖放:这是为了在 Web 应用中启用拖放处理。

  • 颜色:这提供了对 CSS3 颜色的抽象,这些颜色当前浏览器不支持。

  • 日期时间:这些是用于日期和时间管理的辅助工具。

  • 格式:这些是用于字符串操作的辅助工具。

  • 迭代:这为 JavaScript 在数据集合上的迭代模式提供了良好的支持。

  • 日志记录日志面板:这些是扩展的日志工具。

  • 信号:这是一个用于处理网络应用中事件及其分发的 API。

  • 样式:这是对 CSS 更好的支持。

  • 可排序:这简化了数据集合排序的方式。

  • 视觉:这些是使网络应用更具吸引力的效果。

Mochikit 不是 JavaScript 工具包领域的唯一主要参与者;还有其他如 Dojo、script.aculo.us、Rico、Yahoo UI Library、JQuery、mooh.fx 等。所有这些都允许你编写丰富的客户端网络应用,选择其中之一既取决于口味也取决于功能。

我们将广泛使用 Mochikit 库来为最终用户提供更动态的体验。

例如,我们可以添加一个显示与当前显示的照片相关联的电影信息的框。默认情况下,这个框是隐藏的,当用户点击链接时才会显示。

开发照片博客设计

我们现在已经拥有了开发网络应用界面的所有工具,现在我们将逐步展示我们的照片博客应用如何通过具体的示例使用这些工具,反映应用的用户界面案例。

HTML 代码

首先,让我们插入一个 HTML 框来显示电影信息。

要插入到index.kid模板中的 HTML 代码:

<div id="film-pane">
<div id="film-infos-pane">
<label class="infos-label">Title:</label>
<span class="infos-content">My last holiday</span>
<label class="infos-label">Created on:</label>
<span class="infos-content">18th August, 2006</span>
<label class="infos-label">Updated on:</label>
<span class="infos-content">27th August, 2006</span>
<label class="infos-label">Description:</label>
<span class="infos-content">Some text here...</span>
</div>
</div>

如您所见,我们定义了一个内框和一个外框,因为我们可能需要添加更多与内框共享相同过程的内容。请注意,我们也从模板本身提供了一些随机数据用于测试目的,在开发界面时。实际上,尽管本书中应用程序是逐步构建的,但在现实生活中的项目中,任务通常是并行完成的,因此通常相互依赖的区域必须在它们的侧面对模拟对象或数据进行工作。这些是硬编码的,但提供了实际的内容来工作。

添加链接

由于此框默认将隐藏,我们需要为最终用户提供一个切换其可见性的链接。为此,我们在index.kid模板中添加以下 HTML 代码:

<span id="toggle-film-infos">Film information</span>

注意,尽管我们称之为链接,但这不是一个 HTML<a />元素,而是一个将作为链接从最终用户角度起作用的文本标签。

处理最终用户操作

假设我们有一个名为utils.js的 JavaScript 文件,我们将会定义:

function toggleFilmVisibility(e)
{
toggle($('film-pane'), 'slide');
}
function initialize(e)
{
hideElement($('film-pane'));
connect('toggle-film-infos', 'onclick', toggleFilmVisibility);
};
connect(window, 'onload', initialize);

首先,我们创建一个简单的 JavaScript 函数,它只接受一个参数,即包含当前事件详细信息、调用者和被调用者的 DOM 事件对象。此函数仅执行两个操作:

  • 它隐藏了具有film-pane作为id的 DOM 元素。Mochikit 提供了$(name)作为获取 DOM 树中 DOM 节点的快捷方式。

  • 它将具有id名为toggle-film-infos的元素的onclick信号连接到名为toggleFilmVisibility的函数。该函数仅切换电影框的可见状态。

然后,我们将window DOM 对象的onload信号连接到initialize函数。这意味着一旦window对象被加载,initialize将被调用。

修改模板

common.kid模板中,我们只需在<head />元素中添加以下行:

<script type="application/javascript" src="img/MochiKit.js" />
<script type="application/javascript" src="img/New.js" />
<script type="application/javascript" src="img/utils.js" />

修改 CSS

正如我们在示例中所见,我们的不同 HTML 元素要么有id属性和/或class属性。两者都将允许我们为这些元素应用特定的样式,正如我们现在将要看到的:

/* will inform the end-user the text is clickable as link */
span#toggle-film-infos
{
cursor: pointer;
text-align: left;
}
span#toggle-film-infos:hover
{
text-decoration: underline;
}
#film-pane
{
border: 1px #663 solid;
padding: 3px 3px 3px 3px;
background-color: #fff;
}
#film-infos-pane
{
text-align: left;
}
/* the following rules allow the information to be
organized and displayed as in table */
infos-content, .infos-label
{
display: block;
width: 170px;
float: left;
margin-bottom: 2px;
}
infos-label
{
text-align: left;
width: 95px;
padding-right: 20px;
font-weight: bold;
}

让我们更加灵活...

在我们刚才的示例中,我们从 HTML 框将直接包含在主模板中的事实开始。Mochikit 附带了一个方便的 DOM 工具箱,其中包含以常见 HTML 元素(如DIV, SPAN, INPUT, FORM等)命名的函数。它提供了一种在浏览器持有的 DOM 树中动态生成 HTML 元素并插入的极其简单的方法。

我们应用程序的一个典型用例将是现有专辑的展示。由于它们的数量会随着时间的推移而变化,因此需要动态生成相关的 HTML 代码,如下面的示例所示:

var albumInfoBlock = DIV({'class': 'albums-infos-pane', 'id':
'album-' + album['id']},
LABEL({'class': 'infos-label'}, 'Title:'),
SPAN({'class': 'infos-content'}, album['title']),
LABEL({'class': 'infos-label'}, 'Created on:'),
SPAN({'class': 'infos-content'}, album['created']),
LABEL({'class': 'infos-label'}, 'Updated on:'),
SPAN({'class': 'infos-content'}, album['updated']),
LABEL({'class': 'infos-label'}, 'Description:'),
SPAN({'class': 'infos-content'}, album['description']));

我们首先创建包含信息的主体块,然后使用模式album-#id#关联一个唯一的标识符,其中#id#是要显示的专辑的id。这样做,我们在 DOM 树中的每个块都提供了一个唯一的标识符。这是必需的,因为我们将在块本身上附加鼠标事件以进行进一步处理。然后,我们通过SPAN元素附加一系列内联元素,并插入专辑链接属性的文本内容。

一旦创建块,我们按照以下方式连接鼠标事件:

connect(albumInfoBlock, 'onclick', selectAlbum);

当用户点击专辑块时,将调用selectAlbum,并执行操作以显示所选专辑,正如我们将在下一章中看到的。

接下来,我们将新创建的元素附加到外部专辑框区域,并显示它:

appendChildNodes(albumsPane, albumInfoBlock);
toggle(albumsPane, 'blind');

包含专辑信息的块的创建将通过遍历从服务器检索到的专辑进行循环,正如我们将在下一章中看到的。

摘要

通过本章,我们介绍了一些今天可用于创建具有动态和吸引设计模式的网络应用程序界面的技术和工具。

这些包括从仍然广泛用于结构化内容的经典 HTML 变体,到 CSS,这是网页设计师的得力助手,用于设计界面,以及资源丰富的 Mochikit,它让我们进入丰富网络应用程序的世界。

仍然缺少一个连接服务器和客户端以整合一切的链接。这个链接现在通常被称为 Ajax。这就是我们将在下一章中解释的内容。

第八章。Ajax

在上一章中,我们通过使用 HTML、DOM 和 JavaScript 构建了照片博客的 Web 界面。我们展示了如何从浏览器本身动态修改网页。然而,我们没有详细说明这种动态的本质,也没有解释如何在不刷新整个网页的情况下从 Web 应用服务器检索数据。能够为我们做到这一点的是 Ajax。因此,本章的目标是介绍 Ajax 的概念。

富客户端应用的兴起

直到 2005 年,在 Web 应用中最常见的模式是每页一个 HTTP 请求。换句话说,通过触发 HTTP 请求来检索链接资源,通过链接在网站上导航。这种模式现在仍然被广泛使用,但与每页多个 HTTP 请求的模式竞争。这种区别可能看起来微不足道,但通过允许浏览器向一个给定的 URI 发出多个 HTTP 请求以获取更多数据,它为希望创建更互动应用的 Web 开发者提供了一条不同但强大的路径。

例如,让我们想象一个通过分页显示结果而不是一次性显示所有结果的 Web 应用。在传统的 Web 应用中,每次最终用户向前或向后移动时,都会向服务器发送一个新的 HTTP 请求来重建整个页面。在这种情况下,浏览器地址栏中显示的 URL 也会根据当前查看的页面而改变。另一方面,想象一下,如果不是获取整个网页,而是只获取要显示的新数据集。我们仍然会在客户从当前位置移动时发出一个请求,但不会替换整个网页。最终用户会有较少的感觉是被网页所控制,这可能会改善通过数据集导航的整体体验,同时减少带宽消耗。

这个简单的例子实际上是一切现代 Web 应用增强的种子,这些增强导致了富客户端应用的兴起。

Ajax

在 2005 年,杰西·詹姆斯·加雷特(www.adaptivepath.com/publications/essays/archives/000385.php)创造了 Ajax 这个术语,用来指代他即将向一位客户展示的一套技术。从那时起,它已经脱离了原作者的手笔,如今成为了我们在上一节中介绍的内容——使网络应用看起来更加动态和交互的术语。

Ajax代表异步 JavaScript 和 XML,它涵盖了一组应用于网络环境的技术。让我们回顾一下这个缩写的每个部分:

  • 异步: 在客户端-服务器环境中,有两个基本原则;要么你的操作与整个程序同步运行,要么不同步。如果是,则程序暂停直到操作终止;如果不是,则操作立即返回并让程序继续。一旦操作完成,它通过回调函数通知其主要程序。

    在 Web 应用程序的背景下,Ajax 的整个目的就是为最终用户提供更多的交互性,这就是为什么它广泛依赖于异步操作。现在,没有任何东西阻止开发者将特定操作同步到应用程序的其余部分。然而,如果操作不是几乎瞬时的,这可能会导致整个浏览器的冻结。

  • JavaScript: 在传统方法中,每个来自最终用户的行为都会导致一个新的 HTTP 请求,这个请求由浏览器本身生成,同时也消耗了 HTTP 响应。在 Ajax 中,HTTP 请求由对底层 HTTP API 的 JavaScript 调用处理,我们将在稍后进行回顾。因此,Web 开发者负责创建一个有效的请求,能够处理其响应,并最终更新最终用户的网页视图。

  • XML: Ajax 的主要目的是在文档对象模型上执行操作,以向最终用户视图插入新内容或从网页中删除部分内容。Ajax 基于通过 HTTP 交换 XML 文档。这些文档包含执行请求操作所需的所有信息和数据。因此,可以使用其他信息格式,XML 不是必需的。最广泛使用的格式是 JSON,我们将在稍后介绍。

Ajax—优点和缺点

初看起来,Ajax 所承载的概念似乎非常有前途,它们确实如此。然而,所需的技术可能导致意外的问题。首先,让我们回顾一下 Ajax 的一些优点:

  • 服务器和带宽使用减少:在传统的 Web 应用程序中,每个页面都是从服务器完整请求的,这既浪费了服务器资源,也浪费了网络资源。这是因为服务器可能需要重新计算页面,并且更多的数据通过电线传输。然而,在两种情况下,合理地使用缓存都会减少这种影响。

    当使用 Ajax 原则时,只需从服务器获取所需的数据。在这种情况下,服务器和中间件可以缓存它。在任何情况下,Ajax 都可以减少服务器上的负载,因为部分处理被移动到客户端本身。

  • 用户体验的总体提升:由于网页视图是在客户端根据用户的行为本地更新的,因此用户可能会感觉到 Web 应用程序更加交互和响应。

  • 强制关注点分离:由于 Web 开发者负责构建要发送的 HTTP 请求,他或她可以根据应用程序的当前上下文决定实际调用不同的 Web 服务。

    例如,在一个传统的 Web 应用中,一个 HTML 表单会被提交到 Web 服务器,服务器会返回一个 HTML 页面。Ajax 让开发者决定哪个服务将处理用户输入。因此,开发者可以调用一个 Atom 发布协议服务,该服务会返回一个 Atom 文档,开发者随后会手动处理。Ajax Web 应用可以在不同的特定服务之间分配其任务。

现在我们来回顾一下与 Ajax 相关的一些缺点:

  • 基于 Ajax 原则的 Web 应用最大的问题之一是它们绕过了浏览器机制,因此,后退和前进按钮的标准行为不再得到保证。从更广泛的角度来看,Ajax 打破了用户已经习惯成为标准 Web 导航方式的习惯。例如,页面到页面的模式是用户操作触发操作并导致网页当前状态发生修改的明显标志,而只修改查看页面一部分的 Web 应用可能会让一些用户感到困惑。

  • Ajax 有时会阻止用户收藏页面。

  • 一些人对 Ajax 和 JavaScript 可能带来的安全漏洞表示担忧。然而,这些说法通常是对那些有弱点但并非由于 JavaScript 而是由于设计功能的方式的应用程序提出的。无论如何,在使用 Ajax 时,你应该始终权衡你自己的需求可能带来的潜在安全风险。例如,永远不要仅信任客户端表单验证;确保在服务器端验证任何传入的数据,并将客户端验证保持在最低限度以减少往返 HTTP 交换。

通常,Web 应用中使用 Ajax 的陷阱是其过度使用。尽管这是一个相当主观的话题,但当 Ajax 的使用没有比更传统的方法改善最终用户体验时,滥用 Ajax 是不被看好的。我们的相册应用将相当多地使用 Ajax。

背后场景:XMLHttpRequest

正如我们所见,Ajax 基于使用 JavaScript 发送 HTTP 请求的理念;更具体地说,Ajax 依赖于XMLHttpRequest对象及其 API 来执行这些操作。这个对象最初是由微软工程师设计和实现的,作为一个 ActiveX 控件,可供 Outlook Express 和 Internet Explorer 使用,但在 Ajax 和丰富 Web 应用兴起之前,它并没有被广泛使用。现在,XMLHttpRequest是每个现代浏览器的一部分,其使用如此广泛,以至于 W3C 特别成立了一个工作组来指定这个对象的范围,以提供跨实现的最小互操作性要求。

让我们回顾一下 W3C 指定的 XMLHttpRequest 接口,因为它提供了浏览器厂商实现的最常见的属性和函数:

属性 描述
readyState 只读属性,表示对象当前的状态:0: 未初始化1: 打开2: 已发送3: 接收4: 已加载
onreadystatechange readyState 属性改变时,会调用一个 EventListener。
responseText 包含从服务器接收到的字节,以字符串形式表示
responseXML 如果响应的 content-type 是与 XML 相关联的类型(text/xml, application/xml+xml),则包含接收到的文档的实例。
status HTTP 响应代码
statusText HTTP 响应文本
方法 描述
--- ---
abort() 取消与服务器的基本网络连接。
getAllReponseHeaders() 返回一个由换行符分隔的所有 HTTP 响应头的字符串。
getResponseHeader(header) 如果响应中存在该头,则返回其值。否则返回空字符串。
setRequestHeader(header, value) 为基本请求设置 HTTP 头。
open(method, uri, async, user, password) 初始化对象:method: 请求要使用的 HTTP 方法uri: 请求应用的 URIasync: 一个布尔值,指示此请求是否必须与程序的其余部分同步usernamepassword: 提供访问资源的凭据
send(data) 实现 HTTP 连接,如果提供了数据,则设置请求体。

API 相当直接和简单。让我们通过使用 MochiKit Async 模块的各个示例来了解。

执行 GET 请求

GET请求如下所示:

var xmlHttpReq = getXMLHttpRequest();
xmlHttpReq.open("GET", "/", true);
var d = sendXMLHttpRequest(xmlHttpReq);
d.addCallback(function (data)
{
alert("Success!");
});
d.addErrback(function (data)
{
alert("An error occurred");
};

现在,我们将看到我们实际上做了什么:

  1. 1. 由于每个浏览器都有自己的 API 供开发者实例化 XMLHttpRequest,Mochikit 提供了getXMLHttpRequest()函数,该函数将根据检查最终用户使用的浏览器返回正确的对象。

  2. 2. 然后我们使用所需值初始化对象。在这种情况下,我们想要以异步方式对当前主机的"/" URI 执行GET请求。

  3. 3. 然后我们通知服务器,它必须在完成我们的请求并发送我们的响应后关闭连接。

  4. 4. 然后我们使用 Mochikit 的sendXMLHttpRequest()函数,该函数返回一个延迟对象。此对象为开发者提供了一个干净的 API 来处理XMLHttpRequest对象在处理过程中可能采取的不同状态。

  5. a. 如果响应状态码指示成功(通常在 HTTP 的 2xx 和 3xx 范围内),则添加一个回调。

  6. b. 我们还关联了一个错误回调,当响应指示错误时(通常在 HTTP 的 4xx 和 5xx 范围内)将应用此回调。

    1. 每个回调必须接受的 data 参数是响应中包含的实体主体,然后可以由回调处理。

执行内容协商 GET 请求

这个 GET 请求如下所示:

var xmlHttpReq = getXMLHttpRequest();
xmlHttpReq.open("GET", "/", true);
xmlHttpReq.setRequestHeader('Accept', 'application/atom+xml');
xmlHttpReq.setRequestHeader('Accept-Language', 'fr');
var d = sendXMLHttpRequest(xmlHttpReq);
d.addCallback(function (data)
{
alert("Success!");
});
d.addErrback(function (data)
{
alert("An error occured");
});

在这个请求中,我们通知服务器我们愿意接受使用 Atom 格式表示并使用法语表示的内容。无法处理此请求的服务器可能会以 406 Not Acceptable 响应,因此将应用错误回调。

执行 POST 请求

这个 POST 请求如下所示:

var qs = queryString(data);
var xmlHttpReq = getXMLHttpRequest();
xmlHttpReq.open("POST", "/album", true);
xmlHttpReq.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
var d = sendXMLHttpRequest(xmlHttpReq, qs);
d.addCallback(function (data)
{
// do something
});
d.addErrback(function (data)
{
// do something else
});

现在,我们将看到我们实际上做了什么:

    1. 我们以编码查询字符串的形式发布一些数据。queryString(data) 函数接受一个键值对的关联数组,并返回一个形式为 key1=value1?key2=value2 的编码字符串。
    1. 我们初始化 XMLHttpRequest 对象。
    1. 我们指定请求实体主体的内容类型:application/x-www-form-urlencoded
    1. 然后,我们从 sendXMLHttpRequest 请求一个延迟对象,但如您所见,我们还传递了我们希望发送的数据。

让我们发布一个 XML 文档

这就是我们将如何做的:

var entry = '<?xml version="1.0" encoding="utf-8"?>
<entry>
<title>This is my family album</title>
<id>urn:uuid:25cd2014-2ab3-11db-902d-000ae4ea7d46</id>
<updated>2006-08-13T11:18:01Z</updated>
<content type="text">Some content</content>
</entry>';
var xmlHttpReq = getXMLHttpRequest();
xmlHttpReq.open("POST", "/album", true);
xmlHttpReq.setRequestHeader('Content-Type', 'application/atom+xml');
var d = sendXMLHttpRequest(xmlHttpReq, entry);
d.addCallback(function (data)
{
// do something
});
d.addErrback(function (data)
{
// do something else
});

执行 PUT、HEAD 或 DELETE 请求

与 HTML 表单不同,XMLHttpRequest 在支持的 HTTP 方法方面没有限制。实际上,XMLHttpRequest 不关注您使用的方法,也不对其进行解释。您使用的方法会原样发送到服务器。这在基于 REST 或 Atom 发布协议的 Web 服务中非常重要,正如我们在前面的章节中看到的。

Cookies

Cookies 会自动与请求一起发送,由托管 XMLHttpRequest 的用户代理完成;因此,开发者无需采取任何特定操作。

使用摘要或基本方案进行身份验证

XMLHttpRequest 的 open() 方法可以接受 usernamepassword 参数,这些参数将与请求一起发送。XMLHttpRequest 支持的身份验证方案由 RFC 2617 定义,即 基本摘要。这两个方案如下:

  • 基本方案:基本方案只是使用 base64 算法对用户名和密码进行编码的传输。问题是,如果第三方获取了编码值,就无法阻止它被解码。这就是为什么基本方案通常被称为明文发送密码,因为应用的编码可以非常容易地被解码。因此,除非在 HTTPS 等安全协议上使用,否则这不是一个安全的身份验证方案。

  • 摘要方案:另一方面,摘要方案不会将密码作为明文发送。相反,双方使用密码和其他种子应用相同的算法来计算这些值的摘要。服务器也会在第一次请求时发送种子值来 标记 该请求。客户端将摘要算法的计算结果发送回服务器,服务器将其与自己的计算结果进行比较。如果两者匹配,则允许请求。这个方案比基本方案更安全,因为密码实际上从未以可以被合理时间内解密的形式发送到线上。

默认情况下,当使用这些方案时,浏览器会弹出一个窗口请求用户名和密码。在由 JavaScript 调用 XMLHttpRequest 发出的请求的上下文中,可以通过直接将用户凭据提供给 open() 方法来避免该弹出窗口。显然,将它们硬编码到 JavaScript 代码中是不合适的。相反,将 HTML 表单集成到网络应用程序中并将输入值动态传递给 JavaScript 调用相当简单,以下示例将演示这一点:

<html>
<head>
<script type="application/javascript" src="img/MochiKit.js">
</script>
<script type="application/javascript" src="img/New.js">
</script>
<script type="application/javascript">
doLogin = function()
{
// create the XMLHttpRequest object
var xmlHttpReq = getXMLHttpRequest();
// initialize the object
// the "/hello/" + username URI is protected by a password
// the magic happens here as we pass dynamically the values
// of the username and password entered by the user
xmlHttpReq.open("GET", "/hello/" + $("username").value, true,
$("username").value, $("password").value);
// start the request
var d = sendXMLHttpRequest(xmlHttpReq);
// let's remove any previous displayed message from the DOM
replaceChildNodes($("message"));
// insert a welcome message if the authentication succeeded
d.addCallback(function (data)
{
appendChildNodes($("message"), SPAN({},
data.responseText));
});
// insert a message if the authentication failed
d.addErrback(function (data)
{
appendChildNodes($("message"), SPAN({}, "You're not
welcome here."));
});
};
</script>
<style type="text/css">
Body
{
text-align: center;
font-family: sans-serif;
}
#loginBox
{
“XMLHttpRequestauthenticating, digest scheme used"position:relative;
margin: 0px auto;
text-align:left;
width: 250px;
color: #2F2F2F;
padding-top: 25px;
}
Fieldset
{
background-color: #E9F3FF;
}
input, label
{
display: block;
float: left;
margin-bottom: 2px;
}
Label
{
text-align: left;
width: 70px;
padding-right: 10px;
}
Input
{
border: 1px #000 solid;
}
#loginButton
{
cursor: pointer;
font-weight: bold;
text-decoration: none;
color: #2F2F2F;
}
#loginButton:hover
{
text-decoration: underline;
}
“XMLHttpRequestauthenticating, digest scheme used"</style>
</head>
“XMLHttpRequestauthenticating, basic scheme used"<body>
<div id="loginBox">
<form name="login" id="login">
<fieldset>
<label>Username:</label>
<input type="text" name="username" id="username" />
<br /><br />
<label>Password:</label>
<input type="password" name="password" id="password" />
<br /><br />
<span onclick="doLogin();" id="loginButton">Connect</span>
</fieldset>
</form>
</div>
<div id="message" />
</body>
</html>

以下是一个 CherryPy 脚本示例,它将提供前面的页面:

import os.path
import cherrypy
class Root:
@cherrypy.expose
def index(self):
return file('ajaxdigest.html').read()
class Hello:
@cherrypy.expose
def default(self, username):
return "Hello %s" % username
if __name__ == '__main__':
r = Root()
r.hello = Hello()
current_dir = os.path.abspath(os.path.dirname(__file__))
def get_credentials():
return {'test': 'test'}
conf = {'/hello': {'tools.digest_auth.on': True,
'tools.digest_auth.realm': 'localhost',
'tools.digest_auth.users': get_credentials},
'/MochiKit': {'tools.staticdir.on': True, 'tools.staticdir.dir':
os.path.join(current_dir, 'MochiKit')}}
cherrypy.quickstart(r, config=conf)

当你访问 localhost:8080/ 时,你应该看到以下页面:

使用摘要或基本方案进行身份验证

如果你输入用户名 test 和密码 test,你将在屏幕上看到以下视图:

使用摘要或基本方案进行身份验证

另一方面,如果你提供错误值,你将看到如下屏幕:

使用摘要或基本方案进行身份验证

不幸的是,浏览器从服务器接收关于身份验证失败的 401 HTTP 错误代码 的消息,并自行处理。截至目前,没有跨浏览器的解决方案可以避免这个问题,使得弹出窗口不会出现。如果你点击弹出窗口的 取消 按钮,浏览器随后将返回到你的 JavaScript 代码,并应用错误回调。

此外,由于你不能通过 XMLHttpRequest 对象访问底层会话(因为它由浏览器处理),你不能通过抑制会话凭据来强制注销。用户必须关闭浏览器才能与应用程序断开连接。

因此,尽管 XMLHttpRequest 允许你在网络应用程序中提供一种更花哨的方式来启用基本和摘要身份验证,但仍然有一些陷阱需要承认。

JSON

正如我们在本章中已经看到的,尽管 Ajax 的名字中带有 XML,但它并不阻止传输其他格式。例如,你将看到的一个极其常见的格式是 JSONJavaScript 对象表示法)。

简而言之,JSON 是一种携带序列化 JavaScript 对象的方式,以便 JavaScript 应用程序可以评估它们并将它们转换为应用程序可以操作的对象。

例如,当用户请求以 JSON 格式格式化的 album 对象时,服务器将返回以下内容:

{'description': 'This is a simple demo album for you to test',
'author': 'Sylvain'}

我们随后使用 Mochikit 中的 evalJSONRequest() 函数,如下所示:

var data = evalJSONRequest(incoming);

现在数据是一个 JavaScript 关联数组,描述字段可以通过以下方式访问:

data['description'];

JSON 得到广泛应用,因为它简单、易用且构建或评估效率高。它支持所有常见的基类型,如数字、布尔值、数组、字符串或空对象。更复杂的对象被转换为关联数组,其中对象属性名作为键来访问其关联的值。

photoblog 应用程序在其操作中主要使用 JSON 格式。

当你的 CherryPy 应用程序大量依赖 JSON 时,编写一个自动执行 JSON 序列化和反序列化的工具可能很有趣。

import cherrypy
import simplejson
def dejsonify(encoding='utf-8'):
if cherrypy.request.method in ['POST', 'PUT']:
if 'content-type' in cherrypy.request.headers:
if cherrypy.request.headers['content-type'] ==
'application/json':
body_as_dict = simplejson.loads(
cherrypy.request.body.read())
for key in body_as_dict:
cherrypy.request.params[key.encode(encoding)] =
body_as_dict[key]
def jsonify():
if isinstance(cherrypy.response.body, dict):
cherrypy.response.headers['Content-Type'] = 'application/json'
cherrypy.response.body = simplejson.dumps(
cherrypy.response.body)
cherrypy.tools.dejsonifier = cherrypy.Tool('before_handler',
dejsonify)
cherrypy.tools.jsonifier = cherrypy.Tool('before_finalize', jsonify)
class Root:
def index(self):
return {'message': 'Hello'}
index.exposed = True
def process(self, name):
# do something here
return "Processed %s" % name
process.exposed = True
if __name__ == '__main__':
conf = {'/': {'tools.dejsonifier.on': True,
'tools.jsonifier.on': True}}
cherrypy.quickstart(Root(), config=conf)

我们使用简单的 JSON 模块创建了两个工具来执行转换。第一个工具仅在设置了 application/json 内容类型的 POST 和 PUT 请求上从 JSON 反序列化请求体。该工具加载请求体并将其转换为字典,然后将其注入到 cherrypy.request 对象的 params 属性中,使得 CherryPy 页面处理程序可以期望 JSON 字典的键作为常规参数,正如你在过程页面处理程序中看到的那样。请注意,我们必须将这些键从 Unicode 编码为 Python 字符串,因为 CherryPy 页面处理程序期望字符串。

第二个工具将页面处理程序返回的字典序列化为 JSON。

将 Ajax 应用于我们的应用程序

我们的 photoblog 应用程序将广泛使用 Ajax,为了解释这一点,我们将回顾如何处理 photoblog 的相册。

定义所需的命名空间

我们的第一步将是定义 JavaScript 命名空间,这将允许我们在不同的上下文中重用常见的函数名,同时避免命名冲突。使用“命名空间”这个术语稍微有些出乎意料,因为 JavaScript 本身并没有这个概念,但可以通过多种方式来模拟这个功能。在本应用的情况下,我们将使用足够简单的 JavaScript 继承来实现我们的需求。

photoblog 应用程序将使用的两个命名空间是:uiservices

ui 命名空间将涵盖与最终用户的各种交互,而 services 命名空间将负责与服务器交换数据。因此,ui 命名空间中的类和函数将调用 services 中的函数来执行最终用户请求的操作。

要实现这两个命名空间,我们只需定义两个空 JavaScript 函数,如下所示:

function service()
{
};
function ui()
{
};

实现命名空间

现在我们有了我们的函数,我们可以给它们添加属性。这里我们有专辑类的声明,它将处理从客户端视角的专辑实体的所有方面:

function albums()
{
this.visibility = false;
this.current = null;
this.position = 0;
this.step = 3;
};
ui.prototype.albums = new albums();
var ui = new ui();

在这里,我们首先创建一个常规 JavaScript 函数,用作album类的构造函数。我们还通过 JavaScript 关键字this声明了一些附加到该对象上的属性。

然后我们将albums实例作为ui函数对象原型的属性,并最终在用户会话中创建我们将在整个应用程序生命周期中使用的ui类的唯一实例。

从现在起,我们可以使用albums实例来调用它的edit方法:

ui.albums.edit(...)

然后,我们在services命名空间内类似地定义album类。

function album()
{
};
service.prototype.albums = new album();
var services = new service();

向类中添加方法

我们将要添加到我们的类中的第一个方法将是切换我们专辑容器可见状态的方法。这个容器将显示现有专辑的信息,并在用户点击相关链接时淡入或淡出。让我们看看如何添加方法:

albums.prototype.toggle = function(event)
{
toggle($('content-pane'), 'blind');
if(this.visibility == false)
{
this.visibility = true;
this.forward(e);
}
Else
{
this.visibility = false;
replaceChildNodes(albumsPane);
}
toggle($('albums-pane'), 'blind');
};

此方法首先切换包含当前照片的内容面板的可见性。然后如果切换意味着打开albums面板,我们将它的可见性设置为true并调用forward方法。否则,我们将可见性设置为false并删除附加到该容器的任何元素,这样它们就不会浪费内存。最后,我们请求 Mochikit 更改albums面板的可见状态。然后我们将该方法连接到相关链接的onclick信号,如下所示:

connect($('albums'), 'onclick', ui.albums, 'toggle');

forward方法定义如下:

albums.prototype.forward = function(event)
{
var start = this.position;
var end = start + this.step;
services.albums.fetch_range(start, end, this);
this.position = end;
};

该方法首先定义了我们将从服务器获取的专辑范围。然后我们调用services.albums对象的fetch_range()方法,并最终设置下一次调用该方法的起始位置。

现在我们来回顾一下services.albums对象本身:

album.prototype.fetch_range = function(start, end, src)
{
var xmlHttpReq = getXMLHttpRequest();
xmlHttpReq.open("GET", albumsBaseUri.concat(start, "-", end), true);
xmlHttpReq.setRequestHeader('Accept', 'application/json');
var d = sendXMLHttpRequest(xmlHttpReq);
d.addCallback(function (data)
{
var data = evalJSONRequest(data);
src.populate(data);
});
};

你可能会注意到这个方法接受一个额外的参数,名为src,它是调用对象,这样我们的回调就可以在从服务器收到响应时在该对象上应用方法。

请求的 URI albumsBaseUri.concat(start, "-", end). albumsBaseUri是一个全局字符串变量,包含执行针对专辑集合的请求的基本 URI。

我们指定我们希望服务器发送给我们 JSON 内容,因为这是我们用来填充检索到的专辑的内容。

发出的请求看起来像这样:

http://localhost:8080/services/rest/albums/0-3
GET /services/rest/albums/0-3 HTTP/1.1
Host: localhost:8080
Accept: application/json
Connection: close

其响应将是:

HTTP/1.x 200 OK
Connection: close
Date: Tue, 19 Sep 2006 20:29:07 GMT
Content-Length: 763
Content-Type: application/json
Allow: GET, HEAD
Server: CherryPy/3.0.0beta

返回的内容将由 MochiKit 函数evalJSONRequest()评估,以返回一个 JavaScript 对象实例;在这种情况下是一个关联数组的数组。一旦我们收到并评估了内容,我们就调用ui.album类的populate()方法来显示检索到的专辑。该方法定义如下:

albums.prototype.populate = function(albums)
{
// get the albums container
var albumsPane = $('albums-pane');
// we remove any already displayed albums form the DOM tree
replaceChildNodes($('albums-pane'));
// define a set of links that we will use to move through the
// set of albums
var previous = SPAN({'id': 'previous-albums', 'class':
'infos-action'}, 'Previous');
connect(previous, 'onclick', this, 'rewind');
var next = SPAN({'id': 'next-albums', 'class': 'infos-action'},
'Next');
connect(next, 'onclick', this, 'forward');
// we also add a link that when triggered will display the
// form to create a new Album
var create = SPAN({'class': 'infos-action'}, 'Create');
connect(create, 'onclick',this, 'blank');
// in case no albums were retrieved we simply display a default
// message
if(albums.length == 0)
{
appendChildNodes(albumsPane, SPAN({'id': 'info-msg', 'class':
'info-msg'}, 'No more album to view.'));
appendChildNodes(albumsPane, previous);
return;
}
// now we traverse the array of retrieved albums to construct
// a tree structure of each that we will then insert into the
// main DOM tree
for(var album in albums)
{
album = albums[album];
var albumInfoBlock = DIV({'class': 'albums-infos-pane', 'id':
'album-' + album['id']},
LABEL({'class': 'infos-label'}, 'Title:'),
SPAN({'class': 'infos-content'}, album['title']), BR(),
LABEL({'class': 'infos-label'}, 'Created on:'),
SPAN({'class': 'infos-content'}, album['created']), BR(),
LABEL({'class': 'infos-label'}, 'Updated on:'),
SPAN({'class': 'infos-content'}, album['modified']), BR(),
LABEL({'class': 'infos-label'}, 'Description:'),
SPAN({'class': 'infos-content'}, album['description']), BR());
// we provide a link Edit and Delete to each album displayed
var editAlbumElement = SPAN({'class': 'infos-action'}, 'Edit');
connect(editAlbumElement, 'onclick', this, 'fetch_for_edit');
var deleteAlbumElement = SPAN({'class': 'infos-action'},
'Delete');
connect(deleteAlbumElement, 'onclick', this, 'ditch');
appendChildNodes(albumInfoBlock, editAlbumElement);
appendChildNodes(albumInfoBlock, deleteAlbumElement);
// we finally connect the onclick signal of the block
// carrying the album information. When a user clicks
// it will toggle the albums panel visibility and
// display the selected album.
connect(albumInfoBlock, 'onclick', this, 'select');
appendChildNodes(albumsPane, albumInfoBlock);
}
// we eventually insert all those new elements into the
// main DOM tree to be displayed.
appendChildNodes(albumsPane, previous);
appendChildNodes(albumsPane, next);
appendChildNodes(albumsPane, create);
};

创建新专辑的方法

现在我们能够显示相册了,我们将回顾如何创建一个新的相册。要做到这一点,我们首先需要一个表单来收集用户输入。让我们解释一下负责通过动态将其插入 DOM 树来显示表单的ui.albums.blank()方法。

albums.prototype.blank = function(e)
{
// those two elements will be links to either submit the form
// or canceling the process by closing the form
var submitLink = SPAN({'id': 'form-submit', 'class': 'form-link'},
'Submit');
var cancelLink = SPAN({'id': 'form-cancel', 'class': 'form-link'},
'Cancel');
// we will insert error messages when specific fields are
// not filled
var successMessage = SPAN({'id': 'form-success', 'class':
'form-success'}, 'Album created');
var errorMessage = SPAN({'id': 'form-error', 'class':
'form-error'}, 'An unexpected error occured');
var titleErrMsg = SPAN({'id': 'form-title-error', 'class':
'form-error'}, 'You must provide a title');
var authorErrMsg = SPAN({'id': 'form-author-error', 'class':
'form-error'}, 'You must specify the author name');
var descErrMsg = SPAN({'id': 'form-desc-error', 'class':
'form-error'}, 'You must provide a description');
// the main form
var albumForm = DIV({'id': 'pageoverlay'},
DIV({'id': 'outerbox'},
DIV({'id': 'formoverlay'},
SPAN({'class': 'form-caption'}, 'Create a new album'),
BR(),BR(),
FORM({'id': 'create-album', 'name':"albumForm"}, titleErrMsg,
LABEL({'class': 'form-label'}, 'Title:'),
INPUT({'class': 'form-input', 'name': 'title', 'id':
'album-title', 'value': ''}),
BR(),
LABEL({'class': 'form-label'}, 'Segment:'),
INPUT({'class': 'form-input', 'name': 'segment', 'id':
'album-segment', 'value': ''}), BR(), authorErrMsg,
LABEL({'class': 'form-label'}, 'Author:'),
INPUT({'class': 'form-input', 'name': 'author', 'id':
'album-author', 'value': ''}), BR(), descErrMsg,
LABEL({'class': 'form-label'}, 'Description:'),
TEXTAREA({'class': 'form-textarea', 'name': 'description',
'id': 'album-desc', 'rows': '2', 'value': ''}), BR(),
LABEL({'class': 'form-label'}, 'Content:'),
TEXTAREA({'class': 'form-textarea', 'name': 'content', 'id':
'album-content', 'rows': '7', 'value': ''}), BR()),
successMessage, errorMessage,
DIV({'id': 'form-links'},
submitLink,
cancelLink))));
hideElement(titleErrMsg);
hideElement(authorErrMsg);
hideElement(descErrMsg);
hideElement(errorMessage);
hideElement(successMessage);
connect(submitLink, 'onclick', this, 'create');
connect(cancelLink, 'onclick', closeOverlayBox);
appendChildNodes($('photoblog'), albumForm);
};

表单块的创建需要进一步解释。为了提供一个带有表单的更华丽的面板,我们使用了在LightboxThickbox等脚本中部署的技术。两者都依赖于 CSS 应用于 DOM 的覆盖能力来显示在其它元素之上的元素。覆盖允许以非顺序方式显示元素,而不是堆叠。这个功能结合了合理使用 HTML 块作为DIV和适当的颜色,可以提供一种吸引人的内容展示方式,如下面的截图所示:

创建新相册的方法

如果您不填写必填字段并提交表单,您将看到以下截图所示的屏幕:

创建新相册的方法

如果您填写了必填字段并提交了表单,您将看到以下截图所示的屏幕:

创建新相册的方法

为了避免用户尝试重新提交表单的情况,我们移除了提交链接,现在用户可以安全地关闭这个屏幕。

HTTP 交换将看起来像这样:

POST /services/rest/album/ HTTP/1.1
Host: localhost:8080
Accept: application/json
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Content-Type: application/x-www-form-urlencoded
Content-Length: 167
Pragma: no-cache
blog_id=1&title=My%20holiday%20on%20Mars&author=Sylvain&description= My%20holiday%20on%20Mars.&content=Mars%20is%20nice%20but%20a%20little%20quiet.
HTTP/1.x 201 Created
Connection: close
Content-Length: 289
Server: CherryPy/3.0.0beta
Location: http://localhost:8080/album/19
Allow: DELETE, GET, HEAD, POST, PUT
Date: Wed, 20 Sep 2006 19:59:59 GMT

注意,响应给出了直接访问新创建相册的 URI。

处理之前 HTTP 交换的方法是services.album.create(),如下所示:

album.prototype.create = function(data, src)
{
var qs = queryString(data);
var xmlHttpReq = getXMLHttpRequest();
xmlHttpReq.open("POST", albumBaseUri, true);
xmlHttpReq.setRequestHeader('Content-Type',
'application/x-www-form-urlencoded');
xmlHttpReq.setRequestHeader('Accept', 'application/json');
var d = sendXMLHttpRequest(xmlHttpReq, qs);
d.addCallback(function (data)
{
src.showSuccessMessage();
});
d.addErrback(function (data)
{
src.showErrorMessage();
});
};

data参数是表单字段的 JavaScript 关联数组。src参数是ui.albums实例,它扩展了以下方法:

albums.prototype.create = function(event)
{
if(this.validate())
{
// blogId is a global variable containing the current photoblog
// identifier
var data = {'blog_id': blogId, 'title': $('album-title').value,
'author': album-author').value,
'description': $('album-desc').value,
'content': $('album-content').value};
services.albums.create(data, this);
}
};
albums.prototype.validate = function()
{
var ready = true;
hideElement($('form-title-error'));
hideElement($('form-author-error'));
hideElement($('form-desc-error'));
if($('album-title').value == '')
{
appear($('form-title-error'));
ready = false;
}
if($('album-author').value == '')
{
appear($('form-author-error'));
ready = false;
}
if($('album-desc').value == '')
{
appear($('form-desc-error'));
ready = false;
}
return ready;
};
albums.prototype.showSuccessMessage = function()
{
hideElement($('form-title-error'));
hideElement($('form-author-error'));
hideElement($('form-desc-error'));
appear($('form-success'));
fade($('form-submit'));
};
albums.prototype.showErrorMessage = function()
{
hideElement($('form-title-error'));
hideElement($('form-author-error'));
hideElement($('form-desc-error'));
appear($('form-error'));
};

更新现有相册的方法

这遵循了我们之前章节中看到的相同原则,只不过我们提供了一个album对象来自动填充表单的值。

删除现有相册的方法

最后,我们需要一个方法来删除一个相册:

// method part of the ui namespace
albums.prototype.ditch = function(event)
{
// stop the propagation of the click event so that
// the select method is not applied
event.stop();
// shows a modal dialogbox asking the confirmation of the deletion
var doit = confirm("Are you sure you want to delete this album?");
if(doit)
{
// we retrieve the id of the album to delete from
// the block carrying the album <div id="album-19">...</div>
var currentAlbumId = (e.src().parentNode.id).substr(6);
services.albums.remove(currentAlbumId);
switchOff(e.src().parentNode);
}
};
// method part of the services namespace
album.prototype.remove = function(id)
{
if(id != null)
{
var xmlHttpReq = getXMLHttpRequest();
xmlHttpReq.open("DELETE", albumBaseUri + id, true);
var d = sendXMLHttpRequest(xmlHttpReq);
}
};

HTTP 交换看起来将是这样:

DELETE /services/rest/album/19 HTTP/1.1
Host: localhost:8080
Connection: close
Content-Length: 0
HTTP/1.x 200 OK
Connection: close
Date: Wed, 20 Sep 2006 20:39:49 GMT
Content-Length: 0
Allow: DELETE, GET, HEAD, POST, PUT
Server: CherryPy/3.0.0beta

我们已经解释了如何操作照片博客应用中的相册的基本方法。相同的原理将应用于应用的其他实体:电影和照片。

摘要

本章向您介绍了 Ajax,以及更广泛地介绍了使用 JavaScript 进行客户端编程的基础。可能性几乎是无限的,并且不久的将来应该会看到非常有趣和强大的网络应用,它们将逐渐取代它们的富客户端对应物。

第九章:测试

到目前为止,我们已经回顾了构建照片博客应用程序所涉及的不同步骤,但我们还没有测试我们的设计和实现。本章将介绍一些测试技术,例如使用开源产品如 unittest、CherryPy webtest、FunkLoad 和 Selenium 进行的单元测试、功能测试和负载测试。到本章结束时,你应该能够很好地理解如何在特定环境中使用这些工具,并改进你应用程序的测试套件。

为什么需要测试

为什么需要测试,有些人可能会问?它能为应用程序带来任何价值吗?你可能会认为,如果你的代码中发现了问题,它将被报告并最终得到修复。因此,你可能会认为测试相当无关紧要且耗时。如果你确实这样认为,那么在本章的帮助下,我们将尝试向你展示测试不仅仅是蛋糕上的樱桃,实际上它是成功配方的一部分。

测试是一个过程,在这个过程中,从不同的角度对应用程序进行审计,以便:

  • 发现错误

  • 找出预期结果和实际结果、输出、状态等之间的差异。

  • 理解实现的完整性

  • 在发布前在现实场景中测试应用程序

测试的目标不是将责任归咎于开发者,而是提供工具来估计在特定时间点应用程序的健康状况。测试衡量应用程序的质量。

因此,测试不仅只是应用程序生命周期的一部分,实际上它是衡量应用程序在该生命周期中位置的真正晴雨表。代码行数没有意义;但测试总结和测试报告是不同项目成员可以联系起来了解已实现的内容、还需要实现的内容以及如何规划它们的参考点。

测试规划

从上一节中我们可以得出,由于测试对项目至关重要,因此应该对一切进行测试和审查。这是真的,但这并不意味着应该将相同数量的资源和努力分配给测试系统中的每个部分。

首先,这取决于项目在其生命周期中的位置。例如,在项目开始时进行性能测试的需求很小。如果应用程序不需要大量的硬件或网络资源,可能不需要进行容量测试。话虽如此,一些测试将在项目的整个生命周期中进行。它们将通过连续的迭代逐步建立,每次迭代都会使测试更具强度。

总结来说,测试需要提前规划,以便定义:

  • 目标:测试哪些内容与其相关,以及为了什么目的?

  • 范围:测试的范围包括什么?不包括什么?

  • 需求:测试将涉及哪些资源(人力、软件、硬件等)?

  • 风险:如果测试未通过,与该测试相关的风险是什么?将采取哪些缓解措施和行动?它是否会停止项目?影响是什么?

在规划测试时,只需记住以下几点。

另一个重要的一点是,测试一旦应用程序发布并不意味着结束。它也可以在之后进行,以确保生产发布满足定义的要求。在任何情况下,由于测试汇集了众多不同方面,因此应将其视为一个漫长、持续的过程。

常见测试方法

测试是对系统或应用程序上要验证的一系列方面的通用术语。以下是一个简要的常见列表:

  • 单元测试:通常由开发者自己执行。单元测试旨在检查代码单元是否按预期工作。

  • 可用性测试:开发者可能通常忘记他们正在为最终用户编写应用程序,而这些用户可能不了解系统,最终可能导致应用程序不可用。功能和可用性测试提供了一种确保应用程序将满足用户期望的方法。

  • 功能/验收测试:虽然可用性测试检查应用程序或系统是否可用,但功能测试确保每个指定的功能都已实现。

  • 加载和性能测试:一旦应用程序或系统达到一定程度的完整性,可能需要进行加载和性能测试,以了解系统是否能够应对预期的峰值负载,并找到潜在瓶颈。这可能导致更改硬件、优化 SQL 查询等。

  • 回归测试:回归测试验证产品连续发布不会破坏任何之前工作的功能。在某些方面,单元测试可以被视为回归测试的一部分。

  • 可靠性和弹性测试:某些应用程序或系统不能承受在任何时候崩溃。可靠性和弹性测试可以验证系统应用程序如何应对一个或多个组件的故障。

上述列表远非详尽无遗,每个系统或应用程序环境可能需要定义特定的测试类型。

单元测试

我们的照片博客应用程序将广泛使用单元测试来不断检查以下内容:

  • 新功能按预期正确工作。

  • 新代码发布不会破坏现有功能。

  • 缺陷已修复且保持修复状态。

Python 提供了一个标准的 unittest 模块,还提供了一个 doctest 模块,它提供了一种不同的单元测试方法,我们将在后面解释。

unittest

unittest 源于由 Kent Beck 和 Erich Gamma 开发的 Java 单元测试包 JUnit,而 JUnit 又源于 Kent Beck 开发的 Smalltalk 测试框架。现在让我们回顾一下这个模块的基本示例。

单元测试通常可以在所谓的模拟对象上工作,这些对象支持与应用程序的域对象相同的接口,但实际上并不执行任何操作。它们只是返回定义好的数据。因此,模拟对象允许我们对我们的设计接口进行测试,而无需依赖于整个应用程序的部署。它们还提供了一种从其他测试中独立运行测试的方法。

首先,让我们定义一个模拟类,如下所示:

class Dummy:
def __init__(self, start=0, left_boundary=-10, right_boundary=10,
allow_positive=True, allow_negative=False):
self.current = start
self.left_boundary = left_boundary
self.right_boundary = right_boundary
self.allow_positive = allow_positive
self.allow_negative = allow_negative
def forward(self):
next = self.current + 1
if (next > 0) and (not self.allow_positive):
raise ValueError, "Positive values are not allowed"
if next > self.right_boundary:
raise ValueError, "Right boundary reached"
self.current = next
return self.current
def backward(self):
prev = self.current - 1
if (prev < 0) and (not self.allow_negative):
raise ValueError, "Negative values are not allowed"
if prev < self.left_boundary:
raise ValueError, "Left boundary reached"
self.current = prev
return self.current
def __str__(self):
return str(self.current)
def __repr__(self):
return "Dummy object at %s" % hex(id(self))

这个类提供了一个接口,用于在由左右边界定义的范围内获取下一个或上一个值。我们可以将其想象为一个更复杂类的模拟对象,但提供的是模拟数据。

这个类的简单用法如下:

>>> from dummy import Dummy
>>> dummy = Dummy()
>>> dummy.forward()
1
>>> dummy.forward()
2
>>> dummy.backward()
1
>>> dummy.backward()
0
>>> dummy.backward()
Traceback (most recent call last):
File "<stdin>", line 1, in ?
File "dummy.py", line 27, in backward
raise ValueError, "Negative values are not allowed"
ValueError: Negative values are not allowed

让我们想象我们希望对这个令人兴奋的模块进行单元测试,以确保代码的正确性。

import unittest
class DummyTest(unittest.TestCase):
def test_01_forward(self):
dummy = Dummy(right_boundary=3)
self.assertEqual(dummy.forward(), 1)
self.assertEqual(dummy.forward(), 2)
self.assertEqual(dummy.forward(), 3)
self.assertRaises(ValueError, dummy.forward)
def test_02_backward(self):
dummy = Dummy(left_boundary=-3, allow_negative=True)
self.assertEqual(dummy.backward(), -1)
self.assertEqual(dummy.backward(), -2)
self.assertEqual(dummy.backward(), -3)
self.assertRaises(ValueError, dummy.backward)
def test_03_boundaries(self):
dummy = Dummy(right_boundary=3, left_boundary=-3,
allow_negative=True)
self.assertEqual(dummy.backward(), -1)
self.assertEqual(dummy.backward(), -2)
self.assertEqual(dummy.forward(), -1)
self.assertEqual(dummy.backward(), -2)
self.assertEqual(dummy.backward(), -3)
self.assertRaises(ValueError, dummy.backward)
self.assertEqual(dummy.forward(), -2)
self.assertEqual(dummy.forward(), -1)
self.assertEqual(dummy.forward(), 0)
self.assertEqual(dummy.backward(), -1)
self.assertEqual(dummy.forward(), 0)
self.assertEqual(dummy.forward(), 1)
self.assertEqual(dummy.forward(), 2)

让我们一步一步地解释这段代码:

    1. 要使用unittest标准模块提供单元测试功能,你只需要导入该特定模块。
    1. 创建一个类,它继承自unittest.TestCase,这是为我们代码提供单元测试功能的接口。这个类被称为测试用例
    1. 创建以单词test开头的方法。每个以它开头的方法都将由unittest内部处理程序调用。请注意,这个类定义的方法也使用两位数的模式。这并不是unittest的要求,但它允许我们强制方法按照我们希望的顺序调用。实际上,unittest按照字母数字顺序调用方法,这有时会导致意外结果。提供这样的数字是一种很好的方法来绕过这种限制。
    1. 调用TestCase类提供的不同assert/fail方法来执行值、异常、输出等的检查。

下一步是按照以下方式运行这个测试用例:

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

这假设main()的调用是在包含TestCase类的同一模块中进行的。这个测试的结果看起来如下:

...
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK

通常会将输出设置得更加详细,如下所示:

if __name__ == '__main__':
unittest.main(testRunner=unittest.TextTestRunner(verbosity=2))

这将产生以下输出:

test_01_forward (__main__.DummyTest) ... ok
test_02_backward (__main__.DummyTest) ... ok
test_03_boundaries (__main__.DummyTest) ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK

现在,让我们引发一个错误,以便其中一个测试失败。在test_01_forward中将第一个assertEqual替换为以下内容:

self.assertEqual(dummy.forward(), 0)

然后在再次运行测试时,你应该得到以下输出:

test_01_forward (__main__.DummyTest) ... FAIL
test_02_backward (__main__.DummyTest) ... ok
test_03_boundaries (__main__.DummyTest) ... ok
======================================================================
FAIL: test_01_forward (__main__.DummyTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "dummy.py", line 54, in test_01_forward
self.assertEqual(dummy.forward(), 0)
AssertionError: 1 != 0
----------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (failures=1)

如你所见,unittest模块在测试失败时不会停止处理任何剩余的测试用例。相反,它显示抛出的断言错误的跟踪信息。这里的测试是错误的,但在你的断言是有效的情况下,它将指向你的应用程序的失败。

假设我们编写了一个测试,试图在右边界小于起点时向前移动。我们假设该方法的文档告诉我们,它应该抛出一个异常,表示该类已拒绝此情况。

让我们相应地创建test_00_construct

def test_00_construct(self):
self.assertRaises(ValueError, Dummy, start=34)

让我们现在运行这个测试:

test_00_construct (__main__.DummyTest) ... FAIL
test_01_forward (__main__.DummyTest) ... ok
test_02_backward (__main__.DummyTest) ... ok
test_03_boundaries (__main__.DummyTest) ... ok
======================================================================
FAIL: test_00_construct (__main__.DummyTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "dummy.py", line 50, in test_00_construct
self.assertRaises(ValueError, Dummy, start=34)
AssertionError: ValueError not raised
----------------------------------------------------------------------
unit testingunittestRan 4 tests in 0.003s
FAILED (failures=1)

正如你所见,新的测试用例确实失败了。原因是Dummy.__init__()方法没有包含任何对此情况的错误处理,这与文档告诉我们的不同。让我们通过在__init__方法底部添加以下代码来修复这个问题:

if (start > right_boundary) or (start < left_boundary):
raise ValueError, "Start point must belong to the boundaries"

现在我们重新运行测试:

test_00_construct (__main__.DummyTest) ... ok
test_01_forward (__main__.DummyTest) ... ok
test_02_backward (__main__.DummyTest) ... ok
test_03_boundaries (__main__.DummyTest) ... ok
----------------------------------------------------------------------
Ran 4 tests in 0.000s
OK

之前的例子表明,有时在实现功能本身之前编写测试是有好处的,这样可以避免设计测试以匹配代码行为。这通常被称为测试驱动开发。另一种实现方式是将应用程序或库的 API 提供给第三方,第三方将根据该 API 以中立的方式编写测试用例。无论如何,之前的例子表明,单元测试只有在测试与设计一致并且用于测试实现时才有意义。

现在我们已经介绍了unittest模块,让我们来介绍doctest模块。

doctest

doctest模块支持在对象 docstring 中运行内联的 Python 代码。这种技术的优点是测试用例与它们要测试的代码非常接近。不便之处在于,一些复杂的测试可能难以用这种方式实现。让我们看看我们之前定义的类的一个例子。

class Dummy:
def __init__(self, start=0, left_boundary=-10, right_boundary=10,
allow_positive=True, allow_negative=False):
"""
>>> dummy = Dummy(start=27)
Traceback (most recent call last):
...
raise ValueError, "Start point must belong to the
boundaries"
ValueError: Start point must belong to the boundaries
>>> dummy = Dummy()
>>> dummy.backward()
Traceback (most recent call last):
...
raise ValueError, "Negative values are not allowed"
ValueError: Negative values are not allowed
"""
self.current = start
self.left_boundary = left_boundary
self.right_boundary = right_boundary
self.allow_positive = allow_positive
self.allow_negative = allow_negative
if (start > right_boundary) or (start < left_boundary):
raise ValueError, "Start point must belong to the
boundaries"
def forward(self):
"""
>>> dummy = Dummy(right_boundary=3)
>>> dummy.forward()
1
>>> dummy.forward()
2
>>> dummy.forward()
3
>>> dummy.forward()
Traceback (most recent call last):
...
raise ValueError, "Right boundary reached"
ValueError: Right boundary reached
"""
next = self.current + 1
if (next > 0) and (not self.allow_positive):
raise ValueError, "Positive values are not allowed"
if next > self.right_boundary:
raise ValueError, "Right boundary reached"
self.current = next
return self.current
def backward(self):
"""
>>> dummy = Dummy(left_boundary=-3, allow_negative=True)
>>> dummy.forward()
1
>>> dummy.backward()
0
>>> dummy.backward()
-1
>>> dummy.backward()
-2
>>> dummy.backward()
-3
>>> dummy.backward()
Traceback (most recent call last):
...
raise ValueError, "Left boundary reached"
ValueError: Left boundary reached
"""
prev = self.current - 1
if (prev < 0) and (not self.allow_negative):
raise ValueError, "Negative values are not allowed"
if prev < self.left_boundary:
raise ValueError, "Left boundary reached"
self.current = prev
return self.current
def __str__(self):
return str(self.current)
def __repr__(self):
return "Dummy object at %s" % hex(id(self))

正如你所见,你希望测试的每个方法都必须有一个包含将直接由doctest模块运行的用例的 docstring。

然后你可以按照以下步骤运行测试:

if __name__ == '__main__':
doctest.testmod()
sylvain@6[test]$ python dummy.py -v
Trying:
dummy = Dummy(start=27)
Expecting:
Traceback (most recent call last):
...
raise ValueError, "Start point must belong to the boundaries"
ValueError: Start point must belong to the boundaries
ok
Trying:
dummy = Dummy()
Expecting nothing
ok
Trying:
dummy.backward()
Expecting:
Traceback (most recent call last):
...
raise ValueError, "Negative values are not allowed"
ValueError: Negative values are not allowed
ok
Trying:
dummy = Dummy(left_boundary=-3, allow_negative=True)
Expecting nothing
ok
Trying:
dummy.forward()
Expecting:
1
ok

我们没有复制完整的输出结果,因为对于本章的目的来说,它太长了。你可以考虑,将代码和文档混合在一起可能会降低两者的效率,使得文档更难以阅读。这个担忧实际上是由doctest模块的文档本身提出的,它明智地建议要小心处理docstring 示例。确实,由于代码属于 docstring,所以在查看时也会显示出来。

>>> from dummy import Dummy
>>> help(Dummy.forward)
Help on method forward in module dummy:
forward(self) unbound dummy.Dummy method
>>> dummy = Dummy(right_boundary=3)
>>> dummy.forward()
1
>>> dummy.forward()
2
>>> dummy.forward()
3
>>> dummy.forward()
Traceback (most recent call last):
...
raise ValueError, "Right boundary reached"
ValueError: Right boundary reached

在这种情况下,测试可以是文档的一部分,或者过于复杂,使得文档无法使用。

简而言之,unittestdoctest模块都值得根据你的需求进行审查,并且在一个项目中同时使用这两个模块以提供强大的单元测试套件是很常见的。无论如何,我们建议你阅读这两个模块的文档,这将证明其中包含的内容远不止本章所提供的简要介绍。此外,还有一个非常有信息量的邮件列表可供参考,网址为lists.idyll.org/listinfo/testing-in-python

单元测试 Web 应用程序

在上一节中,我们介绍了两个标准模块,用于在 Python 应用程序和包中进行单元测试。不幸的是,它们目前缺少一些常见功能,无法帮助在特定上下文中,如网络应用程序中进行测试。显然,Python 社区已经提出了解决方案,并且有几种好的扩展来增强 unittest 或完全独立的测试包来帮助我们。

我们将使用由 CherryPy 提供的 unittest 扩展,称为 webtest,由 Robert Brewer 开发。

此模块提供了与 CherryPy 的透明集成,并提供了一个命令行辅助工具来测试服务器的不同配置。它允许在发生失败时停止测试,当引发错误时提供对 HTTP 栈的访问,还支持代码覆盖率分析和性能分析等。总之,此模块会自动启动 CherryPy 服务器,每个测试用例都使用它来挂载 CherryPy 应用程序,以适应测试运行,并在该服务器上执行 HTTP 请求。

本节将展示我们照片博客应用程序的所有不同测试用例,但您将在应用程序的源代码中找到它们。根据我们在上一节中解释的内容,我们设计测试用例如下:

class TestServicesREST(PhotoblogTest):
def test_00_REST(self):
self.getPage("/services/rest/")
self.assertStatus(404)
self.getPage("/services/rest/album/", method="XYU")
self.assertStatus(405)
def test_02_REST_GET(self):
# missing the ID
self.getPage("/services/rest/album/")
self.assertStatus(400)
# missing the Accept header
self.getPage("/services/rest/album/2")
self.assertStatus(406)
# wrong ID type
self.getPage("/services/rest/album/st",
headers=[("Accept", "application/json")])
self.assertStatus(404)
self.getPage("/services/rest/album/2",
headers=[("Accept", "application/json")])
self.assertStatus(200)
self.assertHeader('Content-Type', 'application/json')
self.assertHeader('Allow', 'DELETE, GET, HEAD, POST, PUT')
self.getPage("/services/rest/album?album_id=2",
headers=[("Accept", "application/json")])
self.assertStatus(200)
self.assertHeader('Content-Type', 'application/json')
self.assertHeader('Allow', 'DELETE, GET, HEAD, POST, PUT')
def test_03_REST_POST(self):
blog = self.photoblog
params = {'title': 'Test2',
'author': 'Test demo', 'description': 'blah blah',
'content': 'more blah blah bluh', 'blog_id':
str(blog.ID)}
# let's transform the param dictionary
# into a valid query string
query_string = urllib.urlencode(params)
self.getPage("/services/rest/album/", method="POST",
body=query_string,
headers=[("Accept", "application/json")])
self.assertStatus(201)
self.assertHeader('Content-Type', 'application/json')
# here we miss the Accept header
self.getPage("/services/rest/album/", method="POST",
body=query_string)
self.assertStatus(406)
def test_04_REST_PUT(self):
blog = self.photoblog
params = {'title': 'Test2',
'author': 'Test demo', 'description': 'blah blah',
'content': 'meh ehe eh', 'blog_id': str(blog.ID)}
query_string = urllib.urlencode(params)
# at this stage we don't have yet an album with that ID
self.getPage("/services/rest/album/23", method="PUT",
body=query_string,
headers=[("Accept", "application/json")])
self.assertStatus(404)
self.getPage("/services/rest/album/4", method="PUT",
body=query_string,
headers=[("Accept", "application/json")])
self.assertStatus(200)
self.assertHeader('Content-Type', 'application/json')
def test_06_REST_DELETE(self):
self.getPage("/services/rest/album/4", method="DELETE")
self.assertStatus(200)
# DELETE is idempotent and should always return 200 in case
# of success
self.getPage("/services/rest/album/4", method="DELETE")
self.assertStatus(200)
def test_05_REST_Collection_GET(self):
self.getPage("/services/rest/albums/3")
self.assertStatus(400, 'Invalid range')
self.getPage("/services/rest/albums/a")
self.assertStatus(400, 'Invalid range')
self.getPage("/services/rest/albums/0-")
self.assertStatus(400, 'Invalid range')
self.getPage("/services/rest/albums/a+3")
self.assertStatus(400, 'Invalid range')
self.getPage("/services/rest/albums/3-a")
self.assertStatus(400, 'Invalid range')
self.getPage("/services/rest/albums/0+3")
self.assertStatus(400, 'Invalid range')
# valid range but missing Accept header
self.getPage("/services/rest/albums/0-3")
self.assertStatus(406)
self.getPage("/services/rest/albums/0-3",
headers=[("Accept", "application/json")])
self.assertStatus(200)
self.assertHeader('Content-Type', 'application/json')
json = simplejson.loads(self.body)
self.failUnless(isinstance(json, list))
self.failUnlessEqual(len(json), 3)

上述测试用例只是我们针对应用程序可以进行的各种测试的一个示例,实际上可能需要更多的测试来确保应用程序按预期工作,并执行回归测试。

如您所见,我们的测试用例执行 HTTP 请求,并验证响应的内容及其标题。这些验证的简单性得益于 webtest 模块提供的单元测试扩展。现在让我们详细看看如何设置该模块以运行前面显示的测试用例。

首先,让我们创建一个包含以下代码的 test.py 模块:

import os.path
import sys
# Tell Python where to find our application's modules.
sys.path.append(os.path.abspath('..'))
# CherryPy main test module
from cherrypy.test import test as cptest
# load the global application settings
current_dir = os.path.abspath(os.path.dirname(__file__))
conf.from_ini(os.path.join(current_dir, 'application.conf'))
from models import Photoblog, Album, Film, Photo
# dejavu main arena object
arena = storage.arena
# register our models with dejavu
storage.setup()
def initialize():
for cls in (Photoblog, Album, Film, Photo):
arena.create_storage(cls)
def shutdown():
for cls in (Photoblog, Album, Film, Photo):
if arena.has_storage(cls):
arena.drop_storage(cls)
def run():
"""
entry point to the test suite
"""
try:
initialize()
# modules name without the trailing .py
# that this test will run. They must belong
# to the same directory as test.py
test_list = ['test_models', 'test_services']
cptest.CommandLineParser(test_list).run()
finally:
shutdown()
print
raw_input('hit enter to terminate the test')
if __name__ == '__main__':
run()

让我们检查 test.py 模块可以实现什么:

sylvain@[test]$ python test.py --help
CherryPy Test Program
Usage:
test.py --server=* --host=127.0.0.1 --port=8080 --1.0 --cover
--basedir=path --profile --validate --conquer --dumb --tests**
* servers:
--server=modpygw: modpygw
--server=wsgi: cherrypy._cpwsgi.CPWSGIServer (default)
--server=cpmodpy: cpmodpy
--host=<name or IP addr>: use a host other than the default
(127.0.0.1).
Not yet available with mod_python servers.
--port=<int>: use a port other than the default (8080)
--1.0: use HTTP/1.0 servers instead of default HTTP/1.1
--cover: turn on code-coverage tool
--basedir=path: display coverage stats for some path other than
--cherrypy.
--profile: turn on profiling tool
--validate: use wsgiref.validate (builtin in Python 2.5).
--conquer: use wsgiconq (which uses pyconquer) to trace calls.
--dumb: turn off the interactive output features.
** tests:
--test_models
--test_services

如您所见,我们的测试支持一系列功能,允许我们在不同的配置下运行测试,例如使用内置的 HTTP 服务器或 mod_python 处理器,我们将在第十章(Chapter 10:
def photoblog(self):
blog = Photoblog.find_by_name(blog_name)
if not blog:
self.fail("Could not find blog '%s'" % blog_name)
return blog
photoblog = property(photoblog, doc="Returns a blog object to
work against")


`PhotoblogTest` 类继承自 CherryPy 的 `CPWebCase` 类,该类提供了一系列函数来对网络测试进行断言检查。例如,`CPWebCase` 类定义了以下内容:

+   `assertStatus(status)` 用于验证最后响应的状态

+   `assertHeader(name, value=None)` 用于验证是否存在标题,并确保如果 `value` 不是 `None`,则其值是提供的值

+   `assertBody(value)` 用于检查返回的正文是否是我们预期的

+   `assertInBody(value)` 用于验证返回的内容是否包含给定的值

此类还提供了 `getPage(uri, method, headers, body)` 方法来发起 HTTP 请求。

我们的 `PhotoblogTest` 类定义了 photoblog 属性,以便测试可以轻松地获取对测试期间默认创建的博客的引用。

`blogtest.py` 模块还包含以下函数,用于设置测试生命周期的服务器:

```py
from lib import storage
import services
from models import Album, Film, Photo
def populate_storage():
photoblog = Photoblog()
photoblog.create(blog_name, u'Yeah')
a1 = Album()
a1.create(photoblog, "Test album",
"Test", "blah blah", "more blah blah")
def reset_storage():
# here we simply remove every object a test has left
# in the storage so that we have a clean
# storage for the next test case run
photoblog = Photoblog.find_by_name(blog_name)
photoblog.delete()
def setup_photoblog_server():
# Update the CherryPy global configuration
cherrypy.config.update(os.path.join(current_dir, 'http.conf'))
# fill the storage with default values for the purpose of the
#test
populate_storage()
# Construct the published trees
services_app = services.construct_app()
# Mount the applications on the '/' prefix
engine_conf_path = os.path.join(current_dir, 'engine.conf')
service_app = cherrypy.tree.mount(services_app, '/services',
config=engine_conf_path)
service_app.merge(services.services_conf)
def teardown_photoblog_server():
reset_storage()

setup_photoblog_server() 函数负责设置 photoblog 应用程序并加载不同的配置设置。这些必须作为测试目录的一部分。例如,我们可以为存储提供不同的数据库名称,这样我们就不需要在生产数据库上运行测试。

最后,我们在名为 test_services.py 的模块中定义我们的测试用例,如下所示:

import httplib
import os.path
import urllib
import cherrypy
import simplejson
from models import Photoblog, Album, Film, Photo
from blogtest import PhotoblogTest, blog_name, \
setup_photoblog_server, teardown_photoblog_server
current_dir = os.path.abspath(os.path.dirname(__file__))
def setup_server():
setup_photoblog_server()
def teardown_server():
teardown_photoblog_server()
# Here we insert the TestServicesREST class definition
# that we have seen at the beginning of this section

让我们解释一下这个模块是如何构建的:

    1. 我们必须导入许多模块以执行测试的特定任务。
    1. 我们的测试用例是继承自我们之前描述的 PhotoblogTest 类。
    1. 我们需要定义两个函数——setup_server()teardown_server(),这两个函数将由 CherryPy 测试模块在每次开始和结束运行测试模块时自动调用。这允许我们在测试用例中对 photoblog 应用程序进行初始化。
    1. 最后,我们添加 TestServicesREST 类作为我们的测试用例。

现在我们来运行整个测试套件:

sylvain@[test]$ python test.py
Python version used to run this test script: 2.5
CherryPy version 3.0.0
HTTP server version HTTP/1.1
Running tests: cherrypy._cpwsgi.CPWSGIServer
No handlers could be found for logger "cherrypy.error"
test_00_Photoblog_unit (test_models.TestModels) ... ok
test_01_Photoblog_create (test_models.TestModels) ... ok
test_02_Photoblog_retrieve_by_name (test_models.TestModels) ... ok
test_03_Photoblog_retrieve_by_unknown_name (test_models.TestModels)
... ok
test_04_Photoblog_retrieve_by_unsupported_id_type
(test_models.TestModels) ... ok
test_05_Photoblog_update (test_models.TestModels) ... ok
test_06_Photoblog_populate (test_models.TestModels) ... ok
test_10_Album_unit (test_models.TestModels) ... ok
test_99_Photoblog_delete (test_models.TestModels) ... ok
test_00_REST (test_services.TestServicesREST) ... ok
test_01_REST_HEAD (test_services.TestServicesREST) ... ok
test_02_REST_GET (test_services.TestServicesREST) ... ok
test_03_REST_POST (test_services.TestServicesREST) ... ok
test_04_REST_PUT (test_services.TestServicesREST) ... ok
test_05_REST_Collection_GET (test_services.TestServicesREST) ... ok
test_06_REST_DELETE (test_services.TestServicesREST) ... ok

如果你只想运行一个模块:

sylvain@[test]$ python test.py --models
Python version used to run this test script: 2.5
CherryPy version 3.0.0
HTTP server version HTTP/1.1
Running tests: cherrypy._cpwsgi.CPWSGIServer
No handlers could be found for logger "cherrypy.error"
test_00_Photoblog_unit (test_models.TestModels) ... ok
test_01_Photoblog_create (test_models.TestModels) ... ok
test_02_Photoblog_retrieve_by_name (test_models.TestModels) ... ok
test_03_Photoblog_retrieve_by_unknown_name (test_models.TestModels)
... ok
test_04_Photoblog_retrieve_by_unsupported_id_type (test_models.
TestModels) ... ok
test_05_Photoblog_update (test_models.TestModels) ... ok
test_06_Photoblog_populate (test_models.TestModels) ... ok
test_10_Album_unit (test_models.TestModels) ... ok
test_99_Photoblog_delete (test_models.TestModels) ... ok

如您所见,使用 CherryPy 测试模块编写单元测试使得基于 CherryPy 的应用程序测试变得简单,因为 CherryPy 负责处理许多常见负担,使测试人员能够专注于真正重要的事情。

性能和负载测试

根据你正在编写的应用程序以及你对容量的期望,你可能需要运行负载和性能测试,以检测应用程序中可能阻止其达到一定性能水平的潜在瓶颈。

本节不会详细介绍如何进行性能或负载测试,因为这超出了其范围,但我们将回顾一个 Python 解决方案,即由 Nuxeo 提供的 FunkLoad 软件包,Nuxeo 是一家专注于用 Python 编写的免费软件的法国公司。您可以通过 easy_install 命令安装 FunkLoad。FunkLoad 可在 funkload.nuxeo.org/ 获取。

FunkLoad 是 webunit 模块的一个扩展,这是一个面向单元测试 Web 应用的 Python 模块。FunkLoad 提供了一个相当广泛的 API 和工具集,负责从负载测试中提取指标,最终生成带有美观图表的测试报告。

让我们看看使用 FunkLoad 的一个极其基本的例子。

from funkload.FunkLoadTestCase import FunkLoadTestCase
class LoadHomePage(FunkLoadTestCase):
def test_homepage(self):
server_url = self.conf_get('main', 'url')
nb_time = self.conf_getInt('test_homepage', 'nb_time')
home_page = "%s/" % server_url
for i in range(nb_time):
self.logd('Try %i' % i)
self.get(home_page, description='Get gome page')
if __name__ in ('main', '__main__'):
import unittest
unittest.main()

让我们详细理解这个例子:

    1. 你的测试用例必须继承自 FunkLoadTestCase 类,这样 FunkLoad 才能在其测试过程中跟踪发生的情况。
    1. 你的类名很重要,因为 FunkLoad 会寻找一个以该名称命名的文件,在我们的例子中:测试目录中的 LoadHomePage.conf 文件。
    1. 你的测试可以直接访问配置文件,并按以下方式获取值:
    • conf_get(section, key)返回一个字符串。

    • conf_getInt(section, key)以整数值返回值。

    • conf_getFloat(section, key)以浮点数形式返回值。

    • conf_getList(section, key)以列分隔的字符串列表形式返回值。

    1. 然后,你只需调用get()post()方法向服务器发出请求并检索这些方法返回的响应。

内部 Funkload 将创建一系列测试指标并将它们保存到一个可以稍后处理的XML文件中。

让我们分析LoadHomePage.conf设置:

[main]
title=Photoblog home page
description=Access the photoblog home page
url=http://localhost:8080
[test_homepage]
description=Access %(nb_time)s times the following pages:
%(pages)s.
nb_time=3
pages=/
[ftest]
log_to = console file
log_path = logs/load_home_page.log
result_path = logs/load_home_page.xml
sleep_time_min = 0
sleep_time_max = 2

main部分包含测试的全局设置,而test_homepage部分包含我们测试用例的test_homepage()方法的特定值。ftest部分由 FunkLoad 用于内部处理。

在启动 photoblog 应用程序服务器的一个实例后,我们运行测试:

sylvain@[test]$ python test_load_home_page.py
test_homepage: Starting -----------------------------------
Access 3 times the following pages: /.
test_homepage: Try 0
test_homepage: GET: http://localhost:8080/
Page 1: Get gome page ...
test_homepage: Done in 0.039s
test_homepage: Load css and images...
test_homepage: Done in 0.044s
test_homepage: Try 1
test_homepage: GET: http://localhost:8080/
Page 2: Get gome page ...
test_homepage: Done in 0.041s
test_homepage: Load css and images...
test_homepage: Done in 0.000s
test_homepage: Try 2
test_homepage: GET: http://localhost:8080/
Page 3: Get gome page ...
test_homepage: Done in 0.051s
test_homepage: Load css and images...
test_homepage: Done in 0.000s
.
----------------------------------------------------------------------
Ran 1 test in 2.149s
OK

之前的测试还不是真正的负载测试。要将其用作负载或性能测试,我们需要使用一个名为fl-run-bench的 FunkLoad 工具。这个命令行工具将使用我们刚刚创建的测试运行基准测试。

基准测试将模拟虚拟用户以并发运行,以执行对服务器的实际使用。例如,如果我们想在 30 秒内基准测试 5、10 和 20 个虚拟用户的三轮循环,我们会这样做。

首先在配置文件中添加以下部分:

[bench]
cycles = 5:10:20
duration = 30
startup_delay = 0.05
sleep_time = 1
cycle_time = 1
log_to = file
log_path = logs/load_home_page.log
result_path = logs/load_home_page.xml
sleep_time_min = 0
sleep_time_max = 0.6

然后启动基准测试:

sylvain@[test]$ fl-run-bench test_load_home_page.py \
LoadHomePage.test_homepage
=======================================
Benching LoadHomePage.test_homepage
=======================================
Access 3 times the following pages: /.
------------------------------------------------------------------------
Configuration
=============
* Current time: 2007-02-28T13:43:22.376339
* Configuration file: load/LoadHomePage.conf
* Log xml: logs/load_home_page.xml
* Server: http://localhost:8080
* Cycles: [5, 10, 20]
* Cycle duration: 30s
* Sleeptime between request: from 0.0s to 0.6s
* Sleeptime between test case: 1.0s
* Startup delay between thread: 0.05s
Benching
========
Cycle #0 with 5 virtual users
-----------------------------
* Current time: 2007-02-28T13:43:22.380481
* Starting threads: ..... done.
* Logging for 30s (until 2007-02-28T13:43:52.669762): .... done.
* Waiting end of threads: ..... done.
* Waiting cycle sleeptime 1s: ... done.
* End of cycle, 33.46s elapsed.
* Cycle result: **SUCCESSFUL**, 76 success, 0 failure, 0 errors.
Cycle #1 with 10 virtual users
------------------------------
* Current time: 2007-02-28T13:43:55.837831
* Starting threads: .... done.
* Logging for 30s (until 2007-02-28T13:44:26.681356): .... done.
* Waiting end of threads: .......... done.
* Waiting cycle sleeptime 1s: ... done.
* End of cycle, 34.02s elapsed.
* Cycle result: **SUCCESSFUL**, 145 success, 0 failure, 0 errors.
Cycle #2 with 20 virtual users
------------------------------
* Current time: 2007-02-28T13:44:29.859868
* Starting threads: ....... done.
* Logging for 30s (until 2007-02-28T13:45:01.191106):
* Waiting end of threads: .................... done.
* Waiting cycle sleeptime 1s: ... done.
* End of cycle, 35.59s elapsed.
* Cycle result: **SUCCESSFUL**, 203 success, 0 failure, 0 errors.
Result
======
* Success: 424
* Failures: 0
* Errors: 0
Bench status: **SUCCESSFUL**

现在我们已经运行了基准测试,我们可以使用fl-build-report命令行工具创建报告如下:

sylvain@[test]$ fl-build-report --html -o reports
logs/load_home_page.xml
Creating html report: ...done:
reports/test_homepage-2007-02-28T13-43-22/index.html

这将生成一个 HTML 页面,其中包含从基准测试中收集的统计数据,如下所示:

性能和负载测试

除了这些模块之外,FunkLoad 还提供测试 XML-RPC 服务器或从浏览器直接记录测试的工具,这使得开发复杂的测试变得容易。请参考 FunkLoad 文档以获取有关这些功能的更多详细信息。

总体而言,Funkload 是一个非常强大的工具,同时灵活且易于使用,为 Python Web 应用程序提供了一个全面的负载和性能测试环境。

功能测试

当你的应用程序功能开始成形时,你可能想要进行一系列的功能测试,以便你可以验证你的应用程序是否符合规格。对于一个 Web 应用程序,这意味着可以通过浏览器等工具遍历应用程序。然而,由于测试需要自动化,它将需要使用像 Selenium 这样的第三方产品(Selenium 可在www.openqa.org/selenium/找到)。

Selenium 是一个基于 JavaScript 的开源产品,由 OpenQA 团队开发和维护,用于执行功能性和验收测试。它直接从它针对的浏览器工作,有助于确保应用程序客户端代码的可移植性。

Selenium 有几个包:

  • 核心包:核心包允许测试人员直接从浏览器使用纯 HTML 和 JavaScript 设计和运行测试。

  • 远程控制:此包允许使用 Python、Perl、Ruby、Java 或 C#等常用编程语言执行测试。用这些语言编写的脚本可以驱动浏览器在测试期间自动化执行的操作。

  • IDE:Selenium IDE 作为 Firefox 扩展提供,可以帮助通过浏览器本身记录操作来创建测试。然后可以将测试导出以供核心和远程控制包使用。

待测试的应用程序

在解释 Selenium 组件如何工作之前,我们必须介绍一个应用程序示例。这个应用程序将简单地提供一个包含两个链接的网页。其中一个将用新页面替换当前页面。第二个链接将使用 Ajax 获取数据。我们使用这个示例而不是我们的照片博客应用程序,因为它的简单性。应用程序的代码如下:

import datetime
import os.path
import cherrypy
import simplejson
_header = """<html>
<head><title>Selenium test</title></head>
<script type="application/javascript" src="img/MochiKit.js">
</script>
<script type="application/javascript" src="img/New.js">
</script>
<script type="application/javascript">
var fetchReport = function() {
var xmlHttpReq = getXMLHttpRequest();
xmlHttpReq.open("GET", "/fetch_report", true);
xmlHttpReq.setRequestHeader('Accept', 'application/json');
var d = sendXMLHttpRequest(xmlHttpReq);
d.addCallback(function (data) {
var reportData = evalJSONRequest(data);
swapDOM($('reportName'), SPAN({'id': 'reportName'},
reportData['name']));
swapDOM($('reportAuthor'), SPAN({'id': 'reportAuthor'},
reportData['author']));
swapDOM($('reportUpdated'), SPAN({'id': 'reportUpdated'},
reportData['updated']));
});
}
</script>
<body>
<div>
<a href="javascript:void(0);" onclick="fetchReport();">Get report via
Ajax</a>
<br />
<a href="report">Get report</a>
</div>
<br />
"""
_footer = """
</body>
</html>
"""
class Dummy:
@cherrypy.expose
def index(self):
return """%s
<div id="report">
<span>Name:</span>
<span id="reportName"></span>
<br />
<span>Author:</span>
<span id="reportAuthor"></span>
<br />
<span>Updated:</span>
<span id="reportUpdated"></span>
</div>%s""" % (_header, _footer)
@cherrypy.expose
def report(self):
now = datetime.datetime.now().strftime("%d %b. %Y, %H:%M:%S")
return """%s
<div id="report">
<span>Name:</span>
<span id="reportName">Music report (HTML)</span>
<br />
<span>Author:</span>
<span id="reportAuthor">Jon Doe</span>
<br />
<span>Updated:</span>
<span id="reportUpdated">%s</span>
</div>%s""" % (_header, now, _footer)
@cherrypy.expose
def fetch_report(self):
now = datetime.datetime.now().strftime("%d %b. %Y, %H:%M:%S")
cherrypy.response.headers['Content-Type'] =
'application/json'
return simplejson.dumps({'name': 'Music report (Ajax)',
'author': 'Jon Doe',
'updated': now})
if __name__ == '__main__':
current_dir = os.path.abspath(os.path.dirname(__file__))
conf = {'/test': {'tools.staticdir.on': True,
'tools.staticdir.dir': "test",
'tools.staticdir.root': current_dir},
'/MochiKit': {'tools.staticdir.on': True,
'tools.staticdir.dir': "MochiKit",
'tools.staticdir.root': current_dir},
'/selenium': {'tools.staticdir.on': True,
'tools.staticdir.dir': "selenium",
'tools.staticdir.root': current_dir}}
cherrypy.quickstart(Dummy(), config=conf)

我们定义了三个路径作为静态目录提供服务。第一个包含我们的 Selenium 测试套件和将在后面详细说明的测试用例。第二个包含 MochiKit JavaScript 工具包,最后一个包含 Selenium 核心包。实际上,Selenium 核心必须在执行测试的同一服务器上提供服务。

在浏览器中,应用程序将看起来如下:

待测试的应用程序

当点击第一个链接时,fetch_report() JavaScript 函数将被触发,通过XMLHttpRequest获取报告数据。结果将如下所示:

待测试的应用程序

当点击第二个链接时,当前页面将被替换为包含如下报告的新页面:

待测试的应用程序

如您所见,这个应用程序并没有做什么特别的事情,但它为我们提供了现代 Web 应用程序中的常见用例。因此,在接下来的章节中,我们将描述两个测试用例,每个链接一个。

Selenium 核心

Selenium 测试通过三列和所需行数的 HTML 表格描述,每行描述 Selenium 要执行的操作。三列如下:

  • 要执行 Selenium 操作的名称。

  • Selenium 在页面文档对象模型中要查找的目标。它可以是一个元素的标识符或指向元素的 XPath 语句。

  • 值。要比较或由操作使用的值。

例如,让我们描述以下测试:

    1. 获取主页。
    1. 点击获取报告链接并等待返回的页面。
    1. 验证我们可以在新页面中找到 HTML 字符串。

这将转换为(将以下内容保存到test/test_html.html):

<html>
<head />
<body>
<table>
<thead>
<tr><td rowspan="1" colspan="3">HTML Test</td></tr>
</thead>
<tbody>
<tr>
<td>open</td>
<td>/</td>
<td></td>
</tr>
<tr>
<td>clickAndWait</td>
<td>link=Get report</td>
<td></td>
</tr>
<tr>
<td>verifyTextPresent</td>
<td></td>
<td>HTML</td>
</tr>
</tbody>
</table>
</body>
</html>

现在我们来描述我们的第二个用例以测试我们的 Ajax 代码:

    1. 获取主页。
    1. 点击通过 Ajax 获取报告链接。
    1. 暂停几秒钟。
    1. 验证我们能否在新的页面中找到 Ajax 字符串。

第三步是强制性的,因为在执行 XMLHttpRequest 时,Selenium 不会等待响应。在这种情况下,您必须暂停 Selenium 的执行,以便它有时间等待响应返回并更新页面的文档对象模型。前面的用例将转换为(将其保存在 test/test_ajax.html 中):

<html>
<head />
<body>
<table cellpadding="1" cellspacing="1" border="1">
<thead>
<tr><td rowspan="1" colspan="3">Test Ajax</td></tr>
</thead>
<tbody>
<tr>
Seleniumcore<td>open</td>
<td>/</td>
<td></td>
</tr>
<tr>
<td>click</td>
<td>link=Get report via Ajax</td>
<td></td>
</tr>
<tr>
<td>pause</td>
<td>300</td>
<td></td>
</tr>
<tr>
<td>verifyTextPresent</td>
<td></td>
<td>Ajax</td>
</tr>
</tbody>
</table>
</body>
</html>

现在我们已经将测试用例放在了 test 目录中,我们可以创建一个测试套件,如下所示:

<html>
<head>
<link rel="stylesheet" type="text/css"
href="/selenium/core/selenium.css" />
<head>
<body>
<table class="selenium">
<tbody>
<tr><td><b>Test Suite</b></td></tr>
<tr><td><a href="test_html.html">Test HTML</a></td></tr>
<tr><td><a href="test_ajax.html">Test Ajax</a></td></tr>
</tbody>
</table>
</body>
</html>

我们现在拥有了运行测试所需的一切。要做到这一点,我们将使用 Selenium 核心包提供的测试运行器。在浏览器中打开以下页面:

http://localhost:8080/selenium/core/TestRunner.html

这将显示一个如下所示的页面:

Selenium 核心组件

我们现在可以加载我们的测试套件,通过在页面左上角的 TestSuite 输入框中输入以下路径来获取下一个屏幕:../../test/testsuite.html

Selenium 核心组件

如您所见,左侧面板列出了所有我们的测试用例,中间面板显示了当前选定的测试用例,右侧面板显示了 Selenium 的控件和结果。最后,页面底部将显示每个测试用例的结果网页。

下一步是通过点击 All 按钮,这将生成以下屏幕:

Selenium 核心组件

Selenium 测试运行器将使用颜色代码来通知您测试用例的执行情况。绿色表示一切正常,黄色表示步骤尚未完成,红色表示测试过程中出现错误。

Selenium IDE

在前面的部分中,我们直接从文本编辑器中编写了测试用例,这对于长用例来说可能会变得有点繁琐。幸运的是,OpenQA 团队为 Mozilla Firefox 浏览器提供了一个集成的开发编辑器,作为扩展程序提供。这个 IDE 的优点包括:

  • 无需在服务器上安装 Selenium 核心包

  • 能够通过在浏览器中遵循业务流程来录制操作

  • 能够手动修改任何生成的测试

  • 测试用例的逐步调试

  • 录制的测试用例可以导出为 HTML 或 Selenium 远程控制包支持的任何语言

要录制一个测试用例,您首先需要在以下窗口中提供服务器的基准 URL,localhost:8080

Selenium IDESeleniumcore

由于默认情况下,当您启动 IDE 时,它会以录制模式运行,您现在可以转到浏览器并遵循您的业务流程。Selenium IDE 将自动记录每个步骤。例如,通过点击 Get Report,将生成 clickAndWait 步骤。要验证给定文本的存在,您必须突出显示目标文本,右键单击以打开弹出菜单,并选择 verifyTextPresent

此时,您的 IDE 将看起来如下所示:

Selenium IDESeleniumcore

现在我们已经录制了一个测试,我们可以通过点击绿色三角形来运行它。

Selenium IDESeleniumcore

如你所见,使用 IDE 创建脚本的步骤要简单得多。此外,多亏了其出色的灵活性,你可以在 IDE 中插入新步骤,或者在 IDE 无法记录动作的情况下删除和修改现有步骤。你还可以将手动创建的测试加载到 IDE 中并从那里运行它们。

最后,你可以导出你的记录步骤,以便你可以通过测试运行器或通过我们在下一节中将要看到的 Selenium 远程控制包来运行它。

Selenium 远程控制

Selenium 远程控制RC)包提供了使用从多种编程语言记录的步骤来驱动浏览器的可能性。这非常有趣,因为你的测试因此可以作为常规单元测试运行。

你需要首先从 Selenium RC 包中获取 Python 模块。一旦它们出现在你的 PYTHONPATH 中,你应该能够执行以下操作:from selenium import selenium

下一步将是将之前录制的测试导出为 Python 语言。生成的脚本将如下所示:

from selenium import selenium
import unittest, time, re
class TestHTML(unittest.TestCase):
def setUp(self):
self.verificationErrors = []
self.selenium = selenium("localhost", 4444, "*firefox",
"http://localhost:8080")
self.selenium.start()
def test_TestHTML(self):
# Get a reference to our selenium object
sl = self.selenium
sl.open("/")
sl.click("link=Get report")
sl.wait_for_page_to_load("5000")
try: self.failUnless(sl.is_text_present("HTML"))
except AssertionError, e: self.verificationErrors.append(str(e))
def tearDown(self):
self.selenium.stop()
self.assertEqual([], self.verificationErrors)
if __name__ == "__main__":
unittest.main()

如你所见,这是一个来自 unittest 标准模块的纯测试用例。

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

    1. 在每个测试方法之前调用的 setUp() 方法,初始化一个 Selenium 对象,指定 Selenium 代理的主机和端口,以及测试期间应使用哪种浏览器。
    1. test_TestHTML() 方法执行了我们测试用例的实际步骤。
    1. 在每个测试方法之后调用的 tearDown() 方法停止了 Selenium 对象的这个实例。

在运行测试之前,你必须启动 Selenium 代理,它将处理所选浏览器的启动以及运行测试。然后,它将所有结果返回到我们的测试用例。

Selenium RC 包附带了一个用 Java 编写的默认代理服务器,这是我们将在示例中使用的。然而,当然没有任何阻止任何人用不同的语言编写代理的限制。要启动服务器,你必须转到 Selenium RC 包目录,并执行以下命令,假设你的机器上已安装了 1.4.2 或更高版本的 Java 虚拟机:

sylvain@[selenium]$ java -jar server/selenium-server.jar 

服务器启动后,你必须启动你的应用程序服务器,然后你可以按照以下方式运行测试:

python test_html.py
.
----------------------------------------------------------------------
Ran 1 test in 6.877s
OK

如果你查看 Selenium 代理服务器日志,你应该会看到以下类似的内容:

queryString =
cmd=getNewBrowserSession&1=%2Afirefox&2=http%3A%2F%2Flocalhost%3A8080
Preparing Firefox profile...
Launching Firefox...
3 oct. 2006 17:35:10 org.mortbay.util.Container start
INFO: Started HttpContext[/,/]
Got result: OK,1159893304958
queryString = cmd=open&1=%2F&sessionId=1159893304958
Got result: OK
queryString = cmd=click&1=link%3DGet+report&sessionId=1159893304958
Got result: OK
queryString = cmd=waitForPageToLoad&1=5000&sessionId=1159893304958
Got result: OK
queryString = cmd=isTextPresent&1=HTML&sessionId=1159893304958
Got result: OK,true
queryString = cmd=testComplete&sessionId=1159893304958
Killing Firefox...
Got result: OK

这将启动一个 Firefox 实例,运行测试,并将结果作为正常输入传递回你的测试用例。

在本节中,我们介绍了一个开源解决方案 Selenium,用于执行验收和功能测试,以验证我们应用程序的正确性。尽管这不是唯一的解决方案,但它已经得到了社区的广泛支持。它的灵活性和庞大的功能集为测试人员提供了一个构建测试的大色板。

摘要

在本章中,我们介绍了测试应用程序的不同方面。尽管这不是一个详尽无遗的清单,但它应该为理解应用程序可以以及应该如何进行测试提供一个良好的起点。重要的是要注意,测试不应发生在应用程序开发的最后阶段,而应尽早成为其构建过程的一部分。

第十章:部署

我们最后一章将在第一部分解释如何配置基于 CherryPy 的应用程序,然后回顾通过使用 Apache 和 lighttpd 部署此类应用程序的不同方法。最后,我们将回顾如何通过内置的 CherryPy HTTP 服务器以及使用 Apache 和 lighttpd 功能来使基于 CherryPy 的应用程序启用 SSL。

配置

在开发应用程序时,您始终需要对其进行参数化,以便根据宿主环境的需要对其进行调整。例如,使用的数据库类型,PostgreSQL 或 MySQL,应用程序所在的目录,管理员联系方式等。

在像我们的照片博客这样的 Web 应用程序中,需要不同级别的配置设置:

  • Web 服务器:与 HTTP 服务器相关的设置

  • 引擎:与应用程序宿主引擎相关的设置

  • 应用程序:我们的应用程序将使用的设置

CherryPy——Web 和引擎配置系统

由于我们的应用程序正在使用 CherryPy,我们将使用 CherryPy 的配置能力来配置 Web 服务器和引擎。CherryPy 使用的是基于微软定义的 INI 格式语法的配置。

CherryPy 配置文件的格式如下:

[section]
key = value

原始 INI 格式与 CherryPy 使用的格式之间的主要区别在于,后者的值是 Python 数据类型。例如:

[global]
server.socket_host = "localhost"
server.socket_port = 8080

除了[global]之外,配置文件的各个部分与请求的 URI 路径段相匹配,如下例所示:

[/css/style.css]
tools.staticfile.on = True
tools.staticfile.file = "app.css"
tools.staticfile.root = "/var/www/photoblog/design/default/css"

当 CherryPy 尝试匹配/css/style.css请求时,它将检查配置设置以查找匹配的部分。如果找到,它将使用该部分定义的设置。

在我们解释 CherryPy 如何区分 Web 服务器和引擎设置之前,让我们看看如何在 Python 字典中定义配置设置。以下代码片段展示了相同的设置:

{'/css/style.css': {'tools.staticfile.on': True,
'tools.staticfilE.file': "app.css" 'tools.staticfile.root':
"/var/www/photoblog/design/default/css"}}

从功能上讲,这两种方法将提供相同的性能。使用 Python 字典的优点是它位于代码本身中,因此可以提供更复杂的数据类型作为值。最终,这通常取决于个人喜好。

现在我们已经介绍了如何声明配置设置,让我们看看如何将它们传递给相应的组件。在这一点上,CherryPy API 非常直接:

  • cherrypy.config.update(文件或字典)用于配置 CherryPy Web 服务器。

  • cherrypy.tree.mount(应用程序、配置文件或字典)用于提供挂载应用程序的设置。

  • _cp_config属性绑定到页面处理程序,或者绑定到包含页面处理程序的类,并调用定义为字典的控制器(在这种情况下,设置通过 CherryPy 传播到该控制器的所有页面处理程序)。它用于直接将设置传递到需要的地方。

我们将回顾一个示例,以了解如何在我们的环境中使用该 API:

import cherrypy
class Root:
@cherrypy.expose
def echo(self, some):
repeat = cherrypy.request.config.get('repeat', 1)
return some * repeat
echo._cp_config = {'repeat': 3}
if __name__ == '__main__':
http_conf = {'global': {'environment': 'production',
'server.socket_port': 9090,
'log.screen': True,
'log.error_file': 'error.log',
'log.access_file': 'access.log'}}
cherrypy.config.update(http_conf)
app0_conf = {'/echo': {'tools.response_headers.on': True,
'tools.response_headers.headers':
('Content-Type', 'text/plain')]}}
cherrypy.tree.mount(Root(), script_name='/app0',
config=app0_conf)
app1_conf = {'/echo': {'tools.gzip.on': True,
'repeat': 2}}
cherrypy.tree.mount(Root(), script_name='/app1',
config=app1_conf)
cherrypy.server.quickstart()
cherrypy.engine.start()

让我们看看我们在示例中做了什么:

    1. 首先,我们声明一个名为echo的页面处理程序的应用程序。这个处理程序的目的就是返回请求体,并按照配置设置键repeat定义的次数重复它。为此,我们使用绑定到页面处理程序上的_cp_config属性。这个值也可以从主配置字典中传递。在这种情况下,来自主字典的值将优先于_cp_config属性。
    1. 接下来,我们在字典中声明 Web 服务器设置,然后使用该字典调用cherrypy.config.update()。请注意,当使用字典时,使用名为 global 的键不是强制性的。CherryPy 确实以完全相同的方式解释它;因此,前一个示例的语义等效可以写成以下形式:
    http_conf = {'environment': 'production',
    'server.socket_port': 9090,
    'log.screen': True,
    'log.error_file': 'error.log',
    'log.access_file': 'access.log'}
    cherrypy.config.update(http_conf)
    
    
    1. 最后,我们在两个不同的前缀上挂载两个不同的应用程序,并使用两种不同的配置设置。重要的是要注意,我们使用的键是页面处理程序相对于应用程序挂载位置的路径。这就是为什么我们使用/echo,而不是/app0/echo/app1/echo。这也意味着配置设置不会泄漏到挂载的应用程序之间。CherryPy 确保每个应用程序只接收它声明的设置。

注意

将与应用程序相关的配置设置传递给cherrypy.config.update()方法是一个常见的错误。这不会将设置传播到挂载的应用程序。您必须使用cherrypy.tree.mount()config属性来获得预期的行为。

照片博客应用程序配置系统

应用程序的配置设置通常不会通过位于较低级别的 CherryPy 配置系统传递。应用程序通常会从它们的领域级别定义实体,将这些值存储在后端存储中,与它们的其他数据一起,并最终提供一个前端界面,允许管理员或用户修改它们。

照片博客应用程序不会走那么远,但会通过使用纯 INI 文件来保持提供配置设置的相当简单的方法。我们做出这个选择是因为在照片博客应用程序的情况下,配置设置将是简单、定义明确的,并且可以被应用程序管理员编辑。因此,我们将避免开发比 INI 文件更复杂的解决方案。

然而,为了简化对这些设置的访问,我们将定义一个特定的类,该类将 INI 部分、键和值转换为 Python 对象:

from ConfigParser import ConfigParser
class Config(object):
def from_ini(self, filepath, encoding='ISO-8859-1'):
config = ConfigParser()
config.readfp(file(filepath, 'rb'))
for section in config.sections():
section_prop = Config()
section_prop.keys = []
setattr(self, section, section_prop)
for option in config.options(section):
section_prop.keys.append(option)
value = config.get(section, option).decode(encoding)
setattr(section_prop, option, value)

此类将简单地遍历 INI 文件,并在运行时向 Config 类的实例添加属性。例如,假设您有以下 INI 文件:

[app]
base_url = http://localhost:8080
copyright = Creative Commons Attribution-ShareAlike2.5 License
[storage]
host = localhost
dbname = photoblog
user = test
password = test
type = postgres

使用上述类,我们可以进行以下修改:

import config
photoblogconfiguringc = config.Config()
c.from_ini('application.conf')
dir(c)
['__class__', '__delattr__', '__dict__', '__doc__',
'__getattribute__', '__hash__', '__init__', '__module__'
'__new__', '__reduce__', '__reduce_ex__', '__repr__',
'__setattr__', '__str__', '__weakref__', 'app', 'storage']
c.app.copyright
u'Creative Commons Attribution-ShareAlike2.5 License'

如您所见,我们现在已将 INI 文件修改为绑定到 Config 类实例的属性树。Photoblog 应用程序将有一个全局的此类实例,因此可以从应用程序的任何地方访问它。

在本节中,我们简要回顾了使用其内置配置系统参数化 CherryPy 应用程序的方法。我们还介绍了一个使用 INI 文件格式的简单配置系统,允许应用程序设置。这种方法因此提供了一种在转向基于系统的数据库之前,模拟传递参数的简单方法,该数据库可能要求更高。

部署

部署基于 CherryPy 的应用程序可以像将应用程序放入一个环境中一样简单,其中所有必需的包(CherryPy、Kid、simplejson 等)都可通过 Python 系统路径获取。然而,在共享的托管环境中,CherryPy 网络服务器很可能位于前端服务器(如 Apache 或 lighttpd)之后,这样主机提供商可以在需要时执行一些过滤操作,或者例如让前端以比 CherryPy 更高效的方式提供静态文件。

本节将介绍一些在 Apache 和 lighttpd 网络服务器后面运行 CherryPy 应用程序的方法。

在解释如何在 Apache 或 lighttpd 后使用 CherryPy 之前,让我们定义一个简单的应用程序,我们将在整个示例中使用它:

import.cherrypy
def setup_app():
class Root:
@cherrypy.expose
def index(self):
# Will return the hostname used by CherryPy and the remote
# caller IP address
return "Hello there %s from IP: %s " %
(cherrypy.request.base, cherrypy.request.remote.ip)
cherrypy.config.update({'server.socket_port': 9091,
'environment': 'production',
'log.screen': False,
'show_tracebacks': False})
cherrypy.tree.mount(Root())
if __name__ == '__main__':
setup_app()
cherrypy.server.quickstart()
cherrypy.engine.start()

如前所述,有几种方法可以部署基于 CherryPy 的应用程序。现在,我们将讨论不同的部署方法。

Apache 与 mod_rewrite 模块

当在 Apache 网络服务器后面运行时,您可以审查的第一个解决方案是使用 mod_rewrite 模块。此模块允许您定义一组规则,该模块将分析这些规则以转换传入的 HTTP 请求并将它们重新调度到后端服务器。

在我们的示例中,我们将做出以下假设,这些假设实际上是要求:

  • 您运行 Apache 2.2。

  • 您可以访问 Apache 配置,通常可以在名为 httpd.conf 的文件中找到。您还可以停止和重新启动 Apache 进程。这些要求意味着您要么有机器的管理员权限,要么您有一个用于实验的本地 Apache 安装。

  • 您将使用 VirtualHost 指令,该指令允许封装仅针对特定主机的指令。这允许不同的主机由 Apache 的单个实例处理。

  • 我们还假设您本地可以解析 myapp.com。为此:

    在 Linux 下,将以下行添加到 /etc/hosts 文件中:

    127.0.0.1 myapp.com myapp www.myapp.com

  • 您的操作系统现在应该将 myapp.com 主机的请求解析到您的本地环境中。

现在我们来解释如何配置 Apache:

    1. 加载所需的 Apache 模块,如下所示:
LoadModule rewrite_module modules/mod_rewrite.so

  • 注意,在某些环境中,您可能需要提供模块本身的完整路径。
    1. 接下来我们声明 VirtualHost,如下所示:
# Create a virtual host in your apache configuration
# to handle requests for the myapp.com hostname
<VirtualHost 127.0.0.1:80>
ServerName myapp.com
ServerAlias www.myapp.com
# Where our application files reside
DocumentRoot /home/sylvain/photoblog
# What is our directory index by default
DirectoryIndex index.html
# Message to return when our CherryPy server is down and
# apache could not forward the request.
ErrorDocument 502 "Server down"
# mod_proxy magic
# First enable the mod_rewrite engine
RewriteEngine on
# Now we simply rewrite incoming requests URI so that they
# are proxied to our CherryPy web server
# http://myapp.com/archives/2006/10/12/my-article
# would become
# http://127.0.0.1:9091/archives/2006/10/12/my-article
RewriteRule ^(.*) http://127.0.0.1:9091$1 [P]
# Now define the format of the logs to be used by Apache
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\"
\"%{User-Agent}i\"" combined LogFormat
"%t %a %D %I %O %s %{Content-Type}o %{Host}i
\"%r\" \"%{Referer}i\"" host
CustomLog /home/sylvain/photoblog/access_myapp.log combined
Errorlog /home/sylvain/photoblog/error_myapp.log
</VirtualHost>

    1. 下一步是停止并重新启动您的 Apache 进程,以便考虑这些修改。
    1. 然后启动您的 CherryPy 应用程序服务器。

mod_rewrite 模块文档详细说明了如何构建重写规则。在先前的示例中,我们通过将请求 URI 路径映射到新的主机名定义了最通用的规则。

当导航到 URL myapp.com 时,您现在应该看到以下消息:

Hello there http://127.0.0.1:9091 from IP: 127.0.0.1

既然我们知道如何通过 Apache 将主机映射到我们的 CherryPy 应用程序,我们可能想要获取实际的主机名和远程 IP 地址而不是本地地址。在生成如下链接时需要前者:

link = "%s/%s" % (cherrypy.request.base, path)

有两种方法可以实现这一点,因为它们是相互独立的:

    1. 使用 Apache 的 mod_proxy 模块转发主机。
    • 首先,您需要以这种方式加载模块(请参阅您的文档):

      LoadModule proxy_module modules/mod_proxy.so
      LoadModule proxy_http_module modules/mod_proxy_http.so
      
      
    • 将以下指令添加到 VirtualHost:

      ProxyPreserveHost on
      
      
    • 重新启动 Apache。

    1. 使用 CherryPy 代理工具,如下所示:
    • 将以下条目添加到您的全局配置中:
'tools.proxy.on': True

  • 重新启动您的 CherryPy 应用程序。

在这两种情况下,您现在将在浏览器中看到以下消息:

你好,来自 IP: 127.0.0.1 的 http://myapp.com

IP 地址保持不变,因为测试是在托管服务器的同一台机器上进行的,在本地接口上。

现在我们来解释前面的配方是如何工作的。在第一种情况下,通过使用 ProxyPreserveHost 指令,我们告诉 Apache 保持 HTTP header host 字段不变,而不是用本地 IP 地址覆盖它。这意味着 CherryPy 将接收到 Host 头部的原始值。

在第二种情况下,我们告诉 CherryPy 在使用原始主机名进行代理时查找 Apache 设置的特定头信息。CherryPy 默认查找的头信息是 X-Forwarded-Host。

带有 mod_proxy 模块的 Lighttpd

Lighttpd 是另一个流行且非常高效的 HTTP 服务器。前一个部分可以使用 mod_proxy 以类似的方式翻译成 lighttpd。以下是一个配置 lighttpd 以代理到 CherryPy 服务器的示例:

$HTTP["host"] == "myapp.com"
{
proxy.server = ( "" => (("host" => "127.0.0.1",
"port" => 8080)))
}

将此添加到 lighttd.conf 文件中,并重新启动服务器。当浏览到 myapp.com 时,您将看到以下消息:

你好,来自 IP: 127.0.0.1 的 http://myapp.com

带有 mod_python 模块的 Apache

在 2000 年,Gregory Trubetskoy 发布了 mod_python 的第一个版本。这是一个 Apache 的模块,允许 Python 解释器嵌入到 Apache 服务器中,为 Apache 网络服务器和 Python 应用程序之间提供桥梁。mod_python 的一个优点是,与需要为每个请求启动 Python 进程的 CGI 不同,mod_python 没有这样的要求。因此,它为开发者提供了利用 Apache 运行模块时启动的 Python 进程的持久性(例如保持数据库连接池)的机会。

在了解如何配置 Apache 和 mod_python 之前,让我们回顾一下需要满足的要求:

  • Apache 2.2

  • mod_python 3.1.x 或更高版本

我们将假设 mod_python 已正确安装在你的环境中。

现在,让我们解释如何配置 mod_python 以运行基于 CherryPy 的应用程序:

LoadModule python_module modules/mod_python.so
<Location "/">
PythonPath "sys.path + ['/home/sylvain/app']"
SetHandler python-program
PythonHandler cherrypy._cpmodpy::handler
PythonOption cherrypy.setup my_app::setup_app
PythonDebug On
</Location>

我们将按顺序带你完成这个过程:

    1. 首先我们加载 mod_python 模块。
    1. 我们定义一个位置指令,指定 Apache 应对以 "/" 开头的请求执行的操作。
    1. 然后我们定义几个 mod_python 指令:
    • PythonPath 扩展了系统路径,并确保我们的应用程序模块可以被找到。例如,这里 my_app.py 模块位于 /home/sylvain/app

    • SetHandler 指示所有以位置指令中提供的路径开头的请求将由 mod_python 处理。

    • PythonHandler 设置了通用的处理器,它将负责生成输出返回给用户代理。我们使用 CherryPy 提供的内置 mod_python 处理器。

    • PythonOption 将选项传递给通用处理器。这里选项将被命名为 cherrypy.setup,并将其绑定到我们的应用程序提供的 setup_app 函数。我们假设应用程序保存在一个名为 my_app.py 的 Python 模块中。setup_app 方法必须是挂载应用程序的那个方法。

    • PythonDebug 已启用。

    1. 最后,我们按照以下方式修改应用程序:
import cherrypy
def setup_app():
class Root:
@cherrypy.expose
def index(self):
return "Hello there %s from IP: %s " % \
(cherrypy.request.base,cherrypy.request.remote.ip)
cherrypy.tree.mount(Root())
cherrypy.engine.start(blocking=False)

  • 不同之处在于我们以非阻塞模式启动 CherryPy 引擎,这样通过 mod_python 启动的 Python 进程就不会挂起。

现在,你可以停止并重新启动 Apache 进程,并导航到 myapp.com URL,你应该看到以下内容:

来自 IP: 127.0.0.1 的 http://myapp.com,你好

mod_python 与 WSGI 应用程序

在先前的方法中,我们使用了内置的 mod_python 处理器,它在通常由 CherryPy 托管的程序上运行良好。如果你的应用程序遵循 WSGI 接口,你可能想使用 Robert Brewer 开发的 ModPythonGateway 处理器 (projects.amor.org/misc/wiki/ModPythonGateway)。

首先,让我们看看 my_app.py 模块中的 CherryPy 应用程序:

import cherrypy
class Root:
@cherrypy.expose
def index(self):
return "Hello there %s from IP: %s " % (cherrypy.request.base,
cherrypy.request.remote.ip)
# Create an application respecting the WSGI interface
wsgi_app = cherrypy.Application(Root())
# This will be call on the first request
def setup_app(req):
cherrypy.engine.start(blocking=False)

现在,让我们回顾如何配置 Apache 以使用 ModPythonGateway 处理器:

<Location "/">
PythonPath "sys.path + ['/home/sylvain/app']"
SetHandler python-program
PythonHandler modpython_gateway::handler
PythonOption wsgi.startup my_app::setup_app
PythonOption wsgi.application my_app::wsgi_app
PythonOption wsgi.cleanup cherrypy::engine.stop
</Location>

多亏了ModPythonGateway处理程序,你可以在 Apache 服务器的强大功能中使用基于 WSGI 的中间件的丰富性。

SSL

SSL安全套接字层)可以通过 CherryPy 原生支持 CherryPy 应用程序。要启用 SSL 支持,你必须满足以下要求:

  • 在你的环境中安装PyOpenSSL

  • 在服务器上拥有 SSL 证书和私钥

在本章的其余部分,我们将假设你已经正确安装了PyOpenSSL。让我们解释如何生成一对私钥和证书。为了实现这一点,我们将使用 OpenSSL,它是 SSL 规范的常见开源实现。

创建证书和私钥

让我们处理证书和私钥:

    1. 首先,我们需要一个私钥:
openssl genrsa -out server.key 2048 

    1. 此密钥没有密码短语保护,因此保护相当弱。如果你更喜欢提供密码短语,你应该发出如下命令:
openssl genrsa -des3 -out server.key 2048 

  • 程序将需要密码短语。如果你的 OpenSSL 版本允许你提供一个空字符串,请这样做。否则,输入默认密码短语,然后按照以下方式将其从生成的密钥中删除:
openssl rsa -in server.key -out server.key 

    1. 现在,我们按照以下方式创建证书:
openssl req -new -key server.key -out server.csr 

    1. 此过程将要求你输入一些详细信息。上一步已生成证书,但它尚未由私钥签名。为此,你必须发出以下命令:
openssl x509 -req -days 60 -in server.csr -signkey
server.key -out server.crt 

新签发的证书将有效期为 60 天。

注意

注意,由于证书未由 VeriSign 等认可的机构签名,当访问应用程序时,你的浏览器将显示一个弹出窗口,以便用户可以接受或拒绝证书。

现在,我们可以看看创建证书和密钥的不同方法。

使用 CherryPy SSL 支持

让我们看看我们如何做到这一点:

import cherrypy
import os, os.path
localDir = os.path.abspath(os.path.dirname(__file__))
CA = os.path.join(localDir, 'server.crt')
KEY = os.path.join(localDir, 'server.key')
def setup_server():
class Root:
@cherrypy.expose
def index(self):
return "Hello there!"
cherrypy.tree.mount(Root())
if __name__ == '__main__':
setup_server()
cherrypy.config.update({'server.socket_port': 8443,
'environment': 'production',
'log.screen': True,
'server.ssl_certificate': CA,
'server.ssl_private_key': KEY})
cherrypy.server.quickstart()
cherrypy.engine.start()

关键是向全局 CherryPy 配置提供server.ssl_certificateserver.ssl_private_key值。下一步是启动服务器;如果一切顺利,你应该会在屏幕上看到以下消息:

https://localhost:8443/上通过 HTTP 服务 HTTPS

通过导航到应用程序 URL,你应该会看到一个类似的消息:

使用 CherryPy SSL 支持

如果你接受证书,你将能够通过 HTTPS 继续使用 Web 应用程序。

之前解决方案的一个缺点是现在你的应用程序不能通过非安全 HTTP 访问。幸运的是,CherryPy 提供了一个相当简单的方法来解决这个问题,只需同时启动两个 HTTP 服务器即可。你可以看到它是如何完成的:

import cherrypy
from cherrypy import _cpwsgi
from cherrypy import wsgiserver
import os, os.path
localDir = os.path.abspath(os.path.dirname(__file__))
CA = os.path.join(localDir, 'server.crt')
KEY = os.path.join(localDir, 'server.key')
def setup_app():
class Root:
@cherrypy.expose
def index(self):
return "Hello there!"
cherrypy.tree.mount(Root())
if __name__ == '__main__':
setup_app()
# Create a server which will accept HTTP requests
s1 = _cpwsgi.CPWSGIServer()
# Create a server which will accept HTTPS requests
SSLin CherryPys2 = _cpwsgi.CPWSGIServer()
s2.ssl_certificate = CA
s2.ssl_private_key = KEY
# Our first server uses the default CherryPy settings
# localhost, 8080\. We thus provide distinct ones
# for the HTTPS server.
s2.bind_addr = ('localhost', 8443)
# Inform CherryPy which servers to start and use
cherrypy.server.httpservers = {s1: ('localhost', 8080),
s2: ('localhost', 8443)}
cherrypy.server.start()
cherrypy.engine.start()

在启动应用程序后,你现在应该在屏幕上看到以下几行:

https://localhost:8443/上通过 HTTP 服务 HTTPS

http://localhost:8080/上通过 HTTP 服务 HTTP

你的应用程序现在将通过 HTTP 和 HTTPS 可访问。

使用 lighttpd SSL 支持

在 lighttpd 中设置 SSL 支持就像在 lighttpd 的全局配置中添加以下内容一样简单:

ssl.engine = "enable"
ssl.pemfile = "/home/sylvain/application/server.pem"

server.pem文件是我们之前创建的server.keyserver.crt文件的连接。例如,在 UNIX 系统下,我们执行以下命令:

cat server.key server.crt > server.pem 

通过使用前面章节中描述的这两行和代理方法,我们已经说明了如何为 CherryPy 应用程序提供 SSL 支持。

注意

然而,需要注意的是,从 lighttpd 到 CherryPy 的路径将是 HTTP 未加密的。SSL 支持将在 lighttpd 级别停止。

使用 Apache mod_ssl 支持

这种方法包括使用基于 OpenSSL 的 Apache mod_ssl模块来处理在将请求转发到 CherryPy 服务器之前的 SSL 交换,就像我们在 lighttpd 中做的那样。

要这样做,你需要按照以下方式修改你的 Apache 配置:

LoadModule ssl_module modules/mod_ssl.so
Listen 127.0.0.1:443 

第一行加载了mod_ssl模块。第二行请求 Apache 在指定的 IP 地址上的 443 端口(需要管理员权限)监听传入的套接字连接。

然后,我们按照以下方式修改VirtualHost

<VirtualHost 127.0.0.1:443>
SSLEngine On
SSLCertificateFile /home/sylvain/application/server.crt
SSLCertificateKeyFile /home/sylvain/application/server.key
</VirtualHost>

一旦你重启了 Apache 进程,你应该能够导航到 URL myapp.com

摘要

在本章中,我们回顾了几种使用常见产品(如 Apache 和 lighttpd)配置和部署基于 CherryPy 的应用程序的可能性。我们还处理了 SSL 支持。这些应该足够你开始并适应你自己的环境和需求。

然而,部署不仅限于设置 Web 服务器,本章没有涵盖将代码推送到生产环境的讨论,也没有解释如何在生产中更新应用程序。这超出了本章的范围,因此没有讨论。

作者观点

如果你已经阅读了这本书,我只能假设你对 CherryPy 库作为个人项目的候选者感兴趣。然而,我写这本书的动机有两个。首先,我想提供一个坚实的 CherryPy 3 参考,这样,希望如此,可以满足使用它的开发者的好奇心,这正是我在本书的前四章中努力实现的目标。

其次,我希望向我的同行读者介绍一些关于网络应用程序发展的一些不同方面。我没有计划将这本书作为所有涉及主题的参考书,因为这需要另外十卷。相反,我试图提供一些关键信息,让你明白编写网络应用程序与其他类型的应用程序在过程上并没有什么不同。

考虑到这个观点,第五章告诉我们,像关系数据库这样的持久化机制可以通过 Dejavu、SQLObject 或 SQLAlchemy 这样的对象关系映射进行抽象。这是一个基本概念,它允许你以轻松的方式设计你的应用程序,与操作的数据相关。此后,第六章提醒我们,网络应用程序不仅可以提供 HTML 页面,还可以暴露一个称为网络服务的 API。这个 API 正是将我们的网络应用程序转变为实际提供有价值服务的提供者的关键。这意味着我们应该忘记实际的用户体验,在应用程序界面的设计上浅尝辄止吗?显然不是,第七章和第八章在转向客户端脚本和 Ajax 的附加功能之前,回顾了模板背后的理念。最终,第九章确保我们永远不会忘记一个未经测试的应用程序是一个有缺陷的应用程序,而第十章提供了一些在常见环境中部署我们的应用程序的技巧。

我希望这本书能告诉你一个关于网络应用开发的故事,这个故事不仅超越了 CherryPy 本身或任何介绍的产品。这是一个让我们记住没有对错之分,但一些已经探索过的路径可能很好,可以信赖,有时甚至应该进一步推进的故事。

正如我之前所说,我写这本书并不是作为参考书,而是作为一本入门书。完全有可能你认为有一些替代方案或更好的方法来实现所涵盖的一些主题。在这种情况下,我很乐意在 CherryPy 邮件列表上与你讨论这个问题。另一方面,如果你关闭这本书并思考其内容的一部分,那么我就达到了我的目标。

注意

WebFaction 成立于 2003 年,由 CherryPy 的原始创建者创立,是 CherryPy 应用程序的可靠且经济实惠的托管提供商。

当你通过 WebFaction 注册并使用促销代码"CHERRYPYBOOK"时,你可以获得独家 20%的折扣。有关更多详情,请访问www.webfaction.com

posted @ 2025-09-18 12:47  绝不原创的飞龙  阅读(26)  评论(0)    收藏  举报