Python3-Web-开发初学者指南-全-

Python3 Web 开发初学者指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

构建您自己的 Python Web 应用为您提供了无限制地拥有强大功能的机会。然而,使用 Python 创建 Web 应用并不简单。结合学习开发 Web 应用的新技能,您通常还必须学习如何与框架一起工作。

《Python 3 Web 开发入门指南》向您展示如何独立构建自己的 Web 应用,该应用易于使用、性能流畅,且符合您的口味,而无需学习另一个 Web 框架。

Web 开发可能需要时间,并且往往很难正确完成。本书将向您展示如何从头到尾设计和实现一个复杂的程序。每一章都探讨不同类型的 Web 应用,这意味着您将了解广泛的功能以及如何将它们添加到您定制的 Web 应用中。您还将学习如何在 Web 应用中实现 jQuery,以提供额外的功能。通过使用一系列广泛的工具的正确组合,您可以在短时间内拥有一个功能齐全、复杂的 Web 应用。

一本构建和定制您自己的 Python Web 应用的实用指南,无需受预定义框架的限制。

本书涵盖内容

第一章,选择您的工具,探讨了设计 Web 应用的多个方面。目的是为您提供一份概述,这可能有助于您在后续章节中识别组件,并让您对决定使用哪个工具或库的论点有所了解。我们还展示了在设计不直接涉及编码的应用程序时相关的一些问题,例如安全性和可用性。

第二章,创建简单的电子表格,开发了一个简单的电子表格应用程序。电子表格功能将完全使用 JavaScript 和 jQuery UI 实现,但在服务器端,我们将首次遇到应用服务器 CherryPy,并使用 Python 代码对其进行扩展,以动态提供包含电子表格应用的页面。

第三章,任务列表 I:持久性,一个完整的 Web 应用需要服务器上存储信息的功能以及识别不同用户的方式。在本章中,我们将解决这两个问题,同时开发一个简单的应用程序来维护任务列表。

第四章,任务列表 II:数据库和 AJAX,重构了上一章中开发的任务列表应用程序。我们将使用 SQLite 数据库引擎在服务器上存储项目,并使用 jQuery 的 AJAX 功能动态更新 Web 应用的内容。在展示方面,我们将遇到 jQuery UI 的事件系统,并学习如何对鼠标点击做出反应。

第五章, 实体和关系,大多数现实生活中的应用程序都包含多个实体,其中许多实体之间都有关系。在关系数据库中,建模这些关系是它的一个强项。在本章中,我们将开发一个简单的框架来管理这些实体,并使用这个框架来构建一个应用程序,以维护多个用户的书籍列表。

第六章, 构建 Wiki,开发了一个 Wiki 应用程序,在这个过程中,我们关注了构建 Web 应用的两个重要概念。第一个是数据层的设计。Wiki 应用程序相当复杂,在本章中,我们试图了解我们简单框架的局限性在哪里。第二个是输入验证。任何接受来自整个互联网输入的应用程序都应该检查它接收到的数据,在本章中,我们探讨了客户端和服务器端输入验证。

第七章, 重构代码以复用,在做了大量工作之后,退一步批判性地审视自己的工作,看看是否可以做得更好,通常是一个好主意。在本章中,我们探讨了使实体框架更通用的方法,并将其用于第二次实现书籍应用程序。

第八章, 管理客户关系,实体框架和 CherryPy 应用程序代码不仅仅是浏览列表。用户必须能够添加新实例并编辑现有实例。本章是 CRM 应用程序开发的起点,将在后面的章节中扩展和细化。

第九章, 创建全功能 Web 应用:实现实例,专注于用户界面组件的设计和实现,以独立于实体类型的方式添加和维护实体及其关系。这种功能立即在我们的 CRM 应用程序中得到了应用。在探索基于角色的访问控制概念时,管理用户权限是我们遇到的其他问题之一。

第十章, 定制 CRM 应用程序,是最后一章,它通过查看浏览、过滤和排序大量实体来扩展我们的框架和 CRM 应用程序。我们还探讨了允许最终用户定制应用程序外观和功能所需的内容。

附录 A, 资源引用,是 Web 和纸质资源的便捷概述。

您需要这本书的内容

除了需要一个运行 Windows 或 Linux 的计算机来开发和测试你的应用程序外,你还需要以下这些开源软件组件:

  • Python 3.2

  • CherryPy 3.2.0

  • jQuery 1.4.4

  • jQuery UI 1.8.6

如何获取和安装这些包的详细说明在第二章中。我们还使用了一些额外的 jQuery 插件,并在适当的地方提供了安装说明。

你还需要一个网络浏览器来与你的应用程序进行交互。这些应用程序在 Firefox 3 和 Internet Explorer 8 上进行了测试,但任何相对较新的这些浏览器的版本可能也会同样有效,Chrome 也是如此。然而,Firefox 的 Firebug 扩展是一个用于调试 Web 应用程序客户端的更优越的工具,所以如果你还没有尝试过,你可能想试试看。附录 A 提供了必要的资源链接。

最后,你还需要一个文本编辑器,最好是具有 Python、JavaScript 和 HTML 语法高亮功能的编辑器。作者在 Windows 上使用 Notepad++(http://notepad-plus-plus.org/),在 Linux 上使用 JOE(http://joe-editor.sourceforge.net/),但选择完全取决于你。

本书面向对象

对于有一定经验的 Python 程序员来说,他们想学习如何创建相对复杂、数据库驱动、跨浏览器兼容且易于维护且外观良好的 Web 应用程序,会发现这本书非常有用。书中所有应用程序都是用 Python 3 开发的,但具备 Python 2.x 的经验就足以理解所有示例。JavaScript 在许多示例应用程序中扮演着重要的辅助角色,并且对 JavaScript 的一些入门级知识可能有用,但并非绝对必要,因为重点是主要在 Python 开发上,JavaScript 代码要么作为构建块使用,要么被详细解释,以至于任何熟悉 Python 的人应该都能理解它。

习惯用法

在这本书中,你将发现几个经常出现的标题。

为了清楚地说明如何完成一个程序或任务,我们使用:

动作时间标题

  1. 动作 1

  2. 动作 2

  3. 动作 3

指令通常需要一些额外的解释,以便它们有意义,因此它们后面跟着:

刚才发生了什么?

这个标题解释了你刚刚完成的任务或指令的工作原理。

你还会在书中找到一些其他的学习辅助工具,包括:

快速问答标题

这些是简短的多项选择题,旨在帮助你测试自己的理解。

勇敢的尝试者标题

这些设置实际挑战,并为你提供了对所学内容进行实验的想法。

你还会发现许多不同风格的文本,用于区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。

文本中的代码词如下所示:“运行 CherryPy 的setup.py脚本在 Python 的Lib\site-packages目录中安装了多个模块。”

代码块设置如下:

<div id="main">
<ul>
<li>one</li>
<li class="highlight">two</li>
<li>three</li>
</ul>
</div>
<div id="footer">footer text</div>

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

t=t+'<thead class="ui-widget-header">
	<tr class="ui-helper-reset"><th></th>';
		for(i=0;i<opts.cols;i=i+1){ t=t+'<th class="ui-helper-reset">' +
String.fromCharCode(65+i)+"</th>";
	}

任何命令行输入或输出都应如下编写:

 python -c "import cherrypy"

新术语重要词汇将以粗体显示。你在屏幕上看到的,例如在菜单或对话框中的文字,将以如下方式显示:“通过点击添加新按钮,可以添加新书籍或作者。”

注意

警告或重要注意事项将以如下框中的方式显示。

注意

小贴士和技巧看起来像这样。

读者反馈

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

要发送给我们一般反馈,只需发送电子邮件到< feedback@packtpub.com>,并在邮件主题中提及书籍标题。

如果您需要一本书并且希望我们看到它出版,请通过www.packtpub.com上的建议标题表单发送给我们,或者发送电子邮件到< suggest@packtpub.com>

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

客户支持

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

下载本书的示例代码

您可以从您在www.PacktPub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.PacktPub.com/support并注册,以便将文件直接通过电子邮件发送给您。

勘误

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

侵权

在互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过< copyright@packtpub.com>与我们联系,并提供疑似盗版材料的链接。

我们感谢您在保护我们作者以及为我们提供有价值内容方面的帮助。

问题

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

第一章. 选择你的工具

在本章中,我们将探讨设计 Web 应用程序的许多方面。目的是为你提供一个概述,这可能有助于你在后续章节中识别组件,并对你了解用于决定使用哪个工具或库的论据有所启发。

此外,由于本书不仅涵盖开发示例应用程序,我们还展示了在设计不直接涉及编码的应用程序时相关的一些问题,如安全或可用性。

在本章中,我们将:

  • 识别构成 Web 应用程序的组件

  • 选择合适的工具

  • 考虑到为可维护性和可用性设计意味着什么

有很多内容需要覆盖,所以让我们开始吧。

识别 Web 应用程序的组件

一个 Web 应用程序不是一个单一的对象。在设计这样的应用程序时,如果你将应用程序视为一系列相关对象,每个对象都有其明确的目的,这可能会帮助你集中注意力。这可以通过多个级别的细节来完成,甚至从高空俯瞰也可能已经提供了一些有价值的见解。

采取行动,了解 Web 应用程序概述

从以下图片中可以看出,Web 应用程序不是一个单一的事物。它由位于服务器上的部分和运行在用户计算机上的部分组成。这两部分同样重要;尽管服务器可能持有应用程序数据并实现根据用户请求修改数据的逻辑,但数据是由运行在客户端计算机浏览器上的 Web 应用程序部分显示的,用户通过与浏览器中的用户界面组件交互来表示其请求,例如,通过点击“确定”按钮。

采取行动,了解 Web 应用程序概述

  • 思考你的应用程序,并考虑服务器端和客户端。查看单个部分的优势在于我们可能会做出对特定部分最优的选择。

  • 查看客户端的一般要求。例如,因为我们希望为用户提供一个复杂的用户界面,所以我们选择了 jQuery UI 库。这个决定并没有触及到服务器端的整体设计决策,因为除了提供 jQuery UI 库所包含的文件外,用户界面库的选择对数据库引擎或服务器操作系统的选择等没有影响。

  • 查看服务器端的要求。例如,考虑使用哪种实现语言。我们选择 Python 作为实现服务器端代码的语言,但如果我们有充分的理由切换到 C#,我们可以在不改变客户端任何内容的情况下做到这一点。

如果我们进一步观察我们的网络应用,会出现许多相互作用的层,每一层都封装了明确的功能部分。每当两层接触时,信息都会通过一个定义良好的接口(API)流动。这有助于概念上的分离(我们的应用只与数据库层通信以存储和检索持久数据,并且只与网络服务器通信以在请求时返回数据),但在实践中,这些层之间的分离在所有情况下并不总是完全清晰。例如,我们应用的客户端部分实际上是网络服务器的一个组成部分。

这个网络应用的基本架构图几乎与常规的客户端-服务器架构完全相同。然而,当我们更仔细地观察客户端的实现以及客户端与服务器之间的交互时,我们会发现一些差异,正如我们将在下一节中更近距离观察到的。

发生了什么?

在确定了应用的两部分之后,我们现在可以更深入地观察每一部分。

这将使我们能够获得更详细的图像,这将帮助我们就构成我们应用的小组件做出明智的决定。

主要组件很容易识别:

  • 数据存储在服务器上保存数据(通常是一个数据库引擎,有时只是文件系统上的文件)。

  • 服务器端应用处理从网络服务器传递过来的请求。

  • 网络服务器将这些响应再次转发给客户端,并且可能还会提供静态文件。

网络浏览器负责运行应用的客户端部分,但在浏览器内部,我们可以识别出几个活动层。这些包括:

  • 获取内容以结构化数据(通常是 HTML 文件)

  • 运行 JavaScript 代码以增强数据的展示效果

  • 允许与用户交互

发生了什么?

当然,我们可以进一步放大以揭示更多细节,比如客户端和服务器上的操作系统,甚至硬件和网络组件,尽管偶尔有用,但通常会过度。在主要组件明确识别后,我们可以采取下一步,选择合适的工具来实现这些组件。

选择合适的工具

如果你想要开发高质量的应用,你需要合适的工具。当然,工具并不能保证质量,但它们可以使生活变得更加容易。在开发网络应用时,你需要考虑两种类型的工具:用于设计、构建、测试和部署应用的工具,如编辑器、版本管理系统、测试框架,可能还有打包工具,以及将应用交付给最终用户的工具。最后一组工具包括从服务器操作系统、网络服务器、数据库引擎,一直到网络浏览器和用于显示和与应用交互的 JavaScript 库的整个组件链。

当我们开始一个项目时,我们必须知道我们需要哪些工具,并理解这些工具众多变体的功能和限制。例如,有相当多的 JavaScript 库可以用来提供跨浏览器的用户交互兼容性。

关键在于做出明智的选择。这些选择不一定局限于开源工具。如果预算允许,利用许多商业开发工具和库提供的特殊功能可能是有益的,但在这本书中,我们限制自己使用开源和/或免费资源。这在小型项目中是有意义的,因为工具和许可证的成本可能会在预算中造成重大缺口。

在部署环境中,可能不存在使用免费工具的机会。你很可能在自己的 Linux 服务器上开发应用程序,但测试和部署它却在 Windows 服务器上。后者需要许可证,而这不会是免费的,即使是开源选项也不总是免费的。如今,许多公司转向将他们的应用程序部署到云端,尽管这些机器可能运行的是开源操作系统,但你不仅需要为 CPU 功率和带宽付费,还需要为支持付费,这在应用程序运行不正常时会损失金钱。然而,使用开源工具通常能给你提供更广泛的选择,因为许多工具在任何平台上都能运行得同样好。

在接下来的章节中,我们将探讨构成工具链的许多组件,并尝试展示在本书开发应用程序时使用的论据,以及(如果有的话)有哪些可行的替代方案。请注意,有些论据相当主观,最终做出的选择并不一定意味着替代方案不好;我们当然不是试图就哪个工具更好而引发争论。我们只是按照我们的看法列出应用程序开发的要求,并尝试找到适合任务的工具。在某些情况下,另一个工具可能更好,但为了这本书,我们试图找到可以用于所有示例应用程序的匹配的工具集,这些工具是免费的(就像啤酒一样),并且易于学习和使用。

选择交付框架的时间,也称为 Web 服务器

在本章的第一节中,我们展示了 Web 应用程序同时存在于两个领域,即在服务器和客户端。为了向客户端提供信息并接收相应的响应,我们的 Web 应用程序需要在服务器上具备两个重要元素:一个交付框架和一个用于组合内容并响应请求的应用程序。

交付框架可能是一个功能齐全的通用 Web 服务器,如 Apache 或 Microsoft Information Server,尽管这些服务器非常灵活,提供了许多选项来调整 Web 服务器以满足您的特定需求,但它们确实需要相当长的时间来熟悉,并且需要额外的关注来将这些服务器与您应用程序的动态内容集成。如果性能至关重要或您的项目要求您的应用程序必须作为这些服务器的一部分部署,您可能别无选择,但否则,值得考虑使用更简单或提供集成优势的替代方案。

那么,我们需要什么?

  • 一个相当轻量级的 Web 服务器,易于配置和维护

  • 这使得静态和动态内容的平滑集成成为可能

  • 它附带可重用组件,简化了开发过程

  • 它是积极维护和开发的

考虑到这些要求,我们选择的交付框架是 CherryPy。

刚才发生了什么?

CherryPy 非常适合。其主要优势包括:

  • CherryPy 是用 Python 编写的,提供动态内容的组件作为与 CherryPy 核心紧密集成的 Python 类编写。

  • CherryPy 附带了一系列工具;可重用的组件,可用于实现从自定义错误页面到会话管理的一切。

  • CherryPy 作为更大 TurboGears 网络的核心 Web 服务器,有着可靠的记录。

  • 最后,CherryPy 正在积极开发中,并拥有庞大的用户群体。

使用 Python 编写的缺点是性能可能不是最佳,但我们在下一节中会探讨这个问题。

选择服务器端脚本语言的时间到了

在开发 Web 应用程序时,您几乎可以选择使用任何编程语言,因此我们必须考虑我们项目中的重要因素,并在必要时做出权衡。

  • 考虑到开发时间与性能相比的重要性。如果 CPU 资源稀缺或您不想以可读的格式分发源代码,可以使用 C#或 C++等编译型语言。但当开发时间非常宝贵时,使用脚本语言通常可以节省时间,因为它们使得以增量方式开发应用程序变得更容易,甚至可以交互式地输入命令以查看可能的结果,并将这些试验后来整合到代码中。

    性能通常不是问题,尤其是在使用编译成中间字节码的脚本语言时,例如 Python 和 Perl 等语言。虽然脚本语言每次运行时都会被编译,但当程序是一个长期运行的 Web 应用时,这种影响微乎其微。

  • 考虑调试的重要性。解释型语言通常比编译型语言更容易调试,因为调试器可以访问更多可能交互式探索的信息,如果出现问题,并且你可以通过交互式调用函数来尝试你编写的任何模块,以查看会发生什么。

  • 超越项目本身。一旦实施并部署,你的应用程序可能会拥有漫长而快乐的生命,但这不可避免地意味着会有对较小或较大更改的请求,选择合适的语言可以帮助减少维护工作量。与通常具有相当低级指令和语言结构的编译型语言相比,解释型语言具有(非常)高级的结构,这使得代码紧凑,能在少量语句中包含大量意义。这不仅更容易阅读,而且也更快地被解释,最终这些高级结构一旦被解释,将以(几乎)编译的速度运行,使得性能差异有时难以察觉。更多的意义和更少的代码确实使得阅读更容易,这在维护代码时是一个巨大的好处。

最后,选择用于实现 Web 应用程序的语言至少部分是一个口味问题,但在这本书中,我们选择 Python,因为它在考虑不同因素之间提供了最佳权衡。

刚才发生了什么?

现在我们已经选择了 Python 作为我们的服务器端脚本语言,让我们好好看看这些论点:

  • Python 易于阅读,因此易于学习和维护。尽管 Python 在许多地方将空白视为有意义的,这在编程语言中相对独特,但这确实大大增强了可读性。

  • Python 是一种非常高级的语言,包含了诸如列表推导和函数式编程等概念。这允许编写紧凑的程序,在少量代码中包含大量功能,增强了可读性并减少了维护工作量。

  • Python“自带电池”。Python 附带大量精心设计和维护良好的库(模块),提供从访问.csv文件和解析 XML 到使用少量代码构建 HTTP 服务器的功能,这些模块至少与语言本身一样有良好的文档。这意味着我们可以减少开发时间,因为在许多情况下,我们不必自己重新发明轮子。

  • Python 有许多第三方模块。即使某个模块没有包含在发行版中,也有可能有人已经编写了你正在寻找的模块。

  • Python 是一种面向对象的语言。这通常被认为是一件好事,因为它有助于数据抽象,但它对开发数据库驱动应用程序的人们的吸引力主要在于它允许以自然的方式将表映射到类型(类)。汽车表中的记录可以映射到‘Car’类,然后可以像处理字符串或列表等本地类一样操作这个类的实例。这又使得阅读代码和因此维护代码变得更加容易。

  • Python 可在许多云平台上使用。要在服务器上运行 Python 程序,你需要在那个服务器上部署 Python。如果你有完全的访问权限,这可能不是问题,实际上,托管公司提供(虚拟)机器,这些机器已经安装了 Python,但对于像 Google Gears 这样的非常轻量级的云平台,你的可用语言选择可能有限。然而,Python(与 Java 一起)在 Google Gears 中得到完全支持,尽管这并不是本书中示例应用程序的考虑因素,但它可能对你的应用程序是。

本书使用的 Python 版本是 3 版(写作时为 3.2 版)。尽管并非所有第三方模块都已(尚未)移植到这个新版本,但如果你想要以未来证明的方式开发,这是最好的版本。

注意

目前 Python 的多线程能力并不允许最优地使用多核处理器。大多数 Python 的实现并不允许真正并行地运行独立的线程。然而,这远没有你想象的那么糟糕,因为这个限制主要适用于解释型 Python 代码,并不一定适用于在例如操作系统内核中运行的代码。而且,由于在 Web 服务器中,大量时间被花费在等待网络上的数据包发送或接收,这通常不会影响你的 Python 代码的性能。在将来,Python 的多线程实现可能会改变,但这是一个激烈争论的话题。更多关于这个话题的信息可以通过搜索“Python 3 GIL”来找到。

选择数据库引擎的时间

任何 Web 应用程序的关键要求之一是它必须能够访问某种形式的持久存储。这可能用于存储核心数据,如汽车零部件目录,但密码文件也需要某种形式的持久存储。

通常,在文件系统中存储信息是可能的,实际上,我们在这本书中开发的一些应用程序就是这样做的,但如果你有大量的结构化数据或者你发现很多人同时想要访问这些数据,通常将数据存储在数据库中并通过数据库引擎访问这些数据是一个更好的选择。

当选择数据库引擎时,你应该考虑以下因素:

  • 它是否提供了您需要的功能?数据库引擎是复杂的软件组件,通常提供很多功能,通常比您需要的还要多。虽然这听起来可能是一个优点,但所有这些功能都必须由开发者学习才能利用它们,并且可能会使您的代码复杂化,从而增加维护应用程序的努力。

  • 它是否易于安装和维护?数据库引擎通常作为独立的应用程序运行,通过网络进行访问。这意味着它们必须单独安装、测试和维护。这可能会显著增加部署应用程序所需的努力。而且,安装甚至不是全部;您还必须考虑运营问题,例如,设置合适的备份方案需要多少努力,或者如何监控数据库的可用性。

  • 它是否提供了一个易于从您选择的编程语言使用的 API,并且这个 API 是否提供了访问所有必要功能的能力?

  • 最后,它是否足够高效,能够迅速响应当前应用程序的需求,即使在高峰期也是如此?

Python 提供了一个标准化的 API 来访问许多可用的数据库引擎,包括 MySQL 和 PostgreSQL。完全符合其“内置电池”的哲学,Python 还内置了一个数据库引擎和一个模块来访问它。这个数据库被称为 SQLite,是一种所谓的嵌入式数据库:它不是作为一个可以通过某种进程间通信方式访问的独立进程运行,而是作为使用它的程序的组成部分。它的唯一外部部分是包含数据库本身数据的单个文件,并且可能被包含 SQLite 引擎的其他程序共享。由于它符合我们的要求,SQLite 将成为我们在本书中开发的应用程序所使用的数据库引擎。

刚才发生了什么?

我们选择 SQLite 作为许多应用程序的数据库是很容易得到解释的:

  • 虽然它不像 MySQL 那样功能丰富,但它确实提供了我们需要的功能。

  • 由于 SQLite 随 Python 一起提供,安装实际上是一个不费脑力的过程。

  • sqlite3模块提供的 API 可以访问所有功能。

  • 它的性能足以满足我们的需求(尽管在事先很难做出关于性能的声明)。

支持在我们的应用程序中使用 SQLite 的主要论据不是它的速度、小内存占用或可靠性(尽管这些当然不是缺点,因为 SQLite 作为移动电话设备的数据库引擎的选择证明了这一点),而是因为它嵌入到你的程序中,从而消除了需要单独配置和维护数据库引擎的需求。这大大减少了维护工作,因为数据库引擎是需求苛刻的生物,需要大量的照顾和喂养。此外,因为它包含在 Python 中,所以在部署应用程序时减少了外部依赖的数量。

最后一个论据是它的类型系统与 Python 的类型系统非常相似;与许多其他数据库引擎相比,SQLite 允许你在列中存储任何值,无论在创建该列时如何对其进行类型化,就像你可以在最初用于存储整数值的 Python 变量中存储字符串一样。这种类型之间的紧密对应关系使得将 Python 值直观地映射到数据库中存储的值成为可能,这是我们将在遇到第一个使用 SQLite 的应用程序时密切研究的优势。

小贴士

Python 的集成非常紧密,以至于可以在用于查询 SQLite 的 SQL 表达式中使用 Python 函数。与其它数据库引擎相比,SQLite 的原生函数集相当小,但使用 Python 函数的能力完全消除了这一限制。例如,添加来自 Python 的 hashlib 模块的哈希函数非常简单,这在实现密码数据库时非常方便。

行动时间:决定对象关系映射器

关系型数据库引擎,如 SQLite,使用由行和列组成的表作为它们的主要数据抽象模型。面向对象的语言,如 Python,定义类以实例化具有属性的对象。这些概念之间存在相当多的对应关系,因为类定义模仿表定义,其中具有属性的实例对象与具有列的记录相关联,但维护这种关系的完整性并不那么简单。

问题不仅在于定义表和类的不同语言。关系型数据库中的主要问题是维护引用完整性。例如,如果你有一个表示汽车零件的记录,它引用了另一个表示汽车类型的记录,那么关系型数据库允许你定义在例如汽车类型记录被删除时执行显式操作的明确操作。当然,这些约束也可以在 Python 数据结构中实现,但这需要付出相当大的努力。

最后,大多数数据库引擎要求每个列都有固定的数据类型,而 Python 变量和属性可以引用任何类型的数据。这种限制在 SQLite 中不存在,但即使是 SQLite 也无法在不进行转换的情况下存储一切。例如,一个 Python 变量可能引用一个对象列表,这是无法存储在关系数据库的单个列中的。

尽管如此,我们非常希望有一种方法可以存储对象实例在关系数据库中,或者至少存储这些对象实例中的数据,并且有定义类与表之间关系的方式,以便进行维护。为此,许多人已经设计了以对象关系映射器形式存在的解决方案。对于 Python 来说,存在许多既成熟又健壮的工具(如 SQLAlchemy)。

在决定使用哪个工具时,你应该至少考虑以下因素:

  • 学习使用它将花费多少时间。这些工具通常非常灵活,而且通常需要相当多的努力来学习。

  • 它将如何影响开发和维护?复杂的工具可能有助于解决创建一个有效的和高效的类与表之间的映射的挑战,但可能需要一种会削弱你对实现清晰概述的成语。如果你的数据模型由许多类组成,并且性能是一个重要的考虑因素,那么这可能是值得的,但对于较小的项目来说,增加的复杂性可能会在显著影响开发时间时成为一个很大的缺点。

因为这本书的重点在于理解实现 Web 应用程序和持久存储的选择,使用像对象关系映射器这样的复杂工具可能会隐藏所有必要的方面,这些方面对于获得理解是必要的。

因此,我们不会在本书的示例中使用第三方对象关系映射器,而是在每一章中实现越来越通用的存储解决方案,在遇到具体要求时解决它们。我们将看到,在许多情况下,对象关系映射器是多余的,但在最后几章中,我们将自己构建一个简单的框架,不仅提供一个工具,而且还能深入了解将复杂的类集合映射到数据库表中的复杂性。

选择演示框架的行动时间

Web 应用程序可能完全关于在 Web 浏览器中访问和操作数据,但应用程序的外观和感觉对用户来说同样重要。一个非直观、反应迟缓或在某些主流浏览器上无法工作的用户界面不会鼓励用户再次使用你的应用程序。

HTML,这种常用于显示内容的标记语言,确实允许通过使用<form>元素以及使用层叠样式表来呈现页面样式,但它的使用有一些主要的缺点:

  • 从基本构建块创建类似于常用应用程序的用户界面组件相当困难。

  • 使用 HTML 感觉迟缓,因为每个表单在提交时都会获取一个全新的页面。

幸运的是,所有主流浏览器都支持 JavaScript,并且可以使用该语言添加全新的交互级别。然而,为了消除浏览器之间所有不一致性,当您使用一个处理这些不一致性并添加跨浏览器兼容的用户界面组件(控件)的 JavaScript 库时,您可以节省大量的开发时间。

虽然这些库在客户端使用,但 HTML 页面可以以这种方式组成,指示浏览器从中央源获取这些库,例如,为 Web 应用程序提供服务的同一服务器。这样,使用这些库对浏览器没有额外的要求。

选择合适的库时需要考虑的一些要点是:

  • 它真的具有跨浏览器兼容性吗?并非所有库都支持每个浏览器。如果您的应用程序仍然需要与相当旧的浏览器一起工作,这可能会很重要。

  • 它是否提供了您需要的图形组件和功能?

  • 它是否设计良好、文档齐全、可扩展且实施一致?毕竟,这样的库应该相对容易学习,而且没有任何库可以提供一切,可扩展性以及扩展它的难易程度都是重要的考虑因素。

  • 它是否拥有活跃的用户社区?在这里这一点尤为重要,因为这样的社区不仅可能回答您的问题,还可能是一个可重用组件的良好来源。

基于这些考虑,我们选择使用两个紧密相连的 JavaScript 库:jQuery 和 jQuery UI。

刚才发生了什么?

让我们来看看为什么 jQuery 和 jQuery UI 是如此好的选择。

jQuery 提供了在页面上选择和操作 HTML 元素的功能,而 jQuery UI 提供了一系列复杂的控件和效果。共同使用,它们提供了许多优势:

  • jQuery 不仅隐藏了浏览器的不一致性,而且其方法在即使不支持 CSS3 样式的浏览器上也使用 CSS3 兼容的选择器。

  • 这两个库都得到了广泛的使用,积极维护,免费,并且以小型文件的形式分发。后者很重要,因为您需要考虑这些文件需要从服务器传输到客户端,所以任何节省的带宽都是好的。

  • jQuery UI 提供了一套设计精良、外观专业的图形组件和效果。

这些库广泛采用的其他优点包括,有许多资源可以帮助你入门,以及许多人编写了插件来进一步扩展这些库的可用性。正如我们将在许多场合看到的那样,高效开发良好应用程序的本质通常就是选择适合这项工作的正确插件。

设计以维护性和可用性为导向

想出如何实现某些 Web 应用程序的伟大想法是一回事,但以使其易于维护和使用的方式设计应用程序则是另一回事。考虑到这些因素进行设计,将使专业应用程序和一般应用程序之间产生巨大的差异。

测试

每个人都会同意在部署应用程序之前对其进行测试是有意义的,但彻底的测试需要一些严肃的努力。测试通常被认为很无聊,甚至可能分散对“真正”开发工作的注意力,这与编写文档有着相似的气息。

然而,测试能让你更好地了解你交付的应用程序的质量,而无论测试框架多么简单,总比没有要好,尤其是在我们这本书中探讨的小到中等规模的 Web 应用程序中,因为这些应用程序往往由非常小的团队快速原型设计,并且随着洞察力的进步和客户需求的变化,经常更改代码。拥有一个测试套件可以确保至少代码中不改变的部分能够按预期执行。

当然,不是所有东西都可以测试,测试你代码一部分所需的工具应该易于使用,否则就没有继续使用它们的动力。我们将查看我们开发的 Python 模块中的单元测试。单元测试是一种尝试定义独立代码片段(例如,单个方法)的行为,并检查此代码是否产生预期结果的方法。如果代码的实现发生变化,但测试仍然没有显示失败,我们知道新的实现可以安全使用。

选择测试框架的行动时间

在选择测试框架时,请自问以下问题:

  • 我想测试什么?你不能测试一切,开发测试需要时间。

  • 编写和维护测试有多容易?这个问题对于开发测试和一般代码开发同样相关。

  • 执行测试需要多少努力?如果测试容易自动化,它们可以作为额外的检查作为部署的一部分运行。

仅就 Python 而言,就有相当多的测试框架可用,但我们将选择与 Python 一起分发的unittest模块。请注意,尽管我们选择只为应用程序的 Python 部分编写自动化测试,但这并不意味着我们没有测试 JavaScript 部分,但用户交互往往不太适合自动化测试,所以我们在这本书中不涉及这一点。

刚才发生了什么?

对于 Python 单元测试,我们限制自己使用与 Python 一起分发的unittest模块,因为这不会引入任何对外部工具的新依赖,而且也因为:

  • 它的学习和使用相对简单。

  • 如果测试失败,它会产生清晰的错误信息。

  • 它易于自动化,并且可以轻松集成,例如,与设置脚本集成。

版本管理

版本管理工具通常不是网络应用程序的一部分,也不是开发网络应用程序的严格要求。然而,当你想要跟踪代码的变化,尤其是当文件数量持续增长时,版本管理工具是无价的。

大多数版本控制工具都集成了显示版本差异的功能,并且所有工具都有注释版本或修订的功能,以便清晰地标记它们。广泛使用的开源解决方案包括 gitsvn

它们都可以作为服务器运行,并通过网页浏览器访问,但同时也提供了命令行工具,而 svn 甚至还在 Windows 文件资源管理器中提供了非常用户友好的集成。它们各有优缺点,很难明确地说出哪个是赢家。这本书及其伴随的示例都是使用 svn 维护的,主要是因为 Windows 客户端的使用简便性。

可用性

网络应用程序是为最终用户构建的,而不是为开发者。设计一个易于使用的界面并不总是容易。实际上,设计真正优秀的界面是困难的,需要相当多的技能和知识。然而,这并不意味着没有一些可以帮你避免可用性灾难的经验法则。我们将在以下章节中探讨一些这些法则。

符合常见 GUI 范式的美观设计

如果界面组件已经熟悉,应用程序的使用就会更加容易。因此,通常查看那些成功并被许多人使用的应用程序是个好主意。

在许多应用程序中,一个常见的担忧是需要在小空间内展示大量信息。因此,许多现代应用程序使用手风琴菜单和/或标签页界面来组织数据,如下面的截图所示:

符合常见 GUI 范式的美观设计

手风琴菜单非常适合在侧边栏中显示大量信息,但还可以通过标签页展示更多信息:

符合常见 GUI 范式的美观设计

在常见的办公生产力软件、网络浏览器和 CRM 应用程序的最新版本中都可以找到这些例子。仔细查看你喜欢的应用程序可能是一个好的开始。在这本书中开发的大型应用程序中,我们肯定会参考一些可能作为灵感的键应用程序。

可定制的

选择一个一致且令人愉悦的色彩方案和字体可以使应用程序更加协调,因此使用起来更加愉悦。信息过载可能会让人感到困惑,而使用混乱的色彩方案或多种不同的字体也不会有助于对展示的数据有一个全面的了解。

但是,无论你的用户界面是否支持易于更改的主题概念,这在其他领域也起着重要作用。你可能希望你的 Web 应用程序能够很好地融入你的网站的其他部分,或者传达某种公司或品牌身份。使用一致的色彩方案会有所帮助。甚至可能希望向最终用户提供主题选择,例如,为视力受损的人提供高对比度主题以改善可读性。该库完全支持主题的使用,并使得将这种可定制性扩展到我们自行设计的 widget 变得简单。

跨浏览器兼容

Web 应用程序通常针对特定的受众,因此,可能只有单一浏览器被指定为需求,但通常,我们不希望拒绝用户使用他们/她们的 favorite 浏览器。jQuery 消除了支持多个浏览器的大部分痛苦。我们的应用程序是为 Internet Explorer 8、Firefox 3.x 和 Google Chrome 设计的,但可能也会在大多数其他浏览器上运行。请注意,“可能”可能不够好,并且始终在所需平台上具体测试你的应用程序是一个好主意!

跨平台兼容

在客户端,Web 浏览器是我们需要关注的链中的关键组件,因此,它所运行的操作系统很可能不会成为问题来源。

在服务器端,我们也希望保持我们的选择开放。幸运的是,Python 是一个跨平台解决方案,因此,任何在 Windows 上运行的 Python 程序通常也可以在 GNU/Linux 上运行,反之亦然。

然而,当我们使用不随 Python 一起分发且不是纯 Python 的模块时,我们应该小心。这些模块可能在每个平台上都可用,但最好事先进行检查。本书中的应用程序仅使用标准 Python 分发中的模块,除了 CherryPy,它是一个纯 Python 模块,应该能在每个平台上运行。

可维护性

编写代码是一项艰苦的工作,维护它可能更加困难。我们之前在讨论测试框架的使用时简要提到了这个问题,但维护代码不仅仅是能够测试它。

遵循标准

创建易于维护的代码的一个重要概念是遵循标准。遵循标准意味着其他人有更大的机会理解你的代码。

例如,SQL 是一种大多数数据库引擎都理解的查询语言。因此,对于维护代码的人来说,我们使用哪种引擎不太相关,因为他们不必学习一种晦涩的查询语言。

另一个例子是客户端和服务器之间的通信。我们可以自己设计协议,在 JavaScript 中构建请求,并在 Python 中响应这些请求,但使用像 AJAX 这样的文档化标准来通信以及 JSON 来编码数据要少出错得多。这也节省了文档,因为人们可以参考任何数量的书籍,如果他们想了解更多关于这些标准的信息。

注意

标准并不一定意味着“由某个独立组织批准”。许多标准是非正式的,但它们因为每个人都使用并撰写它们而工作。AJAX 和 JSON 就是这样的例子。此外,Python 编程语言是一个事实上的标准,而 JavaScript 享有正式标准(这并不意味着所有实现都遵循标准)。

安全性

安全性通常被认为是一个晦涩或神秘的主题,但安全性涵盖了众多实际的问题,这些问题甚至在最小的 Web 应用中也发挥着作用。我们不想任何人访问付费的 Web 应用,例如。然而,安全性不仅仅是访问控制,我们将在下一节简要介绍一些安全性的方面。

可靠的

Web 应用在使用上应该是可靠的。没有什么比在填写抵押贷款申请过程中遇到服务器端错误更令人烦恼的了。作为开发人员和测试人员,你应该彻底测试软件,希望捕捉到任何错误,但在实施应用之前,应该考虑软件及其使用的库的可靠性。

你应该特别小心在生产软件中使用某个库的最新和最酷的功能。当快速制作一些原型或概念应用时,这可能会很有趣,但请问问自己,你的客户是否真的需要这个前沿功能,他们是否不如使用经过验证和测试的版本更好。

许多开源项目(包括 Python)开发和维护所谓的稳定分支和开发分支,以展示新功能。你应该在生产应用中使用前者,而后者应该在别处尝试。

强健的

应用程序不仅应该尽可能无错误,而且在压力下也应该表现良好。在负载下,性能应该尽可能高,但同样重要的是,你应该知道当负载达到某个阈值时,你期待的是什么。

不幸的是,性能调优是想象中最棘手的工作之一,因为链中的所有组件都可能发挥作用。服务器端考虑的是使用的数据库引擎的性能、脚本语言和 Web 服务器。

在客户端,展示框架的质量和 Web 浏览器的整体性能很重要,而在服务器和客户端之间是底层网络特性的未知领域。

由于有这么多变量,提前设计一个最优解并不容易。然而,我们可以测试单个组件的性能,看看该组件是否是瓶颈。例如,如果刷新一个由 Web 应用提供的页面需要三秒钟,如果你可以独立计时数据库访问,那么你可以排除数据库引擎作为瓶颈。在创建单元测试时获得的知识可以在这里重用,因为我们已经知道如何隔离某些功能,添加一个计时器并断言查询的响应足够快可以成为一个测试本身。

使用像 Firebug 这样的工具单独测量获取 Web 组件所需的时间和在浏览器中渲染它的时间是完全可行的,并可以了解客户端或服务器是否是瓶颈。(Firebug 是 Firefox 的一个扩展,可以在getfirebug.com/找到)。

访问控制和身份验证

在本书中我们开发的几乎每个应用中,我们都实现了一些形式的身份验证方案。大多数时候,我们将使用简单的用户名/密码组合来验证用户是否是他/她声称的人。一旦用户经过验证,我们就可以决定只提供某些信息,例如,只提供他/她所属的任务列表,而不提供任何其他用户的任务。

然而,是否允许访问信息并不总是那么基本。即使在简单的应用中,也可能存在一些用户应该比其他人有更多的权限,例如添加新用户或重置密码。如果用户被允许执行的不同事情的数量很少,这很容易实现,但如果情况更复杂,那么实现起来就不那么容易,更不用说维护了。

因此,在本书的后续章节中介绍的更复杂的应用中,我们将采用基于角色的访问控制的概念。其思想是定义角色,描述在承担某个角色时允许执行哪些操作。例如,在客户关系管理应用中,可能有三个角色:销售人员,他只能访问其客户的资料;销售经理,可以访问所有信息;管理员,可能无法访问任何信息,但允许备份和恢复信息,例如。

一旦这些角色的权限明确,我们就可以将这些角色中的任何一个或所有与特定人员关联。例如,一个小的组织可能有一个技术熟练的销售人员,他也可以承担管理员角色,但仍然无法以这种方式访问除他自己的客户以外的任何客户信息。

如果与某个角色关联的权限发生变化,我们不必为可能承担该角色的每个人重复此信息,从而使管理变得更加简单。

保密性

在某些应用程序中,我们可能想确保没有人正在监听浏览器和 Web 服务器之间传输的数据。毕竟,通常你不知道你的数据走的是哪条路,因为它通过互联网路由,在任何时候都可能有人可以拦截你的数据。

确保机密性的最简单方法是使用连接级加密,HTTPS 协议正是这样做的。我们使用的 Web 服务器 CherryPy 当然能够通过 HTTPS 提供服务,配置它这样做相当简单,但它涉及到创建签名证书,这超出了这本书的范围。有关更多信息,请参阅www.cherrypy.org/wiki/ServerObject

完整性

在这个背景下,我们讨论的最后一个安全方面是数据完整性。数据的损坏可能并不总是可以防止的,但通过适当的备份和恢复协议,可以防止大规模的破坏。

然而,数据损坏也潜伏在非常小的角落里。最棘手的事情之一是插入错误数据的可能性。例如,如果可以输入月份范围在 1-12 之外的日期,如果应用程序在其他地方依赖于日期具有正确的格式,那么可能会发生非常奇怪的事情。

因此,通过在客户端构建某种形式的验证来防止用户输入错误数据是很重要的。一个很好的例子是 jQuery UI 的datepicker小部件,我们将在第三章中遇到,任务列表 I:持久性。如果一个文本输入字段被datepicker装饰,用户只能通过选择datepicker中的日期来输入日期。这对最终用户来说是一个很大的帮助,但我们永远不应该依赖于客户端验证,因为我们的客户端验证可能不足(因为它包含错误或没有检查所有情况),而且绝对不能防止恶意用户连接到服务器并主动插入错误数据。我们确实需要服务器端输入验证来防止这种情况,我们将在其中遇到一些例子。

关键在于提供两个方面:作为最后手段的服务器端验证和作为用户辅助的客户端验证。

关于安全的最后一点

安全性是复杂且棘手的,细节很容易被忽视。你可能知道你有一扇由 10 厘米橡木制成的门,配有最先进的钢锁,但如果你忘记锁上后门,所有这些橡木和钢都没有作用。在这本书提到的所有主题中,安全是你应该总是与专家讨论的一个主题。即使是专家也不能给你保证,但重新审视安全需求可能会让你避免麻烦。确保你在安全的环境中运行这本书提供的示例应用程序,并有一个管理良好的防火墙。

帮助,我感到困惑!

阅读本章后,你可能会有这样的感觉:即使使用正确的工具,开发 Web 应用也是极其复杂的!可能有那么多因素在起作用!不过,不要气馁。

行动时间:保持概览

如果你仔细观察,你会发现这并不是什么火箭科学,最多只需要常识和对细节的关注,在每一章中,我们都会用直接的语言突出相关的问题,当它相关时。记住,这是一本实用的书,会有许多详细检查的工作示例,我们不会让你淹没在理论中,而是只给你足够的信息来完成工作。

在开发过程的每一步,都要问自己以下问题?

  • 需要做什么?没有必要同时处理所有事情,实际上这是不可能的。首先形成一个高级的想法,然后在下一级识别组件。当大纲还不清晰时,不要被细节所困扰。

  • 应用程序涉及哪些组件?在开发某个功能时,确定涉及的具体组件。识别层和组件的整体想法是为了在开发时能够专注于应用程序的有限部分。

    这可能并不总是完美无缺,但它确实有助于保持专注。例如,当开发表示层的一部分时,你可能会发现需要额外的内容,这些内容应由交付层提供。与其立即将注意力转向那个交付层,通常更简单的是定义所需的内容,并完成你正在工作的表示层部分。

  • 需求是什么?没有必要实现不需要的功能。这听起来可能很显然,但许多开发者仍然会陷入这个陷阱。当然,设计尽可能灵活的代码很有吸引力,但这需要花费大量时间,而且随着需求的变化,它可能不足以证明足够灵活。相反,更好的做法是编写易于理解的代码,这样在需求不可避免地发生变化时,处理这些变化所需的时间会更少。

刚才发生了什么?

当提出这些问题并考虑到本章中我们做出的选择时,绘制一幅新图来展示我们将使用的技术可能会有所帮助:

刚才发生了什么?

构成 Web 应用的服务器和客户端的不同组件可以想象成一个分层堆栈。对于每一层,我们都选择了一种或几种技术,如下面的图所示:

刚才发生了什么?

我们遇到的每个应用都将基于这个模型,所以如果你觉得你失去了方向,偶尔参考这个图可能会有所帮助。

在阅读这本书之后,你可能会感到编写好的、可用的网络应用程序可能比你最初想象的要复杂一些,但这对即使是规模最小的团队来说也是可以达到的。凭借所有的新知识和实践经验,你甚至不需要在最小的项目中妥协质量。

摘要

本章为我们提供了对创建高质量网络应用程序所涉及的组件和技术的概述的先导。具体来说,我们探讨了:

  • 构成网络应用程序的组件。

  • 我们选择实现这些组件的技术。

  • 哪些其他问题在设计过程中发挥作用,比如安全和可用性。

带着这些额外的知识,没有什么可以阻止我们用 Python 编写我们的第一个网络应用程序,这正是我们在下一章将要做的。

第二章:创建简单的电子表格

在本章中,我们将开发一个简单的电子表格应用程序。电子表格功能将完全使用 JavaScript 和 jQuery UI 实现,但我们将配置 CherryPy 动态地交付包含电子表格应用程序的页面。

在演示方面,我们将遇到我们的第一个 jQuery UI 小部件(按钮),并了解如何设计其他元素以符合 jQuery UI 标准,使其无缝地融入 jQuery UI 的主题框架。我们还将了解如何查找和使用公开可用的 jQuery 插件,并将 jEditable 插件集成到我们的应用程序中。

这一次要掌握的内容很多,但如果第一次没有完全清楚,请不要担心。我们将在其他章节中遇到这里首次遇到的问题的许多变体,并将再次在它们的上下文中解释所有相关细节。

在本章中,我们将:

  • 创建一个用于开发和交付我们应用程序的环境

  • 设计一个简单的电子表格应用程序

  • 学习如何配置 CherryPy 来交付此应用程序

  • 遇到我们的第一个 jQuery UI 小部件

  • 设计我们自己的 jQuery 插件

有很多内容需要覆盖,所以让我们开始吧...

Python 3

Python 3 是我们将用来开发应用程序服务器端部分的编程语言。在撰写本文时,当前稳定版本是 3.2。安装程序和源存档适用于各种平台(包括 Windows、Mac OS 和 Linux 发行版),可以从 www.python.org/download/ 下载。

安装 Python 3 的时间到了

下载和安装 Python 并不困难。许多平台的安装程序可以从 www.python.org/download/ 下载。

  1. 下载适用于您平台的安装程序,并按照安装说明进行操作。

  2. 通过在命令行(例如,在 Windows 命令提示符或 Linux xterm 内)输入以下命令来验证你是否正确安装了 Python:

 >python -version

  • 响应将是版本:
 Python 3.2

刚才发生了什么?

在类 UNIX 系统(如 Ubuntu Linux 或 Mac OS)上,Python 可能已经安装好了,所以首先尝试步骤 2 中的说明来验证一下是个好主意。如果返回的版本低于 3,你应该更新你的 Python 发行版。请注意,在安装 2.x 版本的 Python 旁边安装 3.x 版本是完全可能的,这样就不会破坏依赖于 2.x 版本的程序(Python 3 与 2 版本不向后兼容)。

CherryPy

在 Python 中编写 HTTP 服务器并不困难,但编写和维护一个健壮且功能齐全的可以作为应用程序服务器的网络服务器则相当不同。正如我们在第一章中解释的,选择你的工具,我们将使用 CherryPy 作为我们的应用程序服务器。在撰写本文时,CherryPy 为 Python 3 的最新稳定版本是 3.2.0,可以从download.cherrypy.org/cherrypy/3.2.0/下载。

注意

Windows 用户应使用 zip 存档,并在继续下一节的说明之前将其解压。在指定位置还有一个msi安装程序可用,但这个安装程序可能无法在 Windows 注册表中找到正确的 Python 安装,并且只能在 32 位 Windows 版本上工作。因此,解压 zip 存档并遵循下一节的设置说明是一个更安全的做法,并且在 Windows 和类 Unix 平台上都是相同的。

安装 CherryPy 的行动时间

当你安装 CherryPy 时需要小心的一点是,如果你系统中有多个 Python 版本,你必须确保将其安装到正确的目录中。CherryPy 使用一个设置脚本来自动安装,确保 CherryPy 模块最终位于正确位置的一种方法是通过使用完整路径显式调用 Python,例如:

 cd C:\CherryPy-3.2.0rc1
c:\Python32\python.exe setup.py install

刚才发生了什么?

运行 CherryPy 的setup.py脚本会在 Python 的Lib\site-packages目录中安装多个模块。你可以通过在命令行中输入以下内容来验证这是否成功:

 python -c "import cherrypy"

这检查我们是否可以导入cherrypy模块。如果一切安装正确,此命令将不会产生任何输出。然而,如果 CherryPy 没有安装,可能会通过错误信息来表示:

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

当你安装了多个 Python 版本时,请注意输入 Python 可执行文件的完整路径以选择正确的版本,例如:

 C:\python32\python c "import cherrypy"

安装 jQuery 和 jQuery UI

在本章和下一章中我们将设计和实现的应用程序高度依赖于 jQuery 和 jQuery UI 库。这些库主要由 JavaScript 文件和一些级联样式表以及用于美化小部件的图像组成。

这些文件作为应用程序的一部分提供给网页浏览器,通常,它们可能从以下两个位置提供:

  1. 在服务器上运行 CherryPy 的(子)目录,以及构成我们应用程序的其他文件。

  2. 或者从外部网络位置,如 Google 或 Microsoft 的内容分发网络。

如果您的应用程序流量很大,后者可能是一个更好的选择,因为这些公开可用的资源是为高可用性设计的,可以处理大量的请求。这可能会大大减少您服务器的负载,从而降低成本。更多关于此信息可以在 jQuery 的下载部分找到docs.jquery.com/Downloading_jQuery#CDN_Hosted_jQuery

在开发过程中,通常更好的做法是下载必要的文件,并从提供应用程序其余部分的服务器上提供服务。这样,当出现错误时,我们可以轻松地检查这些文件,甚至决定调整内容。如果我们选择以定制的方式为主题我们的应用程序(参见 jQuery UI 的 themroller 信息框),则层叠样式表将与标准样式表不同,因此我们无论如何都需要从我们的 Web 服务器上提供服务。

在本书提供的示例代码中,我们包括了 jQuery 和 jQuery UI 库,它们位于每个章节目录的static子目录中。还有一个css子目录,其中包含一组优化的自定义样式表,这些样式表旨在在打印和屏幕上提供易于阅读的视觉风格。本书中使用的 jQuery 库版本是从code.jquery.com/jquery-1.4.2.js下载的。有关下载(可能的主题化)版本的 jQuery UI 的信息可以在jqueryui.com/download找到。

小贴士

使用 jQuery UI 的 themroller

本书使用的主题被称为smoothness,可以通过选择Gallery标签并点击Smoothness示例下方的Download按钮从jqueryui.com/themeroller/下载。甚至可以根据标准主题之一创建一个完全定制的主题,通过在Roll Your Own标签中选择一个主题并进行调整。一旦你对外观满意,就可以下载结果。有关所有详细信息,请查看在线文档jqueryui.com/docs/Getting_Started

服务应用程序

我们设定的第一个任务是向最终用户提供内容。最终,这应该是有用的内容,当然,但让我们首先确保我们可以编写一个微型的 Web 应用程序,至少能够提供一些内容。

开始服务一个虚拟应用程序

现在我们已经建立了必要的构建块,我们可以开始开发我们的应用程序。让我们从一个非常简单的应用程序开始:

  1. 前往您解压示例代码的目录。

  2. 前往第二章目录。

  3. 双击文件nocontent.py,将打开一个文本窗口(或者您也可以从命令行输入python nocontent.py):动态服务一个虚拟应用程序

  4. 打开您喜欢的浏览器,并在地址栏中输入http://localhost:8080。您将看到一个相当无聊的页面:动态服务一个虚拟应用程序

小贴士

如果您的浏览器无法连接到http://localhost:8080,这可能是因为您的本地名称服务器没有配置为解析名称localhost。如果您没有纠正这一问题的手段,那么输入http://127.0.0.1:8080到浏览器地址栏中也是同样有效,尽管不那么方便。

也可能应用程序将要监听的自定义端口(8080)已经被占用,在这种情况下,Python 将引发异常:IOError: '127.0.0.1'上的端口 8080 不可用。如果是这种情况,我们可以配置 CherryPy 在另一个端口上监听(参见下一节的信息框)。

刚才发生了什么?

双击nocontent.py会导致 Python 解释器启动并执行脚本。这打开了一个控制窗口,其中 CherryPy 框架记录了它启动的事实以及它将在127.0.0.1(本地机器的所谓回环 IP 地址,即使没有连接到互联网,该地址也会出现在机器上)的8080端口上监听。

这个地址和端口是我们指向浏览器的,之后 HTTP 服务器为我们提供 HTML 文件,以及一些 JavaScript 文件来提供服务。浏览器检索到的每个文件都会在控制窗口中记录,并附带状态信息。这将会方便我们查找缺失的文件,例如。

我们可以通过关闭控制窗口或按Ctrl + Break(在 Windows 上)或Ctrl + C(在 Windows 和大多数其他平台上)来停止脚本提供服务。

动态服务 HTML 内容的时间

我们已经看到了如何运行应用程序以及如何使用网络浏览器访问它,现在让我们看看完成这个任务所需的 Python 代码。我们需要提供静态文件,但除了这些静态文件之外,我们还想动态生成主要 HTML 内容。这并不是严格必要的,因为我们同样可以很容易地将其作为静态文件提供服务,但它作为一个简单的例子,说明了如何生成动态内容:

Chapter2/nocontent.py

import cherrypy
import os.path current_dir = os.path.dirname(os.path.abspath(__file__))

您可以从www.PacktPub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.PacktPub.com/support并注册,以便将文件直接通过电子邮件发送给您。

nocontent.py 以导入 cherrypyos.path 模块开始。后者是必要的,以便确定 nocontent.py 所在的目录(高亮显示),这样我们就可以相对于 nocontent.py 引用其他静态文件和目录。这样,一旦我们想要将此应用程序移动到生产服务器上的最终目的地,我们就会使生活变得更加容易。

Chapter2/nocontent.py

class Root(object): ... <omitted> ...
if __name__ == "__main__": cherrypy.quickstart(Root(),config={
		'/static':
		{ 'tools.staticdir.on':True,
			'tools.staticdir.dir':os.path.join(current_dir,"static")
		}
	})

刚才发生了什么?

下一步是使用 quickstart() 函数启动 CherryPy 服务器(高亮显示)。我们传递两个参数:第一个参数是一个类的对象实例,该类向 CherryPy 提供了一些可能提供动态内容的方法。我们将在下一分钟查看这一点。

第二个(命名)参数是一个包含多个配置项的字典。在这种情况下,我们只配置了一个静态目录,但在其他情况下,这里可能还会出现其他配置项。URL 组件 /static 是通过连接之前确定的 current_dir 来指向文件系统上的位置的。我们再次使用 Python 的 os.path 模块中的函数 os.path.join(),以平台无关的方式创建文件路径。

static 目录包含我们将为这个应用程序需要的所有 jQuery 和 jQuery UI 文件,以及所有 CSS 文件和图像来美化应用程序。在这个例子中,由于没有实际内容,除了属于 jQuery 和 jQuery UI 库的文件外,没有其他文件,但如果我们需要它们,我们也可以将它们放在这里。

注意

如果我们想让 CherryPy 监听不同的端口,我们应在全局配置中指明这一点。这可以通过在调用 cherrypy.quickstart() 之前添加 cherrypy.config.update({'server.socket_port':8088}) 来实现。CherryPy 提供了丰富的配置选项,甚至可以指示它从文件中读取配置。所有可能性的良好起点是 www.cherrypy.org/wiki/ConfigAPI

我们仍然需要实现一个 Root 类,为 CherryPy 提供一个可能作为文档层次结构根的对象实例,CherryPy 可能会为其提供服务。实际上,在我们可以创建一个实例并将其传递给 quickstart() 方法之前,我们应该定义这个类,但我想要首先集中精力了解如何启动服务器,然后再集中精力生成内容:

Chapter2/nocontent.py

class Root(object):
	content = '''... <omitted> ...''' @cherrypy.expose
	def index(self):
			return Root.content

这个 Root 类包含一个单独的类变量 content,它保存我们将要服务的 HTML 代码。我们将在下一节中详细检查它。这个 HTML 是由 index() 方法生成的,并传递给 HTTP 服务器,然后服务器将其传递给请求的浏览器。

它通过@cherrypy.expose装饰器(突出显示)暴露给 CherryPy。只有暴露的方法才会被 CherryPy 调用以生成内容。在默认配置中,CherryPy 会将形式为/name的 URL 映射到名为name()的方法。仅包含一个正斜杠/的 URL 将映射到名为index()的方法,就像我们在这里定义的那样。这意味着我们现在已经配置了 CherryPy,当用户将浏览器指向http://127.0.0.1:8080/(他甚至可以省略最后的斜杠,因为 CherryPy 默认会忽略尾随斜杠)时,它会提供动态内容。

注意,我们让index()返回单个字符串变量的内容,但我们本可以返回任何东西,这使得这是一种真正动态的内容生成方式。

谁提供什么:概述

从动态和静态内容的混合中提供服务可能会很快变得令人困惑。在早期形成一个清晰的组件、数据流和目录结构之间的关系图可能有所帮助。这建立在第一章中概述的总体图景之上,并在每一章中进一步扩展。

本书中的几乎所有应用都是从相同的目录结构中提供的:

谁提供什么:概述

顶级目录包含一个或多个可以执行并启动 CherryPy 服务器的 Python 文件。这些 Python 文件实现了应用的客户端。它们可以从同一顶级目录导入额外的模块。

顶级目录还包含一个名为static的子目录。它包含几个 JavaScript 文件,包括 jQuery 和 jQuery UI 库以及任何额外的插件。它还包含一个名为css的目录,其中包含一个或多个额外的 CSS 样式表和 jQuery UI 主题的图像。

注意,尽管我们的应用是由 Web 服务器提供的,但看不到任何 HTML 文件,因为所有 HTML 内容都是动态生成的。

从应用的角度来看,理解一个 Web 应用的最佳方式是将它视为分布式应用。其中一些代码(在我们的例子中是 Python)在服务器上运行,而其他代码(JavaScript)在浏览器中运行。它们共同构成了以下图像中可视化的完整应用:

谁提供什么:概述

CherryPy 内容服务快速问答

我们选择从index()方法提供服务,以便用户可以通过仅以斜杠(/)结尾的 URL 来获取内容。但如果我们希望内容可以通过类似127.0.0.1/content?这样的 URL 访问,我们需要做哪些改变?

HTML:分离表单和内容

几乎总是,将表单和内容分开是一个好主意。这使我们能够专注于我们想要呈现的信息的逻辑结构,并使得以后更改数据的显示方式变得更容易。这甚至允许以可维护的方式应用主题。

我们数据的结构是在我们提供给浏览器的 HTML 中确定的。更准确地说,结构数据可以在<body>元素中找到,但 HTML 的<head>元素也包含重要信息。例如,引用将要用于样式化数据和增强用户交互的样式表和 JavaScript 库。

在下面的代码中,我们使用一个<link>元素来引用从 jQuery UI 网站下载的主题中的 CSS 样式表(高亮显示)。在这个例子中,我们实际上并没有使用这个样式表,jQuery 和 jQuery UI 库也没有包含在<script>元素中,但这个例子展示了如何从我们生成的 HTML 中引用这些库,在接下来的例子中,我们还将看到这也是我们引用我们将自己创建的任何额外 JavaScript 库的位置。实际内容包含在突出显示的<div>元素中。

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head> <link rel="stylesheet"
href="static/css/redmond/jquery-ui-1.8.1.custom.css"
type="text/css" media="screen, projection" />
<script type="text/javascript"
	src="img/jquery-1.4.2.js" ></script>
<script type="text/javascript"
	src="img/jquery-ui-1.8.1.custom.min.js" ></script>
</head>
<body id="spreadsheet_example"> <div id="example">an empty div</div>
</body>
</html>

是时候进行一个单位转换器操作了

只提供一段文本并不十分有用,因此我们的下一步是添加一些 HTML 内容,并通过 JavaScript 增强显示和功能:

  1. 前往nocontent.py可能存在的同一目录。

  2. 双击文件unitconvertor.py,CherryPy 控制台将再次在文本窗口中打开。

  3. 在浏览器地址栏中输入http://localhost:8080(或者如果它仍然打开在那个地址,点击刷新)。你现在将看到一个小的单位转换器:是时候进行一个单位转换器操作了

你可以在左侧的文本输入框中输入任何数字(可选分数),选择要转换的单位和目标单位后,按下转换按钮将在右侧显示转换后的数字。

刚才发生了什么?

我们 Web 应用的基本结构并没有改变。我们提供的内容不同,但这几乎不会改变我们需要提供的 Python 代码。实际内容,即当调用index()函数时我们提供 HTML,它必须定义我们的单位转换器所包含的<form>元素,并且我们还想执行一些 JavaScript。

HTML:基于表单的交互

HTML 的<head>部分不需要更改,因为它已经引用了我们想要使用的样式表和 JavaScript 库。然而,我们必须更改<body>元素以包含构成我们的单位转换器的结构元素。

单位转换器以 <form> 元素(高亮显示)的形式构建。它包含两个下拉列表,用于选择要转换的单位,这两个列表都使用 <select> 元素实现,还有一个文本 <input> 元素,用户可以在其中输入数字。第二个文本 <input> 元素用于显示转换结果。这个元素被设置为只读,因为它不打算接收用户的输入。最后一个元素是一个用户可以点击以启动转换的 <button>

你可能已经注意到 <form> 元素缺少 action 属性。这是故意的,因为没有与服务器进行交互。当用户点击按钮时发生的转换完全在 JavaScript 中实现。这段 JavaScript 包含(并执行)在最终的脚本元素(高亮显示)中。我们将在下一节中检查这个脚本。

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/
TR/html4/strict.dtd">
<html>
<head>
<link rel="stylesheet" href="static/css/redmond/jquery-ui-
1.8.1.custom.css" type="text/css" media="screen, projection" />
<script type="text/javascript" src="img/jquery-1.4.2.js" ></script>
<script type="text/javascript" src="static/jquery-ui-1.8.1.custom.min.
js" ></script>
</head>
<body id="spreadsheet_example">
<div id="example">
	<form id="unitconversion">
	<input name="from" type="text" value="1" />
	<select name="fromunit">
		<option selected="true">inch</option>
		<option>cm</option>
	</select>
	<label for="to">=</label>
	<input name="to" type="text" readonly="true" />
	<select name="tounit">
		<option>inch</option>
		<option selected="true">cm</option>
	</select>
	<button name="convert" type="button">convert</button>
	</form>
</div> <script type="text/javascript" src="img/unitconverter.js" ></script>
HTMLHTMLform based interaction</body>
</html>

JavaScript:使用 jQuery UI 小部件

屏幕元素或 小部件 对于让最终用户与你的应用程序交互至关重要。这些小部件可能是简单的按钮,当用户点击它们时启动某些操作,或者更复杂的小部件,如下拉框、单选按钮,甚至是允许你选择日期的小日历。jQuery UI 库提供大量预定义且易于配置的小部件,因此我们的下一步是使用 jQuery UI 让我们的转换应用程序中的按钮对鼠标点击做出反应,并启动单位转换。

使用 unitconverter.js 进行动作转换

unitconverter.js 包含执行实际转换所需的 JavaScript 代码。它从定义一个转换映射开始,这是一个包含我们想要定义的任何转换的转换因子的字典。我们限制自己从英寸到厘米以及相反方向的转换,但可以轻松添加额外的转换因子。

conversion_map = {
	"inch cm":1.0/2.54,
	"cm inch":2.54
}; $("button").button().click(function(){
		value=$("input[name='from']").val();
		f=$("select[name='tounit'] option:selected").val();
		t=$("select[name='fromunit'] option:selected").val();
		if(f != t){
			c=conversion_map[f+' '+t];
			result=parseFloat(value)*c;
		}else{
			result = value;
		}
		$("input[name='to']").val(result);
	}
);
$("form *").addClass("ui-widget");

在上一段代码中高亮显示的行是我们第一次接触 jQuery 和 jQuery UI 库,值得密切关注。$("button") 这部分选择页面上的所有 <button> 元素。在这种情况下,它将只有一个。这个 <button> 元素通过 button() 方法从 jQuery UI 库转换为一个按钮小部件。这是一个简单的部件,将元素样式化为一个易于主题化和定制的可识别按钮。

刚才发生了什么?

当用户点击按钮时实际发生的事情是由我们通过 click() 方法传递给按钮元素的匿名函数定义的 点击处理器。这个匿名函数在用户每次点击按钮时都会被调用。

这个处理器首先通过 $("input[name='from']").val() 获取具有 name 属性等于 from 的文本 <input> 元素的 内容。接下来,它从两个 <select> 元素中检索当前选定的单位。

如果这些单位不相同,它将从转换映射中获取具有连接单位作为键的转换系数。转换结果通过乘以转换系数和<input>元素的内容来计算。我们从任何<input>元素检索的内容始终以字符串形式返回,因此我们必须使用内置的 JavaScript 函数parseFloat()将其解释为浮点数。如果两个单位相同,结果简单地与输入值相同。

计算结果存储在具有name属性为to的文本<input>元素中。请注意,尽管这个元素有一个只读属性来防止用户输入任何文本,我们仍然可以在脚本中更改其内容。

添加图标到按钮的即兴测验

一个只有简单文本的按钮可能适用于许多应用,但如果它还显示了适当的图标,看起来会更好。既然知道按钮小部件高度可配置,你将如何给你的按钮添加一个图标?

尝试添加一个动态标题的英雄

  • nocontent.py示例中我们提供的 HTML 只是类变量的内容,所以并不真正是动态的!如果我们想提供包含显示当前日期的<title>元素的 HTML,我们需要做些什么?

  • 提示:<title>元素应该包含在<head>元素中。因此,而不是一次性返回所有 HTML,你可以重写 Python 代码以返回由三部分组成的 HTML:第一部分和最后一部分是静态 HTML 片段,中间部分是动态生成的字符串,代表一个包含日期的<title>元素。这个日期可以通过 Python 标准time模块中的asctime()函数获取。

  • 一种可能的实现可以在文件nocontenttitle.py中找到。

jQuery 选择器

jQuery 选择器在许多地方出现,从某种意义上说,它们是任何使用 jQuery 库的 JavaScript 程序的重点。本书的范围不包括完整的概述(关于这一点,请参阅附录中的一些 jQuery 书籍,其中包含大量示例,或检查 jQuery 的docs.jquery.com/Main_Page文档部分,特别是关于选择器的部分),但基本上 jQuery 允许我们以 CSS 3 兼容的方式选择任何元素或元素集。换句话说,即使在尚未支持 CSS 3 的浏览器中,它也能正常工作。

为了让大家有个大致的概念,下面给出了一些示例,所有这些示例都假设有一个包含以下标记的 HTML 文档:

<div id="main">
<ul>
<li>one</li>
<li class="highlight">two</li>
<li>three</li>
</ul>
</div>
<div id="footer">footer text</div>

  • 选择所有<li>元素:$("li")

  • 选择第一个<li>元素:$("li:first")

  • 选择具有highlight类的<li>元素:$(".highlight")

  • 选择 id 等于footer<div>$("#footer")

jQuery 函数(通常用别名 $ 表示)返回一个 jQuery 对象,该对象指向匹配的元素集合。jQuery 对象有许多方法可以操作这个集合中的元素。例如,$("li").addClass("red-background") 将红色背景类添加到所有 <li> 元素。

jQuery UI 库通过添加将元素转换为标准小部件的功能,进一步扩展了可用方法。这就是为什么在我们的例子中,$("button").button() 会改变按钮元素的样式,使其变为 jQuery UI 提供的样式化按钮小部件。

我们的示例应用程序还展示了另一个重要的 jQuery 概念:链式操作。大多数 jQuery 和 jQuery UI 方法返回它们操作的选中项。这样,就很容易在同一个选中项上调用多个方法。在我们的例子中,$("button").button() 在将选中的按钮元素转换为按钮小部件后返回这些按钮元素,这允许我们通过编写 $("button").button().click(…) 来链式调用点击方法,以定义鼠标点击行为。

CSS:将 jQuery UI 主题应用到其他元素

unitconverter.js 中的最后一行显示了如何以与标准 jQuery UI 小部件相同的方式设置任何元素的样式。在这种情况下,这是通过使用 $("form *") 选择 <form> 元素中包含的所有元素,然后使用 addClass() 方法添加 ui-widget 类来实现的。

任何带有 ui-widget 类的元素都将获得与任何 jQuery UI 小部件相同的样式。在我们的例子中,这体现在 inputselect 元素使用的字体和颜色上。即使我们更改主题,这种更改也将被统一应用。还有更多预定义的类可供使用,以实现更精细的控制,我们将在下一节创建自己的 jQuery UI 插件时遇到这些类。

理解预定义的 jQuery UI 类对元素的影响是很重要的。类本身并不会改变元素的显示方式,但 jQuery UI 框架将各种 CSS 样式元素与预定义的类关联起来。当与元素关联的类发生变化时,浏览器会再次检查应用哪些样式元素,从而实现即时样式更改。

也可以直接更改与元素关联的 CSS 样式。然而,为特定类定义样式并更改类,使得在不需为每个要更改的元素单独使用样式组件的情况下,更容易保持一致的视觉效果。

尝试为表格添加斑马条纹的英雄

在设置 HTML 表格样式时,一个经常需要的功能是使用交替的背景颜色渲染表格的行。

由于 jQuery 允许我们使用 CSS 3 兼容的选择器,并通过 .addClass() 方法向元素的 class 属性添加内容,因此即使在不支持 CSS 3 的浏览器中,现在也可以轻松完成这项任务。

给定以下示例 HTML,应向最后一个<script>元素添加什么 JavaScript 代码以将所有偶数行的背景渲染为浅灰色?(提示:CSS 3 有一个:even选择器,当您使用 jQuery 向一个元素添加类时,任何适用于该类的 CSS 样式都将重新评估)。

检查zebra.html以查看解决方案(它包含在第二章的示例代码中。在您的浏览器中打开该文件以查看效果):

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
	<script type="text/javascript" src="img/jquery-1.4.2.js" >
	</script>
	<style>
		.light-grey { background-color: #e0e0e0; }
	</style>
</head>
<body>
	<table>
		<tr><td>one</td><td>line 1</td></tr>
		<tr><td>two</td><td>line 2</td></tr>
		<tr><td>three</td><td>line 3</td></tr>
		<tr><td>four</td><td>line 4</td></tr>
		<tr><td>five</td><td>line 5</td></tr>
		<tr><td>siz</td><td>line 6</td></tr>
	</table>
	<script type="text/javascript">
	/* insert some JavaScript here to color even rows grey */
	</script>
</body>
</html>

浏览器中的结果将类似于以下内容(请注意,元素从零开始编号,因此结果可能不是您预期的):

尝试将斑马条纹添加到表格中

将单位转换器转换为插件的操作时间

重新使用许多精心设计的 jQuery UI 小部件之一是有益的,因为它可以节省我们的开发和维护时间,但 jQuery UI 框架的真正力量在于它使我们能够设计出与框架的其他部分无缝融合且在使用上与标准小部件不可区分的新小部件。为了说明可能实现的内容,让我们再次实现我们的单位转换器,但这次作为 jQuery 插件:

  1. 前往包含第二章示例代码的目录。

  2. 双击文件unitconverter2.py,CherryPy 控制台将再次在窗口中打开。

  3. 在浏览器地址栏中输入http://localhost:8080(如果该地址仍然打开,请点击刷新)。您现在将看到一个稍微重新设计的单位转换器:将单位转换器转换为插件的操作时间

与这个新的单位转换器的交互与之前的完全相同。

刚才发生了什么?

我们现在不再使用包含多个额外元素的<form>元素来构建小部件,而是采用更简单的方法。我们将设计一个可重用的单位转换器小部件,可以插入到任何<div>元素中。现在,我们的 HTML 骨架变得更加简单,因为其主体将只包含一个<div>元素:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/
TR/html4/strict.dtd">
<html>
<head>
<link rel="stylesheet" href="static/css/redmond/jquery-ui-
1.8.1.custom.css" type="text/css" media="screen, projection" />
<script type="text/javascript" src="img/jquery-1.4.2.js" ></script>
<script type="text/javascript" src="static/jquery-ui-1.8.1.custom.min.
js" ></script> <script type="text/javascript" src="img/unitconverter2.js" ></script>
</head>
<body id="spreadsheet_example">
<div id="example"></div>
<script type="text/javascript"> $("#example").unitconverter(
{
'km_mile':1.0/0.621371192,
'mile_km':0.621371192
});
</script>
</body>
</html>

第一条突出显示的行包含包含新实现单位转换器的 JavaScript 文件。我们在<body>元素末尾附近的 JavaScript 代码中引用了此文件中定义的插件(最后突出显示的行)。此脚本通过其 id(在这种情况下为#example)引用我们想要添加单位转换器的<div>元素,并应用unitconvertor()方法。

当我们查看实现我们的转换插件的 JavaScript 代码时,unitconverter()接受一个选项对象作为其唯一参数。此选项对象可以包含任何数量的键,用于定义此插件实例的附加转换系数。在这种情况下,我们传递附加信息以允许从英里转换为公里,反之亦然。

单项测验:向 unitconverter 实例添加转换

当我们想要添加一个可以将立方英尺转换为升的单位转换插件时,JavaScript 会是什么样子呢?

JavaScript:创建一个 jQuery UI 插件

所有 jQuery UI 插件都是通过向 jQuery 对象的 fn 属性(我们通常通过其别名 $ 来引用的对象)添加一个新函数来定义的。在 unitconverter2.js 中,这正是我们所做的,如下代码的第一行所示。

接下来,我们将传递给插件的任何选项与默认值合并(高亮显示)。jQuery 提供了一个 extend() 方法,该方法合并任意数量的对象的属性,并返回第一个对象。由于我们不希望覆盖我们在 $.fn.unitconverter.conversion_map 中定义的默认选项,我们传递一个空对象。该对象将接收默认属性和 options 对象中定义的任何属性,覆盖名称相同的属性。这些合并后的属性集存储在 cmap 变量中:

jQuery.fn.unitconverter = function(options){ var cmap = $.extend({},$.fn.unitconverter.conversion_map,options);

转换系数通过 unit1_unit2 形式的键来引用。为了从键构建两个下拉选择器,我们遍历所有这些键,并使用 JavaScript 的 split() 方法检索单个单位(高亮显示)。然后,这些单位被存储在 fromto 数组中:

var from = new Array();
var to = new Array(); for (var key in cmap){
	var units = key.split("_");
	from.push(units[0]);
	to.push(units[1]);
}

下一步是构建插件需要向用户展示的 HTML。结构与上一个例子中手工制作的类似,一个包含 <input><select> 元素和 <button><form><form> 元素被添加了一个随机的 id 属性。这样我们就可以在页面上有多个单位转换器的情况下,稍后引用它。

<select> 元素包含多个 <option> 元素,这些元素是通过逐个检索存储在 fromto 数组中的单位名称,并使用 pop() 方法创建的。默认情况下,第一个选项被选中(高亮显示)。然后,HTML 代码被传递到 this.append() 方法中。this 是一个变量,它对实现插件的函数是可用的,它包含应用插件的元素,在我们的例子中是具有 #example id 的 <div> 元素:

	var id = "unitconverter" + new String(Math.floor(Math.random() 
* 255 * 255));
	var html = '<form id="' + id + '"><input name="from" type="text" 
value="1" />';
	html += '<select name="fromunit">'; html += '<option selected="true">'+from.pop()+'</option>';
	var len = from.length;
	for (var i=0; i<len; i++){
html += '<option>' + from.pop() + '</option>' };
	html += '</select> = ';
	html += '<input name="to" type="text" readonly="true" />';
	html += '<select name="tounit">';
	html += '<option selected="true">' + to.pop() + '</option>';
	var len = to.length;
	for (var i=0; i<len; i++){
html += '<option>' + to.pop() + '</option>'};
	html += '</select>';
	html += '<button name="convert" type="button">convert</button>'
html += '</form>';
	this.append(html);

随机生成的表单元素 id 现在非常有用,可以用来选择我们当前正在构建的表单中的 <button> 元素,并将其转换为按钮:我们通过连接相关部分来构建一个合适的选择器,即 "#"+id+" button"

注意,在自定义插件中包含其他插件或小部件是完全有效的。这次我们选择通过传递一个适当的选项对象来构建一个外观略有不同的按钮,该按钮只有一个图标而没有文本。从 jQuery UI 随带的大量图标中,我们选择最能代表按钮功能的图标:ui-icon-refresh(高亮显示)。

当用户点击按钮时发生的转换是通过一个我们将很快遇到的功能实现的,该功能通过按钮对象(作为click()方法中的this变量可用)和合并的转换系数映射传递:

$("#"+id+" button").button({
			icons: { primary: 'ui-icon-refresh'
			},
			text: false
	}).click(function(){return convert(this,cmap);});

最后一步是将我们的小部件以一致的方式样式化。jQuery 为我们提供了一个css()方法,允许我们直接操作任何元素的样式属性。我们首先处理一个布局问题:我们将float:left样式应用到<form>元素上,以确保它不会完全填满页面,而是收缩/围绕它包含的元素包裹:

$("#"+id).css('float','left');

然后,我们将来自<button>元素的一些背景样式属性复制到<form>元素中,以使<form>元素的外观与应用于标准按钮小部件的主题保持一致。主题中的其他样式元素,如字体和字体大小,通过添加ui-widget类(突出显示)应用到表单元素上。最后,我们通过返回this变量(在我们的例子中包含我们选择的<div>元素,但现在添加了刚刚添加到其中的<form>元素)来完成。这允许我们链式调用额外的 jQuery 方法:

	$("#"+id).css('background-color',
$("#"+id+" button").css('background-color'));
	$("#"+id).css('background-image',
$("#"+id+" button").css('background-image'));
	$("#"+id).css('background-repeat',
$("#"+id+" button").css('background-repeat')); $("#"+id).addClass("ui-widget");
	return this;
};

当然,我们仍然需要定义一个函数,当单位转换器的按钮被点击时执行实际的转换。它与之前的实现略有不同。

convert()函数接收被点击的<button>元素和一个包含转换系数的映射。包含按钮的<form>元素通过parent()方法确定并存储在form变量中。

我们想要转换的输入值是从具有name属性等于from<input>元素中检索的。我们可以通过选择存储在form中的<form>元素的所有子元素并传递一个合适的选择器到.children()方法(突出显示)来找到这个特定的元素。

以类似的方式,我们确定两个<select>元素中选择了哪个选项:

function convert(button,cmap){
	var form = $(button).parent(); var value = form.children("input[name='from']").val();
	var f = form.children("select[name='tounit']").
children("option:selected").val();
	var t = form.children("select[name='fromunit']").
children("option:selected").val();

剩下的就是实际的转换。如果转换单位不相等,我们从映射中检索转换系数(突出显示),然后将其乘以<input>元素的内容,该内容被解释为浮点数。如果输入不能被解释为浮点数或映射中没有合适的转换系数,乘法的结果是一个NaN(不是一个数字),我们通过在结果中放置错误文本来表示这一点。然而,如果一切顺利,我们使用 JavaScript 的toFixed()方法将结果转换为具有四位小数的数字:

var result = value;
	if(f != t){ var c=cmap[f+'_'+t];
		result=parseFloat(value)*c;
		if (isNaN(result)){
				result = "unknown conversion factor";
		}else{
				result = result.toFixed(4);
		}
	}
	form.children("input[name='to']").val(result);
};

unitconverter2.py通过定义一个具有默认值的对象来结束。

jQuery.fn.unitconverter.conversion_map = {
	inch_cm":1.0/2.54,
	"cm_inch":2.54
}

突击测验更改选项默认值

如果我们:

  1. 将单位转换器添加到具有 ID #first 的<div>元素中。

  2. 将从立方英尺到升的转换添加到默认转换映射中。

  3. 最后,将一个单位转换器添加到具有 ID #last 的<div>元素中。

代码看起来可能像这样:

$("#first").unitconverter();
$.extend($.fn.unitconverter.conversion_map, {'cubic feet_
litres':1.0/28.3168466});
$("#last").unitconverter();

如果我们执行前面的代码,哪些 <div> 元素将获得带有附加转换功能的 unitconverter?

  1. 带有 #first ID 的 div

  2. 带有 #last ID 的 div

  3. 两者

设计电子表格应用程序

我们本章的目标是能够向用户提供一个简单的电子表格应用程序,我们几乎做到了。我们知道如何提供 HTML,我们也看到了如何实现自定义 jQuery UI 小部件,所以让我们将这些知识应用到设计电子表格插件中。首先,让我们看看它将是什么样子:

是时候提供电子表格应用程序了

前往包含示例代码的目录,第二章

  1. 双击文件 spreadsheet.py,现在熟悉的 CherryPy 控制台将在一个文本窗口中打开。

  2. 在浏览器地址栏中输入 http://localhost:8080(或者如果该地址仍然打开,请点击刷新)。现在您将看到一个简单的电子表格应用程序:是时候提供电子表格应用程序

  3. 您可以点击任何单元格来编辑其公式。您不应该以等号开始公式:42, D2+19"text"(包括双引号)是有效的公式的例子。实际上,任何 JavaScript 表达式都是有效的。

刚才发生了什么?

提供给最终用户的电子表格应用程序由两个主要部分组成,HTML 用于构建电子表格结构,以及一些 JavaScript 用于提供交互。我们将依次查看这些部分。

HTML:保持简单

我们需要的电子表格 HTML 几乎与单位转换器的 HTML 相同。以下代码中高亮显示的行显示了差异。spreadsheet.js 包含插件的定义,最后的 <script> 元素将一个 8x10 的电子表格插入到 #example div 中。将 <div> 元素转换为完全功能的电子表格小部件与转换为标准按钮小部件一样简单!

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/
TR/html4/strict.dtd">
<html>
<head>
<link rel="stylesheet"
href="static/css/redmond/jquery-ui-1.8.1.custom.css" type="text/css" media="screen, projection" />
<script type="text/javascript"
src="img/jquery-1.4.2.js" ></script>
<script type="text/javascript"
src="img/jquery-ui-1.8.1.custom.min.js" ></script>
<script type="text/javascript"
src="img/jeditable.js" ></script> <script type="text/javascript"
src="img/spreadsheet.js" ></script>
</head>
<body id="spreadsheet_example">
<div id="example"></div>
<script type="text/javascript"> $("#example").sheet({cols:8,rows:10});
</script>
</body>
</html>

JavaScript:创建电子表格插件

文件 spreadsheet.js 包含了实现可重用电子表格小部件所需的全部 JavaScript 代码。从 jQuery 的角度来看,这个电子表格与我们之前的单位转换器非常相似,尽管实现用户交互的实际 JavaScript 代码要复杂一些。同样,我们的插件是一个与 jQuery 的 fn 属性关联的函数,正如在以下代码的第一行中可以看到的,我们使用名称 sheet 定义了我们的小部件。

接下来,我们将电子表格插件(在文件末尾定义)的默认选项与传递给函数的选项合并:

jQuery.fn.sheet = function(options){
	var opts = $.extend({}, $.fn.sheet.defaults, options);

下一步是创建一个将代表我们的电子表格的表格。我们通过添加所需的 HTML 到变量t中,逐步创建这个<table>元素,并赋予它一系列相关的类:一个独特的sheet类,以便一旦创建就可以轻松识别为电子表格插件,一个ui-helper-reset类,这将导致 jQuery 应用适当的 CSS 以重置浏览器添加的任何不想要的默认样式,最后是一个ui-widget类,这将导致应用选定的主题。然后我们逐步创建表格内容,通过逐步添加所需的 HTML 到变量t中:

/* create a cols x rows grid */
var t='<table class="sheet ui-helper-reset ui-widget"
cellspacing="0">';

表格包含一个将被设置为ui-widget-header样式的<thead>元素。它包含一个<th>元素的单一行。这些<th>元素包含列标签,一个由fromCharCode()方法构造的大写字母,该字母来自列索引(突出显示):

	t=t+'<thead class="ui-widget-header">
<tr class="ui-helper-reset"><th></th>';
	for(i=0;i<opts.cols;i=i+1){ t=t+'<th class="ui-helper-reset">' +
String.fromCharCode(65+i)+"</th>";
	}

表格的主体由一个包含多个行和<td>元素的<tbody>元素组成。每一行的第一个<td>元素包含行标签(一个数字)并将被设置为ui-widget-header样式,就像列标签一样。常规单元格,即包含我们的公式和值的单元格,将属于ui-widget-content类以适当地样式化。这些单元格还将属于一个cell类,以便在我们向它们添加附加功能时容易区分(突出显示)。

在这样的单元格中最初没有任何内容,除了一个将包含公式并设置为ui-helper-hidden样式的<span>元素,这使得公式不可见。评估后的公式的值将同时以文本内容的形式存储在<td>元素中(与<span>元素并排),以及作为一个与单元格名称相同的全局变量。在这个上下文中,全局变量是浏览器定义的顶层window对象的一个命名属性,可以通过window[name]访问。

将单元格的值存储在全局变量中同样允许我们使用任何 JavaScript 表达式作为单元格中的公式,因为我们现在可以通过名称引用任何其他单元格的值。例如,A1+B3*9将是一个完全有效的表达式,因为A1B3将被定义为全局变量:

t=t+'</tr></thead><tbody class="ui-widget-content" >';
for(i=0;i<opts.rows;i=i+1){
		t=t+'<tr class="ui-helper-reset">
		<td class="rowindex ui-helper-reset ui-widget-header">'
				+ (i+1)+"</td>";
		for(j=0;j<opts.cols;j=j+1){
			id=String.fromCharCode(65+j)+(i+1) t=t+'<td class="cell ui-helper-reset ui-widget-content" 
			id="'+id+'">
				<span class="formula ui-helper-hidden">
				</span></td>';
				/* create a global variable */
				window[id]=0
		}
		t=t+"</tr>";
}
t=t+"</tbody></table>";
this.append(t);

我们创建的表格的 HTML 被插入到我们使用sheet()方法并使用this对象的.append()方法应用的 jQuery 选择器中。this对象对任何定义插件的函数都是可用的,并持有当前的 jQuery 选择器。

要编辑一个单元格,我们将使用 jEditable 插件。这个插件将处理用户点击单元格以编辑其内容时的用户交互。为此,它需要获取和设置单元格内容的功能。

注意

我们在这里使用的 jEditable 插件包含在本章提供的示例代码中。最新版本可以从 Mika Tuupola 的网站获取:www.appelsiini.net/projects/jeditable。它附带了一套相当全面的文档。将 <td> 元素转换为当用户用鼠标点击时变为可编辑文本框的功能,就像选择元素并调用 editable() 方法一样简单。例如,$(".editable").editable("[www.example.com/save](http://www.example.com/save)") 会在点击后使任何具有 editable 类的元素变为可编辑文本框,并将编辑后的内容发送到作为 editable() 方法第一个参数传递的 URL。jEditable 插件附带了许多选项,当我们使用 jEditable 插件编辑电子表格单元格时,我们将遇到其中的一些。

我们需要定义一个函数,该函数将由 jEditable 调用以提取元素的内容。此函数将需要两个参数:

  1. 我们正在编辑的元素(在我们的例子中是一个 <td> 元素)。

  2. 传递给 jEditable 插件的原设置。我们现在忽略这些设置。

<td> 元素的结构是这样的,公式本身存储在一个(隐藏的)<span> 元素中。然后 getvalue() 函数必须首先获取对这个 <span> 元素的访问权限,然后才能获取公式。

因此,我们首先将 <td> 元素转换为 jQuery 对象(高亮显示),然后过滤它包含的元素,只保留具有 formula 类的元素。这相当于我们想要的文本是公式的 <span> 元素:

function getvalue(org, settings){ d=$(org)
	return d.filter(".formula").text()
}

对应的 setvalue() 函数被 jEditable 用于将编辑后的公式再次存储在 <td> 元素中。当调用此函数时,它传递两个参数:

  1. 元素的编辑内容。

  2. 传递给 jEditable 插件的原设置及其代码相当复杂,因为存储公式不是它唯一要做的。它还必须计算公式的结果并更新依赖于更新单元格的任何单元格。

我们正在编辑的单元格(即 <td> 元素)作为 this 变量可用。我们将单元格索引存储为其 id 属性,所以我们首先检索它(高亮显示)。传递给 setvalue() 函数的 value 参数是编辑后的公式。

由于我们使用 JavaScript 语法编写这些公式,我们可以简单地调用 JavaScript 的eval()函数来计算公式的值。我们必须将结果存储在具有单元格名称的全局变量中,以便其他单元格可以重复使用。请注意,这些全局变量只是浏览器上下文中window对象的属性,因此将值分配给此类属性就是我们在if … else …子句内部所做的。如果评估公式的结果以某种方式未定义(例如,由于错误),我们将结果设置为字符串'#undef',以向用户指示这种情况:

function setvalue(value, settings) {
	/* determine cell index and update global var */ currentcell=$(this).attr( 'id');
	currentresult=eval(value);
	if (typeof(currentresult) == 'undefined'){
			currentresult='#undef';
			window[currentcell]=0;
		}else{
			window[currentcell]=currentresult;
		}

在我们评估了当前单元格的公式并存储了其结果之后,我们现在必须重新计算所有其他单元格,因为它们可能依赖于我们刚刚更改的单元格的内容。

我们通过选择工作表中的所有单元格并对每个单元格应用一个函数(突出显示)来影响这一点。如果我们正在查看的不是刚刚更改的单元格(我们通过比较它们的id属性来确定这一点),我们将重新计算其<span>元素中包含的公式。如果结果与为单元格存储的先前值不同,我们将更改变量设置为 true。我们重复整个过程,直到没有变化,或者我们重复的次数比工作表中的单元格多,这时我们必须在某处有一个循环引用,我们通过将单元格的值设置为合适的文本来向用户指示这一点。这当然不是重新计算电子表格的最有效方法,也不是检测所有循环引用的万无一失的方法,但它足够有效:

/* update all other cells */
var changed;
var depth = 0;
do{
		depth++;
		changed = false; $('.sheet').find('.cell').
			each(function (index,element){
			cell=$(element).attr('id');
			if(currentcell != cell){
				span=$(element).
							children('span').first();
				orig = window[cell];
				window[cell]=0;
				formula=span.text();
				if(formula.length > 0){
						result=eval(formula);
						if (result != orig) {
								changed = true;
						}
						if(typeof(result)=='undefined'){
								result='#undef';
						}else{
							window[cell]=result;
						}
					}else{
						result = ' ';
					}
					$(element).empty().
append('<span class="formula ui-helper-hidden replaced">' + 
formula+'</span>'+result);
					}
				});
		}while(changed && (depth <opts.cols*opts.rows));
		if ( depth >= opts.cols*opts.rows){
				currentresult = '#Circular!';
		}
		return('<span
				class="formula ui-helper-hidden">'
						+value+'</span>'+currentresult);
}

定义函数以从<td>元素设置和获取值的目的是能够将 jEditable 插件应用到每个单元格。我们在sheet插件的最后几行这样做。我们找到所有具有cell类的子元素(突出显示),并对每个子元素调用一个匿名函数。

此函数首先通过调用editable()方法并使用对setvalue()函数的引用作为第一个参数以及一个选项对象作为第二个参数,在元素上应用 jEditable 插件。type属性将此可编辑元素标记为文本元素(而不是,例如,多行文本区域元素),而将onblur设置为cancel表示在编辑时点击单元格外部将内容恢复到原始状态。data属性指向我们的getvalue()函数,以指示插件如何获取我们想要编辑的值。

函数的第二个作用是应用 CSS 样式属性到每个单元格。在这种情况下,固定的widthborder-collapse属性将确保单元格之间的边框与外围单元格的边框一样宽:

	/* make every cell editable with the jEditable plugin */ this.find(".cell").each(function (index,element) {
	$(this).
	editable(setvalue,{type:'text',onblur:'cancel',data:getvalue})
	});
	$(".cell").css({'width':opts.width,'border-collapse':'collapse'});
	return this;
}

spreadsheet.js通过定义一个默认选项对象来完成:

jQuery.fn.sheet.defaults = {
	rows : 4,
	cols : 4,
	width: '100px',
	logging: false
}

尝试添加数学函数的英雄

在我们设计的电子表格中,用户可以使用任何 JavaScript 表达式作为单元格公式。如果我们想使用加法(+)或乘法(*)这样的运算符,那是没有问题的,但如果我们想使用,例如,正弦函数(sin())或余弦函数(cos())这样的三角函数呢?

这可以通过引用内置 JavaScript 对象Math的方法(例如Math.sin(A1)+Math.cos(B1))来实现,但每个函数前都加上Math前缀显得有些笨拙。设计一种方法,使这些函数在没有Math前缀的情况下可用。(提示:我们已经在全局命名空间中创建名称的方法中看到了如何进行赋值window[<name>])

spreadsheet2.js中可以找到解决方案。其效果可以通过运行spreadsheet2.py来测试。

缺失的部分

在设计和构建电子表格应用程序时,我们发现通过充分利用 jQuery 和 jQuery UI 库,并明智地选择广泛可用的附加插件(如 jEditable),实现相当复杂的用户交互相对简单。

然而,尽管我们的电子表格应用程序是由 CherryPy 服务器提供的,但应用程序的功能仅限于客户端活动。例如,服务器上没有保存或加载电子表格的可能性,也没有限制对电子表格访问仅限于授权用户的方法。这两个要求都依赖于以持久方式存储数据的方法,而处理持久性将是我们在开发 Web 应用程序道路上的下一步。

摘要

在本章中,我们学到了很多。具体来说,我们涵盖了:

  • 如何创建一个开发和交付我们应用程序的环境。我们看到了如何安装 Python、CherryPy 以及 jQuery 和 jQuery UI 框架。

  • 简单电子表格应用程序的设计。

  • 如何配置 CherryPy 以提供静态和动态内容。

  • 如何使用标准的 jQuery UI 小部件和第三方插件;特别是按钮小部件和 jEditable 插件。

  • 我们自己的 jQuery 插件的实现。

我们还讨论了如何重用 jQuery UI 的ui-widget类概念来以与 jQuery UI 主题无缝融合的方式对我们的小部件组件进行样式化。

现在我们已经了解了 Web 应用程序的客户端,我们准备解决服务器端问题,这是下一章的主题。

第三章。任务列表 I:持久性

在上一章中,我们学习了如何向用户传递内容。这些内容包括用于结构化信息的 HTML 标记以及一些 JavaScript 库和代码来创建用户界面。

我们注意到这还不是一款完整的网络应用程序;它缺少在服务器上存储信息的功能,也没有识别不同用户或验证他们的方法。在本章中,我们将设计一个简单的任务列表应用程序时解决这两个问题。

这个任务列表应用程序将能够为多个用户提供服务,并在服务器上存储每个用户的任务列表。

具体来说,我们将探讨:

  • 如何设计任务列表应用程序

  • 如何实现登录界面

  • 会话是什么以及它是如何使我们能够同时与不同用户一起工作的

  • 如何与服务器交互并添加或删除任务

  • 如何使用 jQuery UI 的日期选择器小部件使输入日期变得吸引人且简单

  • 如何设置按钮元素的样式并提供工具提示和内联标签给输入元素

设计任务列表应用程序

设计应用程序应该从对预期的明确想法开始。这不仅是为了确定技术需求,而且同样重要的是,为了定义清晰的边界,这样我们就不至于在那些只是“锦上添花”的事情上浪费时间。如果项目中有剩余时间,锦上添花的功能是可以添加的。

因此,让我们列出我们任务列表应用程序的相关功能清单。其中一些可能看起来很明显,但正如我们将看到的,这些直接影响了我们必须做出的某些实现选择,例如:

  • 应用程序将由多个用户使用

  • 任务列表应该无限期地存储

  • 任务列表可能包含无限数量的任务,但用户界面是为最多 25 个任务左右的最佳性能设计的

  • 任务可以添加、删除和标记为完成

尽管这份列表并不详尽,但它有一些重要的含义。

由于任务列表应用程序将由多个用户使用,这意味着我们必须识别和授权想要使用它的人。换句话说,我们需要某种登录界面和一种检查人们是否与某种密码数据库匹配的方法。因为我们不希望用户每次刷新或更改任务列表时都要进行身份验证,所以我们需要一种实现会话概念的方法。

网络应用程序使用无状态的 HTTP 协议。这意味着,从服务器的角度来看,每个请求都是一个单独的、无关的事件,并且服务器不会保留任何信息。如果我们想执行一系列相关操作,这显然会给我们带来问题。解决方案是要求网络浏览器在应用程序识别用户后,将一小块信息随每个请求一起发送给应用程序。

这可能通过多种方式实现。服务器可能在其生成的任何网页中的所有链接中添加一个额外的参数,通常称为会话 ID,或者使用更通用的cookie概念。

一旦服务器要求网页浏览器存储 cookie,这个 cookie 就会随后续对同一网站的每个请求一起发送。cookie 的优势在于,常见的 Web 应用框架(如 CherryPy)已经配备了处理它们的功能,使用 cookie 实现会话比设计应用程序以更改它生成的所有超链接以包含适当的会话 ID 要简单得多。劣势可能在于,人们可能会阻止浏览器存储 cookie,因为一些网站使用它们来跟踪他们的点击行为。

我们优先考虑实现的简单性,并选择使用 cookie。如果用户想要阻止 cookie,这并不是一个大问题,因为大多数浏览器也提供了选择性地允许来自指定网站的 cookie 的选项。

以下图像展示了 CherryPy 如何借助 cookie 管理会话:

设计任务列表应用程序

这一切始于客户端(网页浏览器)向 CherryPy 发送请求。在收到请求后,首先检查的是网页浏览器是否发送了一个带有会话 ID 的 cookie。如果没有,将生成一个新的会话 ID。此外,如果有一个带有会话 ID 的 cookie,如果这个 ID 不再有效(例如,因为已过期,或者是一个来自非常旧的交互的残留物,且不存在于当前的会话 ID 缓存中),CherryPy 也会生成一个新的会话 ID。

在这个阶段,如果这是一个新的会话,则不会存储任何持久信息,但如果是一个现有的会话,则可能存在可用的持久数据。如果有,CherryPy 会创建一个Session对象,并用可用的持久数据初始化它。如果没有,它将创建一个空的Session对象。这个对象作为全局变量cherrypy.session可用。

CherryPy 的下一步是传递控制权到将处理请求的函数。这个处理程序可以访问Session对象,并可能对其进行更改,例如,通过存储额外的信息以供以后重用。(注意,Session对象的行为类似于字典,因此您可以使用cherrypy.session['key']=value简单地使用键将值关联起来。对键和值的唯一限制是,如果持久存储在磁盘上,它们必须是可序列化的)。

然后在返回处理程序生成的结果之前,CherryPy 会检查Session对象是否已更改。如果(并且仅当)它已更改,则将Session对象的内容保存到更持久的存储中。

最后,返回响应,并附带一个带有会话 ID 的 cookie。

创建登录界面的操作时间

我们的首要任务是创建一个简单的应用程序,它所做的不仅仅是向用户提供登录界面。它将成为我们的任务列表应用程序以及许多其他应用程序的起点。

本例代码以及本书中的大多数其他示例代码均可在 Packt 网站找到。如果您尚未下载,这可能是一个下载的好时机。

将以下代码片段输入并保存到与本章其他文件相同的目录中(第三章中的示例代码)的名为logonapp.py的文件中:

Chapter3/logonapp.py

import cherrypy
import logon
class Root(object):
logon = logon.Logon(path="/logon",
					authenticated="/",
					not_authenticated="/goaway")
@cherrypy.expose
def index(self):
		username=logon.checkauth('/logon')
		return '''
	<html><body>
	<p>Hello user <b>%s</b></p>
	</body></html>'''%username
@cherrypy.expose
def goaway(self):
		return '''
	<html>
	<body><h1>Not authenticated, please go away.</h1>
	</body></html>'''
@cherrypy.expose def somepage(self):
		username=logon.checkauth('/logon',returntopage=True)
		return '''<html> 
				  <body><h1>This is some page.</h1> 
				  </body> 
				  </html>'''
if __name__ == "__main__":
	import os.path
	current_dir = os.path.dirname(os.path.abspath(__file__))
	cherrypy.quickstart(Root(),config={
			'/': {'tools.sessions.on': True }
			}
			)

如果您现在运行logonapp.py,一个非常简单的应用程序将在端口 8080 上可用。当访问顶级页面http://localhost:8080/时,它会向用户展示登录界面。以下是一个示例:

创建登录界面的行动时间

如果输入了正确的用户名/密码组合,将显示欢迎信息。如果输入了未知用户名或错误的密码,用户将被重定向到http://localhost:8080/goaway

somepage()方法(突出显示)返回一个包含(可能)一些有用内容的页面。如果用户尚未认证,将显示登录界面,并在输入正确的凭据后,用户将被引导回http://localhost:8080/somepage

下文展示了登录示例应用程序中的完整网页树以及用户可能选择的可能路径:

创建登录界面的行动时间

小贴士

登录 + 会话 ID 与 HTTP 基本认证

小贴士

您可能会想知道为什么我们选择不重用 CherryPy 捆绑的auth_basic工具,该工具提供基本认证(有关此工具的更多信息,请参阅www.cherrypy.org/wiki/BuiltinTools#tools.auth_basic)。如果我们只想检查用户是否有权访问单个页面,这将是一个不错的选择。基本认证足以验证用户身份,但没有会话的概念。这意味着我们在处理同一用户的后续请求时,缺乏存储需要访问的数据的方法。我们在这里使用的sessions工具确实提供了这项附加功能。

刚才发生了什么?

logonapp.py的魔法之一是通过在 CherryPy 中启用'sessions'工具实现的。这是通过将tools.sessions.on键与值为True传递给quickstart()函数的配置字典来完成的。

然而,在logonapp.py中的大部分艰苦工作实际上是由模块logon:完成的

Chapter3/logon.py

import cherrypy
import urllib.parse def checkauth(logonurl="/", returntopage=False):
	returnpage=''
if returntopage:
	returnpage='?returnpage='
			+ cherrypy.request.script_name
			+ cherrypy.request.path_info
auth = cherrypy.session.get('authenticated',None)
if auth == None :
	raise cherrypy.HTTPRedirect(logonurl+returnpage)
return auth class Logon:
	base_page = '''
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
"http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<script type="text/javascript" src="img/jquery.js" ></script>
<script type="text/javascript" src="img/jquery-ui.js" ></script>
<style type="text/css" title="currentStyle">
	@import "/jquerytheme.css";
	@import "/static/css/logon.css";
</style>
</head>
<body id="logonscreen">
<div id="content">
%s
</div>
<script type="text/javascript">$("button").button({icons: {primary: 'ui-icon-power'}})</script>
</body>
</html>
'''
	logon_screen = base_page % '''
<form class="login" action="%s/logon" method="GET">
<fieldset>
<label for="username">Username</label>
<input id="username" type="text" name="username" />
<script type="text/javascript">$("#username").focus()</script>
<label for="password">Password</label>
<input id="password" type="password" name="password" />
<input type="hidden" name="returnpage" value="%s" />
<button type="submit" class="login-button" value="Log in">
Log in
</button>
</fieldset>
</form>
'''
	not_authenticated =
		base_page % '''<h1>Login or password not correct</h1>''' def __init__(self, path="/logon",
					authenticated="/", not_authenticated="/"):
	self.path=path
	self.authenticated=authenticated
	self.not_authenticated=not_authenticated
@staticmethod def checkpass(username,password):
	if username=='user' and password=='secret': return True
	return False
@cherrypy.expose def index(self,returnpage=''): 
	return Logon.logon_screen % (
				self.path,urllib.parse.quote(returnpage))
@cherrypy.expose def logon(self,username,password,returnpage=''):
	returnpage = urllib.parse.unquote(returnpage)
	if Logon.checkpass(username,password):
		cherrypy.session['authenticated']=username
		if returnpage != '':
			raise cherrypy.InternalRedirect(returnpage)
		else:
			raise cherrypy.InternalRedirect(
								self.authenticated)
	raise cherrypy.InternalRedirect(self.not_authenticated)
@cherrypy.expose def logoff(self,logoff):
	cherrypy.lib.sessions.expire()
	cherrypy.session['authenticated']=None
	raise cherrypy.InternalRedirect(self.not_authenticated)

登录模块实现了一个实用函数 checkauth()(突出显示)。此函数设计为可以从 CherryPy 应用程序的任何位置调用。如果用户已经认证,它将返回用户名;否则,它将重定向用户到应显示登录界面的 URL。如果 returnpage 参数为真,则此 URL 将增加一个额外的参数 returnpage,包含调用 checkauth() 的页面的 URL。登录页面(或者更确切地说,实现它的处理程序)应该设计为在认证成功时将用户重定向到该参数中的 URL。

正如我们所见,checkauth() 函数的典型用途是在每个处理需要认证内容的页面调用它。

checkauth() 本身只做两件事:首先,它通过连接 cherrypy.request 对象中可用的 script_namepath_info 属性来确定要返回的页面(如果需要)。第一个包含 CherryPy 树挂载的路径,最后一个包含该树中的路径。它们共同形成了调用此 checkauth() 函数的处理器的完整路径。

checkauth() 函数的第二个作用是确定 cherrypy.session(一个类似于 Session 对象的字典)是否包含一个 authenticated 键。如果包含,则返回关联的值;如果不包含,则重定向到登录页面。

cherrypy.session 变量是一个 cherrypy.lib.sessions.Session 对象,对每个请求都可用。它像字典一样工作,并且最初它没有任何键。当为第一个新键分配值时,会创建一个与会话 ID 关联的持久对象。在完成请求后,Session 对象被存储,其会话 ID 作为响应头中的 session_id cookie 的值传递。如果后续请求包含带有 session_id cookie 的请求头,则从存储中检索具有相应会话 ID 的 Session 对象,使任何保存的键/值对再次可用。

默认的存储方案是将数据保存在内存中。这既快又简单,但缺点是重启 CherryPy 服务器将丢弃这些数据,实际上会过期所有会话。这可能适用于短暂的会话,但如果需要更持久的解决方案,可以将会话信息存储为文件(通过设置 tools.sessions.storage_type 配置键为 "file")或者甚至存储到数据库后端。有关会话的更多信息,请参阅 CherryPy 在线文档中的相关内容,网址为 cherrypy.org/wiki/CherryPySessions

会话期间客户端和服务器之间通信的各个步骤在以下插图中有展示:

发生了什么?

logon 模块的大部分功能由 Logon 类提供。它实现了几个方法(这些方法在上一页列出的代码中也被突出显示):

  • __init__() 将初始化一个 Logon 实例,以保存此 Logon 实例在处理程序树中挂载的路径,以及重定向到成功和失败认证的默认 URL。

  • checkpass() 是一个静态函数,它接受一个用户名和一个密码,如果这些是匹配的配对,则返回 True。它被设计为可以通过更合适的定义来覆盖。

Logon 还向 CherryPy 引擎公开了三个处理程序方法:

  • index() 是一个将实际登录屏幕提供给用户的方法

  • 当用户点击登录按钮时,logon() 会传递用户名和密码

  • logoff() 将使会话过期,导致后续对 checkauth() 的调用将用户重定向到登录屏幕

Logon 类还包含一些类变量,用于保存 index() 方法显示的 HTML。让我们详细看看这些方法。

注意

那么关于安全性呢?我们在这里设计的 Logon 类没有防止人们窃听传输 HTTP 流量的线缆的设施,如果他们有权访问这些线缆。这是因为我们未加密地传输密码。我们可能自己实现某种加密方案,但如果你的设计需要某种保护形式,通过安全的 HTTPS 通道进行通信可能更好、更简单。CherryPy 可以配置为使用 HTTPS 而不是 HTTP。更多相关信息可以在:cherrypy.org/wiki/ServerObject 找到。

突击测验会话 ID

  1. 如果客户端不断发送新的会话 ID,最终不会填满服务器上的所有存储空间吗?

  2. 如果客户端禁用了 cookie,会话 ID 的生成会发生什么?

    a. 服务器将停止生成新的会话 ID,反复返回相同的 ID

    b. 服务器将停止返回新的会话 ID

    c. 服务器将继续生成并返回新的会话 ID

提供登录屏幕

index() 方法将 HTML 提供给用户,以显示登录屏幕。在其核心,这个 HTML 是一个 <form> 元素,包含三个 <input> 元素:一个常规文本输入,用户可以输入他的/她的用户名;一个密码输入(将隐藏在此字段中输入的字符);以及一个具有 hidden 属性的 <input> 元素。《form》元素有一个 action 属性,它包含当用户点击登录按钮时将处理表单变量的脚本的 URL。此 URL 通过将 /logon 添加到 Logon 实例在 CherryPy 树中挂载的路径来构造,以指向我们的 Logon 类的 logon() 方法。

我们标记为隐藏的 <input> 元素被初始化为保存用户在 logon() 成功验证用户时将被重定向到的 URL。

构成登录界面的表单还包含一小段 JavaScript:

$("#username").focus()

它使用 jQuery 选择将接收用户名的输入元素,并给它聚焦。通过将光标放在这个字段中,我们节省了用户在输入用户名之前先指向并点击用户名字段的努力。现在他可以立即开始输入。请注意,此代码片段不是放在文档的末尾,而是在<input>元素之后,以确保在<input>元素定义后立即执行。登录页面如此之小,这可能无关紧要,但在页面加载缓慢的情况下,如果等到整个页面加载完毕才切换焦点,按键可能会被误导向。

注意

注意,我们构建的登录表单有一个带有action="GET"属性的<form>元素。这没问题,但有一个缺点:使用GET方法传递的参数会被附加到 URL 上,并可能出现在服务器的日志文件中。这在调试时很方便,但可能不适合生产环境,因为这可能会使密码暴露。不过,可以将action属性更改为POST,而无需更改处理请求的 Python 代码,因为 CherryPy 会处理这些细节。传递给POST方法的参数不会被记录,因此POST方法可能更适合密码验证请求。

设置会话

logon()方法将表单中所有<input>元素的值作为参数传递。usernamepassword参数传递给checkpass()方法,如果用户的凭据正确,我们通过将用户名与我们的会话存储中的认证密钥关联来建立会话,即cherrypy.session['authenticated']=username

这将产生这样的效果,即发送到浏览器的每个响应都将包含一个带有会话 ID 的 cookie,并且任何包含此 cookie 的后续对 CherryPy 的请求都将导致该请求的处理程序能够访问相同的会话存储。

在成功认证后,logon()会将用户重定向到传递给它的返回页面,如果没有传递,则重定向到Logon实例初始化时传递的默认页面。如果认证失败,用户将被重定向到非授权页面。

过期会话

提供了logoff()方法,以提供主动过期会话的可能性。默认情况下,会话在 60 分钟后过期,但用户可能希望明确注销,无论是为了确保没有人偷偷摸摸地坐在他的键盘后面继续以他的名义操作,还是为了以不同的身份登录。因此,在大多数应用程序中,你都会找到一个独立的注销按钮,通常位于右上角。此按钮(或只是一个链接)必须指向由logoff()方法处理的 URL,并且将立即通过删除所有会话数据来使会话无效。

注意,我们必须采取特殊预防措施以防止浏览器缓存 logoff() 方法的响应,否则它可能只是简单地重新显示上一次按下注销按钮时的响应,而没有实际上调用 logoff()。因为 logoff() 总是引发一个 InternalRedirect 异常,实际的响应来自不同的来源。例如,这个来源,Root 类中的 goaway() 方法必须配置为返回正确的响应头,以防止网络浏览器缓存结果。这是通过在 logonapp.py 中配置 goaway() 方法并使用 CherryPy 的 expires 工具来实现的,如下所示:

Chapter3/logonapp.py

@cherrypy.expose
	def goaway(self):
			return '''
<html><body>
<h1>Not authenticated, please go away.</h1>
</body></html>
''' goaway._cp_config = {
		'tools.expires.on':True,
		'tools.expires.secs':0,
		'tools.expires.force':True}

突出的行是我们配置处理程序(goaway() 方法)通过将配置字典分配给 _cp_config 变量来在响应中设置过期头。

注意

将变量分配给函数的一部分可能看起来很奇怪,但 Python 中的函数和方法只是对象,任何对象都可以有变量。对象定义后,还可以为新变量分配对象。在调用处理程序时,CherryPy 会检查该处理程序是否有 _cp_config 变量,并相应地执行。注意,@cherrypy.expose 装饰器也仅仅是将处理程序的 expose 变量设置为 true

让英雄尝试在电子表格应用程序中添加一个登录界面

在上一章中,我们创建了一个提供电子表格的应用程序。如果你只想向经过身份验证的用户提供这个电子表格,我们使用上一节中介绍的登录模块需要做哪些更改?

提示:你需要做三件事,其中一件涉及到在 CherryPy 树上挂载 Logon 类的实例,另一件是更改提供电子表格的处理程序以检查身份验证,最后你需要启用会话。

一个示例实现可以作为 spreadsheet3.py 提供。

设计任务列表

现在我们已经探讨了用户身份验证的方法,让我们来看看任务列表本身的实现。

如果任务列表的内容在浏览器关闭后消失,那么这个任务列表将无法使用。因此,我们需要某种方法来持久化存储这些任务列表。我们可以使用数据库,本书中的许多示例应用程序确实使用数据库来存储数据。对于这个应用程序,我们将选择使用文件系统作为存储介质,简单地将任务存储为包含任务信息的文件,并为每个用户设置单独的目录。如果我们处理大量的用户或非常长的任务列表,这种实现的性能可能不足以满足需求,但通过使用简单的文件进行存储,我们不需要设计数据库模式,这可以节省我们很多时间。

通过限制任务列表的长度,我们的用户界面可以保持相对简单,因为不需要分页或搜索。这并不意味着用户界面不应该易于使用!我们将集成 jQuery UI 的日期选择器小部件来帮助用户选择日期,并将工具提示添加到用户界面组件中,以提供我们任务列表应用程序的浅学习曲线。

最终要求大致定义了我们理解的任务是什么以及我们打算如何处理它:任务有一个描述和一个截止日期,因为它可以被标记为完成,所以它应该能够存储这一事实。此外,我们将此应用程序限制在添加和删除任务。我们明确不提供任何修改任务的方法,除了将其标记为完成。

运行 tasklist.py 时的行动时间

让我们先看看应用程序的外观:

  1. 从本章的代码目录启动tasklist.py

  2. 将你的浏览器指向http://localhost:8080

  3. 在登录屏幕上,输入用户作为用户名,输入秘密作为密码。

  4. 你现在看到的是一个看起来相当简陋且空的任务列表:运行 tasklist.py 时的行动时间

你应该能够在输入框中输入日期和描述,然后按下添加按钮来添加一个新任务。输入日期可以通过 jQuery UI 的日期选择器小部件来实现,一旦你点击日期输入字段,它就会弹出,如下截图所示:

运行 tasklist.py 时的行动时间

一旦你添加了一个或多个任务,你现在可以通过点击带有小垃圾桶图标按钮来删除这些任务,或者通过点击带有勾选图标按钮来标记它为完成。标记为完成的任务根据所选主题会有略微不同的背景颜色。如果你将任务标记为完成,其完成日期将会是今天。你可以通过点击任务的完成日期(对于未完成的任务显示为None)来选择不同的日期。选择日期后,点击完成按钮,所选日期将被存储为完成日期。以下截图展示了带有众多条目的任务列表:

运行 tasklist.py 时的行动时间

有一些隐藏的魔法可能不会立即明显。首先,所有任务都是根据它们的截止日期进行排序的。这是通过一些 JavaScript 和一个 jQuery 插件在客户端完成的,正如我们将在 JavaScript 部分看到的那样。同样,工具提示也是通过一些 JavaScript 完成的。在每个按钮上的悬停工具提示以及<input>元素内的内联帮助文本都是通过相同的脚本添加的。我们将对此进行深入探讨。

刚才发生了什么?

tasklist.py相当直接,因为它将大部分工作委托给了两个模块:我们在前面的章节中遇到的logon模块和一个处理显示和操作任务列表的task模块。

以下代码中突出显示的行显示了应用程序的核心。它使用合适的配置启动 CherryPy。请注意,我们启用了会话工具,这样我们就可以实际使用logon模块。此外,我们以这种方式构建 jQuery UI 主题样式表的路径,使其依赖于theme变量,以便简化应用程序主题的更改(第二次突出显示)。

我们传递给quickstart()Root类实例创建了一个简单的树:

/
/logon
/logon/logon
/logon/logoff
/task
/task/add
/task/mark

最高级 URL /通过调用Logon实例的index()方法返回与/login相同的内文。我们本可以使用InternalRedirect异常,但这同样简单。以/task开头的路径都由Task类的一个实例处理:

第三章/tasklist.py

import cherrypy
import os.path
import logon
import task
current_dir = os.path.dirname(os.path.abspath(__file__))
theme = "smoothness"
class Root(object):
	task = task.Task(logoffpath="/logon/logoff")
	logon = logon.Logon(path="/logon",
				authenticated="/task",
				not_authenticated="/")
	@cherrypy.expose
	def index(self):
			return Root.logon.index()
if __name__ == "__main__": cherrypy.quickstart(Root(),config={
	'/':
	{ 'log.access_file':os.path.join(current_dir,"access.log"),
	'log.screen': False,
	'tools.sessions.on': True
	},
	'/static':
	{ 'tools.staticdir.on':True,
	'tools.staticdir.dir':os.path.join(current_dir,"static")
	},
	'/jquery.js':
	{ 'tools.staticfile.on':True,
	'tools.staticfile.filename':os.path.join(current_dir,
	"static","jquery","jquery-1.4.2.js")
	},
	'/jquery-ui.js':
	{ 'tools.staticfile.on':True,
	'tools.staticfile.filename':os.path.join(current_dir,
	"static","jquery","jquery-ui-1.8.1.custom.min.js")
	},
	'/jquerytheme.css':
	{ 'tools.staticfile.on':True,
	'tools.staticfile.filename':os.path.join(current_dir,
	"static","jquery","css",theme,"jquery-ui-1.8.4.custom.css")
	},
	'/images':
	{ 'tools.staticdir.on':True, 'tools.staticdir.dir':os.path.join(current_dir,
	"static","jquery","css",theme,"images")
	}
})

Python:任务模块

task模块在task.py文件中实现。让我们看看构成这个文件的各个部分。

实现任务模块的行动时间

看看task.py中的 Python 代码:

第三章/task.py

import cherrypy
import json
import os
import os.path
import glob
from configparser import RawConfigParser as configparser
from uuid import uuid4 as uuid
from datetime import date
import logon

这一部分很好地展示了 Python 的“内置电池”哲学:除了cherrypy模块和我们的logon模块之外,我们还需要相当多的特定功能。例如,为了生成唯一的标识符,我们使用uuid模块,为了操作日期,我们使用datetime模块。所有这些功能都已经包含在 Python 中,为我们节省了大量的开发时间。下一部分是定义将包含我们的任务列表的基本 HTML 结构:

第三章/task.py

base_page = '''
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<script type="text/javascript" src="img/jquery.js" ></script>
<script type="text/javascript" src="img/jquery-ui.js" ></script>
<style type="text/css" title="currentStyle"> @import "/static/css/tasklist.css";
	@import "/jquerytheme.css";
</style> <script type="text/javascript" src="img/sort.js" ></script>
<script type="text/javascript" src="img/tooltip.js" ></script>
<script type="text/javascript" src="img/tasklist.js" ></script>
</head>
<body id="%s">
<div id="content">
%s
</div>
</body>
</html>
'''

结构再次很简单,但除了 jQuery UI 所需的主题样式表(以及我们添加到页面上的元素重用的样式表)之外,我们还需要一个针对我们的任务列表应用程序的特定样式表。它定义了构成我们的任务列表的元素的具体布局属性(首先突出显示)。突出显示的<script>元素表明,除了 jQuery 和 jQuery UI 库之外,我们还需要一些额外的库。每个库都值得解释。

刚才发生了什么?

第一个 JavaScript 库是sort.js,这是 James Padolsey(http://james.padolsey.com/tag/plugins/)的一个代码片段,它为我们提供了一个插件,允许我们排序 HTML 元素。我们需要这个插件来按截止日期排序任务列表。

第二个是 tooltip.js,它结合了来自多个来源的多种技术来实现我们的按钮的提示框以及<input>元素的行内标签。jQuery 有很多可用的提示框插件,但编写我们自己的插件可以给我们提供一些有价值的见解,因此我们将在后面的部分深入探讨这个文件。

最后一个是 tasklist.js。它使用了所有的 JavaScript 库和插件来实际样式化和排序任务列表中的元素。

task.py 的下一部分确定了我们从哪里运行应用程序的目录。我们需要这部分信息,因为我们把单个任务作为文件存储在这个目录的相对位置。gettaskdir() 函数负责确定给定用户名的确切路径(突出显示)。如果这些目录还不存在,它还会使用 os.makedirs() 函数创建 taskdir 目录和以用户名命名的子目录(注意函数名中的最后一个 's':如果它们还不存在,这个函数将创建所有中间目录):

第三章/task.py

current_dir = os.path.dirname(os.path.abspath(__file__)) def gettaskdir(username):
	taskdir = os.path.join(current_dir,'taskdir',username)
	# fails if name exists but is a file instead of a directory
	if not os.path.exists(taskdir):
			os.makedirs(taskdir)
	return taskdir

Task 类定义了 CherryPy 可能用来显示和操作任务列表的处理程序。__init__() 方法存储了一个位置路径,用户可以通过它结束会话。这个路径被其他方法用来创建合适的注销按钮。

index() 方法将向用户展示所有任务以及一个额外行,可以在其中定义新任务。正如我们所看到的,每个任务都附有删除任务或将其标记为完成的按钮。我们首先通过调用 logon 模块中的 checkauth() 函数来检查用户是否已认证(突出显示)。如果这个调用返回,我们就有一个有效的用户名,然后我们可以确定存储该用户任务的位置。

一旦我们知道这个目录,我们就使用 Python glob 模块的 glob() 函数检索具有 .task 扩展名的文件列表。我们将这个列表存储在 tasklist 变量中:

第三章/task.py

class Task(object):
def __init__(self,logoffpath="/logoff"):
	self.logoffpath=logoffpath
@cherrypy.expose
def index(self): username = logon.checkauth()
	taskdir = gettaskdir(username)
	tasklist = glob.glob(os.path.join(taskdir,'*.task'))

接下来,我们创建一个 tasks 变量,它将保存我们在遍历任务列表时构建的字符串列表。它使用一些元素初始化,这些元素共同构成了任务列表的标题。例如,它包含一个带有注销按钮的小表单以及列表上方列的标题。下一步是遍历表示任务的全部文件(突出显示),并创建一个带有适当内容以及删除和完成按钮的表单。

每个 .task 文件的结构与 Microsoft Windows 的 .ini 文件一致。这些文件可以使用 Python 的 configparser 模块进行操作。.task 文件结构为一个 [task] 部分,包含三个可能的键。这是一个格式示例:

[task]
description = something
duedate = 2010-08-26
completed = 2010-08-25

当我们初始化一个configparser对象时,我们传递给它一个包含默认值的字典,以防任何这些键缺失。当我们将打开的文件描述符传递给其readfp()方法时,configparser会读取一个文件。然后,可以使用get()方法检索给定部分中任何键的值,该方法将部分和键作为参数。如果键缺失,它将提供初始化时提供的默认值。第二个突出显示的行显示了如何使用它来检索description键的值。

接下来,我们为每个.task文件构建一个表单。它包含只读<input>元素来显示截止日期、描述和完成日期,以及删除任务或标记为完成的按钮。当点击这些按钮时,表单的内容会传递到/task/mark URL(由mark()方法处理)。该方法需要知道要更新哪个文件。因此,它被传递一个隐藏值:文件的基名。也就是说,没有前导目录的文件名,并且去除了其.task扩展名:

Chapter3/task.py

				tasks = [
'''
<div class="header">
Tasklist for user <span class="highlight">%s</span>
	<form class="logoff" action="%s" method="GET">
		<button type="submit" name="logoffurl"
				class="logoff-button" value="/">Log off
		</button>
	</form>
</div>
'''%(username,self.logoffpath),
'''
<div class="taskheader">
	<div class="left">Due date</div>
	<div class="middle">Description</div>
	<div class="right">Completed</div>
</div>
''','<div id="items" class="ui-widget-content">'] for filename in tasklist:
			d = configparser(
			defaults={'description':'',
					'duedate':'',
					'completed':None})
			id = os.path.splitext(os.path.basename(filename))[0]
			d.readfp(open(filename)) description = d.get('task','description')
			duedate = d.get('task','duedate')
			completed = d.get('task','completed')
			tasks.append(
'''
<form class="%s" action="mark" method="GET">
	<input type="text" class="duedate left"
			name="duedate" value="%s" readonly="readonly" />
	<input type="text" class="description middle"
			name="description" value="%s" readonly="readonly" />
	<input type="text" class="completed right editable-date tooltip"
			title="click to select a date, then click done"
			name="completed" value="%s" />
	<input type="hidden" name="id" value="%s" />
	<button type="submit" class="done-button"
			name="done" value="Done" >Done
	</button>
	<button type="submit" class="del-button"
			name="delete" value="Del" >Del
	</button>
</form>
'''%('notdone' if completed==None else 'done',
	duedate,description,completed,id))
		tasks.append(
'''
<form class="add" action="add" method="GET">
	<input type="text" class="duedate left editable-date tooltip"
			name="duedate" title="click to select a date" />
	<input type="text" class="description middle tooltip"
			title="click to enter a description" name="description"/>
	<button type="submit" class="add-button"
			name="add" value="Add" >Add
	</button>
</form>
</div>
''')
		return base_page%('itemlist',"".join(tasks))

最后,我们添加一个具有相同类型输入字段(截止日期描述)的额外表单,但这次没有标记为只读。这个表单有一个单独的按钮,会将内容提交到/task/add URL。这些将通过add()方法处理。index()方法实际返回的内容是由所有这些生成的行连接在一起,并嵌入到base_page变量的 HTML 中。

添加新任务

新任务是通过add()方法创建的。除了添加按钮的值(这并不相关)之外,它还将接受一个description和一个duedate作为参数。为了防止意外,它首先检查用户是否已认证,如果是,它确定这个用户的taskdir是什么。

我们正在添加一个新任务,因此我们想要在这个目录中创建一个新文件。为了保证它有一个唯一的名称,我们从这个目录的路径和 Python 的uuid()函数从uuid模块提供的全局唯一 ID 对象构造这个文件名。uuid对象的.hex()方法返回一个由十六进制数字组成的长字符串 ID,我们可以将其用作有效的文件名。为了使我们能够识别这个文件为任务文件,我们添加了.task扩展名(已突出显示)。

因为我们希望我们的文件可以被configparser对象读取,我们将使用configparser对象创建它,并向其中添加一个task部分,使用add_section()方法,并使用set()方法添加descriptionduedate键。然后我们打开一个用于写入的文件,并使用上下文管理器(with子句)中的文件句柄来操作该文件,从而确保如果在访问此文件时发生任何错误,它将被关闭,我们将再次将用户重定向到那个任务列表。请注意,我们使用由单个点组成的相对 URL 来获取索引页面。因为add()方法处理类似/task/add的 URL,重定向到.(单个点)将意味着用户将被重定向到/task/,这由index()方法处理:

第三章/task.py

@cherrypy.expose
def add(self,add,description,duedate):
	username = logon.checkauth()
	taskdir = gettaskdir(username) filename = os.path.join(taskdir,uuid().hex+'.task')
	d=configparser()
	d.add_section('task')
	d.set('task','description',description)
	d.set('task','duedate',duedate)
	with open(filename,"w") as file:
		d.write(file)
	raise cherrypy.InternalRedirect(".")

删除任务

删除或标记任务为完成都由mark()方法处理。除了 ID(现有.task文件的基名)外,它还接受duedatedescriptioncompleted参数。它还接受可选的donedelete参数,这些参数根据是否点击了完成或删除按钮而设置。

再次,首先的行动是确定用户是否已认证以及相应的任务目录是什么。有了这些信息,我们可以构建我们将要操作的文件名。我们注意检查id参数的有效性。我们期望它只包含十六进制字符的字符串,一种验证方法是将它使用int()函数转换,以 16 为基数参数。这样,我们防止恶意用户将文件路径传递给另一个用户的目录。尽管 32 个字符的随机字符串很难猜测,但小心总是好的。

下一步是查看我们是否正在对完成按钮的点击进行操作(以下代码中突出显示)。如果是,我们使用configparser对象读取文件,并更新其completed键。

completed键要么是我们作为completed参数传递的日期,要么是如果该参数为空或None时的当前日期。一旦我们更新了configparser对象,我们再次使用write()方法将其写回文件。

另一种可能性是我们正在对删除按钮的点击进行操作;在这种情况下,将设置delete参数。如果是这样,我们只需使用 Python 的os模块中的unlink()函数删除文件:

第三章/task.py

@cherrypy.expose
def mark(self,id,duedate,description,
			completed,done=None,delete=None):
	username = logon.checkauth()
	taskdir = gettaskdir(username)
	try:
			int(id,16)
	except ValueError:
			raise cherrypy.InternalRedirect(self.logoffpath)
	filename = os.path.join(taskdir,id+'.task') if done=="Done":
			d=configparser()
			with open(filename,"r") as file:
				d.readfp(file)
			if completed == "" or completed == "None":
				completed = date.today().isoformat()
			d.set('task','completed',completed)
			with open(filename,"w") as file:
				d.write(file)
elif delete=="Del":
			os.unlink(filename)
raise cherrypy.InternalRedirect(".")

JavaScript: tasklist.js

我们提供给最终用户的按钮需要配置为以适当的方式响应用户的点击,并且如果这些按钮显示一些直观的图标那就更好了。这就是我们在tasklist.js中要处理的事情。

为按钮添加样式的时间

tasklist.js完成的工作主要涉及样式化<button>元素,并为<input>元素添加工具提示和内联标签。到目前为止的结果如下截图所示:

操作时间:按钮样式化

刚才发生了什么?

tasklist.js的第一行所示(代码从下一页开始),要完成的工作是在加载完整文档后通过传递给 jQuery 的$(document).ready()函数来安排。

第一步是向任何带有header类的元素添加ui-widgetui-widget-header类。这将使这些元素以与所选主题一致的方式被样式化。

然后我们配置添加按钮(或者更确切地说,任何带有add-button类的元素)作为 jQuery UI 按钮小部件。传递给它的选项对象将配置它不显示文本,而只显示一个表示粗加号的单一图标。我们还向按钮的点击处理器中添加了一个额外的函数,该函数检查任何带有inline-label类的元素,看其内容是否与其title属性的内容相同。如果是这样,我们将内容设置为空字符串,因为这表示用户没有在这个元素中填写任何内容,我们不希望将内联标签的文本存储为我们新任务的(关于这一点,请参阅关于提示的章节)。请注意,我们没有做任何防止点击事件传播的事情,所以如果这个按钮是submit类型(并且我们的添加按钮是),submit操作仍然会被执行。

所有带有del-button类(突出显示)的元素随后被应用了一个垃圾桶的图标。按钮还接收一个额外的点击处理器,该处理器将从它们的兄弟元素(同一表单中的输入字段)中移除禁用属性,以确保提交操作能够接收到标记为禁用的字段的内容。

接下来,其他<button>元素被添加了适当的图标,并且对于任何文本或密码<input>元素,我们添加一个textinput类来标记它以便于提示库使用。

在第二行突出显示的代码中,我们遇到了 jQuery UI 的日期选择器小部件。日期选择器小部件极大地简化了用户输入日期的过程,并且现在在要求用户输入日期的任何 Web 应用程序或网站上几乎是标准配置。jQuery UI 的日期选择器使用起来非常简单,但提供了大量的配置选项(所有这些选项都有文档记录在jqueryui.com/demos/datepicker/)。

我们使用dateFormat选项来配置日期选择器,以便将日期存储为 YYYY-MM-DD 格式。日期选择器有许多预定义的格式,而这个格式恰好是一个国际标准,并且适合以简单的方式排序日期。我们还配置了日期选择器,当用户关闭日期选择器时调用一个函数。这个函数会移除任何inline-label类,防止新输入的日期出现在与任何内联标签相关的颜色中(正如我们稍后看到的,当查看tasklist.css时,我们以独特的方式为带有inline-label类的任何元素的样式设置颜色)。

之前,我们提到过我们想要按截止日期顺序展示任务列表。因此,我们将 sort.js 中的 sort() 插件应用于所有具有 duedate 类的 <input> 元素。sort() 函数接受两个参数。第一个参数是一个比较函数,它接收两个元素进行比较。在我们的情况下,那将是包含 YYYY-MM-DD 格式日期的 <input> 元素,因此我们可以简单地比较这些元素的值作为文本,并返回正负一。第二个参数是一个不接受任何参数的函数,应该返回要排序的元素。具有截止日期的输入元素作为该函数内的 this 变量可用,我们使用它来返回输入元素的父元素。这个父元素将是包含它的 <form> 元素,因为我们把每个任务表示为一个表单,我们希望这些表单被排序,而不仅仅是这些表单内的 <input> 元素。

tasklist.js 中的最后一组操作将 disabled 属性添加到具有 done 类的元素内的任何 <input> 元素,并禁用任何完成按钮。这将确保标记为完成的任务不能被更改:

第三章/tasklist.js

$(document).ready(function(){
	$(".header").addClass("ui-widget ui-widget-header");
	$(".add-button").button(
		{icons:{primary: 'ui-icon-plusthick' },
		text:false}).click(function(){
		$(".inline-label").each(function() {
				if($(this).val() === $(this).attr('title')) {
					$(this).val('');
				};
	})
}); $(".del-button").button( 
	{icons:{primary: 'ui-icon-trash' },
	text:false}).click(function(){
		$(this).siblings("input").removeAttr("disabled");
});
$(".done-button").button( {icons: {primary:'ui-icon-check'},
							text:false});
$(".logoff-button").button({icons: {primary:'ui-icon-closethick'},
							text:false});
$(".login-button").button( {icons: {primary:'ui-icon-play'},
							text:false});
$(":text").addClass("textinput");
$(":password").addClass("textinput"); $( ".editable-date" ).datepicker({
		dateFormat: $.datepicker.ISO_8601,
		onClose: function(dateText,datePicker){
		if(dateText != ''){$(this).removeClass("inline-label");}}
	});
$("#items form input.duedate").sort(
		function(a,b){return $(a).val() > $(b).val() ? 1 : -1;},
		function(){ return this.parentNode; }).addClass(
												"just-sorted");
$(".done .done-button").button( "option", "disabled", true );
$(".done input").attr("disabled","disabled");
});

JavaScript: tooltip.js

tooltip.js 这个名字有点误导,因为它最有趣的部分并不是关于工具提示,而是内联标签。内联标签是一种通过将文本放在文本输入元素内部来传达有用信息的方式,而不是通过悬停工具提示。当用户点击输入字段并开始输入时,这些文本就会消失。网上可以找到许多实现方式,但我找到的最清晰、最简洁的一个来自trevordavis.net/blog/tutorial/jquery-inline-form-labels/

实现内联标签的时间

再次看看任务列表的截图:

实现内联标签的时间

高亮部分显示了我们所指的内联标签。输入字段显示一些有用的文本来指示其用途,当我们点击这样的字段时,这些文本将消失,我们可以输入自己的文本。如果我们点击输入字段外的地方而没有输入任何文本就取消输入,内联标签将再次显示。

刚才发生了什么?

tooltip.js 展示了一些重要的概念:首先是如何将一个函数应用于选择集的每个成员(高亮显示)。在这种情况下,我们将该函数应用于所有具有 title 属性的 <input> 元素。在传递给 each() 方法的函数中,选中的 <input> 元素在 this 变量中可用。如果一个 <input> 元素的全部内容为空,我们将其内容更改为 title 属性的内容,并给 <input> 元素添加 inline-label 类。这样,如果我们愿意,我们可以以不同的方式样式化内联标签的文本,例如,使其稍微轻一些,以便不那么突出。

展示的第二个概念是绑定到焦点模糊事件。当用户点击<input>元素或使用Tab键导航到它时,它会获得焦点。我们可以通过向focus()方法传递一个函数来对此事件进行操作。在这个函数中,获得焦点的<input>元素再次在this变量中可用,我们检查这个<input>元素的内容是否与其title属性的内容相等。如果是这样,那么用户尚未更改内容,因此我们通过将空字符串分配给它来清空这个元素(突出显示)。

同一行还展示了 jQuery 中的另一个重要概念,即链式调用。大多数 jQuery 方法(如本例中的val())返回它们所作用的选择,允许对同一选择应用其他方法。在这里,我们应用removeClass()来移除inline-label类,以显示用户在此<input>元素中输入的文本,并使用常规字体和颜色。

我们还处理失去焦点的情况(通常称为模糊),例如,当用户点击<input>元素外部或使用Tab键导航到另一个元素时。因此,我们向blur()方法传递一个函数。这个函数检查<input>元素的内容是否为空。如果是这样,那么用户没有输入任何内容,我们再次插入title属性的内容,并用inline-label类标记该元素。

第三章/tooltip.js

$(document).ready(function() { $('input[title]').each(function() {
		if($(this).val() === '') {
			$(this).val($(this).attr('title'));
			$(this).addClass('inline-label');
		}
		$(this).focus(function() {
			if($(this).val() === $(this).attr('title')) { $(this).val('').removeClass('inline-label');
			}
		});
		$(this).blur(function() {
			if($(this).val() === '') {
				$(this).val($(this).attr('title'));
				$(this).addClass('inline-label');
			}
		});
	});
});

CSS: tasklist.css

如果没有一些额外的样式来调整布局,我们的任务列表应用程序看起来可能会有些杂乱,就像之前看到的那样。

我们的主要挑战是对齐所有列并将所有按钮一致地移动到右侧。构成 HTML 标记中列的所有元素都带有类来表示它们属于左侧、中间或右侧列。我们只需根据它们的类设置它们的宽度(突出显示)即可对齐这些列。

tasklist.css的其余大部分内容涉及将元素浮动到右侧(如按钮)或左侧(容器,如具有id属性content<div>元素)。大多数容器不仅向左浮动,而且明确设置为 100%的宽度,以确保它们填充它们所包含的元素。这并不总是必要的,但如果我们不加以注意,如果元素没有填满其包含的元素,包围元素的背景色可能会显示出来:

第三章/tasklist.css

input[type="text"] {
	font-size:1.1em;
	margin:0;
	border:0;
	padding:0;} .left, .right { width: 8em; }
.middle { width: 20em;}
form {
	float:left;
	border:0;
margin:0;
padding:0;
	clear:both;
	width:100%; }
form.logoff{
float:right;
	border:0;
margin:0;
padding:0;
	clear:both;
width:auto;
	font-size:0.5em;}
#items { float:left; clear:both; width:100%; }
.header { width:100%; }
.taskheader, .header, #content{ float:left; clear:both;}
.taskheader div { float:left; font-size:1.1em; font-weight:bold;}
.logoff-button, .done-button, .del-button, .add-button { float:right;}
.done-button, .add-button, .del-button { width: 6em; height: 1.1em; }
#content { min-width:900px;}

注意,我们的样式表仅处理测量和字体大小。任何着色都是通过选择的 jQuery UI 主题应用的。应用了样式后,应用程序看起来整洁多了:

CSS: tasklist.css

突击测验样式屏幕元素

  1. tasklist.js中,我们明确配置了所有按钮仅显示图标而不显示任何文本。但如果我们想同时显示图标和一些文本,我们该怎么办?

  2. 如果我们没有明确设置组成任务的表单的宽度为 100%,最大的缺点会是什么?

尝试一下:更改日期选择器的日期格式

将日期显示为 ISO 8701(或 YYYY-MM-DD)并不是所有人的可读日期格式。对许多人来说,默认的 mm/dd/yy 格式要容易阅读得多。你将如何修改 tasklist.js 以使用这种默认日期格式显示任务?提示:仅当调用 datepicker() 插件时省略 dateFormat 选项是不够的,你还需要更改比较函数以适当地排序任务。

对于那些不耐烦或好奇的读者:一个示例实现作为 tasklist2.js 提供(启动 tasklist2.py 以查看效果)。

尝试一下:从一个不同的 URL 提供任务列表

衡量一段代码的可重用性的一个方法,是在你设计它时还没有考虑到的情境中使用它。当然,这并不意味着我们的任务模块应该能够作为一个汽车制造厂的控制系统应用程序,但如果我们希望它成为从同一根目录提供的一组应用程序的一部分,我们需要做些什么改变吗?

假设我们想要从 URL /apps/task 而不是 /task 提供任务列表应用程序,我们需要做哪些更改?

提示:在 CherryPy 中,你可以通过将对象实例分配给传递给 quickstart() 方法的对象实例的类变量来创建一个 URL 树。

一种可能的实现可以在 tasklistapp.py 中找到。

摘要

在本章中,我们学习了关于会话管理和在服务器上存储持久信息的大量内容。具体来说,我们看到了如何设计任务列表应用程序并实现登录界面。我们了解了会话是什么,以及它是如何使我们能够同时与不同用户工作的,以及如何与服务器交互、添加或删除任务。我们还学习了如何使用 jQuery UI 的日期选择器小部件使输入日期变得吸引人且简单,以及如何对按钮元素进行样式化,并为输入元素提供工具提示和内联标签。

现在你对在服务器上存储数据有了更多了解,你可能想知道在服务器文件系统上以纯文件形式存储信息是否是最方便的解决方案。在许多情况下,这并不是最佳选择,可能需要一个数据库,这正是下一章的主题。

第四章:任务列表 II:数据库和 AJAX

在本章中,我们将重构我们的任务列表应用程序。它将在服务器上使用数据库引擎来存储条目,并使用 jQuery 的 AJAX 功能动态更新网络应用程序的内容。在服务器端,我们将学习如何使用 Python 的内置 SQLite 数据库引擎。在展示方面,我们将遇到 jQuery UI 的事件系统,并学习如何响应用户的鼠标点击。

在本章中,我们将:

  • 学习使用数据库引擎的一些好处

  • 熟悉 SQLite,Python 一起分发的数据库引擎

  • 使用 SQLite 实现密码数据库

  • 学习如何设计和开发一个数据库驱动的任务列表应用程序

  • 实现一个测试框架

  • 学习如何使用 AJAX 调用使网络应用程序更具响应性

  • 看看如何在不使用<form>元素的情况下实现交互式应用程序

那么,让我们开始吧...

与文件系统相比,数据库的优势

将记录存储在文件系统上的单独文件中可能很简单,但确实有几个缺点:

  • 您必须定义自己的接口来访问这些文件并解析其内容。这比听起来要严重得多,因为它迫使您开发并测试大量您本可以从现有库中免费获得的具体功能

  • 访问单个文件比从数据库表中选择记录要慢得多。如果您知道您想要哪个记录(就像在我们的任务列表应用程序中那样),这可能可行,但如果你想要根据某些属性的值来选择记录,那就绝对不可行。这将需要打开每个文件并检查某些属性是否与您的标准匹配。在一个包含数百个或更多条目的数据集中,这将非常慢

  • 此外,实现事务也很困难。如果我们想保证一组操作要么全部成功,要么在部分操作失败时回滚,那么如果我们想在文件系统上使用文件,我们就必须自己编写非常复杂的代码

  • 当在文件系统上使用文件时,定义和维护记录之间的关系很麻烦,尽管我们的任务列表应用程序非常简单,但几乎任何其他应用程序都有多个逻辑对象以及它们之间的关系,因此这是一个严重的问题。

选择一个数据库引擎

可供 Python 访问的数据库引擎有很多,包括商业的和开源的(http://wiki.python.org/moin/DatabaseInterfaces)。选择正确的数据库不是一件简单的事情,因为它不仅可能取决于功能需求,还可能取决于性能、可用预算以及难以定义的需求,如易于维护。

在本书中我们开发的应用程序中,我们选择使用 SQLite 数据库引擎(www.sqlite.org)出于多个原因。首先,它是免费的,并且包含在 Python 的标准发行版中。这对写书的人来说很重要,因为它意味着能够运行 Python 的每个人都可以访问 SQLite 数据库引擎。然而,这并不是一个玩具数据库:事实上,SQLite 是许多智能手机和像 Firefox 这样的高知名度应用程序中使用的数据库,用于存储配置和书签等信息。此外,它可靠且健壮,而且速度相当快。

它确实也有一些缺点:首先,它使用自己的 SQL 方言(用于与数据库交互的语言),但公平地说,大多数数据库引擎都使用自己的方言。

更严重的是,SQLite 的重点在于嵌入式系统,最明显的后果是它没有提供限制用户访问表和列子集的功能。在文件系统中只有一个文件包含数据库的内容,对该文件的访问权限由其所在的文件系统决定。

最后一个问题与其说是缺点,不如说是一个需要认真注意的点:SQLite 不强制执行类型。在许多数据库中,为列定义的类型严格决定了你可以存储在该列中的内容。当一列被定义为 INTEGER 时,通常数据库引擎不会允许你存储字符串或布尔值,而 SQLite 则允许这样做。一旦你将其与 Python 管理变量的方式进行比较,这并不像听起来那么奇怪。在 Python 中,定义一个变量并将其分配一个整数是完全可以接受的,稍后可以将字符串分配给同一个变量。Python 中的变量就像 SQLite 中的列一样;它只是一个指向值的指针,而这个值不仅仅是值本身,还与一个显式关联的类型相关联。

可用性、可靠性和类型系统与 Python 原生处理值的方式紧密相似,这使得 SQLite 在许多应用程序中成为一个非常适合的数据库引擎,尽管具体的应用程序可能需要其他数据库引擎,如 PostgreSQL 或 MySQL 来更好地满足需求。如果您的应用程序将在已经提供 MySQL 的 Web 服务器上运行,那么后者可能是一个有吸引力的替代方案。

数据库驱动认证

在我们开始设计数据库驱动的任务列表应用程序之前,让我们首先熟悉 SQLite 在看似更简单的一组要求中的使用情况:在数据库中存储用户名/密码组合,并重构 Logon 类以与该数据库交互。

功能需求看似简单:验证用户名/密码组合是否有效,我们只需确认提供的用户名/密码组合是否存在于用户名和密码表中。这样的表包含两列,一列名为username,另一列名为password。由于将密码以明文形式存储从来都不是一个好主意,我们使用哈希函数加密密码,这样即使密码数据库被破坏,坏人也会很难恢复密码。这意味着,当然,我们必须使用相同的哈希函数对给定的密码进行哈希处理,然后再将其与存储的用户名对应的密码进行比较,但这并不会增加太多复杂性。

增加复杂性的是,CherryPy 是多线程的,这意味着 CherryPy 由多个轻量级进程访问相同的数据组成。尽管 SQLite 的开发者认为线程是邪恶的(www.sqlite.org/faq.html#q6),但在应用程序中花费大量时间等待的情况下,线程是非常有意义的。这当然适用于花费大量时间等待网络流量完成的 Web 应用程序,即使在宽带连接的时代也是如此。利用这种等待时间最有效的方法是启用不同的线程来服务另一个连接,这样更多的用户可以享受到更好的交互体验。

注意

哈希函数(或者更具体地说,加密哈希函数)以某种方式将任何输入字符串转换为有限长度的输出字符串,使得两个不同的输入字符串产生相同输出字符串的可能性非常低。此外,从输入到输出的转换是一个单向操作,或者至少从输出构造输入将需要大量的计算能力。已知有许多有用的哈希函数,其中最流行的是 Python 的hashlib模块中可用的。我们这里使用的特定哈希函数称为SHA1

更多关于哈希的信息可以在 Python 文档中找到,链接为docs.python.org/py3k/library/hashlib.html,或者在维基百科上查看en.wikipedia.org/wiki/Cryptographic_hash_function

然而,在 SQLite 中,连接对象不能在多个线程之间共享。这并不意味着我们不能在多线程环境中使用 SQLite(尽管线程是邪恶的),但这确实意味着如果我们想从不同的线程访问相同的 SQLite 数据库,每个线程都必须使用为该线程独家创建的连接对象。

幸运的是,指导 CherryPy 在启动新线程时调用一个函数,并让该函数创建一个新的数据库连接,这相当简单,我们将在下一节中看到。如果我们使用许多不同的线程,这可能会造成浪费,因为连接对象会占用一些内存,但只有几十个线程,这并不会造成太大的问题(CherryPy 的默认线程数是 10,可以通过 server.thread_pool 配置选项进行配置)。如果内存消耗是一个问题,还有其他解决方案可用,例如,可以是一个单独的工作线程来处理所有数据库交互,或者是一小群这样的线程。这个起点可能是tools.cherrypy.org/wiki/Databases

使用数据库进行行动认证

为了说明如何使用数据库驱动的用户认证,运行 logondbapp.py。它将向您展示一个登录界面,与上一章中显示的非常相似。您可以输入内置的用户名/密码组合 admin/admin,之后您将看到一个欢迎页面。

为了使这个迷你应用程序能够与基于数据库的用户认证版本一起工作,我们只需要将 Logon 类的实例引用替换为 LogonDB 类的一个实例,如以下代码中高亮显示的那样(完整的代码作为 logondbapp.py 提供):`

Chapter4/logondbdb.py

import cherrypy
import logondb
class Root(object): logon = logondb.LogonDB(path="/logon", authenticated="/", not_
authenticated="/goaway", db="/tmp/pwd.db")
	@cherrypy.expose
	def index(self):
		username=Root.logon.checkauth('/logon')
		return '<html><body><p>Hello user <b>%s</b></p></body></
html>'%username
	@cherrypy.expose
	def goaway(self):
		return '<html><body><h1>Not authenticated, please go away.</h1></
body></html>'
	goaway._cp_config = {'tools.expires.on':True,'tools.expires.
secs':0,'tools.expires.force':True}
	@cherrypy.expose
	def somepage(self):
		username=Root.logon.checkauth('/logon',returntopage=True)
		return '<html><body><h1>This is some page.</h1></body></html>'
if __name__ == "__main__":
	import os.path
	current_dir = os.path.dirname(os.path.abspath(__file__))
	root = Root() def connect(thread_index):
		root.logon.connect()
	cherrypy.engine.subscribe('start_thread', connect)
	cherrypy.quickstart(root,config={ ... } )

与之前的实现相比,另一个重要的区别是高亮显示的 connect() 函数的定义,这个函数应该在 CherryPy 启动每个新线程时调用。它调用 LogonDB 实例的 connect() 方法来创建一个针对给定线程唯一的数据库连接对象。我们使用 cherrypy.engine.subscribe() 函数注册这个函数,并让它在每个 CherryPy 启动的新线程开始时调用我们的 connect() 函数。

刚才发生了什么?

我们 Logon 类的数据库版本 LogonDBLogon 类继承了很多内容。具体来说,所有与 HTML 相关的逻辑都被重用了。LogonDB 重写了 __init__() 方法来存储数据库文件的路径,并确保如果数据库文件不存在,则使用 initdb() 方法初始化数据库(高亮显示)。它还重写了 checkpass() 方法,因为这个方法现在必须验证有效的用户名/密码对是否存在于数据库表中。

Chapter4/logondb.py

import logon
import sqlite3
from hashlib import sha1 as hash
import threading
import cherrypy class LogonDB(logon.Logon):
	def __init__( self,path="/logon", authenticated="/", not_
authenticated="/", db="/tmp/pwd.db"):
		super().__init__(path,authenticated,not_authenticated)
		self.db=db
		self.initdb()
	@staticmethod
	def _dohash(s):
		h = hash()
		h.update(s.encode())
		return h.hexdigest() def checkpass(self,username,password):
		password = LogonDB._dohash(password)
		c = self.data.conn.cursor()
		c.execute("SELECT count(*) FROM pwdb WHERE username = ? AND 
password = ?",(username,password))
		if c.fetchone()[0]==1 :return True
		return False
	def initdb(self):
		conn=sqlite3.connect(self.db)
		c = conn.cursor() c.execute("CREATE TABLE IF NOT EXISTS pwdb(username unique not 
null,password not null);")
		c.execute('INSERT OR IGNORE INTO pwdb 
VALUES("admin",?)',(LogonDB._dohash("admin"),))
		conn.commit()
		conn.close()
		self.data=threading.local()
	def connect(self):
		'''call once for every thread as sqlite connection objects cannot 
be shared among threads.'''
		self.data.conn = sqlite3.connect(self.db)

数据库的定义由一个名为 pwdb 的单表组成,该表在突出显示的行中定义(并且只有当该表尚不存在时)。pwdb 表由两列组成,即 usernamepassword。通过将这两列都标记为 not null,我们确保不能在它们中输入任何空值。username 列也被标记为 unique,因为用户名只能出现一次。这种单表数据库模式可以用以下图表表示,其中每个列都有一个带有名称的标题和列出特定列属性的几行(随着我们的数据库设计变得更加复杂,我们将更多地依赖这些图表,而不是详细展示 SQL 代码):

Pwdb
用户名 密码
--- ---
not nullunique not null

备注

熟悉其他 SQL 方言的人可能会注意到列定义缺少任何类型。这是故意的:SQLite 允许我们在列中存储任何类型的值,就像 Python 允许我们在变量中存储任何类型的值一样。值的类型直接与值相关联,而不是与列或变量相关联。SQLite 支持亲和力或首选类型的概念,我们将在本书中创建的其他表中遇到这一点。

除了创建表(在 initdb() 方法中突出显示)之外,如果需要,我们还会用管理员/管理员组合(admin/admin)初始化它,如果管理员用户名尚不存在。如果它已经存在,我们就让它保持原样,因为我们不希望重置已更改的密码,但我们确实想确保存在一个管理员用户名。这是通过 insert or ignore 语句实现的,因为将 admin 用户名插入到已包含一个的表中会因唯一约束而失败。添加非标准或 ignore 子句将忽略此类情况,换句话说,如果已经存在,它将不会插入具有管理员用户名的记录。

insert 语句还说明了我们存储密码不是以明文形式,而是以散列值(这些散列值非常难以再次转换为明文)的形式。我们在这里使用的散列方法是 SHA1,它作为 hash() 从 Python 的 hashlib 模块导入。从明文到散列值的转换由 _dohash() 静态方法(通过其名称前的下划线标记为私有,但请注意,在 Python 中,这仅是一种约定,因为实际上并没有真正的私有方法)处理。

备注

在这个例子中,我们存储密码的方式对于生产环境来说仍然不够安全,但实现更安全的解决方案超出了本书的范围。我强烈建议阅读 www.aspheute.com/english/20040105.asp 了解更多关于这个主题的内容。

initdb() 方法还负责使用 threading.local() 函数创建一个对象,该对象可以用来存储线程本地的数据。因为通常线程中的所有数据都是共享的,我们必须使用这个函数来创建一个地方来存储每个线程不同的数据库连接对象。如果我们把这样的连接对象存储在全局变量中,每个线程都会访问相同的数据库连接,这是 SQLite 不允许的。

我们将密码存储为散列值的事实意味着检查用户名/密码组合必然涉及到在检查存在性之前将明文密码转换为散列值。这通过 checkpass() 方法(突出显示)实现。密码参数在传递给 execute() 方法之前,使用 _dohash() 方法进行转换。

SQL 语句本身计算 pwdb 表中包含给定用户名和(散列的)密码的行数,并检索结果。结果是包含单个值的单行,即匹配行的数量。如果这个数量是 1,我们就有一个有效的用户名/密码组合,否则没有。我们不会区分用户名未知的情况或是否存在多个包含相同用户名的行。这是因为后者由于用户名列上的 unique 约束而不太可能发生。

尝试添加新的用户名/密码的英雄

我们的 LogonDB 类还没有一个方法可以将新的用户名/密码组合添加到数据库中。你将如何实现一个?

提示:你需要提供一个公开的方法,该方法提供一个带有表单的页面,可以在其中输入新的用户名和密码,以及一个可能作为 <form> 元素的 action 属性的方法,并将用户名和密码作为参数传递。

注意,这个方法必须检查用户是否已认证,而且还必须检查添加新用户名/密码的用户是否是管理员,否则每个人都可以添加新账户!logondb.py 中已经提供了一个示例实现。

Tasklist II 存储任务到数据库中

现在我们已经看到了如何使用数据库引擎来存储持久数据,以及如何从 CherryPy 应用程序中访问这些数据,让我们将新知识应用到我们在上一章中设计的任务列表应用程序中。当然,一个应用程序不仅仅是存储数据,我们还将重新设计用户界面,使其更具响应性,并且稍微简单一些以维护。

通过 AJAX 提高交互性

当你查看独立于 PC 的独立应用程序与 Web 应用程序之间的差异时,你可能会在第一眼看到一些细微的差异。然而,如果你更仔细地观察,你会发现一个主要差异:在独立应用程序中,当显示发生变化时,只有实际修改的屏幕元素会被重新绘制。

在传统的网页中,这完全不同。例如,点击一个改变列表排序顺序的按钮,可能会不仅检索并重新绘制那个列表,还可能检索一个完整的页面,包括所有侧边栏、导航标题、广告等等。

如果未修改的内容在互联网上检索缓慢,整个网页可能会感觉反应迟缓,尤其是如果整个网页都在等待最后一块信息的到来以展示其全部魅力。当网页发展到模仿应用程序时,这种交互体验的差异很快变成了一个麻烦,人们开始考虑解决方案。

这些解决方案中最突出的一种是 AJAX。它是异步 JavaScript 和 XML 的缩写,即一种使用浏览器内置的 JavaScript 功能检索数据的方法。如今,每个浏览器都支持 AJAX,jQuery 库平滑了大多数浏览器的不一致性。名称中的 XML 部分不再相关,因为 AJAX 调用可能检索的数据几乎可以是任何东西:除了 XML 和其近亲 HTML 之外,JavaScript 对象表示法(JSON)是一种流行的数据传输格式,它可以通过浏览器中的 JavaScript 解释器比 XML 更简单地处理数据。

AJAX 名称中的异步部分仍然相关:大多数 AJAX 调用在检索数据后会立即返回,而不等待结果。然而,当数据检索完成时,它们会调用一个函数。这确保了应用程序的其他部分不会停滞,并且可以改善整个 Web 应用程序的交互体验。

使用 AJAX 获取时间的时间操作

输入以下代码并运行它。如果你将你的网络浏览器指向熟悉的 localhost:8080 地址,你将看到下面的图片,时间大约每五秒变化一次。(代码也作为 timer.py 提供)

使用 AJAX 获取时间的时间操作

刚才发生了什么?

我们的小 CherryPy 应用程序只提供两种方法(在代码中都已突出显示)。index() 方法返回一个极简的 HTML 页面,其中包含一些静态文本和一小段 JavaScript 代码,负责从服务器检索当前时间。它还包含一个 time() 方法,该方法简单地返回纯文本格式的当前时间。

Chapter4/timer.py

import cherrypy
import os.path
from time import asctime
current_dir = os.path.dirname(os.path.abspath(__file__))
class Root(object):
	@cherrypy.expose def index(self):
		return '''<html>
		<head><script type="text/javascript" src="img/jquery.js" ></script></
head>
		<body><h1>The current time is ...</h1><div id="time"></div>
		<script type="text/javascript"> window.setInterval(function(){$.ajax({url:"time",cache:false,success:
function(data,status,request){
			$("#time").html(data);
		}});},5000);
		</script>
		</body>
		</html>'''
	@cherrypy.expose def time(self,_=None):
		return asctime()
cherrypy.quickstart(Root(),config={
	'/jquery.js':
	{ 'tools.staticfile.on':True,
	'tools.staticfile.filename':os.path.join(current_
dir,"static","jquery","jquery-1.4.2.js")
	}
})

魔法在于那小块 JavaScript(已突出显示)。此脚本在静态页面加载后执行,并调用 window 对象的 setInterval() 方法。setInterval() 方法的参数是一个匿名函数和一个以毫秒为单位的时间间隔。我们将时间间隔设置为五秒。传递给 setInterval() 的函数在每个间隔结束时被调用。

在这个例子中,我们向 setInterval() 传递一个匿名函数,该函数依赖于 jQuery 的 ajax() 函数来检索时间。ajax() 函数的唯一参数是一个对象,该对象可能包含许多选项。url 选项告诉使用哪个 URL 来检索数据,在这种情况下,相对 URL time(相对于嵌入脚本的页面提供的内容,/,因此它实际上指的是 http://localhost:8080/time)。

cache 选项被设置为 false 以防止浏览器在指令获取它已经看到的 URL 的时间时使用缓存的结果。这是通过底层 JavaScript 库通过在 URL 后附加一个额外的 _ 参数(即该参数的名称,由一个单下划线组成)来确保的。这个额外的参数包含一个随机数,因此浏览器会将每次调用视为对新的 URL 的调用。time() 方法被定义为接受此参数,因为否则 CherryPy 会引发异常,但参数的内容会被忽略。

success 选项被设置为当数据成功接收时将被调用的函数。当函数被调用时,它将接收三个参数:由 ajax() 函数检索到的数据、状态和原始请求对象。我们在这里只使用数据。

我们选择具有 time ID 的 <div> 元素,并通过将其数据传递给其 html() 方法来替换其内容。请注意,尽管 time() 方法只是生成文本,但它也可以通过这种方式返回包含一些标记的文本。

我们明确指令 ajax() 函数不要缓存查询的结果,但我们也可能用 CherryPy 的 expires 工具装饰我们的 time() 方法。这将指令 time() 方法在响应中插入正确的 http 头部,以指示浏览器不要缓存结果。这在上面的代码(在 timer2.py 中可用)中得到了说明。

@cherrypy.tools.expires(secs=0,force=True)
	@cherrypy.expose
	def time(self,_=None):
		return asctime()

使用 @cherrypy.tools.expires 装饰器意味着我们不必指令 ajax() 方法不要缓存结果,这给了我们使用快捷方法的选择。然后,JavaScript 代码可以被重写为使用 jQuery 的 load() 方法,如下所示:

<script type="text/javascript">
window.setInterval(function(){$("#time").load("time");},5000);
</script>

load() 方法被传递了它将从中检索数据的 URL,并且在成功的情况下,会用接收到的数据替换选定的 DOM 元素 的内容。

注意

jQuery 提供了许多 AJAX 快捷方法,并且所有这些方法都共享一组通用的默认设置,可以使用 ajaxSetup() 函数来设置。例如,为了确保所有 AJAX 方法都不会缓存任何返回的结果,我们可以这样调用它:$.ajaxSetup({cache:false})

重新设计任务列表应用程序

任务列表应用程序将包括两部分:一个用于身份验证的部分,我们将重用 LogonDB 类和新的 TaskApp 类。TaskApp 类将实现提供已验证用户所有任务概览页面所需的方法,以及响应 AJAX 请求的额外方法。

不同于文件系统,SQLite 将被用来存储所有用户的任务。请注意,这是一个与用于存储用户名和密码的数据库分开的独立数据库。这样的设置使我们能够将身份验证功能与其他关注点分开,从而便于重用。一旦用户通过身份验证,我们当然会使用他的/她的用户名来识别属于他/她的任务。

对任务数据库的访问将被封装在 tasklistdb 模块中。它提供了类和方法来检索、添加和修改特定用户的任务。它不关心检查访问权限,因为这属于 TaskApp 类的责任。你可以将这种分离想象成一个双层模型,顶层检查用户凭据并提供服务,底层实际与数据库交互。

重新设计任务列表应用程序

数据库设计

我们的任务数据库(数据库 模式)的设计非常简单。它由一个包含定义任务的列的单个表组成。

任务
task_id description duedate completed user_id
--- --- --- --- ---
integerprimary keyautoincrement

大多数列没有定义特定的类型,因为 SQLite 允许我们在列中存储任何内容。此外,大多数列没有特殊的约束,除了我们指定的 task_id 列,我们将其指定为主键。我们明确地将 task_id 列类型指定为整数,并指定为 autoincrement。这样,我们不必显式设置此列的值,每次我们向表中添加新任务时,都会为我们插入一个新的唯一整数。

创建任务数据库的时间

首先,让我们花些时间熟悉创建新数据库所需的步骤。

输入以下代码并运行它(它也作为 taskdb1.py 提供)。

第四章/taskdb1.py

import sqlite3
database=':memory:'
connection = sqlite3.connect(database) cursor=connection.executescript('''
create table if not exists task (
	task_id integer primary key autoincrement,
	description,
	duedate,
	completed,
	user_id
);
''')
connection.commit() sql = '''insert into task (description,duedate,completed,user_id) values(?,?,?,?)'''
cursor.execute(sql,('work' 			,'2010-01-01',None,'alice'))
cursor.execute(sql,('more work' 	,'2010-02-01',None,'alice'))
cursor.execute(sql,('work' 			,'2010-03-01',None,'john'))
cursor.execute(sql,('even more work','2010-04-01',None,'john'))
connection.commit()
connection.close()

它将在内存中创建一个临时数据库,并定义一个任务表。它还使用 INSERT 语句向此表填充多个任务。

刚才发生了什么?

在建立数据库连接后,第一个任务是创建task表(已突出显示)。在这里,我们使用connection对象的executescript()方法,因为这个方法允许我们一次传递多个 SQL 语句。在这里,我们的数据库模式由一个单独的create语句组成,所以execute()方法也可以做到这一点,但通常在创建数据库时,我们会创建多个表,然后将所有必要的 SQL 语句一起传递是非常方便的。

当你查看create语句时,你可能注意到它包含一个if not exists子句。在这个例子中,这是完全多余的,因为一个新打开的内存数据库总是空的,但如果我们数据库位于磁盘上,它可能已经包含我们想要的全部表。一旦我们创建了表,我们就使用commit()方法将我们的更改提交到数据库。

第二行突出显示的行显示了如何创建一个插入语句,该语句将向任务表插入新记录。我们将插入的值是占位符,每个占位符都由一个问号表示。在接下来的四行中,我们执行这个插入语句,并提供一个包含将被插入占位符位置的值的元组。

使用选择语句检索信息的操作时间

在 SQL 中,select语句可以用来从数据库中检索记录。你将如何表达一个查询来检索属于用户 john 的所有任务?

答案:select * from task where user_id = 'john'

我们可以将此在 Python 中实现如下(仅显示相关行,完整的实现可在taskdb2.py中找到):

第四章/任务 tdb2.py

connection.row_factory = sqlite3.Row
sql = """select * from task where user_id = 'john'""" cursor.execute(sql)
tasks = cursor.fetchall()
for t in tasks:
	print(t['duedate'],t['description'])

刚才发生了什么?

代码的第一行通常放置在建立数据库连接之后,并确保从fetchone()fetchall()方法返回的任何行都不是普通的元组,而是sqlite3.Row对象。这些对象的行为就像元组一样,但它们的字段可以通过它们所代表的列的名称进行索引。

查询通过将其传递给游标的execute()方法(已突出显示)来执行,然后使用fetchall()方法检索结果,该方法将返回一个元组列表,每个元组代表一个匹配的记录,其元素等于列。我们通过索引我们感兴趣的列名来打印这些元素的一部分。

当运行taskdb2.py时,输出将显示一个任务记录列表,每个记录都有一个日期和描述:

 C:\Tasklist II>python taskdb2.py
2010-03-01 work
2010-04-01 even more work

使用变量选择标准进行的小测验

大多数时候,我们希望传递user_id作为变量进行匹配。正如我们在taskdb1.py中使用的插入语句中看到的,使用?作为占位符来构造查询是可能的。这样,我们可以将包含user_id的变量传递给执行方法。你将如何重构代码以选择包含在变量username中的user_id的所有记录?

TaskDB 与数据库接口

现在我们已经准备好实现任务列表应用程序所需的数据库接口的实际代码了。

数据库接口层必须提供初始化数据库的功能,并提供创建、检索、更新和删除任务(统称为 CRUD)的线程安全方式,以及为给定用户列出所有任务的功能。执行这些操作的代码包含在两个类中,TaskTaskDB(两者都位于 tasklistdb.py 文件中)。TaskDB 封装了数据库连接,并包含初始化数据库的代码以及检索任务选择和创建新任务的方法。这些任务作为 Task 类的实例实现,Task 实例可以被更新或删除。

是时候连接到数据库了

让我们先看看 TaskDB 类。它由一个构造函数 __init__() 组成,该构造函数接受数据库将驻留的文件名作为参数。它调用一个私有方法来初始化这个数据库,并且像 LogonDB 类一样,为每个线程创建一些存储来保存连接对象(已突出显示)。它还定义了一个 connect() 方法,该方法应该为每个线程调用一次,并存储一个特定于线程的连接对象。它还将连接的 row_factory 属性设置为 sqlite3.Row。这会导致例如 fetchall() 返回的元组具有它们所代表的列的名称。这对于 t['user_id'] 比如来说,比 t[1] 这样的方式更有自解释性。

第四章/tasklistdb.py

class TaskDB:
	def __init__(self,db): self.data = threading.local()
		self.db = db
		self._initdb()
	def connect(self):
		'''call once for every thread'''
		self.data.conn = sqlite3.connect(self.db)
		self.data.conn.row_factory = sqlite3.Row

刚才发生了什么?

__init__() 方法的代码并没有在数据库中初始化任何表,而是委托给 _initdb() 方法。这个方法以下划线开头,因此按照惯例是私有的(但仅按惯例)。它的目的是仅从 __init__() 中调用,并在必要时初始化数据库。它打开到数据库的连接并执行一个多行语句(已突出显示)。在这里,我们使用 create if not exists 来创建 task 表,但仅当它尚未存在时。因此,如果我们第一次启动应用程序,数据库将完全为空,这个语句将创建一个名为 task 的新表。如果我们稍后再次启动应用程序,这个语句将不会做任何事情。在关闭连接之前,我们提交我们的更改。

第四章/tasklistdb.py

def _initdb(self):
	'''call once to initialize the metabase tables'''
	conn = sqlite3.connect(self.db) conn.cursor().executescript('''
	create table if not exists task (
		task_id integer primary key autoincrement,
		description,
		duedate,
		completed,
		user_id
	);
	'''
	)
	conn.commit()
	conn.close()

是时候存储和检索信息了

TaskDB 类的最后一部分定义了三个方法,create() 方法将创建一个全新的 Task 实例,retrieve() 方法将根据给定的 task_idtask 表中检索一个任务,并将其作为 Task 实例返回,还有 list() 方法将返回给定用户的 task_ids 列表。

我们将 retrieve()list() 分离,因为检索一个包含所有属性的完整对象可能相当昂贵,并且并不总是需要的。例如,如果我们选择一个包含数千个任务的列表,我们可能会将它们显示为每页大约二十个任务的页面。如果我们需要检索所有这些任务的完整信息,我们可能需要等待一段时间,所以我们可能会选择仅实例化第一页的全部,并在用户翻页时按需获取其余部分。在这本书中,我们还会遇到这种模式几次。

create() 方法本身只是将所有参数传递给 Task 构造函数,以及包含数据库连接的线程局部存储。它返回生成的 Task 实例。

retrieve() 方法接受用于检索的用户名和任务 ID。用户名作为合理性检查,但不是严格必要的。如果找到与 task_idusername 都匹配的记录,则创建并返回一个 Task 实例(高亮显示)。如果没有找到这样的记录,则抛出 KeyError 异常。

list() 方法返回给定用户的 task_ids 列表。它通过从每个元组的第一个(也是唯一一个)元素中获取来构造这个列表(高亮显示)。

第四章/tasklistdb.py

	def create (self, user=None, id=None, description='', duedate=None, 
completed=None):
		return Task(self.data, user=user, id=id, description=description, 
duedate=duedate, completed=completed)
	def retrieve(self, user,id):
		sql = """select * from task where task_id = ? and user_id = ?"""
		cursor = self.data.conn.cursor()
		cursor.execute(sql,(id,user))
		tasks = cursor.fetchall()
		if len(tasks): return self.create(user, tasks[0]['task_id'], tasks[0]
['description'], tasks[0]['duedate'], tasks[0]['completed']) 
		raise KeyError('no such task')
	def list(self,user):
		sql = '''select task_id from task where user_id = ?'''
		cursor = self.data.conn.cursor()
		cursor.execute(sql,(user,)) return [row[0] for row in cursor.fetchall()]

Task 构造函数接受一些可选参数,包括强制性的用户名和一个指向包含数据库连接的线程局部数据的 taskdb 参数。如果未提供 duedate 参数,它将分配今天的日期(高亮显示)。

刚才发生了什么?

上一段代码中 Task 实例的构建值得仔细研究。根据 id 参数的值,构造函数可以执行两件事。

如果已知 id,则此 Task 实例将基于从数据库查询中检索到的数据构建,因此不需要做更多的事情,因为所有参数都已作为实例变量存储。

然而,如果没有提供 id(或 None),我们显然正在创建一个全新的 Task 实例,它尚未存在于数据库中。因此,我们必须使用 insert 语句将其插入到任务表中(高亮显示)。

我们没有将新的 task_id 作为值传递给这个 insert 语句,但因为它将 task_id 列定义为 integer primary key autoincrement,所以会为我们创建一个。这个生成的数字可以通过游标的 lastrowid 属性获取,并且我们将它存储起来以供以后重用。所有这些都是 SQLite 特有的,更多详细信息,请参考信息框。

注意

只有integer primary key列可以被定义为autoincrement,只有integer primary key autoincrement列会被映射到内部的rowid列(而且这甚至不是一个真正的列)。所有这些都是非常有用的,但也非常具有 SQLite 特性。关于这个主题的更多信息可以在 SQLite FAQ 中找到,网址为www.sqlite.org/faq.html,以及 SQL 参考中关于 rowid 的部分,网址为www.sqlite.org/lang_createtable.html#rowid

Chapter4/tasklistdb.py

class Task:
	def __init__(self,taskdb,user,id=None,description='',duedate=None,
completed=None):
	self.taskdb=taskdb
	self.user=user
	self.id=id
	self.description=description
	self.completed=completed self.duedate=duedate if duedate != None else date.today().
isoformat()
	if id == None:
		cursor = self.taskdb.conn.cursor() sql = '''insert into task (description,duedate,completed,user_
id) values(?,?,?,?)'''
		cursor.execute(sql,(self.description,self.duedate,self.
completed,self.user))
		self.id = cursor.lastrowid
		self.taskdb.conn.commit()

更新和删除信息的时机

更新Task记录的操作主要是构造正确的update查询。update会更改与where子句中条件匹配的任何记录。它只会更改其set子句中提到的列,所以我们首先构造这个set子句(已突出显示)。

将参数列表连接并插入到 SQL 查询中可能有点过度,但如果我们稍后想添加一个额外的属性,这将非常简单(而且我们的 SQL 查询字符串现在可以放在一行中,这使得它更容易阅读和排版)。

一旦我们执行了插入操作,我们会检查受影响的行数。这个值可以通过cursor对象的rowcount属性获得,并且应该为1,因为我们使用了唯一的task_id来选择记录。如果不是1,那么就发生了奇怪的事情,我们会回滚插入操作并抛出异常。如果一切顺利,我们会提交我们的更改。

Chapter4/tasklistdb.py

def update(self,user):
	params= []
	params.append('description = ?')
	params.append('duedate = ?')
	params.append('completed = ?')
	sql = '''update task set %s where task_id = ? and user_id = ?''' sql = sql%(",".join(params))
	conn = self.taskdb.conn
	cursor = conn.cursor()
	cursor.execute(sql, (self.description,self.duedate,self.
completed,self.id,user))
	if cursor.rowcount != 1 :
		debug('updated',cursor.rowcount)
		debug(sql)
		conn.rollback()
		raise DatabaseError('update failed')
	conn.commit()

要删除具有给定任务 ID 的任务,我们只需在task表上执行一个delete query,在where子句中有一个表达式与我们的task_id匹配,就像我们为更新所做的那样。我们确实检查我们的删除查询只影响一条记录(已突出显示),否则回滚。这种情况不应该发生,但宁可信其有,不可信其无。

def delete(self,user):
	sql = '''delete from task where task_id = ? and user_id = ?'''
	conn = self.taskdb.conn
	cursor = conn.cursor()
	cursor.execute(sql,(self.id,user)) if cursor.rowcount != 1:
		conn.rollback()
		raise DatabaseError('no such task')
	conn.commit()

测试

在没有测试的情况下开发软件有点像闭着眼睛开车:如果道路是直的,你可能会走得很远,但很可能会在几秒钟内撞车。换句话说,测试是好的。

然而,彻底测试一个应用程序确实需要时间,因此尽可能自动化测试过程是有意义的。如果测试可以轻松执行,这将鼓励开发者经常运行这些测试。当实现发生变化时,这是非常理想的。它还可以在发布新版本之前作为一个理智的检查。所以尽管编写严肃的测试可能有时需要花费与编写代码一样多的时间,但这是一种稳健的投资,因为它可能防止许多不愉快的惊喜,如果代码被更改或代码部署的环境发生变化。

你可能想测试应用程序的许多方面,但并非所有都适合自动测试,例如用户交互(尽管像 Selenium 这样的工具可以让你走得很远。关于这个工具的更多信息可以在 seleniumhq.org/ 找到))。然而,其他部分相当简单就可以自动化。

Python 内置的 unittest 模块简化了重复测试代码小功能单元的任务。单元测试的想法是隔离小块代码,并通过断言任何数量的期望来定义其预期行为。如果这些断言中的任何一个失败,测试就会失败。(单元测试的内容远不止这本书能完全涵盖的。在这里,我们只介绍最基本的内容,以让你尝到一些可能性,并介绍一些旨在让你了解示例代码中提供的测试套件的例子。如果你想要了解更多关于 Python 单元测试的信息,一个好的起点是 Daniel Arbuckle 著,Packt Publishing 出版,978-1-847198-84-6Python Testing)。

Python 的 unittest 模块包含许多类和函数,使我们能够编写和运行测试组及其相关的断言。例如,假设我们有一个名为 factorial 的模块,它定义了一个名为 fac() 的函数来计算阶乘。

一个数 n 的阶乘是从 1 到 n(包括 n)所有数的乘积。例如,fac(4) = 4 * 3 * 2 * 1 = 24。0 是一个特例,因为 0 的阶乘等于 1。阶乘只对整数 >= 0 定义,因此我们设计我们的代码,如果参数 n 不是一个 int 或者是负数(突出显示),则抛出 ValueError 异常。阶乘本身是通过递归计算的。如果 n 是零或一,我们返回一,否则我们返回 n 减一的阶乘乘以 n

第四章/factorial.py

def fac(n): if n < 0 : raise ValueError("argument is negative")
	if type(n) != int : raise ValueError("argument is not an integer")
	if n == 0 : return 1
	if n == 1 : return 1
	return n*fac(n-1)

代码以 factorial.py 的形式提供。

测试 factorial.py 的行动时间

伴随 factorial.py 的测试套件被称为 test_factorial.py。运行它,你应该看到类似以下的输出:

 python test_factorial.py
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK

执行了三个测试,显然一切顺利。

刚才发生了什么?

test_factorial.py 中的代码首先导入我们想要测试的模块(阶乘)和 unittest 模块。然后我们定义一个名为 Test(突出显示)的单个类,它继承自 unittest.TestCase。通过继承这个类,我们的类将被测试运行器识别为测试用例,并将为我们提供一系列 断言 方法。

我们的 Test 类可以包含任何数量的方法。以 test_ 开头名称的方法将被测试运行器识别为测试。因为失败测试的名称将被打印出来,所以给这些测试起一个反映其目的的合理名称是有用的。在这里,我们定义了三个这样的方法:test_number()test_zero()test_illegal()

第四章/test_factorial.py

import unittest
from factorial import fac class Test(unittest.TestCase):
	def test_number(self):
		self.assertEqual(24,fac(4))
		self.assertEqual(120,fac(5))
		self.assertEqual(720,fac(6))
	def test_zero(self):
		self.assertEqual(1,fac(0))
	def test_illegal(self):
		with self.assertRaises(ValueError):
			fac(-4)
		with self.assertRaises(ValueError):
			fac(3.1415)
if __name__ == '__main__':
	unittest.main()

test_number()测试了多个常规案例,以查看我们的函数是否返回合理的结果。在这种情况下,我们检查了三个不同的数字,并使用从TestCase类继承的assertEquals()方法来检查计算出的值(作为第二个参数传递)是否等于预期的值(第一个参数)。

test_zero()断言零的特殊情况确实返回 1。它再次使用assertEqual()方法来检查预期的值(1)是否与返回的值匹配。

test_illegal()最终断言只接受正数参数(或者更确切地说,它断言负值正确地引发了一个ValueError异常),并且fac()的参数应该是int或者也应该引发ValueError

它使用了TestCase提供的assertRaises()方法。assertRaises()将返回一个对象,该对象可以用作 with 语句中的上下文管理器。实际上,它将捕获任何异常并检查它是否是预期的异常。如果不是,它将标记测试为失败。

这些方法在单元测试中显示出一种熟悉的模式:相当少量的测试检查单元在正常情况下是否表现正确,而大部分测试通常都致力于特殊案例(通常被称为边界案例)。同样重要的是,对非法案例被正确标记为非法的测试投入了大量的努力。

test_factorial.py的最后,我们发现了一个对测试运行器unittest.main()的调用。它将寻找任何从TestCase派生的定义好的类,并运行任何以test_开头的方法,统计结果。

现在我们得到了什么?

如果我们改变fac()的实现,例如,不使用递归的代码如下,我们可以通过再次运行test_factorial.py来快速检查它是否按预期工作。

from functools import reduce
def fac(n):
	if n < 0 : raise ValueError("factorial of a negative number is not 
defined")
	if type(n) != int : raise ValueError("argument is not an integer")
	if n == 0 : return 1
	if n == 1 : return 1 return reduce(lambda x,y:x*y,range(3,n+1))

特殊情况的处理保持不变,但突出显示的行显示我们现在使用functools模块中的reduce()函数来计算阶乘。reduce()函数将一个函数应用于列表中的第一对项目,然后再次应用于这个结果和每个剩余的项目。通过传递reduce()一个将返回两个参数乘积的函数,在这种情况下,我们的 lambda 函数,可以计算出列表中所有数字的乘积。

注意

更多关于reduce()函数的信息可以在functools模块的文档中找到,这是 Python 强大的函数式编程库:docs.python.org/py3k/library/functools.html

快速问答:找出错误

  1. 你能预测前一段代码中可能出现的任何错误吗?哪个测试方法会失败?

    • test_number()

    • test_zero()

    • test_illegal()

为 tasklistdb.py 编写单元测试的时间

运行test_tasklistdb.py(本章节代码分发中提供)。输出应该是一个测试结果列表:

 python test_tasklistdb.py
......
----------------------------------------------------------------------
Ran 6 tests in 1.312s
OK

刚才发生了什么?

让我们看看在test_tasklistdb.py中定义的一个类,DBentityTestDBentityTest包含许多以test_开头的方法。这些是实际的测试,它们验证一些常见操作(如检索或删除任务)是否按预期执行。

第四章/test_tasklistdb.py

from tasklistdb import TaskDB, Task, AuthenticationError, 
DatabaseError
import unittest
from os import unlink,close
from tempfile import mkstemp
(fileno,database) = mkstemp()
close(fileno)
class DBentityTest(unittest.TestCase): def setUp(self):
		try:
			unlink(database)
		except:
			pass
		self.t=TaskDB(database)
		self.t.connect()
		self.description='testtask'
		self.task = self.t.create(user='testuser',description=self.
description)
	def tearDown(self):
		self.t.close()
		try:
			unlink(database)
		except:
			pass
	def test_retrieve(self):
		task = self.t.retrieve('testuser',self.task.id)
		self.assertEqual(task.id,self.task.id)
		self.assertEqual(task.description,self.task.description)
		self.assertEqual(task.user,self.task.user)
	def test_list(self):
		ids = self.t.list('testuser')
		self.assertListEqual(ids,[self.task.id])
	def test_update(self):
		newdescription='updated description' self.task.
description=newdescription
		self.task.update('testuser')
		task = self.t.retrieve('testuser',self.task.id)
		self.assertEqual(task.id,self.task.id)
		self.assertEqual(task.duedate,self.task.duedate)
		self.assertEqual(task.completed,self.task.completed)
		self.assertEqual(task.description,newdescription)
	def test_delete(self):
		task = self.t.create('testuser',description='second task')
		ids = self.t.list('testuser')
		self.assertListEqual(sorted(ids),sorted([self.task.id,task.id]))
		task.delete('testuser')
		ids = self.t.list('testuser')
		self.assertListEqual(sorted(ids),sorted([self.task.id]))
		with self.assertRaises(DatabaseError):
			task = self.t.create('testuser',id='short')
			task.delete('testuser')
if __name__ == '__main__':
	unittest.main(exit=False)

所有这些test_方法都依赖于一个初始化的数据库,该数据库至少包含一个任务和一个对此数据库的开放连接。为了避免为每个测试重复此设置,DBentityTest包含一个特殊的方法setUp()(突出显示),它会删除任何之前测试中遗留的测试数据库,然后实例化一个TestDB对象。这将使用适当的表定义初始化数据库。然后它连接到这个新数据库并创建一个单独的任务对象。现在所有测试都可以依赖于它们的初始环境是相同的。提供了一个相应的tearDown()方法来关闭数据库连接并删除数据库文件。

用于存储临时数据库的文件是用 Python 的tempfile模块中的mkstemp()函数创建的,并存储在全局变量database中。(mkstemp()还返回打开文件的文件句柄编号,我们立即使用它来关闭文件,因为我们只对文件名感兴趣。)

test_list()test_delete()方法引入了一个新的断言:assertListEqual()。这个断言检查两个列表是否有相同的项(并且顺序相同,因此有sorted()调用)。unittest模块包含一系列专门用于特定比较的断言。有关unittest模块的更多详细信息,请检查 Python 的在线文档(http://docs.python.org/py3k/library/unittest.html)。

注意

本书我们开发的大多数模块都附带了一套单元测试。我们不会对这些测试进行任何详细检查,但检查其中的一些可能是有教育意义的。如果你在实验代码时,你肯定应该使用它们,因为这正是它们的目的。

为 AJAX 设计

使用 AJAX 检索数据不仅有可能使任务列表应用程序更响应,而且还会使其更简单。这是因为 HTML 将更简单,因为将不需要我们为适应各种删除和完成按钮而创建的许多<form>元素。相反,我们将简单地处理按钮的点击事件,并调用我们的 CherryPy 应用程序中的小方法。所有这些函数必须做的只是执行操作并返回 ok,而在我们应用程序的先前版本中,我们必须返回一个全新的页面。

事实上,除了<head>中的几个<script>元素外,主体中的核心 HTML 相当简短(为了简洁起见,省略了<header>元素和具有taskheader类的<div>元素中的额外元素):

<body id="itemlist">
	<div id="content">
		<div class="header"></div>
		<div class="taskheader"></div>
		<div id="items"></div>
		<div class="item newitem">
			<input type="text" class="duedate left editable-date tooltip"
				name="duedate" title="click for a date" />
			<input type="text" class="description middle tooltip"
				title="click to enter a description" name="description"/>
			<button type="submit" class="add-button"
				name="add" value="Add" >Add</button>
		</div>
	</div>
</body>

包含输入字段和提交按钮的 <div> 元素占据了大部分空间。它结构化了允许用户添加新任务的元素。具有 ID items<div> 元素将保存任务列表,并将通过 AJAX 调用初始化和管理。

tasklistajax.js 中的 JavaScript 代码实现了多个目标:

  • 初始化项目列表

  • 使用交互式小部件(如 datepicker)进行样式设计和增强 UI 元素

  • 根据按钮点击维护和刷新任务列表

让我们看看 tasklistajax.js

第四章/static/js/tasklistajax.js

$.ajaxSetup({cache:false});$.ajaxSetup({cache:false});
function itemmakeup(data,status,req){
	$(".done-button").button( {icons: {primary: 'ui-icon-check' 
}, text:false});
	$(".del-button").button( {icons: {primary: 'ui-icon-trash' }, 
text:false}); $("#items input.duedate").sort(
		function(a,b){return $(a).val() > $(b).val() ? 1 : -1;},
		function(){ return this.parentNode; }).addClass("just-sorted");
		// disable input fields and done button on items that are already 
marked as completed
	$(".done .done-button").button( "option", "disabled", true );
	$(".done input").attr("disabled","disabled");
	$( "#items .editable-date" ).datepicker({
		dateFormat: $.datepicker.ISO_8601,
		onClose: function(dateText,datePicker){ if(dateText != '')
{$(this).removeClass("inline-label");}}
	});
};
$(document).ready(function(){
	$(".header").addClass("ui-widget ui-widget-header"); $(".add-button").button( {icons: {primary: 'ui-icon-plusthick' }, 
text:false}).click(function(){
		$(".inline-label").each(function() {
	if($(this).val() === $(this).attr('title')) {
		$(this).val('');
	};
		})
	var dd=$(this).siblings(".duedate").val();
	var ds=$(this).siblings(".description").val();
	$.get("add",{description:ds, duedate:dd},function(data,status,req)
{
		$("#items").load("list",itemmakeup);
	});
		return false; // prevent the normal action of the button click
	});
	$(".logoff-button").button({icons: {primary: 'ui-icon-
closethick'}, text:false}).click(function(){
	location.href = $(this).val();
	return false;
	});
	$(".login-button").button( {icons: {primary: 'ui-icon-play' }, 
text:false});
	$(":text").addClass("textinput");
	$(":password").addClass("textinput");
	$( ".editable-date" ).datepicker({
		dateFormat: $.datepicker.ISO_8601,
		onClose: function(dateText,datePicker){ if(dateText != '')
{$(this).removeClass("inline-label");}}
	});
	// give username field focus (only if it's there)
	$("#username").focus();
	$(".newitem input").addClass("ui-state-highlight"); $(".done-button").live("click",function(){
		var item=$(this).siblings("[name='id']").val();
		var done=$(this).siblings(".completed").val();
		$.get("done",{id:item, completed:done},function(data,status,req)
{
			$("#items").load("list",itemmakeup);
		});
		return false;
	}); $(".del-button").live("click",function(){
		var item=$(this).siblings("[name='id']").val();
		$.get("delete",{id:item},function(data,status,req){
			$("#items").load("list",itemmakeup);
		});
		return false;
	}); $("#items").load("list",itemmakeup); // get the individual task 
items
});

第一行设置了所有我们将使用的 AJAX 调用的默认值。它确保浏览器不会缓存任何结果。

在页面加载后初始化项目列表是在代码的最后一行突出显示的。它使用一个将被我们的应用程序处理的 URL 调用 load() 方法,并将返回任务列表。如果 load() 调用成功,它不仅会在选定的 <div> 元素中插入这些数据,还会调用作为第二个参数传递给它的函数 itemmakeup()。该函数 itemmakeup() 在文件开头定义。它将为具有 done-buttondel-button 类的任何 <button> 元素添加合适的图标。我们在这里不会添加任何事件处理器到这些按钮,这将在稍后完成。

接下来,我们使用 sort 插件对项目(突出显示)进行排序,即选择任何具有 duedate 类的输入字段,这些字段是具有 ID items<div> 元素的子元素(我们不希望考虑例如新项目 <div> 中的输入字段)。

排序插件以 sort.js 的形式提供,基于詹姆斯·帕多塞(James Padolsey)的代码:james.padolsey.com/javascript/sorting-elements-with-jquery/。该插件可以排序任何 HTML 元素列表,并接受两个参数。第一个参数是一个比较函数,它将返回 1 或-1,第二个参数是一个函数,当给定一个元素时,将返回应该实际移动的元素。这允许我们在交换包含它们的父元素的同时比较子元素的值。

例如,这里我们比较的是截止日期。也就是说,通过它们的 val() 方法检索的所选 <input> 元素的内容,但我们不是对实际的输入字段进行排序,而是对包含构成任务的全部元素的 <div> 元素进行排序。

最后,itemmakeup() 确保带有 done 类的任何按钮都被禁用,以及具有该类的任何输入元素,以防止修改已完成任务,并将具有 editable-date 类的任何输入元素转换为数据选择器小部件,以便用户在标记任务为已完成之前选择完成日期。

点击处理器

除了样式化元素外,$(document).ready()函数还为添加、完成和删除按钮添加了点击处理器(突出显示)。

当页面创建时,只创建一个添加按钮,因此我们可以使用click()方法添加点击处理器。然而,每次刷新项目列表时,新的完成和删除按钮可能会出现。为了确保新出现的与现有按钮具有相同选择标准的按钮接收相同的事件处理器,我们调用live()方法。

注意

jQuery 的live()方法将确保任何事件处理器都附加到任何现在或将来符合某些标准的元素上。有关 jQuery 事件方法的更多信息,请参阅api.jquery.com/category/events/

除了将事件处理器绑定到按钮的方式外,所有按钮的点击操作都是相似的。我们通过从按钮的兄弟元素中选择适当的输入元素,使用siblings()方法检索要传递给服务器的数据。由于每个任务都由列表中的自己的<div>元素表示,而<button><input>元素都是该<div>元素的子元素,因此选择兄弟输入元素仅确保我们引用的是单个任务的元素。

要更好地理解我们使用siblings()方法选择的元素,请查看为项目列表生成的某些(简化后的)HTML 代码:

<div id="items">
	<div class="item"><input name=""/> … <button name="done"></div
	<div class="item"><input name=""/> … <button name="done"></div
	…
</div>

因此,代表任务的每个<div>都包含多个<input>元素和一些<button>元素。任何<button>元素的兄弟元素是同一<div>内的元素(不包括按钮本身)。

当我们从输入元素收集到相关数据后,这些数据随后被传递给一个get()调用。get()函数是另一个 AJAX 快捷方式,它将向其第一个参数(每个按钮类型都不同)指定的 URL 发出 HTTP GET 请求。传递给get()函数的数据作为参数附加到 GET 请求中。成功后,将调用get()的第三个参数传递的函数。这是在页面首次加载时用于刷新项目列表的相同itemmakeup()函数。

应用程序

在实现交互性和访问数据库的手段就绪之后,我们仍然需要定义一个可以作为 CherryPy 应用程序的类。它作为taskapp.py提供,这里我们只展示相关的部分(省略了index()方法,因为它只是简单地传递了之前显示的 HTML)。

第四章/taskapp.py

class TaskApp(object):
	def __init__(self,dbpath,logon,logoffpath):
		self.logon=logon
		self.logoffpath=logoffpath self.taskdb=TaskDB(dbpath)
	def connect(self):
		self.taskdb.connect()

TaskApp的构造函数存储对LogonDB实例的引用,以便在公开的方法中调用其checkauth()方法以验证用户。它还存储logoffpath,这是一个指向将结束用户会话的页面的 URL。dbpath参数是包含任务列表数据库的文件名。它用于创建一个TaskDB实例,在后续方法中使用以访问数据(突出显示)。

对于每个新的 CherryPy 线程,应该调用connect()方法,它简单地调用TaskDB实例上的相应方法。

为了处理应用程序的 AJAX 调用,TaskApp公开了四个简短的方法:list()用于生成任务列表,add()用于添加新任务,以及done()delete()分别用于标记任务为完成或删除任务。所有这些方法都接受一个名为_(单个下划线)的占位符参数,该参数被忽略。它是通过浏览器中的 AJAX 调用添加的,以防止结果缓存。

list()是最长的一个,它从验证发出请求的用户开始(突出显示)。如果用户已登录,这将产生用户名。然后,将此用户名作为参数传递给taskdb.list()方法,以检索属于此用户的所有任务 ID。

每个 ID 都会创建一个Task实例,该实例包含该任务的所有信息(突出显示)。这些信息用于构建构成屏幕上可视化的任务的 HTML。最后,所有单个任务的 HTML 都被连接起来并返回给浏览器。

第四章/taskapp.py

@cherrypy.expose
def list(self,_=None): username = self.logon.checkauth()
	tasks = []
	for t in self.taskdb.list(username): task=self.taskdb.retrieve(username,t)
		tasks.append('''<div class="item %s">
		<input type="text" class="duedate left" name="duedate" 
value="%s" readonly="readonly" />
		<input type="text" class="description middle" name="description" 
value="%s" readonly="readonly" />
		<input type="text" class="completed right editable-date tooltip" 
title="click to select a date, then click done" name="completed" 
value="%s" />
		<input type="hidden" name="id" value="%s" />
		<button type="submit" class="done-button" name="done" 
value="Done" >Done</button>
		<button type="submit" class="del-button" name="delete" 
value="Del" >Del</button>
		</div>'''%('notdone' if task.completed==None else 'done',task.
duedate,task.description,task.completed,task.id))
	return '\n'.join(tasks)

其他方法彼此之间非常相似。add()方法接受descriptionduedate作为参数,并将它们与用户认证后获得的用户名一起传递给TaskDB实例的create()方法。它返回ok以表示成功。(注意,空字符串也可以做到这一点:重要的是返回代码,但这使得代码阅读者更容易理解)。

delete()方法(突出显示)有一个相关的参数,id。此 ID 与用户名一起使用,以检索一个Task实例。然后调用此实例的delete()方法,从数据库中删除此任务。

done()方法(突出显示)也接受一个id参数和completed参数。后者要么包含一个日期,要么为空,在这种情况下,它被设置为今天的日期。以与delete()方法相同的方式检索Task实例,但现在将其completed属性设置为同名参数的内容,并调用其update()方法以同步此更新与数据库。

第四章/taskapp.py

@cherrypy.expose def add(self,description,duedate,_=None):
	username = self.logon.checkauth()
	task=self.taskdb.create(user=username, description=description, 
duedate=duedate)
	return 'ok'
@cherrypy.expose def delete(self,id,_=None):
	username = self.logon.checkauth()
	task=self.taskdb.retrieve(username,id)
	task.delete(username)
	return 'ok'
@cherrypy.expose def done(self,id,completed,_=None):
	username = self.logon.checkauth()
	task=self.taskdb.retrieve(username,id)
	if completed == "" or completed == "None":
		completed = date.today().isoformat()
	task.completed=completed
	task.update(username)
	return 'ok'

将所有这些放在一起的时间

现在我们已经准备好了所有必要的组件(即tasklistdb.pytaskapp.pytasklistajax.js),将它们组合起来就很简单了。如果你运行下面的代码(作为tasklist.py提供)并将你的浏览器指向localhost:8080/,你将看到一个熟悉的登录界面。在输入一些凭据(默认配置的用户名是 admin,密码也是 admin)后,结果界面将几乎与我们在上一章中开发的应用程序相同,如下面的截图所示:

将所有内容整合在一起的时间

刚才发生了什么?

对于 CherryPy 应用程序,我们需要一个根类,它可以作为我们为用户服务的页面树的根。再次强调,我们把这个类简单地命名为Root,并将我们的TaskApp应用程序的一个实例分配给task变量,将LogonDB应用程序的一个实例分配给logon变量(在下面的代码中突出显示)。与index()方法一起,这将创建一个看起来像这样的页面树:

/
/logon
/task

如果用户从顶级页面或登录页面开始,在成功认证后,他/她将被重定向到/task页面。在/task页面下方,当然还有其他实现 AJAX 通信服务器端的页面,例如/task/add

第四章/tasklist.py

import cherrypy
from taskapp import TaskApp
from logondb import LogonDB
import os.path
current_dir = os.path.dirname(os.path.abspath(__file__))
theme = "smoothness"
class Root(object): logon = LogonDB()
	task = TaskApp(dbpath='/tmp/taskdb.db', logon=logon, logoffpath="/
logon/logoff")
	@cherrypy.expose
	def index(self):
		return Root.logon.index(returnpage='/task')
if __name__ == "__main__":
	Root.logon.initdb() def connect(thread_index):
		Root.task.connect()
		Root.logon.connect()
	# Tell CherryPy to call "connect" for each thread, when it starts up
	cherrypy.engine.subscribe('start_thread', connect)
	cherrypy.quickstart(Root(),config={
	'/':
	{ 'log.access_file' : os.path.join(current_dir,"access.log"),
	'log.screen': False,
	'tools.sessions.on': True
	},
	'/static':
	{ 'tools.staticdir.on':True,
	'tools.staticdir.dir':os.path.join(current_dir,"static")
	},
	'/jquery.js':
	{ 'tools.staticfile.on':True,
	'tools.staticfile.filename':os.path.join(current_
dir,"static","jquery","jquery-1.4.2.js")
	},
	'/jquery-ui.js':
	{ 'tools.staticfile.on':True,
	'tools.staticfile.filename':os.path.join(current_
dir,"static","jquery","jquery-ui-1.8.1.custom.min.js")
	},
	'/jquerytheme.css':
	{ 'tools.staticfile.on':True,
	'tools.staticfile.filename':os.path.join(current_dir,"static",
"jquery","css",theme,"jquery-ui-1.8.4.custom.css")
	},
	'/images':
	{ 'tools.staticdir.on':True,
	'tools.staticdir.dir':os.path.join(current_dir,"static","jquery",
"css",theme,"images")
	}
})

在以通常的方式通过调用quickstart()函数启动 CherryPy 应用程序之前,我们首先初始化认证数据库并创建一个名为connect()(突出显示)的函数。这是我们将在 CherryPy 启动每个新线程时注册的函数。该函数将创建连接到包含认证和任务列表数据的 SQLite 数据库。

尝试定期刷新项目列表

如果你从家里访问任务列表并保持应用程序打开,然后从例如工作地点访问它,除非你手动刷新页面,否则你在工作地点对列表所做的任何更改都不会在家庭中可见。这是因为没有实现定期刷新任务列表的功能;它只在点击按钮发起某些操作后才会刷新。

你该如何实现定期刷新?提示:在第一个 AJAX 示例中,我们遇到了 JavaScript 的setInterval()方法。你能想出一个方法,使用load()方法让它替换包含任务列表的<div>元素的 内容吗?

tasklistajax2.js中提供了一个示例实现。你可以将其重命名为tasklistajax.js并运行tasklist.py,或者运行tasklist2.py

摘要

在本章中,我们学到了很多关于使用数据库来存储持久数据的知识。

具体来说,我们涵盖了:

  • 使用数据库引擎的好处

  • 如何使用 Python 附带分发的 SQLite 数据库引擎

  • 如何实现密码数据库

  • 如何设计和开发一个数据库驱动的任务列表应用程序

  • 如何使用 Python 的 unittest 模块实现单元测试

  • 如何使用 AJAX 调用来使 Web 应用程序更具响应性

我们还讨论了如何使 Web 应用程序能够响应用户的鼠标点击,并从服务器请求新数据,而不使用 <form> 元素,而是使用 jQuery 的 click()live() 方法。

现在我们已经迈出了使用数据库的第一步,我们准备创建更复杂的数据库设计,包括多个表,并探讨定义这些表之间关系的方法,这是下一章的主题。

第五章. 实体和关系

大多数现实生活中的应用程序都包含多个实体,并且通常这些实体之间有很多关系。在数据库建模中,这些关系是强项之一。在本章中,我们将开发一个应用程序来维护多个用户的图书列表。

在本章中,我们将:

  • 设计和实现一个由多个实体和关系组成的数据模型

  • 实现可重用的实体和关系模块

  • 深入探讨清晰分离功能层的重要性

  • 并且遇到 jQuery UI 的自动完成小部件

因此,让我们开始吧...

设计一个图书数据库

在我们开始设计我们的应用程序之前,让我们先好好看看它需要处理的不同的实体。我们识别出的实体有书、作者和用户。一本书可能有多个属性,但在这里我们限制自己只包括标题、ISBN(国际标准书号)和出版社。作者只有一个名字,但当然,如果我们想用额外的属性来扩展它,比如出生日期或国籍,我们总是可以稍后添加。最后,用户是一个具有单个属性的实体,即用户 ID。

下一个重要的部分是要对这些实体之间的关系有一个清晰的理解。一本书可能由一位或多位作者撰写,因此我们需要在图书实体和作者实体之间定义一个关系。此外,任何数量的用户都可能拥有一本书的副本。这是我们不得不定义的另一个关系,这次是在图书实体和用户实体之间。以下图表可能有助于更清楚地看到这些实体及其关系:

设计一个图书数据库

这三个实体及其之间的关系需要在两个领域中进行表示:作为数据库表和作为 Python 类。现在我们可以像我们在上一章为 tasklist 应用程序所做的那样,分别对每个实体和关系进行建模,但所有实体都共享很多共同的功能,因此有大量的重用机会。重用意味着更少的代码,更少的代码等于更少的维护,并且通常有更好的可读性。那么,让我们看看我们需要定义一个可重用的 Entity 类需要什么。

实体类

从我们之前章节中学到的知识,我们已经知道每个代表实体的类都需要实现一个共享的功能集:

  • 它需要能够验证数据库中是否存在相应的表,如果不存在则创建一个。

  • 需要实现一种以线程安全的方式管理数据库连接的方法。

此外,每个实体都应该提供一个 CRUD 接口:

  • 创建 新的对象实例

  • 检索 单个对象实例以及找到符合某些标准的实例

  • 更新 对象实例的属性并将这些数据同步到数据库中

  • 删除 对象实例

这有很多共享功能,但当然一本书和作者并不相同:它们在属性的数量和类型上有所不同。在我们查看实现之前,让我们说明我们如何希望使用 Entity 类来定义一个特定的实体,例如一辆车。

使用 Entity 类进行操作的时间

让我们首先定义我们希望如何使用 Entity 类,因为我们要创建的接口必须尽可能接近我们希望在代码中表达的东西。以下示例显示了我们的想法(作为 carexample.py 提供):`

Chapter5/carexample.py

from entity import Entity
class Car(Entity): pass Car.threadinit('c:/tmp/cardatabase.db')
Car.inittable(make="",model="",licenseplate="unique")
mycar = Car(make="Volvo",model="C30",licenseplate="12-abc-3")
yourcar = Car(make="Renault",model="Twingo",licenseplate="ab-cd-12")
allcars = Car.list()
for id in allcars:
	car=Car(id=id)
	print(car.make, car.model, car.licenseplate)

我们的想法是创建一个 Car 类,它是 Entity 的子类。因此,我们必须采取以下步骤:

  1. entity 模块导入 Entity 类。

  2. 定义 Car 类。这个类的主体完全为空,因为我们只是从 Entity 类继承所有功能。当然,我们可以添加特定的功能,但通常这不应该必要。

  3. 初始化数据库连接。在我们能够处理 Car 实例之前,应用程序必须为每个线程初始化数据库连接。在这个例子中,我们没有创建额外的线程,所以只有一个需要连接到数据库的应用程序的主线程。我们在这里使用 threadinit() 方法(已突出显示)创建了一个连接。

  4. 确保数据库中存在适当的表,并且具有必要的列。因此,我们使用 inittable() 方法,并使用指定我们的实体属性(可能包括如何将它们定义为数据库表中的列的额外信息)的参数调用它。我们在这里定义了三个列:make, modellicenseplate。记住,SQLite 不需要显式类型,所以 makemodel 只传递了一个空字符串作为值的参数。然而,在这个例子中,licenseplate 属性被添加了一个 unique 约束。

现在我们可以处理 Car 实例了,正如创建两个不同对象或检索数据库中所有 Car 记录的 ID 并使用这些 ID 实例化 Car 对象以打印 Car 的各种属性的最后几行所示。

这是我们希望它工作的方式。下一步是实现这一点。

刚才发生了什么?

之前的示例展示了我们如何从 Entity 派生出 Car 类并使用它。但 Entity 类是什么样子呢?

Entity 类的定义从定义一个类变量 threadlocal 和一个类方法 threadinit() 开始,用于使用一个包含每个线程本地数据(即 entity.py 中的完整代码)的对象初始化这个变量。

如果这个threadlocal对象还没有connection属性,则会创建一个新的数据库连接(高亮显示),并且我们通过设置其row_factory属性为sqlite.Row来配置这个连接,因为这将使我们能够通过名称访问结果中的列。

我们还执行一个单独的pragma foreign_keys=1语句来启用外键的强制执行。正如我们将看到的,在讨论关系的实现时,这对于维护没有悬空引用的数据库至关重要。这个pragma必须为每个连接单独设置;因此,我们将其放在线程初始化方法中。

Chapter5/entity.py

import sqlite3 as sqlite
import threading
class Entity:
	threadlocal = threading.local()
	@classmethod
	def threadinit(cls,db): if not hasattr(cls.threadlocal,'connection') or 
cls.threadlocal.connection is None:
				cls.threadlocal.connection=sqlite.connect(db)
				cls.threadlocal.connection.row_factory = sqlite.Row
				cls.threadlocal.connection.execute("pragma foreign_
keys=1")
			else:
				pass #print('threadinit thread has a connection 
object already')

接下来是inittable()方法。这个方法应该只调用一次,以验证为这个实体所需的表已经存在,或者如果不存在,则定义一个具有合适列的表。它接受任意数量的键值参数。键的名称对应于列的名称,这样的键的值可能是一个空字符串或带有额外属性(例如,unique或显式的类型如float)的字符串。

注意

虽然 SQLite 允许你在列中存储任何类型的值,但你仍然可以定义一个类型。这个类型(或者更准确地说,亲和性)是 SQLite 在存储到列中时尝试将值转换成的类型。如果它不成功,则值将按原样存储。例如,将列定义为float可能节省很多空间。更多关于这些亲和性的信息可以在www.sqlite.org/datatype3.html找到。

Chapter5/entity.py

@classmethod
def inittable(cls,**kw):
		cls.columns=kw
		connection=cls.threadlocal.connection
		coldefs=",".join(k+' '+v for k,v in kw.items()) sql="create table if not exists %s (%s_id integer primary 
key autoincrement, %s);"%(cls.__name__,cls.__name__,coldefs)
		connection.execute(sql)
		connection.commit()

列定义存储在columns类变量中,供__init__()方法稍后使用,并将它们连接成一个字符串。然后,这个字符串与类的名称(在(可能派生的)类的__name__属性中可用)一起用于组成创建表的 SQL 语句(高亮显示)。

除了基于键值参数定义的列之外,我们还可以创建一个自动填充唯一整数的主键列。这样,我们确保以后可以引用表中的每一行,例如,从一个定义关系的桥接表中。

当我们考虑之前的汽车示例时,我们看到一个 Python 语句如下:

Car.inittable(make="",model="",licenseplate="unique")

转换为以下 SQL 语句:

create table if not exists Car (
Car_id integer primary key autoincrement,
make ,
licenseplate unique,
model
);

注意,我们传递给inittable()方法的键值参数的顺序不一定被保留,因为这些参数被存储在一个dict对象中,而常规的dict对象不保留其键的顺序。

注意

有时保留字典中键的顺序是非常希望的。在这种情况下,列顺序并不重要,但 Python 在其 collections 模块中确实提供了一个 OrderedDict 类(见 docs.python.org/library/collections.html#collections.OrderedDict),我们可以使用它。然而,这将阻止我们使用关键字来定义每个列。

还要注意,没有实现任何形式的合理性检查:任何东西都可以作为列定义中的一个值的参数传递。是否合理留给 SQLite 在通过 execute() 方法将 SQL 语句传递给数据库引擎时判断。

此方法如果 SQL 语句中存在语法错误,将引发 sqlite3.OperationalError。然而,许多问题只是被忽略。如果我们传递一个像 licenseplate="foo" 这样的参数,它将愉快地继续,假设 foo 是它不认识的一种类型,所以它被简单地忽略了!如果执行没有引发异常,我们最后将提交我们的更改到数据库。

尝试检查你的输入

对传递的参数静默忽略不是一个好习惯。没有明确的检查,开发者可能甚至不知道他/她做错了什么,这可能会在以后产生反效果。

你会如何实现代码来限制值到一个有限的指令集?

提示:SQL 列定义中的类型和约束大多由单个单词组成。例如,你可以将每个单词与允许的类型列表进行核对。

创建实例的时间

我们接下来要查看的方法是构造函数 __init__() 方法。它将用于创建实体的单个实例。构造函数可以通过两种方式调用:

  • 使用单个 id 参数,在这种情况下,将从数据库中检索现有记录,并用该记录的列值初始化实例,或者

  • 使用多个关键字参数来创建一个新实例并将其保存为新的数据库记录

实现此行为的代码如下所示:

第五章/entity.py

def __init__(self,id=None,**kw): for k in kw:
			if not k in self.__class__.columns :
				raise KeyError("unknown column")
		cursor=self.threadlocal.connection.cursor()
		if id:
			if len(kw):
				raise KeyError("columns specified on 
retrieval")
			sql="select * from %s where %s_id = ?"%(
			self.__class__.__name__,self.__class__.__name__)
			cursor.execute(sql,(id,))
			entities=cursor.fetchall() if len(entities)!=1 : 
				raise ValueError("not a singular entity")
			self.id=id
			for k in self.__class__.columns: setattr(self,k,entities[0][k])
		else:
			cols=[]
			vals=[]
			for c,v in kw.items():
				cols.append(c)
				vals.append(v)
				setattr(self,c,v)
			cols=",".join(cols)
			nvals=",".join(["?"]*len(vals)) sql="insert into %s (%s) values(%s)"%(
			self.__class__.__name__,cols,nvals)
			try:
				with self.threadlocal.connection as conn:
					cursor=conn.cursor() cursor.execute(sql,vals)
					self.id=cursor.lastrowid
			except sqlite.IntegrityError:
					raise ValueError("duplicate value for unique 
col")

代码反映了这种双重用途。在检查所有关键字确实指向先前定义的列(突出显示)之后,它检查是否传递了 id 参数。如果是,则不应有其他任何关键字参数。如果有额外的关键字,将引发异常。如果存在 id 参数,接下来将构建一个 SQL 语句,该语句将从相关表中检索记录。每条记录的主键应与 ID 匹配。

刚才发生了什么?

因为主键是唯一的,所以这最多匹配一条记录,这是在我们检索匹配的记录后验证的。如果我们没有获取到正好一条(1)记录,将引发异常(突出显示)。

如果一切顺利,我们将使用内置的 setattr() 函数初始化我们正在创建的实例的属性。我们检索到的记录的列可以通过名称访问,因为我们已将连接对象的 row_factory 属性初始化为 sqlite3.Row。我们还存储了列的名称在 columns 类变量中,这使得我们可以使用相应的列名称的值来初始化实例的属性(高亮显示)。

使用以下方式创建一个 Car 实例:

Car(id=1)

将导致如下 SQL 语句:

select * from Car where Car_id = ?

其中问号是传递给 execute() 方法的实际值的占位符。

如果没有提供 id 参数,则执行代码的第二分支(从 else 子句开始)。在这种情况下,我们将分离关键字名称和值,并设置我们正在创建的实例的属性。然后,关键字名称和值用于构建一个 SQL 语句,以在与此 Entity 相关的表中插入一行(高亮显示)。例如:

Car(make="Volvo", model="C30", licenseplate="12-abc-3")

将给出:

insert into Car (make,model,licenseplate) values(?,?,?)

问号再次是用于传递给 execute() 方法的值的占位符。

如果调用 execute() 方法(高亮显示)顺利,我们将使用 lastrowid 属性的值初始化我们正在创建的实例的 id 属性。因为我们定义了主键为一个 primary key integer autoincrement 列,并且在插入语句中没有指定它,所以主键将保留一个新唯一的整数,这个整数作为 lastrowid 属性可用。

注意

这非常特定于 SQLite,并且主键应该以完全相同的方式定义,才能使这一点成立。更多关于此的信息可以在 www.sqlite.org/lang_createtable.html#rowid 找到。

任何可能由于违反唯一性约束而引发的 sqlite3.IntegrityError 都会被捕获并重新引发为带有稍微更有意义文本的 ValueError

update() 方法用于同步实例与数据库。它可以有两种用法:我们首先可以更改实例的任何属性,然后调用 update(),或者我们可以传递关键字参数给 update(),让 update() 更改相应的属性并将实例同步到数据库。这两种方式甚至可以结合使用。无论如何,一旦 update() 返回,数据库将保留所有对应于列的所有属性的当前值。因此,以下两段代码是等效的:

car.update(make='Peugeot')

并且:

car.make='Peugeot'
car.update()

我们传递给 update() 的任何关键字参数都应该匹配列名称,否则将引发异常(高亮显示)。

第五章/entity.py

def update(self,**kw): for k in kw:
			if not k in self.__class__.columns :
				raise KeyError("unknown column")
		for k,v in kw.items():
			setattr(self,k,v)
		updates=[]
		values=[]
		for k in self.columns:
			updates.append("%s=?"%k)
			values.append(getattr(self,k))
		updates=",".join(updates)
		values.append(self.id)
		sql="update %s set %s where %s_id = ?"%(
		self.__class__.__name__, updates, self.__class__.__name__)
		with self.threadlocal.connection as conn:
			cursor=conn.cursor()
			cursor.execute(sql, values) if cursor.rowcount != 1 :
				raise ValueError(
				"number of updated entities not 1 (%d)" %
				cursor.rowcount)

列名称和相应的属性值随后用于构建一个 SQL 语句来更新具有这些值的记录,但仅限于与我们要更新的实例的 ID 匹配的单个记录。SQL 语句可能看起来像这样:

update Car set make=?, model=?, licenseplate=? where Car_id = ?

再次使用问号作为我们传递给 execute() 方法的值的占位符。

在执行此语句后,我们通过验证受影响的记录数确实是 1 来进行合理性检查。就像插入语句一样,这个数字在更新语句之后作为游标对象的 rowcount 属性可用(高亮显示)。

实例的删除是通过 Entity 类的 delete() 方法实现的,主要是由一个 SQL 语句组成,该语句将删除具有与实例的 id 属性相等的键的记录。生成的 SQL 看起来像这样:

delete from Car where Car_id = ?

就像在 update() 方法中一样,我们以一个合理性检查结束,以验证仅影响了一条记录(高亮显示)。请注意,delete() 只会删除数据库中的记录,而不会删除调用它的 Python 实例。如果没有其他东西引用此对象实例,它将被 Python 的垃圾回收器自动删除:

第五章/entity.py

def delete(self):
		sql="delete from %s where %s_id = ?"%(
		self.__class__.__name__,self.__class__.__name__)
		with self.threadlocal.connection as conn:
				cursor=conn.cursor()
				cursor.execute(sql,(self.id,)) if cursor.rowcount != 1 :
					raise ValueError(
					"number of deleted entities not 1 (%d)" %
					cursor.rowcount)

我们遇到的最后一个方法是类方法 list()。当不带参数调用时,此方法可以用来检索实体的所有实例的 ID,或者检索与作为参数传递的某些标准匹配的实例的 ID。例如:

Car.list()

将返回数据库中所有汽车的 ID 列表,而:

Car.list(make='Volvo')

将返回数据库中所有沃尔沃的 ID。

第五章/entity.py

@classmethod
def list(cls,**kw): sql="select %s_id from %s"%(cls.__name__,cls.__name__) 
		cursor=cls.threadlocal.connection.cursor()
		if len(kw):
				cols=[]
				values=[]
				for k,v in kw.items():
						cols.append(k)
						values.append(v) whereclause = " where "+",".join(c+"=?" for c in 
cols)
				sql += whereclause
				cursor.execute(sql,values)
		else:
				cursor.execute(sql)
		for row in cursor.fetchall():
				yield row[0]

实现很简单,从创建一个选择表中选择所有 ID 的 SQL 语句开始(高亮显示)。一个例子将是:

select Car_id from Car

如果向 list() 方法传递了任何关键字参数,这些参数将用于构建一个 where 子句,该子句将限制返回的 ID 仅限于与记录匹配的 ID。这个 where 子句被附加到我们的通用选择语句(高亮显示)。例如:

select Car_id from Car where make=?

在调用 execute() 方法之后,我们通过使用 yield 语句产生所有 ID。通过使用 yield 语句,我们已经将 list() 方法标识为一个 生成器,它将逐个返回找到的 ID,而不是一次性返回。如果我们愿意,我们仍然可以像处理列表一样操作这个生成器,但对于非常大的结果集,生成器可能是一个更好的选择,因为它确实消耗更少的内存,例如。

关系类

Relation 类用于管理实体各个实例之间的关系。如果我们有 Car 实体以及 Owner 实体,我们可能希望定义一个 CarOwner 类,它为我们提供识别特定车主拥有的特定汽车的功能。

与实体一样,通用关系共享许多共同的功能:我们必须能够创建两个实体之间的新关系,删除关系,以及根据主实体列出相关实体,例如列出特定汽车的或特定车主的所有汽车。

关系存储在数据库中的一个表中,通常称为桥接表,其中包含存储相关实体 ID 的列的记录。当应用程序开始使用(Relation类的子类)时,我们必须验证相应的表是否存在,如果不存在,则创建它。

使用Relation类进行操作的时间

让我们看看我们如何使用我们的Relation类:

Chapter5/carexample2.py

from entity import Entity
from relation import Relation
class Car(Entity): pass
class Owner(Entity): pass
Car.threadinit('c:/tmp/cardatabase2.db')
Car.inittable(make="",model="",licenseplate="unique")
Owner.threadinit('c:/tmp/cardatabase2.db')
Owner.inittable(name="") class CarOwner(Relation): pass
CarOwner.threadinit('c:/tmp/cardatabase2.db')
CarOwner.inittable(Car,Owner)
mycar = Car(make="Volvo",model="C30",licenseplate="12-abc-3")
mycar2 = Car(make="Renault",model="Coupe",licenseplate="45-de-67")
me = Owner(name="Michel") CarOwner.add(mycar,me)
CarOwner.add(mycar2,me)
owners = CarOwner.list(mycar)
for r in owners:
	print(Car(id=r.a_id).make,'owned by',Owner(id=r.b_id).name)
owners = CarOwner.list(me)
for r in owners:
	print(Owner(id=r.b_id).name,'owns a',Car(id=r.a_id).make)

  • 如前所述,我们首先定义一个Car类,然后定义一个Owner类,因为我们定义并初始化的CarOwner类只有在关系中的实体存在时才有意义。高亮行显示,定义和初始化关系遵循与初始化实体相同的一般模式。

  • 然后,我们创建了两个Car实体和一个Owner实体,并在这两个实体之间建立了关系(第二组高亮行)。

  • 最后几行显示了如何查找并打印一辆车的车主或车主拥有的车辆。

许多Relation类的要求与Entity类的要求相似,所以当我们查看代码时,一些部分看起来会很熟悉。

刚才发生了什么?

我们遇到的第一种方法是threadinit()类方法(完整代码可在relation.py中找到)。它与我们在Entity类中遇到的方法相同,并且应该为每个线程调用一次。

Chapter5/relation.py

@classmethod
def threadinit(cls,db):
		if not hasattr(cls.threadlocal,'connection') or 
cls.threadlocal.connection is None:
				cls.threadlocal.connection=sqlite.connect(db)
				cls.threadlocal.connection.row_factory = sqlite.Row
				cls.threadlocal.connection.execute(
													"pragma 
foreign_keys=1")

inittable()类方法是我们在启动应用程序时应该调用一次的方法:

Chapter5/relation.py

@classmethod
def inittable(cls, entity_a, entity_b,
							reltype="N:N", cascade=None): sql='''create table if not exists %(table)s (
			%(a)s_id references %(a)s on delete cascade,
			%(b)s_id references %(b)s on delete cascade,
			unique(%(a)s_id,%(b)s_id)
	);
	'''%{'table':cls.__name__,
				'a':entity_a.__name__,'b':entity_b.__name__}
	with cls.threadlocal.connection as conn:
			cursor=conn.cursor()
			cursor.execute(sql)
	cls.columns=[entity_a.__name__,entity_b.__name__]

它接受参与关系的两个类作为参数,以构建一个适当的 SQL 语句来创建一个桥接表(如果尚不存在)(高亮显示)。

例如,CarOwner.inittable(Car,Owner)将产生如下语句:

create table if not exists CarOwner (
				Car_id references Car on delete cascade,
				Owner_id references Owner on delete cascade,
				unique(Car_id,Owner_id)

在这里有几个有趣的事情需要注意。这里有两列,每列都通过references子句引用一个表。因为我们没有在表中明确指出哪一列是我们引用的,所以引用是针对主键的。这是一种方便的记录方式,并且它之所以有效,是因为我们总是为任何表示实体的表定义一个合适的主键。

另一个需要注意的事项是on delete cascade子句。这有助于我们维护所谓的引用完整性。它确保当被引用的记录被删除时,引用它的桥接表中的记录也会被删除。这样,就不会有指向不存在实体的关系的表中的条目。为了确保实际上执行了引用完整性检查,必须为每个数据库连接执行pragma foreign_keys = 1指令。这在threadinit()方法中得到了处理。

最后,对这两个列有 unique 约束。这实际上确保了我们只为两个实体之间的每个关系在这个表中维护最多一个条目。也就是说,如果我拥有一辆车,我只能进入这个特定的关系一次。

如果此语句的执行顺利,inittable() 方法将存储在 columns 类变量中,该变量引用此关系所涉及的实体类名称。

快速问答:如何检查一个类

我们如何确保传递给 initdb() 方法的类是 Entity 的子类?

关系实例

__init__() 方法构建一个 Relation 实例,即我们使用它来记录两个特定实体之间的关系。

Chapter5/relation.py

def __init__(self,a_id,b_id,stub=False):
		self.a_id=a_id
		self.b_id=b_id
		if stub : return
		cols=self.columns[0]+"_id,"+self.columns[1]+"_id"
		sql='insert or replace into %s (%s) values(?,?)'%(
			self.__class__.__name__,cols)
		with self.threadlocal.connection as conn:
			cursor=conn.cursor()
			cursor.execute(sql,(a_id,b_id))
			if cursor.rowcount!=1:
					raise ValueError()

它接受涉及此特定关系的两个 Entity 实例的 ID 和一个 stub 参数。

__init__() 方法不应该直接调用,因为它不知道也不检查传递给它的 ID 是否有意义。如果 stub 参数为真,它简单地存储这些 ID;如果不是,它就在表中插入一条记录。

通常,我们会使用 add() 方法来创建一个新的关系,并进行所有必要的类型检查。将此分离是有意义的,因为所有这些检查都很昂贵,如果我们知道我们传递的 ID 是正确的,则这些检查是不必要的。例如,Relation 类的 list() 方法仅检索有效的 ID 对,这样我们就可以使用 __init__() 方法而无需进行昂贵的额外检查。

构造的 SQL 语句可能看起来像这样,对于一个新的 CarOwner 关系:

insert or replace into CarOwner (Car_id,Owner_id) values(?,?)

如果我们尝试在这两个实体之间插入第二个关系,这两个列上的 unique 约束将违反。如果是这样,insert or replace 子句将确保插入语句不会失败,但仍然只有一个包含这两个 ID 的记录。

注意,插入语句可能会因为其他原因失败。如果我们尝试插入的任何一个 ID 都不指向它所引用的表中的现有记录,它将抛出异常 sqlite3.IntegrityError: foreign key constraint failed

最后,在最后一行进行最终的健康检查,使用 rowcount 属性来验证只插入了一个记录。

add() 方法确实通过检查通过给它的实例的类名与由 inittable() 方法存储的列名是否匹配来确保这些实例的顺序正确。如果这不正确,它会抛出一个 ValueError() 异常,否则它会通过调用类构造函数并传递两个 ID 来实例化一个新的关系。

Chapter5/relation.py

@classmethod
def add(cls,instance_a,instance_b):
		if instance_a.__class__.__name__ != cls.columns[0] :
				raise ValueError("instance a, wrong class")
		if instance_b.__class__.__name__ != cls.columns[1] :
				raise ValueError("instance b, wrong class")
		return cls(instance_a.id,instance_b.id)

list() 方法旨在返回零个或多个 Relation 对象的列表。

Chapter5/relation.py

@classmethod
def list(cls,instance):
		sql='select %s_id,%s_id from %s where %s_id = ?'%(
				cls.columns[0],cls.columns[1],
				cls.__name__,instance.__class__.__name__)
		with cls.threadlocal.connection as conn:
				cursor=conn.cursor()
				cursor.execute(sql,(instance.id,))
				return [cls(r[0],r[1],stub=True)
								for r in cursor.fetchall()]

它需要适用于关系的两边:如果我们向 CarOwner 类的 list() 方法传递一个 Car 实例,例如,我们应该找到所有 Car_id 列与 Car 实例的 id 属性匹配的记录。

同样,如果我们传递一个Owner实例,我们应该找到所有Owner_id列与Owner实例的id属性匹配的记录。但正因为我们在表示关系的表中给出了有意义的列名,这些列名是从类名派生出来的,所以这相当直接。例如,为CarOwner.list(car)构建的 SQL 可能如下所示:

select Car_id,Owner_id from CarOwner where Car_id = ?

而 SQL 查询CarOwner.list(owner)可能看起来如下所示:

select Car_id,Owner_id from CarOwner where Owner_id = ?

这是通过引用作为参数传递的实例的类名(突出显示)来实现的。

执行此 SQL 语句后,使用fetchall()方法检索结果,并将其作为关系实例的列表返回。请注意,如果没有任何匹配的关系,此列表可能为零长度。

Relation类定义的最后一个值得注意的是delete()方法。

Chapter5/relation.py

def delete(self):
		sql='delete from %s where %s_id = ? and %s_id = ?'%(
			self.__class__.__name__,self.columns[0],self.columns[1])
		with self.threadlocal.connection as conn:
			cursor=conn.cursor()
			cursor.execute(sql,(self.a_id,self.b_id))
			if cursor.rowcount!=1:
					raise ValueError()

它构建了一个 SQL 删除语句,在我们的CarOwner示例中可能如下所示:

delete from CarOwner where Car_id = ? and Owner_id = ?

最后一条语句中我们执行的健全性检查意味着如果删除的记录数不是恰好一条,则会引发异常。

注意

如果没有恰好一条记录被删除,那会意味着什么?

如果多于一个,那将表明一个严重的问题,因为所有约束都是为了防止有超过一条记录描述相同的关系,但如果为零,这通常意味着我们试图删除相同的关系多次。

你可能会想知道为什么没有方法可以以任何方式更新Relation对象。原因是这几乎没有任何意义:两个实体实例之间要么存在关系,要么不存在。例如,如果我们想转移一辆车的所有权,简单地删除车辆和当前车主之间的关系,然后添加车辆和新车主之间的关系就足够简单了。

现在我们已经有一个简单的实体和关系框架,让我们看看我们如何使用这个框架来实现我们图书应用程序的基础。

定义图书数据库的时间到了

下一步是创建一个名为booksdb.py的模块,该模块使用entityrelation模块来构建一个数据模型,该模型可以方便地被交付层(处理向客户端提供内容的 Web 应用程序部分)使用。因此,我们必须定义Book, AuthorUser实体以及BookAuthor关系和UserBook关系。

我们还将提供一些更高级别的函数,例如,一个newbook()函数,该函数检查给定标题的书籍是否已经存在,并且只有当作者不同时(可能是因为他们写了同一标题的书籍)才创建新的Book实例。

在上下文中以有意义的术语对数据进行建模的独立层使得理解正在发生的事情变得更容易。它还使交付层更加简洁,因此更容易维护。

刚才发生了什么?

在导入EntityRelation类之后,我们首先定义适当的实体和关系(完整的代码作为booksdb.py提供)。我们遇到的第一个函数是threadinit()(高亮显示)。这是一个便利函数,它调用我们定义的不同实体和关系的所有单个threadinit()方法:

第五章/booksdb.py

from entity import Entity
from relation import Relation
class Book(Entity):
	pass
class Author(Entity):
	pass
class User(Entity):
	pass
class BookAuthor(Relation):
	pass
class UserBook(Relation):
	pass def threadinit(db):
	Book.threadinit(db)
	Author.threadinit(db)
	User.threadinit(db)
	BookAuthor.threadinit(db)
	UserBook.threadinit(db)

同样,inittable()函数是一个便利函数,它调用所有必要的inittable()方法:

第五章/booksdb.py

def inittable():
	Book.inittable(title="",isbn="unique",publisher="")
	Author.inittable(name="")
	User.inittable(userid="unique not null")
	BookAuthor.inittable(Book,Author)
	UserBook.inittable(User,Book)

它将Book定义为Entity的子类,具有title、唯一的isbnpublisher属性。Author被定义为具有仅name属性的Entity子类,而User作为具有唯一且不能为空的useridEntity。此外,BookAuthor以及UserBook之间存在的关联在这里被初始化。

应使用newbook()函数将新书添加到数据库中:

第五章/booksdb.py

def newbook(title,authors,**kw):
	if not isinstance(title,str) :
			raise TypeError("title is not a str")
	if len(title)<1 :
			raise ValueError("title is empty")
	for a in authors :
			if not isinstance(a,Author) :
				raise TypeError("authors should be of type Author")
bl=list(Book.list(title=title,**kw)) if len(bl) == 0:
				b=Book(title=title,**kw)
elif len(bl) == 1:
				b=Book(id=bl[0])
else:
	raise ValueError("multiple books match criteria")
lba=list(BookAuthor.list(b))
if len(authors):
	lba=[Author(id=r.b_id) for r in lba]
	for a in authors:
			known=False
			for a1 in lba:
					if a.id == a1.id :
							known=True
							break
			if not known:
					r=BookAuthor.add(b,a)
return b

需要一个title参数和一个Author对象的列表以及任意数量的可选关键词来选择一本独特的书籍,如果标题不足以识别书籍的话。如果找不到具有给定标题和附加关键词的书籍,将创建一个新的Book对象(高亮显示)。如果找到多个符合标准的书籍,将引发异常。

下一步是检索与这本书相关的作者列表。此列表用于检查传递给newbook()函数的作者列表中是否有任何作者尚未与这本书关联。如果没有,则添加这个新作者。这确保我们不会尝试将作者与同一本书关联多次,同时也使得向现有书籍的作者列表中添加作者成为可能。

newauthor()函数验证传递给参数的名字不为空,并且确实是一个字符串(高亮显示):

第五章/booksdb.py

def newauthor(name): if not isinstance(name,str) :
			raise TypeError("name is not a str")
	if len(name)<1 :
			raise ValueError("name is empty")
	al=list(Author.list(name=name))
	if len(al) == 0:
				a=Author(name=name)
	elif len(al) == 1:
				a=Author(id=al[0])
	else:
			raise ValueError("multiple authors match criteria")
	return a

然后它检查是否已存在具有该名称的作者。如果没有,则创建一个新的Author对象并返回。如果只找到一个Author,则返回该Author而不创建新的。如果相同的名称匹配多个Author,则引发异常,因为我们的当前数据模型不提供具有相同名称的多个作者的概念。

注册书籍的应用程序通常用于查看我们是否已经拥有这本书。因此,列出符合一组标准书籍的功能应该相当灵活,以便为最终用户提供足够的功能,使查找书籍尽可能简单,即使书籍数量达到数千本。

listbooks()函数试图封装必要的功能。它接受多个关键字参数,用于匹配任意数量的书籍。如果存在user参数,返回的结果将限制为该用户拥有的书籍。同样,author参数将结果限制为该作者所写的书籍。pattern参数可能是一个字符串,它将返回的书籍限制为标题包含pattern参数中文本的书籍。

由于匹配的书籍数量可能非常大,listbooks()函数接受两个额外的参数以返回较小的子集。这样,交付层可以分页提供结果列表。offset参数确定子集的起始位置和返回结果的数量。如果limit-1,则返回从给定offset开始的全部结果。例如:

booksdb.listbooks(user=me,pattern="blind",limit=3)

将返回我拥有的标题中包含“盲”文字的前三本书。

给定这些要求,listbooks()的实现相当直接:

第五章/booksdb.py

def listbooks(user=None,author=None,offset=0,limit=-1,pattern=""):
	lba={}
	lbu={}
	if not user is None:
			if not isinstance(user,User):
					raise TypeError("user argument not a User") lbu={r.b_id for r in UserBook.list(user)}
	if not author is None:
			if not isinstance(author,Author):
					raise TypeError("author argument not an Author")
			lba={r.a_id for r in BookAuthor.list(author)}
	if user is None and author is None: lb={b for b in Book.list()}
	else:
			if len(lbu)==0 : lb=lba
			elif len(lba)==0 : lb=lbu
			else : lb = lba & lbu
	books = [Book(id=id) for id in lb] books = sorted(books,key=lambda book:book.title.lower())
	if pattern != "" :
			pattern = pattern.lower()
			books = [b for b in books
								if b.title.lower().find(pattern)>=0 ]
	if limit<0 :
			limit=len(books)
	else:
			limit=offset+limit
	return len(books),books[offset:limit]

它首先检查任何user参数确实是一个User实体的实例,然后找到该用户拥有的所有书籍(突出显示)并将此列表转换为集合。以类似的方式检查任何author参数。如果没有指定作者或用户,我们只需检索所有书籍的列表(突出显示)并将其转换为集合即可。

使用集合非常方便,因为集合永远不会包含重复项,并且可以轻松地进行操作。例如,如果我们有一个与作者关联的非空书籍集合以及一个用户拥有的非空书籍集合,我们可以使用&运算符获取它们的交集(即既被给定所有者拥有又被给定作者所写)。

无论哪种方式,我们最终都会得到一个包含书籍 ID 的lb列表。然后,将这个 ID 列表转换为Book对象,并按标题排序,以确保在处理偏移量时结果的一致性(突出显示)。下一步是减少结果数量,只保留标题中包含pattern参数中文本的书籍。

注意

所有这些匹配、排序和过滤也可以使用 SQL 完成,并且可能更加高效。然而,这意味着 SQL 将相当复杂,我们将会破坏entityrelation模块中定义的低级数据库操作与在这里booksdb中定义的更高级操作的清晰区分。如果效率更重要,那将是一个合理的论点,但在这里我们选择清晰的分离以帮助理解,因为 Python 比 SQL 更容易阅读。

现在剩下的只是根据offsetlimit参数从书籍列表中返回适当的切片,如listbooks()函数的最后一行所示。请注意,我们实际上返回一个元组,第一个元素是匹配书籍的总数,第二个元素是实际的匹配书籍列表。这使得向最终用户展示信息变得简单,例如“显示第 20-29 项,共 311 项”。

listauthors()函数要么在给定book参数的情况下返回与书籍关联的作者列表,要么返回所有作者列表:

第五章/booksdb.py

def listauthors(book=None):
	if not book is None:
			if not isinstance(book,Book):
					raise TypeError("book argument not a Book")
			la=[r.b_id for r in BookAuthor.list(book)]
	else:
			la=Author.list()
	return [Author(id=id) for id in la]

它确实确保任何book参数确实是一个Book实体的实例。

可以调用checkuser()来查看是否已经存在具有给定用户名的用户,如果不存在,则创建一个:

第五章/booksdb.py

def checkuser(username):
	users=list(User.list(userid=username))
	if len(users):
			return User(id=users[0])
	return User(userid=username)

任何使用此应用程序的用户都应该有一个相应的User实体,如果他/她想要能够注册他/她的书籍所有权。此函数确保这是可能的。

注意,我们的应用程序在此级别不认证用户,这留给传输层,正如我们将看到的。传输层使用的任何认证数据库都与我们的书籍数据库中的User实体完全分开。例如,传输层可以使用系统密码数据库来认证用户,并在认证成功后将用户名传递给此层。如果在此点,用户尚未存在于我们的书籍数据库中,我们可以通过调用checkuser()函数来确保他/她存在。

addowner()delowner()函数用于建立或删除书籍与用户之间的特定所有权关系。这两个函数都是Relation类中底层方法的薄包装,但添加了一些额外的类型检查。

第五章/booksdb.py

def addowner(book,user):
	if not isinstance(book,Book):
			raise TypeError("book argument not a Book")
	if not isinstance(user,User):
			raise TypeError("user argument not a User")
	return UserBook.add(user,book)
def delowner(book,user):
	if not isinstance(book,Book):
			raise TypeError("book argument not a Book")
	if not isinstance(user,User):
			raise TypeError("user argument not a User")
	UserBook(user.id,book.id,stub=True).delete()

在下一节中,我们将利用这个基础来实现传输层。

突击测验:如何选择有限数量的书籍

你将如何从数据库中所有书籍的列表中选择第三页的 10 本书?

尝试英雄般地清理书籍数据库

booksdb.py缺少delbooks()函数,因为我们不会在我们的最终应用程序中提供此功能。仅仅删除所有权并保留书籍原样,即使它没有任何所有者,也不是一个缺点,因为其他用户可以通过引用此现有书籍来注册所有权,而无需再次输入。然而,偶尔我们可能想要清理数据库。你将如何实现一个删除所有无所有者书籍的函数?

传输层

由于我们在entity, relationbooksdb模块上建立了坚实的基础,我们现在可以干净地将交付层与应用程序的其余部分分离。交付层仅由几个 CherryPy 应用程序组成。为了验证用户身份,我们将重用我们在前几章中遇到的登录应用程序,而应用程序的其余部分则由一个包含提供两个主要屏幕所需方法的Books类组成:一个可导航和可过滤的书籍列表和一个用于向数据库添加新书籍的屏幕。

设计交付层的时间

为了设计这些屏幕,通常方便的做法是绘制一些草图,以便有一个屏幕的视觉表示。这使得与客户讨论功能变得容易得多。

有许多优秀的应用程序可以帮助你绘制一些原型(例如,微软的 Expression Blend 或 Kaxaml kaxaml.com/),但通常,尤其是在设计应用程序的早期阶段,简单的草图就足够了,即使它是手绘的。插图显示了我在制作草图时使用的草图,两者都是用 GIMP(http://www.gimp.org/)完成的:

设计交付层的时间

第一张图是用户可以与之交互的书籍列表屏幕草图,第二张图显示了添加新书籍的屏幕可能的样子。

设计交付层的时间

这样的图像很容易在讨论期间打印和标注,而不需要计算机应用程序,你只需要一支笔或铅笔。另一种有用的设计技术是在白板上绘制一些轮廓,并在讨论功能时添加细节。在会议结束时,你可以用你的手机拍下白板的照片,并将其作为起点。设计很可能会在开发过程中发生变化,而从这个简单的起点开始可以节省大量的努力,这些努力可能需要在以后撤销。

当我们查看列出书籍的屏幕设计时,我们立即看到关键功能都在按钮栏中。值得注意的是,我们将不得不实现以下功能:

  • 显示书籍列表

  • 在此列表中向前和向后翻页

  • 将书籍列表限制为当前用户拥有的书籍

  • 根据标题中出现的单词过滤书籍列表

添加新书籍的屏幕设计看似简单。用户必须能够输入标题和作者以表明他拥有这本书,但这意味着在后台,我们至少有以下场景需要检查:

  • 数据库中没有具有给定标题的书籍

  • 存在具有给定标题但没有给定作者的书籍

  • 已知书籍和作者的组合

在第一种情况下,我们必须创建一个新的Book实体,如果作者未知,还可能创建一个新的Author实体。

在第二种情况下,我们还将创建一个新的Book实体,因为不同的作者可能以相同的标题写书。在一个更复杂的应用中,我们可能能够根据 ISBN 进行区分。

在最后一种情况下,我们不需要创建新的BookAuthor实体,但我们仍然需要确保我们注册了那本特定书籍的所有权。

最终要求是使用户感到方便。如果有很多用户在数据库中录入书籍,那么有人注册他/她拥有的新书时,这本书可能已经在数据库中。为了节省用户输入,如果我们能在用户输入时显示可能的标题和作者列表,那就很好了。这被称为自动完成,结合 jQuery UI 和 Python 实现起来相当直接。

设计交付层的时间

booksweb.py启动时,书籍列表将看起来像前面的图像,接下来显示添加新书的页面。我们将在本章的最后部分增强这些外观,但首先我们关注booksweb.py中交付层的实现。

设计交付层的时间

自动完成是客户端输入验证的紧密伴侣。通过向用户提供一个选择列表,我们降低了用户输入类似标题但拼写略有不同的情况的风险。当然,还有一些额外的检查要做:例如,标题可能不能为空。如果用户确实输入了错误,也应该有一些反馈,以便他/她可以纠正错误。

当然,客户端验证是一个有用的工具,可以增强用户体验,但它不能保护我们免受恶意尝试破坏我们数据的风险。因此,我们还实施了一些服务器端检查。

刚才发生了什么?

我们首先创建一个全局变量,它包含我们将同时在书单屏幕和添加书籍屏幕中使用的 HTML(完整的代码作为booksweb.py提供):

第五章/booksweb.py

with open('basepage.html') as f:
	basepage=f.read(-1)

我们是从一个单独的文件中读取它,而不是将其作为字符串存储在模块内部。将 HTML 存储在单独的文件中使得编辑变得容易得多,因为编辑器可以使用语法高亮显示 HTML,而不是仅仅将其标记为 Python 中的字符串字面量。该文件作为basepage.html提供:

第五章/basepage.html

<html><head><title>Books</title> <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.
min.js" type="text/javascript"></script>
<script src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.3/
jquery-ui.min.js" type="text/javascript"></script>
<link rel="stylesheet" href="http://ajax.googleapis.com/ajax/libs/
jqueryui/1.8.3/themes/smoothness/jquery-ui.css" type="text/css" 
media="all" />
<link rel="stylesheet" href="http:///books.css" type="text/css" media="all" 
/>
</head><body>
<div id="content">%s</div>
<script src="img/booksweb.js" type="text/javascript"></script>
</body>
</html>

这次,我们选择将所有外部库从 Google 的内容交付网络(突出显示)中整合进来。

你可能不想在生产应用程序中依赖外部机构,但在开发过程中,这确实是一个优势,因为你不必携带这些文件。即使在生产环境中,这个选择也可能是有意义的,因为这个选项将减少对服务器的请求次数并最小化带宽。同样,我们引用了 Google 的内容交付网络(CDN)上我们选择的主题(Smoothness)的相关样式表和文件。

注意

除了 Google 之外,许多其他大型玩家也提供内容交付网络(CDN),你可以使用。即使是 Microsoft(http://www.asp.net/ajaxlibrary/cdn.ashx)也在其 CDN 上提供对 jQuery 和 jQuery UI 库的免费访问。

头部部分还包含一个指向附加样式表 books.css 的链接,该样式表将用于调整不是 jQuery UI 小部件的元素布局和样式。

主体是一个单独的 <div> 元素,其中包含一个 %s 占位符,用于填充不同相关标记以用于书单和新书页面,之后是一个 <script> 标签,它将为特定页面内的其他脚本元素提供常用功能。

booksweb.py 继续定义 Books 类,该类将作为 CherryPy 框架中此应用程序的中心应用。

第五章/booksweb.py

class Books():
	def __init__(self,logon,logoffpath):
			self.logon=logon
			self.logoffpath=logoffpath
	@cherrypy.expose
	def index(self):
			username = self.logon.checkauth()
			return basepage % '<div id="booklist"></div>'

index() 函数使用单个 <div> 元素提供 basepage.html,该元素将包含内容。

list() 方法将在 booksweb.js 中定义的 JavaScript 函数加载后调用,并用于最初填充内容 <div>,以及当导航按钮栏中的按钮被按下时刷新此 div 的内容。

在我们检查 list()addbook() 方法之前,让我们看看 booksweb.js 中的 JavaScript,以了解这些方法是如何从客户端的 AJAX 调用中调用的(完整的 JavaScript 代码作为 booksweb.js 提供)。

第五章/booksweb.js

$.ajaxSetup({cache:false,type:"GET"});

booksweb.js 中遇到的第一项活动是为所有 AJAX 调用设置全局默认值。我们禁用缓存以确保浏览器每次我们请求它时都执行 AJAX 调用,而不会检查它是否之前已经对同一 URL 执行了调用,否则我们实际上不会刷新我们书单的内容。

为了调试目的,我们还确保每个 AJAX 调用都使用 HTTP GET 方法,因为在 POST 调用中,参数通常不会被记录,而在 GET 调用中,参数是请求 URL 的一部分。

我们接下来遇到的 prepnavbar() 函数是我们的工作马:每次我们通过 URL /books/list 获取书籍列表时,一旦请求完成,就会调用 prepnavbar() 一次。

第五章/booksweb.js

function prepnavbar(response, status, XMLHttpRequest){
	$("#firstpage").button({
				text: false,
				icons: {
						primary: "ui-icon-seek-start"
				}
	});
	$("#previouspage").button({
				text: false,
				icons: {
						primary: "ui-icon-seek-prev"
				}
	});
	$("#mine").button({
				text: false,
				icons: {
						primary: "ui-icon-tag"
				}
	});
	$("#nextpage").button({
				text: false,
				icons: {
						primary: "ui-icon-seek-next"
				}
	});
	$("#lastpage").button({
				text: false,
				icons: {
						primary: "ui-icon-seek-end"
				}
	});
	$("#addbook").button({
				text: false,
				icons: {
						primary: "ui-icon-plusthick"
				}
	});
	t=$("#toolbar").buttonset();
	$("span",t).css({padding:"0px"});
	$(".bookrow:odd").addClass('oddline');
};
$("#booklist").load('/books/list',prepnavbar);$("#booklist").load('/
books/list',prepnavbar);

/books/list 返回的 HTML 不仅包含匹配的书籍,还包括导航按钮本身以及返回的匹配书籍数量的附加信息。这些导航按钮尚未进行样式设置,配置此任务由 prepnavbar() 函数完成。

它将每个按钮(除了用于按文本过滤的输入按钮)样式化为一个没有文本但有适当图标的 jQuery UI 按钮小部件。它还向 bookrow 类的每个奇数行添加 oddline 类,这样我们就可以在我们的样式表中引用这个类,例如,给它添加独特的斑马条纹。

booksweb.js 执行时,页面的内容由一个空的 <div> 组成。这个 <div> 元素被调用 /books/list URL 并带有参数(最后一行)返回的 HTML 填充。prepnavbar() 函数作为 load() 方法的第二个参数传递,并在数据加载完成后被调用。

booksweb.js 的剩余部分充满了为所有导航按钮添加实时点击处理器的代码。

第五章/booksweb.js

function getparams(){
	var m=0;
	// apparently the checked attr of a checkbox is magic:
// it returns true/false, not the contents!
	if ( $("#mine").attr("checked")==true ) { m = 1}
	return { offset:Number($("#firstid").text()),
			limit:Number($("#limitid").text()),
				filter:$("#filter").val(),
				mine:m
			};
};
$("#mine").live('click',function(){
	// this function is fired *after* the click
// toggled the checked attr
	var data = getparams();
	if (data.mine) {
			$("#mine").removeAttr("checked");
	} else {
			$("#mine").attr("checked","yes");
	}
	$("#booklist").load('/books/list',data,prepnavbar);
	return true;
}); $("#firstpage").live('click',function(){
	var data = getparams();
	data.offset=0;
	$("#booklist").load('/books/list',data,prepnavbar);
	return true;
});
$("#previouspage").live('click',function(){
	var data = getparams();
	data.offset -= data.limit;
	if(data.offset<0){ data.offset=0;}
	$("#booklist").load('/books/list',data,prepnavbar);
	return true;
});
$("#nextpage").live('click',function(){
	var data = getparams();
	var n=Number($("#nids").text())
	data.offset += data.limit;
	if(data.offset>=n){ data.offset=n-data.limit;}
	if(data.offset<0){ data.offset=0;}
	$("#booklist").load('/books/list',data,prepnavbar);
	return true;
});
$("#lastpage").live('click',function(){
	var data = getparams();
	var n=Number($("#nids").text())
	data.offset = n-data.limit;
	if(data.offset<0){ data.offset=0;}
	$("#booklist").load('/books/list',data,prepnavbar);
	return true;
});
$("#filter").live('keyup',function(event){
	if (event.keyCode == '13') {
			event.preventDefault();
			data = getparams();
			data.offset=0;
			$("#booklist").load('/books/list',data,prepnavbar);
	}
	return true;
});
$("#addbook").live('click',function(){
	window.location.href="http:///books/addbook";
	return true;
});

任何匹配其选择器的元素都将附加一个实时处理程序,即使这些元素在文档中尚未存在。这将确保当我们重新加载包含新导航按钮的书籍列表时,我们在这里定义的点击处理程序也将绑定到这些新按钮上。

这些处理程序中的每一个都会调用 getparams() 函数来检索包含 id="info"<p> 元素中的信息。这些数据作为 JavaScript 对象返回,可以传递给 load() 方法。load() 方法将在此对象中附加的属性作为参数附加到它调用的 URL 上。对象中的信息反映了当前列出的书籍,每个处理程序都会根据其功能修改这些数据。

例如,#firstpage 按钮的处理程序(高亮显示)修改了 offset 属性。它在调用 /books/load 以检索第一组书籍之前,将其简单地设置为零。

#previouspage 按钮的处理程序从偏移量中减去 limit 属性的值以获取包含书籍的前一页,但确保偏移量不小于零。其他可点击按钮的处理程序在调用 /books/load 之前执行类似操作。

例外的是 #mine 按钮的处理程序,它不操作偏移量,而是切换 checked 属性。

#pattern 输入元素也有所不同。它不对点击做出反应,而是在按下 回车 键时做出反应。如果按下该键,它也会像其他处理程序一样调用 getparams()。以这种方式检索到的对象也将包含一个 pattern 属性,该属性包含用户刚刚输入的 #pattern 输入元素的值。将 offset 属性设置为零以确保当我们传递新的模式值时,我们从列表的开始处查看结果列表。

让我们回到 booksweb.py 的服务器端,看看 list() 方法是如何实现的。

第五章/booksweb.py

@cherrypy.expose
def list(self,offset=0,limit=10,mine=1,pattern="",_=None):
		username = self.logon.checkauth()
		userid=booksdb.checkuser(username)
		try:
			offset=int(offset) if offset<0 : raise ValueError("offset < 0")
		except ValueError:
			raise TypeError("offset not an integer")
		try:
			limit=int(limit)
			if limit<-1 : raise ValueError("limit < -1")
		except ValueError:
			raise TypeError("limit not an integer")
		try:
			mine=int(mine)
		except ValueError:
			raise TypeError("mine not an integer")
		if not mine in (0,1) :
			raise ValueError("mine not in (0,1)")
		if len(pattern)>100 :
			raise ValueError("length of pattern > 100")
		# show titles
		yield '<div class="columnheaders"><div class="title">Title</
div><div class="author">Author</div></div>'
		# get matching books
		if mine==0 : userid=None
		n,books = booksdb.listbooks(user=userid,
				offset=offset,limit=limit,pattern=pattern)
		# yield them as a list of divs
		for b in books: a1=booksdb.listauthors(b)[0]
				yield '''<div id="%d" class="bookrow">
<div class="title">%s</div>
<div class="author">%s</div>
</div>'''%(b.id,b.title,a1.name)
		# yield a line of navigation buttons
		yield '''<div id="navigation">
<p id="info">Showing
<span id="limitid">%d</span> of
<span id="nids">%d</span> items,
owned by <span id="owner">%s</span> starting at
<span id="firstid">%d</span>
</p>
<div id="toolbar">
<button id="firstpage" value="First">First</button>
<button id="previouspage" value="Previous">Prev</button>
<input id="mine" type="checkbox" %s /><label for="mine">Mine</label>
<input id="pattern" type="text" value="%s" />
<button id="nextpage" value="Next" >Next</button>
<button id="lastpage" value="Last" >Last</button>
<button id="addbook" value="Add">Add</button>
</div>
</div>'''%(limit,n,username if mine else "all",
					offset,'checked="yes"'if mine else "", pattern)

list()方法接受多个关键字参数以确定要返回哪些书籍。它不会返回一个完整的 HTML 页面,而只是返回一个代表书籍选择的<div>元素列表,以及一些关于所选书籍数量和用于浏览列表的按钮元素的一些附加信息:

  • offset参数确定匹配书籍列表的起始位置。计数从 0 开始。

  • limit参数确定要返回的匹配书籍数量。这是一个最大值,如果没有足够的书籍,将返回更少的书籍。当我们有 14 本匹配的书籍时,偏移量为 10,限制为 10,将返回从 13 到 10 的 10 本书。

  • 如果mine参数非零,匹配的书籍列表将限制为请求用户拥有的书籍。

  • 如果pattern参数不是一个空字符串,匹配的书籍列表将限制为包含该模式字符串的标题的书籍。

  • _(下划线)参数被忽略。我们在booksweb.js中配置了我们的 AJAX 调用不要缓存,jQuery 通过在请求的 URL 中每次附加一个具有随机值的_参数来防止缓存。这样,浏览器每次看到的 URL 都会不同,这将防止缓存。

list()方法的实现首先验证用户是否已登录,然后检索相应的User对象。接下来的步骤系统地验证传递给方法的方法参数,并在验证失败时引发ValueErrorTypeError。例如,offset参数应该大于或等于零(突出显示)。

一旦参数得到验证,这些参数就会被传递给booksdb.listbooks()函数,该函数将负责实际的筛选并将返回一个元组,包含匹配的书籍数量和按标题排序的实际书籍列表。

这个书籍列表用于遍历并生成适当的 HTML 标记。对于每一本书,我们获取书籍的作者(突出显示),然后生成一个包含 HTML 标记的字符串。这个 HTML 包含书籍的标题和第一作者的名字。如果我们还想展示更多信息,例如书籍的 ISBN,我们很容易在这里添加。通过使用yield逐个返回结果,我们避免了在返回之前先构建一个完整的字符串的麻烦。

最后的yield语句包含一个具有id="navigation"<div>元素。我们选择返回完整的导航标记,包括按钮,以便我们能够轻松设置这些按钮的值。例如,<input>模式元素应该显示我们当前过滤的文本。我们可以将其作为单独的信息传递,并使用客户端 JavaScript 来设置这些值,但这会使 JavaScript 变得相当复杂。

尽管如此,offsetlimit值以及匹配的书籍总数都返回在<p>元素内。这服务于两个目的:我们可以将其显示为用户的信息消息,但它也是导航按钮正常工作的必要信息。

添加新书的时间到了

将新书添加到数据库的屏幕是一个简单的表单。我们需要实现的是:

  • 一些 HTML,使其能够显示表单

  • 我们 CherryPy 应用程序中的一个方法,将生成此 HTML

  • 在此表单提交后处理输入的方法

在这里不需要实现两种不同的方法,因为基于传递给方法的参数,我们可以决定是返回表单还是处理同一表单的提交内容。虽然设计一个执行两项任务的方法可能被认为是不好的做法,但它确实将相关的功能放在一起。

刚才发生了什么?

addbookform类变量包含一个模板,该模板引用了多个字符串变量以进行插值。还有一个<script>元素,用于添加一些额外的功能,我们稍后将其检查:

Chapter5/booksweb.py

	addbookform='''<div id="newbook">
<form action="addbook" method="get">
<fieldset><legend>Add new book</legend>
<input name="title" id="title" type="text" value="%(title)s" 
%(titleerror)s />
<label for="title">Title</label>
<input name="author" id="author" type="text" value="%(author)s" 
%(authorerror)s />
<label for="author">Author</label>
</fieldset>
<div class="buttonbar">
<button name="submit" type="submit" value="Add">Add</button>
<button name="cancel" type="submit" value="Cancel">Cancel</button>
</div>
</form>
<div id="errorinfo"></div>
</div>'''

addbook() 方法本身既用于显示初始屏幕,也用于处理结果,即它作为<form>元素的动作属性的靶标,并处理来自各种<input><button>元素的值。

因此,所有参数都是具有默认值的键值参数。如果它们全部缺失,addbook() 将构建一个空表单,否则它将检查并处理信息。在后一种情况下,将有两种可能的场景:值是正确的,在这种情况下,将添加新书,并将用户返回到包含书籍列表的页面,或者一个或多个值不正确,在这种情况下,将再次呈现表单,带有适当的错误标记,但用户输入的值仍然保留以供编辑。

Chapter5/booksweb.py

@cherrypy.expose
def addbook(self,title=None,author=None,submit=None,cancel=None):
		username = self.logon.checkauth()
		userid=booksdb.checkuser(username)
		if not cancel is None: raise cherrypy.HTTPRedirect("/books")
		data=defaultdict(str)
		if submit is None:
				return basepage%(Books.addbookform%data)
		if title is None or author is None:
				raise cherrypy.HTTPError(400,'missing argument')
		data['title']=title
		data['author']=author
		try:
				a=booksdb.newauthor(author)
				try:
						b=booksdb.newbook(title,[a])
						booksdb.addowner(b,userid)
						raise cherrypy.HTTPRedirect("/books")
				except ValueError as e:
data['titleerror']= 'class="inputerror ui-state-error" 
title="%s"'%str(e)
			except ValueError as e:
data['authorerror']= 'class="inputerror ui-state-error" 
title="%s"'%str(e)
			return basepage%(Books.addbookform%data)

addbook() 方法首先验证用户是否已登录,如果是,则获取相应的User对象。下一步是检查表单中包含的取消按钮是否被点击,如果是,则cancel参数将包含一个值,并将用户重定向到书籍列表(突出显示)。

接下来,我们创建一个默认字典,它将为每个访问的缺失键返回一个空字符串。这个默认字典将被用作addbookform中字符串的插值数据。这样,如果我们想设置多个插值变量(例如,<input>元素的value属性中的%(title)s),但如果我们省略了任何内容,它将被自动替换为空字符串。

如果submit参数等于 None,这意味着它不存在,因此调用addbook()来显示空表单,这正是所做之事(高亮显示)。因为此时data中没有任何键,所以所有插入变量都将产生空字符串,从而得到一个空表单。

如果submit参数不为 None,我们正在处理表单中的值。首先我们进行合理性检查。如果titleauthor参数中任何一个缺失,我们抛出一个异常(高亮显示)。即使用户未能输入其中任何一个,相应的值也会出现在参数中,但作为空字符串。所以,如果这些参数中任何一个完全缺失,这不能是用户操作的结果,因此抛出异常是有意义的。

如果两个参数都存在,我们将它们保存在默认字典中,这样我们就可以在需要再次显示表单时,将其作为默认值显示。

下一步是尝试从booksdb模块中的newauthor()函数。它要么返回一个有效的Author对象(因为我们已经知道作者或者创建了一个新的作者)或者抛出一个异常。这样的异常会被捕获,并将错误文本添加到字典中的authorerror键,以及一些 HTML 类属性,这将使我们能够以适当的方式显示相应的<input>元素,以指示错误状态。

一旦我们有一个有效的Author对象,我们将使用相同的方法来检索一个有效的Book对象。这可能会失败(主要如果title参数是空字符串),在这种情况下,我们将titleerror键设置在字典中。

我们通过调用addowner()函数在用户和书籍之间建立所有权关系,然后将用户重定向到列出书籍的页面。

如果出现任何问题,我们会捕获一些异常,最终回到返回语句,这将再次返回表单,但这次字典将包含一些将被插入的键,从而产生合适的默认值(例如,如果title参数为空,但author参数不为空,用户不必再次输入作者的姓名)以及遇到的错误信息。

所有这些字符串插入可能有点令人畏惧,所以让我们简要地看一下一个例子。addbookform变量中标题<input>元素的定义如下:

<input name="title" id="title" type="text" value="%(title)s" 
%(titleerror)s />

如果我们想向用户显示一个空表单,字符串将与一个不包含任何键的默认字典进行插入。因此,引用%(title)s%(titlerror)s将产生空字符串,结果如下:

<input name="title" id="title" type="text" value="" />

一个不带默认值的普通<input>元素。

现在如果定位或创建作者出现问题,字典将包含title键但没有titleerror键(但会有authorerror键)。假设用户输入的书名是“一本书名”,那么最终的插值将看起来像这样:

<input name="title" id="title" type="text" value="A book title" />

最后,如果标题出现了错误,例如,因为用户没有输入书名,title键将存在(尽管在这种情况下,是一个空字符串)以及titleerror键。titleerror键的值包含错误消息作为 HTML 的title属性,以及一个看起来像这样的 HTMLclass属性:

class="inputerror ui-state-error" title="title is empty"

因此,最终的插值结果将是:

<input name="title" id="title" type="text" value="" class="inputerror 
ui-state-error" title="title is empty" />

自动完成

当我们展示了显示添加新书表单的页面的 HTML 标记时,我们跳过了末尾的<script>元素。这个脚本元素用于通过自动完成增强标题和作者<input>元素。

使用具有自动完成的输入字段执行操作的时间

在放置了<script>元素之后,输入元素现在可以通过 AJAX 调用检索可能的完成项。现在,当我们在一个输入字段中输入一些字符时,我们会看到一个选择列表,如图像所示:

使用具有自动完成的输入字段执行操作的时间

让我们详细看看这是如何在 JavaScript 中实现的,令人惊讶的是,只有几行代码。

刚才发生了什么?

如果我们再次查看代码,我们会看到我们在#title#author <input>元素上都调用了autocomplete()方法,但每个都有不同的源参数。jQuery UI 中的自动完成小部件非常灵活且易于应用(显示的代码是booksweb.py的一部分,但我们之前跳过了这部分):

第五章/booksweb.py

<script>
$("#title" ).autocomplete({ source:'/books/gettitles',
											minLength:2}).focus();
$("#author").autocomplete({ source:'/books/getauthors',
											minLength:2});
</script>

我们传递给autocomplete()方法的选项对象的source属性包含一个 URL,该 URL 将用于检索已输入字符的可能完成项列表。

minLength属性确保只有在用户输入至少两个字符后,我们才开始寻找可能的完成项,否则列表可能会非常大,而且帮助不大。请注意,仍然可以在输入字段中输入一个全新的值。用户没有义务从显示的列表中选择一个项目,并且可以继续输入。

自动完成小部件将已输入的文本作为term参数添加到source URL 中。当用户在#author <input>元素中输入foo时,这将导致对类似/books/getauthors?term=foo&_=12345678的 URL 的调用。

这意味着gettitles()getauthors()方法都将接受一个term参数(以及一个_(下划线)参数以确保没有缓存):

第五章/booksweb.py

@cherrypy.expose
	def getauthors(self,term,_=None):
			return json.dumps(booksdb.getauthors(term))
@cherrypy.expose
def gettitles(self,term,_=None):
			titles=json.dumps(booksdb.gettitles(term))
			return titles

两种方法只是将请求传递给相应的 booksdb 函数,但由于自动完成小部件期望结果为 JSON 编码的字符串,我们在返回之前使用 json.dumps() 函数将列表转换为字符串:

Chapter5/booksdb.py

def gettitles(term):
	titles=Book.getcolumnvalues('title')
	re=compile(term,IGNORECASE)
	return list(takewhile(lambda x:re.match(x),
							dropwhile(lambda x:not re.match(x),titles)))
def getauthors(term):
	names=Author.getcolumnvalues('name')
	re=compile(term,IGNORECASE)
	return list(takewhile(lambda x:re.match(x),
							dropwhile(lambda x:not re.match(x),names)))

booksdb.py 中的 getauthors()gettitles() 函数可以简单地检索 AuthorBook 对象的列表,并提取 nametitle 属性。然而,这样做相当慢,因为创建大量对象在处理能力方面成本很高。此外,因为我们真正感兴趣的是字符串列表而不是整个对象,所以在 Entity 类中实现一个 getcolumnvalues() 方法是值得的:

Chapter5/entity.py

@classmethod
def getcolumnvalues(cls,column):
		if not column in cls.columns :
				raise KeyError('unknown column '+column) sql="select %s from %s order by lower(%s)"%(column,
				cls.__name__,column)
		cursor=cls.threadlocal.connection.cursor()
		cursor.execute(sql)
		return [r[0] for r in cursor.fetchall()]

getcolumnvalues() 首先检查请求的列是否存在于这个 Entity(子)类中,如果不存在则抛出异常。然后它构建一个 SQL 语句来返回请求列中的值,排序时不考虑大小写(高亮显示)。结果是包含单个元素的元组列表,在返回之前将其转换为简单的项目列表。

展示层

现在我们已经实现了传输层,应用程序几乎可以使用,但边缘看起来有点粗糙。尽管由于使用了 jQuery UI 小部件内置的样式,一些组件看起来相当不错,但其他部分需要一些重大的调整。

使用增强的展示层进行操作的时间

额外的 JavaScript 代码和 CSS 信息分别包含在 booksweb.jsbooksweb.css 中。以下插图显示了书籍列表页面和添加新书籍页面的最终结果:

使用增强的展示层进行操作的时间

我们添加了一些斑马条纹以帮助可读性,并更改了列标题的外观。

使用增强的展示层进行操作的时间

添加书籍的页面使用了与书籍列表页面上的按钮相同的样式。同时,布局得到了清理,并添加了功能以清晰可见的方式呈现任何返回的错误(在最后一个示例中,标题为空,因此背景为红色)。

使用增强的展示层进行操作的时间

刚才发生了什么?

为了实现前图中看到的变化,我们在 booksweb.js 中添加了以下几行 JavaScript:

Chapter5/booksweb.js

$(".buttonbar").buttonset();
$("#newbook button[name=submit]").button({
				text: false,
				icons: {
						primary: "ui-icon-plusthick"
				}
});
$("#newbook button[name=cancel]").button({
				text: false,
				icons: {
						primary: "ui-icon-trash"
				}
});

这种效果仅仅是改变按钮的外观,而不是通过某种事件处理器添加功能,因为不需要这样做。页面包含一个带有有效 action 属性的常规 <form> 元素,因此我们的提交和取消按钮将按预期工作。

其余的更改,包括边框、字体和颜色,都在 booksweb.css 中实现,这里我们不对其进行检查,因为其中包含的 CSS 非常简单。

摘要

在本章中,我们关于围绕由多个实体和关系组成的数据模型设计和实现 Web 应用学到了很多。

具体来说,我们涵盖了:

  • 如何设计数据模型

  • 如何创建可重用的实体和关系框架

  • 如何在数据库、对象层和交付层之间保持清晰的分离

  • 如何使用 jQuery UI 的自动完成小部件实现自动完成

我们还讨论了输入验证的重要性,包括客户端和服务器端。

我们尚未充分发挥我们的实体和关系框架以及输入验证的潜力,输入验证可能涉及更多。为了锻炼我们的新技能并扩展它们,下一章将介绍设计和构建一个维基应用。

第六章:建立维基

现在,维基是一个广为人知的工具,它能够以合作的方式让人们维护知识体系。维基百科(http://wikipedia.org)可能是今天最著名的维基示例,但无数的论坛都使用某种维基,并且存在许多工具和库来实现维基应用程序。

在本章中,我们将开发自己的维基,在这个过程中,我们将关注构建 Web 应用程序的两个重要概念。第一个是数据层的设计。我们将基于上一章创建的简单框架进行构建,并尝试确定我们当前实现中的局限性。我们将构建的维基应用程序是一个很好的测试案例,因为它比之前开发的图书数据库要复杂得多。

第二个是输入验证。维基通常是一个非常公开的应用程序,甚至可能不采用基本的身份验证方案来识别用户。这使得向维基贡献变得非常简单,但同时也使得维基在某种程度上容易受到攻击,因为任何人都可以在维基页面上放置任何内容。因此,验证任何提交的更改内容是一个好主意。例如,您可以移除任何 HTML 标记或禁止外部链接。

以有意义的方式增强用户交互通常与输入验证密切相关。正如我们在上一章中看到的,客户端输入验证有助于防止用户输入不希望的内容,因此它是任何应用程序的有价值补充,但不能替代服务器端输入验证,因为我们不能信任外部世界不会试图以未预期的方式访问我们的服务器。

当我们在本章开发我们的维基应用程序时,我们将明确处理输入验证和用户交互。

在本章中,我们将:

  • 为维基应用程序实现数据层

  • 实现交付层

  • 仔细研究输入验证

  • 遇到 jQuery UI 的对话框小部件

因此,让我们开始吧...

数据层

一个维基由许多我们可以识别的独立实体组成。我们将通过重用之前开发的实体/关系框架来实现这些实体及其之间的关系。

设计维基数据模型的时间

与任何应用程序一样,当我们开始开发我们的维基应用程序时,我们必须首先采取一些步骤来创建一个可以作为开发起点的数据模型:

  1. 识别在应用程序中扮演角色的每个实体。这可能会取决于需求。例如,因为我们希望用户能够更改主题的标题,并且我们希望存档内容的修订版本,所以我们定义了单独的主题和页面实体。

  2. 识别实体之间的直接关系。我们决定定义独立的主题和页面实体意味着它们之间存在关系,但还可以识别更多关系,例如主题和标签之间的关系。不要指定间接关系:所有标记相同标签的主题在某种程度上是相关的,但通常没有必要记录这些间接关系,因为它们可以从记录的主题和标签之间的关系中轻易推断出来。

图像显示了我们在维基应用程序中可以识别的不同实体和关系。请注意,与书籍应用程序一样,用户是一个独立的实体,与例如密码数据库中的任何用户都不同。

在图中,我们说明了这样一个事实:一个主题可能拥有多个页面,而页面通过将页面表示为矩形堆叠和将用户表示为单个矩形的方式,以一种相当非正式的方式引用单个用户。这样,我们可以一眼抓住关系的最相关方面。当我们想要显示更多关系或具有不同特性的关系时,使用更正式的方法和工具可能是个好主意。一个好的起点是维基百科上的 UML 条目:en.wikipedia.org/wiki/Unified_Modelling_Language

设计维基数据模型的时间

刚才发生了什么?

在我们的数据模型中确定了实体和关系之后,我们可以看看它们的特定品质。

维基中的基本实体是主题。在这个上下文中,主题基本上是一个描述该主题内容的标题。一个主题可以关联任意数量的页面。每个页面实例代表一个修订版;最新的修订版是主题的当前版本。每次编辑主题时,都会在数据库中存储一个新的修订版。这样,如果我们犯了错误,可以简单地回滚到早期版本,或者比较两个修订版的内容。为了简化修订版的识别,每个修订版都有一个修改日期。我们还维护了页面和修改该页面用户之间的关系。

在我们将要开发的维基应用程序中,也可以将任意数量的标签与主题关联。标签实体简单地由一个标签属性组成。重要的是存在于主题实体和标签实体之间的关系。

标签类似,单词实体由一个属性组成。同样,重要的是关系,这次是主题与任意数量的单词之间的关系。我们将维护这种关系以反映主题当前版本(即页面的最后修订版)中使用的单词。这将允许我们有相当响应的全文本搜索功能。

我们遇到的最后一个实体是Image实体。我们将使用它来存储与文本页面一起的图片。我们不会在主题和图片之间定义任何关系。图片可能会在主题的文本中提到,但除了这个文本引用之外,我们不会维护正式的关系。如果我们想维护这样的关系,每次存储页面的新修订时,我们就必须扫描图片引用,并且如果尝试引用一个不存在的图片,我们可能还需要发出某种信号。在这种情况下,我们选择忽略这一点:数据库中不存在的图片引用将简单地显示为无:

from entity import Entity
from relation import Relation
class User(Entity): pass
class Topic(Entity): pass
class Page(Entity): pass
class Tag(Entity): pass
class Word(Entity): pass
class Image(Entity): pass
class UserPage(Relation): pass
class TopicPage(Relation): pass
class TopicTag(Relation): pass
class ImagePage(Relation): pass
class TopicWord(Relation): pass
def threadinit(db):
	User.threadinit(db)
	Topic.threadinit(db)
	Page.threadinit(db)
	Tag.threadinit(db)
	Word.threadinit(db)
	Image.threadinit(db)
	UserPage.threadinit(db)
	TopicPage.threadinit(db)
	TopicTag.threadinit(db)
	ImagePage.threadinit(db)
	TopicWord.threadinit(db) **def inittable():**
	User.inittable(userid="unique not null")
	Topic.inittable(title="unique not null") **Page.inittable(content="",**
						modified="not null default CURRENT_TIMESTAMP")
	Tag.inittable(tag="unique not null")
	Word.inittable(word="unique not null")
	Image.inittable(type="",data="blob",title="",
						modified="not null default CURRENT_TIMESTAMP",
						description="")
	UserPage.inittable(User,Page)
	TopicPage.inittable(Topic,Page)
	TopicTag.inittable(Topic,Tag)
	TopicWord.inittable(Topic,Word) 

因为我们可以重用我们之前开发的实体和关系模块,所以数据库层的实际实现很简单(完整代码作为wikidb.py提供)。在导入这两个模块后,我们首先为我们在数据模型中确定的所有实体定义一个Entity的子类。所有这些类都是直接使用的,因此它们的主体只有一个pass语句。

同样,我们为我们的 wiki 应用程序中需要实现的每个关系定义一个Relation的子类。

所有这些EntityRelation子类仍然需要在每次应用程序启动时调用一次初始化代码,这就是便利函数initdb()的作用所在。它捆绑了每个实体和关系的初始化代码(突出显示)。

我们在这里定义的许多实体都很简单,但其中一些需要更仔细的检查。Page实体包含一个modified列,该列有一个non null约束。它还有一个默认值:CURRENT_TIMESTAMP(突出显示)。这个默认值是 SQLite 特定的(其他数据库引擎将会有其他指定此类默认值的方式),如果我们创建一个新的Page记录而没有明确设置值,它将初始化修改列到当前日期和时间。

Image实体也有一个稍微不同的定义:它的data列被明确定义为具有blob亲和力。这将使我们能够在这个表中无任何问题地存储二进制数据,这是我们存储和检索图片中包含的二进制数据所必需的。当然,SQLite 会高兴地存储我们传递给这个列的任何东西,但如果我们传递一个字节数组(而不是字符串),那么这个数组将按原样存储。

# 传输层

在数据层的基础上,即数据层就绪后,我们在开发传输层时在此基础上构建。在传输层和数据库层之间,还有一个额外的层,它封装了特定领域的知识(即,它知道如何验证新Topic实体的标题是否符合我们在将其存储在数据库之前设定的要求):

传输层

我们的应用程序中的每个不同层都在其自己的文件或文件中实现。很容易混淆,所以在我们进一步深入研究这些文件之前,先看看以下表格。它列出了组成 wiki 应用程序的不同文件,并引用了在第一章中引入的层名称,“选择你的工具”(如前图所示)。

文件
wikiweb.py 内容交付框架 我们的主要 CherryPy 应用程序
wiki.py 对象关系映射器 领域特定部分;由wikiweb.py导入
wikidb.py 对象关系映射器 领域无关部分;由wikiweb.py导入
basepage.html 结构表示 wikiweb.py用于向客户端提供页面
wikiweb.js 图形用户界面 basepage.html中引用;实现鼠标点击等用户交互。
wiki.css 图形用户界面 basepage.html中引用;实现图形组件的布局。

我们首先关注主要 CherryPy 应用程序,以了解应用程序的行为。

实施开篇屏幕的时间

wiki 应用程序的开篇屏幕显示了右侧所有定义的主题列表以及几种在左侧定位主题的方法。请注意,它看起来仍然相当粗糙,因为我们还没有应用任何样式表:

实施开篇屏幕的时间

让我们首先采取一些步骤来识别底层结构。这个结构就是我们希望在 HTML 标记中表示的结构:

  • 识别出分组在一起的相关信息。这些构成了结构化网页的骨干。在这种情况下,左侧的搜索功能形成了一个与右侧主题列表不同的元素组。

  • 在这些更大的组内识别出不同的功能部分。例如,组成单词搜索的元素(输入字段和搜索按钮)是这样的一个功能部分,标签搜索和标签云也是如此。

  • 尝试识别任何隐藏的功能,即将成为 HTML 标记一部分的必要信息,但在页面上并不直接可见。在我们的例子中,我们有 jQuery 和 JQuery UI JavaScript 库的链接以及 CSS 样式表的链接。

识别这些不同的部分不仅有助于组合出反映页面结构的 HTML 标记,而且有助于在交付层中识别必要的功能,因为每个功能部分都关注服务器处理和生成的特定信息。

刚才发生了什么?

让我们更详细地看看我们已识别的开篇页面的结构。

最值得注意的是三个搜索输入字段,可以根据它们正文中的单词、实际标题或与主题关联的标签来定位主题。这些搜索字段具有自动完成功能,允许使用逗号分隔的列表。在同一列中,还有一个用于显示标签云的空间,这是一个按字母顺序排列的标签列表,字体大小取决于带有该标签的主题数量。

结构组件

该打开页面的 HTML 标记如下所示。它作为文件basepage.html提供,该文件的内容通过Wiki类实现的数据层中的多种方法提供,每种方法都包含合适的内容段。此外,一些内容将通过 AJAX 调用填充,正如我们稍后将看到的:

Chapter6/basepage.html

<html>
	<head>
			<title>Wiki</title>
			<script
					src=
"http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"
					type="text/javascript">
			</script>
			<script
					src=
"http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.3/jquery-ui.min.js"
					type="text/javascript">
			</script>
			<link rel="stylesheet"
					href="http://ajax.googleapis.com/ajax/libs/
jqueryui/1.8.3/themes/smoothness/jquery-ui.css"
					type="text/css" media="all" />
			<link rel="stylesheet" href="http:///wiki.css"
					type="text/css" media="all" />
	</head>
	<body> <div id="navigation">
					<div class="navitem">
						<a href="http://./>Wiki Home</a>
					</div>
					<div class="navitem">
						<span class="label">Search topic</span>
						<form id="topicsearch">
								<input type="text" >
								<button type="submit" >Search</button>
						</form>
					</div>
					<div class="navitem">
						<span class="label">Search word</span>
						<form id="wordsearch">
								<input type="text" >
								<button type="submit" >Search</button>
						</form>
					</div>
					<div class="navitem">
						<span class="label">Search tag</span>
						<form id="tagsearch">
								<input type="text" >
								<button type="submit" >Search</button>
						</form>
					</div>
					<div class="navitem">
						<p id="tagcloud">Tag cloud</p>
					</div>
			</div> <div id="content">%s</div>
			<script src="img/wikiweb.js" type="text/javascript"></script>
	</body>
</html>

<head>元素包含指向 CSS 样式表的链接和引用 jQuery 库的<script>元素。这次,我们再次选择从公共内容交付网络检索这些库。

突出显示的行显示了定义页面结构的顶级<div>元素。在这种情况下,我们已识别出导航部分和内容部分,并在 HTML 标记中反映了这一点。

导航部分包含搜索功能,每个功能都在自己的<div>元素中。内容部分目前只包含一个占位符%s,该占位符将由提供此标记的方法填充。在标记的末尾之前,有一个最终的<script>元素,它引用了一个将执行我们应用程序特定操作的 JavaScript 文件,我们将在稍后检查这些文件。

应用程序方法

上一节的标记由Wiki类的index()方法提供,该类的一个实例可以挂载为 CherryPy 应用程序。例如,index()方法是我们生成打开屏幕(完整文件作为wikiweb.py提供,并包含我们将在以下部分检查的几个其他方法)的标记的地方:

Chapter6/wikiweb.py

@cherrypy.expose
def index(self): item = '<li><a href="http://show?topic=%s">%s</a></li>'
		topiclist = "\n".join(
				[item%(t,t)for t in wiki.gettopiclist()])
		content = '<div id="wikihome"><ul>%s</ul></div>'%(
				topiclist,)
		return basepage % content

首先,我们为主页主要区域(突出显示)中将要显示的每个主题定义标记。该标记由一个包含锚元素的列表项组成,该锚元素引用显示打开屏幕的页面的相对 URL。使用相对 URL 允许我们将实现此应用程序部分的功能的类挂载在为 CherryPy 应用程序提供服务的树中的任何位置。将提供此 URL 的show()方法接受一个主题参数,其值将在下一行中为数据库中存在的每个主题进行插值。

结果被连接成一个字符串,然后将其插值到另一个字符串中,该字符串封装了我们刚刚生成的所有列表项,在一个无序列表(标记中的<ul>元素)中,并最终作为basepage变量的插值内容返回。

index() 方法的定义中,我们看到一个在维基应用程序中经常重复的模式:交付层中的方法,如 index(),关注于构建和向客户端提供标记,并将实际检索信息委托给一个了解维基本身的所有信息的模块。在这里,主题列表是由 wiki.gettopiclist() 函数生成的,而 index() 将这些信息转换为标记。这些活动的分离有助于保持代码的可读性,从而便于维护。

实现维基主题屏幕的行动时间

当我们请求形式为 show?topic=value 的 URL 时,这将导致调用 show() 方法。如果 value 等于一个现有主题,以下(尚未样式化)的屏幕就是结果:

实现维基主题屏幕的行动时间

正如首页一样,我们采取了以下步骤:

  • 识别屏幕上的主要区域

  • 识别特定的功能

  • 识别任何隐藏的功能

页面结构与首页非常相似,具有相同的导航项,但与主题列表不同,我们看到了请求的主题内容以及一些附加信息,如与该主题相关的标签和一个可以点击以编辑该主题内容的按钮。毕竟,协同编辑内容是维基的核心所在。

我们故意选择不通过 AJAX 调用仅刷新首页的一部分内容,而是选择了一个简单的链接来替换整个页面。这样,浏览器地址栏中将有一个明确的 URL 指向该主题。这允许轻松地进行书签。如果使用 AJAX 调用,浏览器地址栏中可见的首页 URL 将保持不变,尽管有方法可以缓解这个问题,但我们在这里选择了这个简单的解决方案。

刚才发生了什么?

由于我们确定的主要结构与首页几乎完全相同,因此 show() 方法将重用 basepage.html 中的标记。

第六章/wikiweb.py

@cherrypy.expose
def show(self,topic):
		topic = topic.capitalize() currentcontent,tags = wiki.gettopic(topic)
		currentcontent = "".join(wiki.render(currentcontent))
		tags = ['<li><a href="http://searchtags?tags=%s">%s</a></li>'%(
										t,t) for t in tags]
		content = '''
		<div>
				<h1>%s</h1><a href="edit?topic=%s">Edit</a>
		</div>
		<div id="wikitopic">%s</div>
		<div id="wikitags"><ul>%s</ul></div>
		<div id="revisions">revisions</div>
		''' % ( topic, topic, currentcontent,"\n".join(tags))
		return basepage % content

show() 方法将大部分工作委托给下一节将要讨论的 wiki.gettopic() 方法(已突出显示),并专注于创建将发送给客户端的标记。wiki.gettopic() 将返回一个元组,其中包含主题的当前内容和与该主题相关联的标签列表。

这些标签被转换为指向 searchtags URL 的锚点的 <li> 元素。这个标签列表为读者提供了一个简单的方法,通过单次点击即可找到相关主题。searchtags URL 接受一个 tags 参数,因此这样构造的单个 <li> 元素可能看起来像这样:<li><a href="http://searchtags?tags=Python">Python</a></li>

内容和可点击的标签列表嵌入在basepage的标记中,以及一个指向edit URL 的锚点。稍后,我们将为此锚点设置样式,使其看起来像按钮,当用户点击它时,它将显示一个可以编辑内容的页面。

编辑 wiki 主题的时间

在上一节中,我们展示了如何向用户展示主题的内容,但 wiki 不仅仅是关于找到主题,还必须向用户提供一种编辑内容的方式。以下截图显示了此编辑屏幕:

编辑 wiki 主题的时间

除了左侧的导航栏外,在编辑区域内,我们还可以指出以下功能:

  • 用于更改主题标题的元素。

  • 修改与主题关联的标签(如果有)。

  • 一个大文本区域用于编辑主题的内容。在文本区域的顶部,我们可以看到一些按钮,可以用来插入对其他主题、外部链接和图片的引用。

  • 一个将更改提交到服务器的保存按钮。

刚才发生了什么?

wikiweb.py中的edit()方法负责显示编辑屏幕以及处理用户点击保存按钮后输入的信息:

第六章/wikiweb.py

@cherrypy.expose
def edit(self,topic,
					content=None,tags=None,originaltopic=None):
	user = self.logon.checkauth(
			logonurl=self.logon.path, returntopage=True) if content is None :
			currentcontent,tags = wiki.gettopic(topic)
			html = '''
			<div id="editarea">
					<form id="edittopic" action="edit"
							method="GET">
							<label for="topic"></label>
							<input name="originaltopic"
									type="hidden" value="%s">
							<input name="topic" type="text"
									value="%s">
							<div id="buttonbar">
									<button type="button" 
											id="insertlink">
											External link
									</button>
									<button type="button" 
											id="inserttopic">
											Wiki page
									</button>
									<button type="button" 
											id="insertimage">
											Image
									</button>
							</div>
							<label for="content"></label>
							<textarea name="content"
									cols="72" rows="24" >
									%s
							</textarea>
							<label for="tags"></label>
							<input name="tags" type="text" 
									value="%s">
							<button type="submit">Save</button>
							<button type="button">Cancel</button>
							<button type="button">Preview</button>
					</form>
			</div>
			<div id="previewarea">preview</div>
			<div id="imagedialog">%s</div>
			<script>
					$("#imagedialog").dialog(
								{autoOpen:false,
								width:600,
								height:600});
			</script>
			'''%(topic, topic, currentcontent,
								", ".join(tags),
								"".join(self.images()))
			return basepage % html
		else :
			wiki.updatetopic(originaltopic,topic,content,tags)
			raise cherrypy.HTTPRedirect('show?topic='+topic)

edit()方法的第一个优先级是验证用户是否已登录,因为我们只想让已知的用户编辑主题。通过将returntopage参数设置为 true,checkauth()方法将在用户验证后返回此页面。

edit() 方法旨在显示主题的编辑屏幕,并在用户点击保存按钮时处理此编辑的结果,因此需要相当多的参数。

区分是基于content参数。如果此参数不存在(高亮显示),则方法将生成标记来显示编辑屏幕中的各种元素。如果内容参数不等于None,则edit()方法是在提交编辑屏幕中显示的表单内容后调用的,在这种情况下,我们将实际更新内容的工作委托给wiki.updatetopic()方法。最后,我们将客户端重定向到将再次以最终形式显示编辑内容的 URL,而不显示编辑工具。

到目前为止,你可能想知道为什么会有topicoriginaltopic参数。为了允许用户在标题被用于查找我们正在编辑的主题实体时更改主题的标题,我们将主题的标题作为隐藏变量传递到编辑表单中,并使用此值检索原始主题实体,这是必要的策略,因为在此点,我们可能有一个新的标题,但仍然需要找到仍然以旧标题存在于数据库中的相关主题。

注意

跨站请求伪造

当我们处理发送给edit()函数的数据时,我们确保只有经过认证的用户提交任何内容。不幸的是,如果用户被诱骗代表其他人发送经过认证的请求,这可能还不够。这被称为跨站请求伪造(CSRF),尽管有防止这种攻击的方法,但这些方法超出了本例的范围。然而,有安全意识的人应该了解这些漏洞,一个好的起点是www.owasp.org/index.php/Main_Page,以及针对 Python 的特定讨论www.pythonsecurity.org/

快速问答

我们可以将哪个Topic实体的属性传递以获取我们正在编辑的主题的引用?

其他功能

在打开屏幕以及显示主题内容和编辑页面的页面上,有很多隐藏的功能。我们已经在wiki模块中遇到了几个函数,我们将在本节中详细检查它们,以及一些 JavaScript 功能来增强用户界面。

选择图像的行动时间

在允许我们编辑主题的页面上,我们半隐藏了一个重要元素:插入图像的对话框。如果点击插入图像按钮,会出现一个对话框,如下面的图像所示:

选择图像的行动时间

因为对话框在某种程度上是一个独立的页面,所以我们采取相同的步骤来识别功能组件:

  • 识别主要结构

  • 识别特定功能组件

  • 识别隐藏功能

对话框由两种形式组成。顶部的一种包含一个输入字段,可以用来根据给定的标题查找图像。它将增加 jQuery UI 的自动完成功能。

第二种形式允许用户在上传新文件的同时,对话框中填充任意数量的图像。点击任意一个图像将关闭对话框,并在编辑页面的文本区域中插入对该图像的引用。也可以通过点击右上角的小关闭按钮或按Esc键不选择图像而再次关闭对话框。

刚才发生了什么?

整个对话框由images()方法提供的标记组成。

Chapter6/wikiweb.py

@cherrypy.expose
def images(self,title=None,description=None,file=None):
		if not file is None:
				data = file.file.read()
				wikidb.Image(title=title,description=description,
						data=data,type=str(file.content_type))
		yield '''
		<div> <form>
						<label for="title">select a title</label>
						<input name="title" type="text">
						<button type="submit">Search</button>
				</form>
				<form method="post" action="./images"
						enctype="multipart/form-data">
						<label for="file">New image</label> <input type="file" name="file">
						<label for="title">Title</label>
						<input type="text" name="title">
						<label for="description">Description</label>
						<textarea name="description"
								cols="48" rows="3"></textarea>
						<button type="submit">Upload</button>
				</form>
		</div>
		'''
		yield '<div id="imagelist">\n'
		for img in self.getimages():
				yield img
			yield '</div>'

这里有一些需要很好理解的技巧:从edit()方法中,我们调用这个images()方法来提供我们插入到请求edit URL 的客户端页面中的标记,但由于我们用@cherrypy.expose装饰器装饰了images()方法,所以images()方法对外部可见,并且可以通过images URL 进行请求。如果以这种方式访问,CherryPy 将负责添加正确的响应头。

能够以这种方式调用此方法有两个用途:因为对话框是一个非常复杂的页面,包含许多元素,我们可能检查它的外观而不会被它作为对话框的一部分所打扰,并且我们可以将其用作图像对话框中表单的目标,该表单允许我们上传新的图像。与 edit() 方法一样,区别再次基于是否存在某个参数。用于此目的的参数是 file,如果此方法在响应图像提交时被调用(已突出显示),则将包含一个 file 对象。

file 对象是一个 cherrypy.file 对象,不是一个 Python 内置的 file 对象,并且有几个属性,包括一个名为 file 的属性,它是一个常规的 Python 流对象。这个 Python 流对象充当 CherryPy 创建的临时文件的接口,用于存储上传的文件。我们可以使用流的 read() 方法来获取其内容。

注意

对于所有关于 file 的引用,我同意这可能有点令人困惑。如果需要,请阅读两遍并放松。此摘要可能很有用:

此项目有一个 which 是 a

images() 方法的 file 参数 herrypy.file 对象

一个 cherrypy.file 对象的 file 属性 Python stream 对象

一个 Python stream 对象的 name 属性,是磁盘上文件的名称

Python 流可以属于多个类,其中所有类都实现了相同的 API。有关 Python 流的详细信息,请参阅 docs.python.org/py3k/library/functions.html#open

cherrypy.file 还有一个 content_type 属性,其字符串表示形式我们与标题和二进制数据一起使用来创建一个新的 Image 实例。

下一步是展示将生成对话框的 HTML 标记,可能包括上传的图像。此标记包含两个表单。

第一个(在上一个代码片段中突出显示)由一个输入字段和一个提交按钮组成。当我们在检查 wikiweb.js 时,我们将看到输入字段将增加自动完成功能。当点击提交按钮时,它将替换图像选择。这也在 wikiweb.js 中实现,通过添加一个点击处理程序,该处理程序将对 getimages URL 执行 AJAX 调用。

下一个形式是文件上传表单。使其成为文件上传表单的是类型为 file<input> 元素(已突出显示)。幕后,CherryPy 将将文件类型 <input> 元素的 内容存储在一个临时文件中,并通过提交表单将其传递给服务请求 URL 的方法。

还有一点魔法需要注意:我们将对话框的标记作为 edit() 方法提供的标记的一部分插入,但对话框只有在用户点击插入图像按钮时才会显示。这种魔法是由 jQuery UI 的对话框小部件执行的,我们通过调用其 dialog 方法来转换包含对话框标记的 <div> 元素,如 edit() 方法提供的以下标记片段所示:

<script>$("#imagedialog").dialog({autoOpen:false});</script>

通过将 autoOpen 选项设置为 false,我们确保在页面加载后对话框保持隐藏,毕竟,对话框只有在用户点击插入图像按钮时才应该打开。

打开对话框是通过几段 JavaScript 代码完成的(完整代码作为 wikiweb.js 提供)。第一段代码将一个点击处理程序与插入图像按钮关联起来,该处理程序将 open 选项传递给对话框,使其显示:

第六章/wikiweb.js

$("#insertimage").click(function(){
	$("#imagedialog").dialog("open");
});

注意,对话框的默认操作是在按下 Escape 键时关闭自身,所以我们不必对此做任何事情。

在对话框中,我们必须配置显示的图像,以便在点击时在文本区域中插入引用,然后关闭对话框。我们通过配置一个 live 处理程序来为 click 事件完成此操作。一个 live 处理程序将应用于匹配选择器(在这种情况下,具有 selectable-image 类的图像)的元素,即使它们尚未存在。这是至关重要的,因为我们可能上传新的图像,这些图像在对话框首次加载时尚未出现在图像列表中:

第六章/wikiweb.js

$(".selectable-image").live('click',function(){
	$("#imagedialog").dialog("close");
	var insert = "<" + $(this).attr("id").substring(3) + "," + 
$(this).attr("alt") + ">"; var Area = $("#edittopic textarea");
	var area = Area[0];
	var oldposition = Area.getCursorPosition();
var pre = area.value.substring(0, oldposition);
	var post = area.value.substring(oldposition);
	area.value = pre + insert + post;
	Area.focus().setCursorPosition(oldposition + insert.length);
});

此处理程序的第一项活动是关闭对话框。下一步是确定我们想要插入文本区域(高亮显示)的文本。在这种情况下,我们决定将数据库中图像的引用表示为一个数字,后面跟着一个尖括号内的描述。例如,数据库中的图像编号 42 可能表示为<42,"Picture of a shovel">。当我们检查 wikiweb.py 中的 render() 方法时,我们将看到我们将如何将这种尖括号表示法转换为 HTML 标记。

函数的其余部分涉及将此引用插入 <textarea> 元素。因此,我们首先检索匹配我们的文本区域(高亮显示)的 jQuery 对象,并且由于这样的选择始终是一个数组,我们需要访问 <textarea> 元素的底层 JavaScript 功能,所以我们获取第一个元素。

<textarea> 元素的 value 属性包含正在编辑的文本,我们将此文本分为光标位置之前的部分和之后的部分,然后再次将其与我们的图像引用组合。然后我们确保文本区域再次获得焦点(当用户使用对话框时可能会改变),并将光标定位在刚插入文本之后的位置。

实现标签云的时间到了

我们之前确定的一个独特功能是所谓的标签云。

实现标签云的时间

在所有页面的导航部分中显示的标签云是一个按字母顺序排序的标签列表。单个标签的样式表示带有此标签的主题的相对数量。点击标签将显示相关主题的列表。在这个实现中,我们只改变了字体大小,但我们也可以选择通过改变颜色来增加额外的效果。

在我们实现标签云之前,我们应该退后一步,仔细看看我们需要实现什么:

  • 我们需要检索一个标签列表

  • 我们需要对这些进行排序

  • 我们需要展示标记。这个标记应该包含指向一个合适的 URL 的链接,该 URL 将表示带有此标签的主题列表。此外,这个标记必须以某种方式表明具有此标签的主题的相对数量,以便可以适当地进行样式化。

最后一个要求又是将结构从表示中分离出来的问题。通过更改样式表来适应特定的样式比更改结构标记更容易。

刚才发生了什么?

如果我们查看表示示例标签云的 HTML,我们会注意到标签由具有表示其权重的class属性的<span>元素表示。在这种情况下,我们将权重的范围分为五个部分,从对最不重要的标签的weight0类到对最重要的标签的weight4类:

<span class="weight1"><a href="http://searchtags?tags=Intro">Intro</a></span>
<span class="weight1"><a href="http://searchtags?tags=Main">Main</a></span>
<span class="weight4"><a href="http://searchtags?tags=Python">Python</a></
span>
<span class="weight2"><a href="http://searchtags?tags=Tutorial">Tutorial</a></
span>

我们用来表示这些权重的实际字体大小由wiki.css中的样式决定。

.weight0 { font-size:60%; }
.weight1 { font-size:70%; }
.weight2 { font-size:80%; }
.weight3 { font-size:90%; }
.weight4 { font-size:100%; }

标签云本身是由wikiweb.py中的tagcloud()方法提供的。

Chapter6/wikiweb.py

@cherrypy.expose
def tagcloud(self,_=None):
		for tag,weight in wiki.tagcloud():
				yield '''
				<span class="weight%s">
						<a href="http://searchtags?tags=%s">%s</a>
				</span>'''%(weight,tag,tag)

此方法遍历从wiki.tagcloud()(突出显示)检索到的所有元组。这些元组由一个权重和一个标签名称组成,并将它们转换为链接,并封装在一个具有合适class属性的<span>元素中:

Chapter6/wiki.py

def tagcloud():
	tags = sorted([wikidb.Tag(id=t) for t in wikidb.Tag.list()],
							key=attrgetter('tag'))
	totaltopics=0
	tagrank = []
	for t in tags:
		topics = wikidb.TopicTag.list(t)
		if len(topics):
				totaltopics += len(topics)
				tagrank.append((t.tag,len(topics)))
	maxtopics = max(topics for tag,topics in tagrank)
	for tag,topics in tagrank:
		yield tag, int(5.0*topics/(maxtopics+1)) # map to 0 - 4

wiki.py中的tagcloud()函数首先检索所有Tag对象的列表,并根据它们的tag属性进行排序。接下来,它遍历所有这些标签,检索它们的相关主题(突出显示)。然后,它通过检查主题列表的长度来检查是否真的有主题。一些标签可能没有任何相关主题,并且不会计入这次排名操作。

小贴士

当一个标签从主题中移除时,如果它不再有任何相关主题,我们实际上不会删除该标签本身。这可能会导致未使用的标签积累,如果需要,你可能想实现一些清理方案。

如果一个标签确实有相关主题,主题的数量将被加到总数中,并且一个包含标签名称和主题数量的元组将被附加到tagrank列表中。由于我们的Tag对象列表已经排序,当我们完成主题计数后,tagrank也将被排序。

为了确定标签的相对权重,我们再次迭代,这次是遍历tagrank列表以找到与任何标签关联的主题的最大数量。然后,在最后的迭代中,我们提供一个包含标签名称及其相对权重的元组,其中相对权重是通过将主题数量除以我们遇到的最大数量(加一,以防止除以零错误)来计算的。这个权重将在零和一(不包括)之间,通过将这个数乘以 5 并向下取整到整数,可以得到一个介于 0 到 4(包括)之间的整数。

搜索单词的行动时间

为了能够找到包含一个或多个特定单词的所有主题的列表,我们在导航区域向用户提供一个搜索表单。在设计此类表单时需要考虑以下因素:

  • 用户必须能够输入多个单词以找到包含所有这些单词的内容的主题

  • 搜索应该是大小写不敏感的

  • 定位这些主题应该很快,即使我们有很多主题并且文本量很大

  • 自动完成将有助于帮助用户指定实际上是某些主题内容部分的单词

所有这些考虑因素将决定我们如何在交付层和展示层实现该功能。

发生了什么?

导航区域中的搜索选项和在编辑屏幕中的标签输入字段都具备自动完成功能。我们在上一章中遇到了自动完成功能,当时它被用来显示标题和作者列表。

在维基应用程序中的单词和标签搜索字段中,我们希望更进一步。在这里,我们希望对以逗号分隔的项目列表进行自动完成。插图显示了如果我们输入一个单词会发生什么,以及当我们输入第二个单词时会发生什么:

发生了什么?

我们不能简单地将带有逗号的物品列表发送到服务器,因为在那种情况下,我们无法强制设置最小字符限制。当然,对于第一个单词来说,这是可行的,但一旦第一个单词出现在输入字段中,每个后续字符的输入都会导致向服务器发送请求,而我们所希望的是,当第二个单词的最小字符数达到时才发生。

幸运的是,jQuery UI 网站已经展示了如何在这种情况下使用自动完成小部件的示例(请查看jqueryui.com/demos/autocomplete/#multiple-remote)中的示例)。由于这个在线示例的注释中解释得相当清楚,我们这里不再列出,但请注意,技巧在于,除了向自动完成小部件提供源 URL 之外,还提供了一个回调函数,该函数将代替直接检索信息而被调用。此回调函数可以访问输入字段中的逗号分隔项字符串,并且可以仅使用列表中的最后一个项调用远程源。

在交付方面,单词搜索功能由两个方法表示。第一个方法是wikiweb.py中的getwords()方法:

Chapter6/wikiweb.py

@cherrypy.expose
def getwords(self,term,_=None): term = term.lower()
		return json.dumps(
				[t for t in wikidb.Word.getcolumnvalues('word')
						if t.startswith(term)])

getwords()将返回一个以term参数中的字符开头的单词列表,并将其作为 JSON 序列化字符串返回,以便用于我们将添加到单词搜索表单输入字段的自动完成函数。单词在数据库中以小写形式存储。因此,在匹配任何单词之前,term参数也被转换为小写(高亮显示)。请注意,json.dumps()的参数用方括号括起来,以将列表推导式返回的生成器转换为列表。这是必要的,因为json.dumps不接受生成器。

第二种方法称为searchwords(),它将返回一个包含所有以逗号分隔的单词字符串传递给它的主题的列表,这些主题包含所有传递给它的单词。列表将按主题名称进行字母排序:

Chapter6/wikiweb.py

@cherrypy.expose
def searchwords(self,words):
		yield '<ul>\n'
		for topic in sorted(wiki.searchwords(words)):
				yield '<li><a href="http://show?topic=%s">%s</a></li>'%(
						topic,topic)
		yield '</ul>\n'

注意,searchwords()返回的标记不是完整的 HTML 页面,因为它将在用户点击搜索按钮时异步调用,并将结果替换内容部分。

同样,实际上找到包含这些单词的主题的艰苦工作不是在交付层完成的,而是委托给wiki.searchwords()函数:

Chapter6/wiki.py

def searchwords(words):
	topics = None
	for word in words.split(','): word = word.strip('.,:;!? ').lower() # a list with a final 
comma will yield an empty last term
			if word.isalnum():
					w = list(wikidb.Word.list(word=word))
					if len(w):
							ww = wikidb.Word(id=w[0]) wtopic = set( w.a_id for w in wikidb.
TopicWord.list(ww) )
							if topics is None :
									topics = wtopic
							else:
									topics &= wtopic
							if len(topics) == 0 :
									break
if not topics is None:
		for t in topics:
				yield wikidb.Topic(id=t).title

这个searchwords()函数首先通过分割其word参数中的逗号分隔项,并通过去除每个项的前导、尾随标点符号和空白字符以及将其转换为小写(高亮显示)来清理每个项。

下一步是仅考虑仅由字母数字字符组成的项,因为这些是唯一作为单词实体存储的,以防止无意义的缩写或标记的污染。

我们通过调用Word类的list()方法来检查项目是否存在于数据库中。这将返回一个空列表或只包含单个 ID 的列表。在后一种情况下,这个 ID 被用来构建一个Word实例,我们使用它通过调用TopicWord类的list()方法(突出显示)来检索与该单词关联的Topic ID 列表,并将其转换为集合以便于操作。

如果这是我们检查的第一个单词,topics变量将包含None,我们只需将其赋值为集合。如果topic变量已经包含一个集合,我们用存储的集合和我们现在检查的单词关联的主题 ID 集合的交集来替换这个集合。两个集合的交集是通过&运算符(在这种情况下,直接替换左侧,因此是&=变体)计算的。交集的结果将是我们有一个包含所有已检查单词的主题 ID 集合。

如果结果集合包含任何 ID,这些 ID 将被转换为Topic实例以获取它们的title属性。

输入验证的重要性

任何传递给服务 wiki 应用的方法的参数,都可能损害该应用。这听起来可能有点悲观,但请记住,在设计应用时,你不能依赖于公众的善意,尤其是当应用可以通过互联网访问,而你的公众可能包括愚蠢的搜索引擎机器人或更糟糕的情况。

我们可以通过实施某种身份验证方案,仅授予我们认识的人编辑页面的权限来限制风险,但我们不希望这些人通过插入各种 HTML 标记、不存在或甚至恶意的 JavaScript 片段来破坏主题的外观。因此,在我们将内容存储到数据库之前,我们希望去除内容中存在的任何不受欢迎的 HTML 元素,这个过程通常被称为清洗

注意

在这个网页上深入探讨了防止跨站脚本攻击(XSS)(正如在网页中包含不受欢迎的代码所称呼的):www.pythonsecurity.org/wiki/cross-sitescripting/.

清洗内容的时间

许多维基不允许使用任何 HTML 标记,而是使用更简单的标记方法来表示项目符号列表、标题等。

考虑以下内容:

  • 用户能否理解一些 HTML 标记,或者完全选择不使用 HTML 标记?

  • 维基将包含什么?仅仅是文本,还是也包括外部引用或存储在维基中的二进制对象(如图片)的引用?

对于这个维基,我们将实施一种混合方法。我们将允许一些 HTML 标记,如<b><ul>,但不允许任何链接。维基中主题的引用可以输入为[Topic],而外部页面的链接可以表示为{www.example.org}。存储在维基中的图片可以引用为<143>。每种类型的引用都可以有一个可选的描述。以下是一个用户输入的示例标记:

This topic is tried with a mix of legal and illegal markup.
A <b>list</b> is fine:
<ul>
<li>One</li>
<li>Two</li>
<li>Three</li>
</ul>
A link using an html tag referring to a <a href="http://www.example.
com" target="blank">nasty popup</a>.
A legal link uses braces {http://www.example.com, "A link"}

当查看时,它将看起来像以下图片:

内容清理行动时间

刚才发生了什么?

当我们在wikiweb.py中遇到edit()方法时,我们看到实际更新主题内容的工作被委托给了wiki.py中的updatetopic()函数,因此让我们看看这个函数是如何组织的:

第六章/wiki.py

def updatetopic(originaltopic,topic,content,tags):
	t=list(wikidb.Topic.list(title=originaltopic))
	if len(t) == 0 :
			t=wikidb.Topic(title=topic)
	else:
			t=wikidb.Topic(id=t[0])
			t.update(title=topic)
	content=scrub(content)
	p=wikidb.Page(content=content)
	wikidb.TopicPage(t.id,p.id)
	# update word index
	newwords = set(splitwords(content))
	wordlist = wikidb.TopicWord.list(t)
	topicwords = { wikidb.Word(id=w.b_id).word:w
								for w in wordlist }
	updateitemrelation(t,topicwords,newwords,
			wikidb.Word,'word',wikidb.TopicWord)
	# update tags
	newtags = set(t.capitalize()
								for t in [t.strip()
										for t in tags.split(',')] if 
t.isalnum())
	taglist = wikidb.TopicTag.list(t)
	topictags = { wikidb.Tag(id=t.b_id).tag:t
								for t in taglist }
	updateitemrelation(t,topictags,newtags,
			wikidb.Tag,'tag',wikidb.TopicTag)

首先它会检查主题是否已经存在,通过检索具有匹配originaltopic参数的title属性的Topic对象列表。如果这个列表为空,它会创建一个新的主题(高亮显示),否则我们更新找到的第一个匹配主题的title属性。(有关edit()方法的解释,请参阅背后的原因)。

然后它调用scrub()函数来清理内容,然后创建一个新的Page实例来存储这些内容,并将其与Topic实例t关联起来。因此,每次我们更新内容时,我们都会创建一个新的修订版,旧的修订版仍然可用于比较。

下一步是更新主题中使用的单词列表。因此,我们通过将内容传递给splitwords()函数(此处未显示,可在wiki.py中找到)并将单词列表转换为集合来创建一个唯一的单词集。将列表转换为集合将删除任何重复的项目。

我们将单词集转换为以Word对象为键、单词本身为值的字典,并调用updateitemrelation()函数来执行更新。

同样的场景也用于与主题相关联的任何标签。updateitemrelation()函数可能看起来令人畏惧,但这主要是因为它被设计得足够通用,可以处理任何Relation,而不仅仅是TopicWordTopicTag之间的关系。通过设计一个通用的函数,我们维护的代码更少,这很好,尽管在这种情况下,可读性可能受到了过多的损害。

第六章/wiki.py

def updateitemrelation(p,itemmap,newitems,Entity,attr,Relation):
	olditems = set()
	for item in itemmap:
			if not item in newitems:
					itemmap[item].delete()
			else:
					olditems.add(item) for item in newitems - olditems:
			if not item in itemmap:
					ilist = list(Entity.list(**{attr:item}))
					if (len(ilist)):
							i = Entity(id=ilist[0])
					else:
							i = Entity(**{attr:item})
					Relation.add(p,i)

首先,我们确定与主实体p当前关联的任何项目是否不在新的项目列表中。如果是这样,它们将被删除,也就是说,主实体与项目之间的记录关系将从数据库中删除,否则我们将它们存储在olditems集中。

下一步确定 newitemsolditems(突出显示)之间的差异。结果表示那些需要与主要实体关联的项目,但可能尚未存储在数据库中。这是通过使用 list() 方法查找任何实体来确定的,如果没有找到实体,则创建一个。最后,我们在主要实体和项目之间添加一个新的关系

scrub() 方法用于从内容中移除任何未明确列出为允许的 HTML 标签:

第六章/wiki.py

def scrub(content): parser = Scrubber(('ul','ol','li','b','i','u','em','code','pre','h1',
'h2','h3','h4'))
	parser.feed(content)
	return "".join(parser.result)

为了这个目的,它实例化了一个具有非常有限允许标签列表(突出显示)的 Scrubber 对象,并将内容传递给其 feed() 方法。然后,结果可以在 Scrubber 实例的结果属性中找到:

第六章/wiki.py

class Scrubber(HTMLParser):
	def __init__(self,allowed_tags=[]):
			super().__init__()
			self.result = []
			self.allowed_tags = set(allowed_tags)
	def handle_starttag(self, tag, attrs):
			if tag in self.allowed_tags:
					self.result.append('<%s %s>'%(tag,
								" ".join('%s="%s"'%a for a in attrs)))
	def handle_endtag(self, tag):
			if tag in self.allowed_tags:
					self.result.append('</'+tag+'>')
	def handle_data(self,data):
			self.result.append(data)

Scrubber 类是 Python 的 html.parser 模块中提供的 HTMLParser 类的子类。我们在这里覆盖了适当的方法来处理开始和结束标签以及数据,并忽略其他部分(如处理指令等)。只有当开始和结束标签存在于允许的标签列表中时,它们才会被附加到结果中。常规数据(即文本)简单地附加到结果中。

动作时间:渲染内容

我们在文本区域编辑器中添加了特定的 JavaScript 功能,以插入对外部网站、其他维基主题和维基图片的引用,这些引用是我们自己设计的格式,不能被解释为 HTML。现在我们必须提供将这种表示法转换为客户端可以理解的代码。

刚才发生了什么?

识别那些需要转换为 HTML 的项目主要通过使用正则表达式完成。因此,我们首先定义了三个正则表达式,每个代表一个独特的模式。请注意,我们在这里使用原始字符串以防止转义字符的解释。在正则表达式中,反斜杠是有意义的,如果我们不使用原始字符串,我们就必须用反斜杠转义每个反斜杠,这将导致一个难以阅读的反斜杠海洋:

第六章/wiki.py

topicref = re.compile(r'\[\s*([^,\]]+?)(\s*,\s*([^\]]+))?\s*\]')
linkref = re.compile(r'\{\s*([^,\}]+?)(\s*,\s*([^\}]+))?\s*\}')
imgref = re.compile(r'\<\s*(\d+?)(\s*,\s*([^\>]*))?\s*\>')

注意

想了解更多关于 Python 正则表达式的信息,请查看 docs.python.org/py3k/library/re.html 或查阅附录中的阅读清单。

接下来,我们定义了三个实用函数,每个模式一个。每个函数接受一个表示匹配模式的 match 对象,并返回一个可以在 HTML 中使用以显示或链接到该引用的字符串:

第六章/wiki.py

def topicrefreplace(matchobj):
	ref=matchobj.group(1)
	txt=matchobj.group(3) if (not matchobj.group(3)
								is None) else matchobj.group(1)
	nonexist = ""
	if(len(list(wikidb.Topic.list(title=ref)))==0):
			nonexist = " nonexisting"
	return '<a href="http://show?topic=%s" class="topicref%s">%s</a>'%(
							ref,nonexist,txt)
def linkrefreplace(matchobj):
	ref=matchobj.group(1)
	txt=matchobj.group(3) if (not matchobj.group(3)
							is None) else matchobj.group(1)
	ref=urlunparse(urlparse(ref,'http'))
	return '<a href="http://%s class="externalref">%s</a>'%(ref,txt)
def imgrefreplace(matchobj):
	ref=matchobj.group(1)
	txt=matchobj.group(3) if (not matchobj.group(3)
							is None) else matchobj.group(1)
	return '''<img src="img/showimage?id=%s" alt="%s"
						class="wikiimage">'''%(ref,txt)
def render(content):
	yield '<p>\n'
	for line in content.splitlines(True):
			line = re.sub(imgref ,imgrefreplace ,line)
			line = re.sub(topicref,topicrefreplace,line)
			line = re.sub(linkref ,linkrefreplace ,line) if len(line.strip())==0 : line = '</p>\n<p>'
			yield line
	yield '</p>\n'

render() 函数接受一个包含要转换为 HTML 的内容的字符串。对于内容中的每一行,它尝试找到预定义的模式,并通过将适当的函数传递给 re.sub() 方法来转换它们。如果一行只包含空白字符,则生成适当的 HTML 以结束一个段落(突出显示)。

摘要

在本章中,我们关于实现由多个实体及其关系组成的网络应用学到了很多。

具体来说,我们涵盖了:

  • 如何创建一个准确描述实体和关系的模型

  • 如何创建一个具有安全意识且对传入数据谨慎处理的传输层

  • 如何使用 jQuery UI 的对话框小部件并扩展自动完成小部件的功能

我们也遇到了一些限制,尤其是在我们的实体/关系框架中。例如:

  • 初始化数据库的工作量很大,因为每个实体和关系都需要自己的初始化代码

  • 在检索实体时指定排序顺序等事项很不方便

  • 难以统一检查输入值或显示格式

  • 难以区分不同类型的关联,如一对一或多对多

这对于我们中等复杂度的维基应用来说几乎不构成问题,但更复杂的应用只能通过一个更灵活的框架来构建,这正是下一章的主题。**

第七章。重构代码以实现重用

在做了一段时间的大量工作之后,退一步批判性地审视你的工作,看看是否可以做得更好,通常是一个好主意。通常,在开发过程中获得的见解可以很好地应用于任何新的代码,甚至如果好处如此之大以至于值得额外的工作,还可以用于重构现有代码。

通常,这种关键的重新评估是由观察到的应用程序的不足之处所激发的,比如性能不佳,或者注意到请求的更改所需的时间比我们希望的要多,因为代码的设计不是最优的。

现在我们已经在上一章中基于简单的实体/关系框架设计和实现了几个应用程序,现在是时候进行批判性的审视,看看是否有改进的空间。

是时候采取行动,进行批判性的审视

检查每一块主要的代码(通常是您实现的 Python 模块),并问自己以下问题:

  • 我能否在不做任何修改的情况下重用它?

  • 实际使用该模块需要多少额外的代码?

  • 你真的理解了文档(即使是你自己写的)吗?

  • 代码中有多少是重复的?

  • 添加新功能有多容易?

  • 它的表现如何?

当我们对我们开发的实体和关系模块提出这些问题时,我们发现:

  • 重新使用这些模块非常容易

  • 但它们确实需要相当多的额外代码,例如,初始化表和线程

此外,我们故意编写了特定的模块来处理特定领域的代码,如输入验证,但值得检查此代码,看看我们是否可以发现模式并增强我们的框架以更好地支持这些模式。一个例子是我们经常需要自动完成,因此值得看看这是如何实现的。

在书籍应用程序中,我们在性能方面几乎没有解决大量书籍的浏览方式,这确实需要关注,如果我们希望在我们的框架中重用,特别是在处理大量列表的环境中。

刚才发生了什么?

现在我们已经指出了几个可能改进我们的框架模块的领域,现在是时候考虑这是否值得付出努力。

框架模块旨在被许多应用程序重用,所以一个允许模块以更少的额外代码使用的设计重制是一个好主意,因为代码越少意味着维护越少。当然,重写可能意味着如果现有应用程序想要使用这些新版本,它们可能需要重写,但这是为了更好的维护而付出的代价。

重构现有功能通常问题不大,但得益于良好的测试框架来检查新的实现是否仍然按预期行为,会带来很大的好处。同样,添加全新的功能可能问题更少,因为现有应用程序还没有使用这个功能。

因为在我们的情况下,我们认为优势大于劣势,所以我们将重新工作实体/关系框架。我们不仅将关注使用更少的代码,而且还将关注使新的实体和关系类的定义更容易阅读。这将提供更直观地使用这些类。

我们还将专门设置一个部分来开发浏览实体列表的功能,即使列表很大且需要排序或过滤,这种方式也能很好地扩展。

重构

我们将首先重构的是我们如何使用Entity类的方式。我们的目标是实现更直观的使用,无需显式初始化数据库连接。为了了解可能实现的功能,让我们首先看看我们如何使用重构的entity模块的例子。

定义新实体的时间:它应该看起来是什么样子

输入并运行以下代码(也可作为testentity.py使用)。它将使用重构的entity模块来定义一个MyEntity类,并处理这个类的几个实例。我们将创建、列出和更新实例,甚至可以看到更新失败,因为我们尝试分配一个无法通过属性验证的值:

第七章/testentity.py

from entity import *
class Entity(AbstractEntity):
	database="/tmp/abc.db"
class MyEntity(Entity): a=Attribute(unique=True, notnull=True, affinity='float',
		displayname='Attribute A', validate=lambda x:x<5)
a=MyEntity(a=3.14)
print(MyEntity.list())
e=MyEntity.list(pattern=[('a',3.14)])[0]
print(e)
e.delete()
a=MyEntity(a=2.71)
print([str(e) for e in MyEntity.list()])
a.a=1
a.update()
print([str(e) for e in MyEntity.list()])
a.a=9

打印函数产生的输出应该看起来类似于以下列出的行,包括由无效更新尝试引发的异常:

 [MyEntity(id=5)]
<MyEntity: Atrribute A=3.14, id=5>
['<MyEntity: Atrribute A=2.71, id=6>']
['<MyEntity: Atrribute A=1.0, id=6>']
Traceback (most recent call last):
File "testentity.py", line 25, in <module>
	a.a=9
File "C:\Documents and Settings\Michel\Bureaublad\MetaBase\Books II\entity.py"
, line 117, in __setattr__
	raise AttributeError("assignment to "+name+" does not validate")
AttributeError: assignment to a does not validate

刚才发生了什么?

我们首先注意到,没有显式初始化任何数据库,也没有任何代码显式初始化任何线程。所需的一切只是从entity模块提供的AbstractEntity类中派生实体类,并定义一个指向用作数据库的文件的数据库类变量。

接下来,尽管我们以与之前类似的方式定义了一个特定的类(在这个例子中是 MyEntity),我们现在通过定义类变量来指定任何属性,这些变量被分配了Attribute实例。在示例中,我们只为单个属性a(突出显示)这样做。Attribute实例封装了关于约束的大量知识,并允许定义默认值和验证函数。

创建实例并没有什么不同,但正如第二个list()示例所示,这种实现允许过滤,因此不需要检索所有实例 ID,将它们实例化为真正的对象并比较它们的属性。

a属性的最终赋值展示了验证功能的作用。它引发了一个AttributeError异常,因为尝试将 9 赋值给它触发了我们的验证函数。

这些新且不那么繁琐的语义主要归功于使用元类所能实现的功能,我们将在下一节中探讨这个概念。

元类

虽然 Python 文档警告说使用元类会让你的头爆炸(例如,阅读http://www.python.org/doc/newstyle/),但它们并不那么危险:它们可能会引起头痛,但这些头痛在实验和重新阅读文档之后大多会消失。

元类允许你在定义最终成为程序员可用的类定义之前检查和修改类的定义。这是可能的,因为在 Python 中,类本身也是对象;具体来说,它们是元类的实例。当我们实例化一个对象时,我们可以通过定义__init__()__new__()方法来控制这个实例的初始化方式。同样,我们也可以通过在其元类中定义合适的__init__()__new__()方法来控制类的初始化方式。

正如所有类最终都是object类的子类一样,所有元类都源自type元类。这意味着,如果我们想让我们的类成为不同类型的元类的实例,我们必须继承type并定义我们的类。

在阅读了前面的段落之后,你可能仍然担心你的头可能会爆炸,但像大多数事情一样,一个例子要简单得多。

使用元类进行操作的时间

假设我们想要验证我们定义的类总是有一个__info__()方法。我们可以通过定义一个合适的元类,并使用对该元类的引用来检查任何新定义的类来实现这一点。看看下面的示例代码(也作为metaclassexample.py可用):`

Chapter7/metaclassexample.py

class hasinfo(type):
	def __new__(metaclass, classname, baseclasses, classdict):
		if len(baseclasses) and not '__info__' in classdict:
			raise TypeError('does not have __info__')
		return type.__new__(metaclass,
				classname, baseclasses, classdict)
class withinfo(metaclass=hasinfo):
	pass
class correct(withinfo):
	def __info__(self): pass
class incorrect(withinfo):
	pass

这将对incorrect类引发异常,但不会对correct类引发异常。

刚才发生了什么?

如你所见,元类的__new__()方法接收一些重要的参数。首先,元类本身和正在定义的类的classname,一个(可能为空)的baseclasses列表,最后是类字典。最后一个参数非常重要。

当我们使用类定义语句定义一个类时,我们在这里定义的所有方法和类变量最终都会出现在一个字典中。一旦这个类完全定义,这个字典将作为类的__dict__属性可用。这是传递给元类的__new__()方法的字典,我们可以随意检查和修改这个字典。

在这个例子中,我们只是检查这个类字典中是否存在一个名为__info__的键,如果不存在则引发异常。(我们并不真正检查它是否是一个方法,但这当然也是可能的)。如果一切顺利,我们调用type(所有元类的母亲)的__new__()方法,因为这个方法将负责在当前作用域中使类定义可用。

然而,这里有一个额外的技巧。withinfo类在抽象意义上定义了需要__info__()方法,因为它引用了hasinfo元类,但它本身并没有定义它。然而,因为它引用了hasinfo元类,所以会引发异常,因为它的类字典是以与子类相同的方式检查的。为了防止这种情况,我们只有在类是子类的情况下才检查__info__()方法的存在,也就是说,当基类列表(在baseclasses参数中可用)不为空时。

检查必需的方法是好的,但有了这么多信息,可以做更多的事情。在下一节中,我们利用这种能力来确保新类的定义将负责在数据库后端创建合适的表。

MetaEntity 和 AbstractEntity 类

除了创建数据库表之外,如果需要,我们将定义的元类还将检查分配给任何类变量的Attribute实例,以构建显示名称和验证函数的字典。这样,子类可以很容易地通过使用列名作为键来检查是否存在这样的属性,从而避免了再次检查所有类变量的需要。

MetaEntity 和 AbstractEntity 类

实现 MetaEntity 和 AbstractEntity 类的时间

让我们看看这是如何完成的:

Chapter7/entity.py

class Attribute:
	def __init__(self, unique=False, notnull=False,
								default=None, affinity=None, validate=None,
								displayname=None, primary=False): self.coldef = (
				(affinity+' ' if not affinity is None else '') +
				('unique ' if unique else '') +
				('not null ' if notnull else '') +
				('default %s '%default if not default is None else '')
		)
		self.validate = validate
		self.displayname = displayname
		self.primary = primary

Attribute类主要是用来以结构化的方式存储关于属性的信息的载体。我们本可以使用字符串并解析它们,但通过使用Attribute类,可以明确识别出作为数据库列存储的属性类变量。这样,我们仍然可以定义具有不同目的的类变量。此外,编写解析器是一项大量工作,而检查参数则容易得多。

突出的代码显示,大多数参数都用于创建一个字符串,该字符串可以用作创建表语句的一部分的列定义。其他参数(displayname 和validate)则直接存储以供将来参考:

Chapter7/entity.py

class MetaEntity(type):
	@classmethod
	def __prepare__(metaclass, classname, baseclasses, **kwds):
		return collections.OrderedDict()
	@staticmethod
	def findattr(classes,attribute):
		a=None
		for c in classes:
			if hasattr(c,attribute):
				a=getattr(c,attribute)
				break
			if a is None:
				for c in classes:
					a = MetaEntity.findattr(c.__bases__,attribute)
					if not a is None:
						break
			return a
	def __new__(metaclass,classname,baseclasses,classdict):
		def connect(cls):
			if not hasattr(cls._local,'conn'):
				cls._local.conn=sqlite.connect(cls._database)
				cls._local.conn.execute('pragma foreign_keys = 1')
				cls._local.conn.row_factory = sqlite.Row
			return cls._local.conn
		entitydefinition = False if len(baseclasses):
			if not 'database' in classdict:
				classdict['_database']=MetaEntity.findattr(
							baseclasses,'database')
				if classdict['_database'] is None:
					raise AttributeError(
						'''subclass of AbstractEntity has no
						database class variable''')
					entitydefinition=True
			if not '_local' in classdict:
				classdict['_local']=MetaEntity.findattr(
							baseclasses,'_local')
			classdict['_connect']=classmethod(connect) classdict['columns']=[
				k for k,v in classdict.items()
					if type(v) == Attribute]
			classdict['sortorder']=[]
			classdict['displaynames']={
				k:v.displayname if v.displayname else k
				for k,v in classdict.items()
				if type(v) == Attribute}
			classdict['validators']={
				k:v.validate for k,v in classdict.items()
				if type(v) == Attribute
					and not v.validate is None} classdict['displaynames']['id']='id'
			PrimaryKey = Attribute()
			PrimaryKey.coldef = 'integer primary key '
			PrimaryKey.coldef+= 'autoincrement'
			if entitydefinition:
				sql = 'create table if not exists '
				sql+= classname +' ('
				sql+= ", ".join([k+' '+v.coldef
					for k,v in [('id',PrimaryKey)]
							+list(classdict.items())
							if type(v) == Attribute])
				sql+= ')'
				conn = sqlite.connect(classdict['_database'])
				conn.execute(sql)
			for k,v in classdict.items():
				if type(v) == Attribute:
					if v.primary:
						classdict['primary']=property(
							lambda self:getattr(self,k))
						classdict['primaryname']=k
						break
			if not 'primary' in classdict:
				classdict['primary']=property(
					lambda self:getattr(self,'id'))
				classdict['primaryname']='id'
		return type.__new__(metaclass,
			classname,baseclasses,classdict)

我们将使用的元类来同步数据库表的创建和实体类的创建被称为MetaEntity。它的__new__()方法是所有动作发生的地方,但还有一个重要的附加方法:__prepare__()

调用__prepare__()方法以提供一个可以用作类字典的对象。默认情况下,由type类提供,只是返回一个常规 Python dict对象。这里我们返回一个有序字典,这是一个将记住其键的输入顺序的字典。这将使我们能够使用类变量声明的顺序,例如,将其用作显示列的默认顺序。如果没有有序字典,我们就无法控制,并且必须提供单独的信息。

__new__()方法首先检查我们是否是MetaEntity的子类,通过检查基类列表是否非零(突出显示),因为MetaEntity本身没有数据库后端。

然后它检查数据库类变量是否已定义。如果没有,我们是一个具有数据库后端的特定实体,我们尝试在我们的超类中定位数据库类变量。如果我们找到了它,我们将其本地存储;如果没有找到,我们抛出异常,因为我们无法在没有数据库引用的情况下运行。

AbstractEntity类将定义一个_local类变量,它包含对线程局部存储的引用,子类将有自己的_local变量,它指向相同的线程局部存储。

下一步是从所有引用Attribute实例的类变量中收集各种信息。首先我们收集一个列名称列表(突出显示)。记住,因为我们使类字典成为一个有序字典,所以这些列名称将是它们定义的顺序。

同样,我们定义了一个显示名称列表。如果任何属性没有displayname属性,其显示名称将与列名称相同。我们还构建了一个验证器字典,即按列名称索引的字典,它包含一个在将值分配给列之前验证任何值的函数。

每个实体都将有一个id属性(以及数据库表中的对应列),它将自动创建,无需显式定义。因此,我们单独添加其displayname,并构建一个特殊的属性实例(突出显示)。

这个特殊Attributecoldef属性以及其他Attribute实例的coldef属性将用于组成一个 SQL 语句,该语句将创建具有正确列定义的表。

最后,我们将修改后的类字典、原始基类列表和类名一起传递给类型类的__new__()方法,该方法将负责实际的类构造。

任何Entity的其余功能不是由其元类实现的,而是以常规方式实现的,即在类中提供所有实体都应该继承的方法:AbstractEntity:

第七章/entity.py

 class AbstractEntity(metaclass=MetaEntity):
	_local = threading.local()
	@classmethod
	def listids(cls,pattern=None,sortorder=None): sql = 'select id from %s'%(cls.__name__,)
		args = []
		if not pattern is None and len(pattern)>0:
				for s in pattern:
						if not (s[0] in cls.columns or s[0]=='id'):
								raise TypeError('unknown column '+s[0])
				sql += " where "
				sql += " and ".join("%s like ?"%s[0] for s in 
pattern)
				args+= [s[1] for s in pattern]
		if sortorder is None:
			if not cls.sortorder is None :
					sortorder = cls.sortorder
		else:
			for s in sortorder:
					if not (s[0] in cls.columns or s[0]=='id'):
							raise TypeError('unknown column '+s[0])
					if not s[1] in ('asc', 'desc') :
							raise TypeError('illegal sort 
argument'+s[1])
		if not (sortorder is None or len(sortorder) == 0):
			sql += ' order by '
			sql += ','.join(s[0]+' '+s[1] for s in sortorder)
		cursor=cls._connect().cursor()
		cursor.execute(sql,args)
		return [r['id'] for r in cursor]
	@classmethod
	def list(cls,pattern=None,sortorder=None):
		return [cls(id=id) for id in cls.listids(
				sortorder=sortorder,pattern=pattern)]
	@classmethod
	def getcolumnvalues(cls,column):
		if not column in cls.columns :
			raise KeyError('unknown column '+column)
		sql ="select %s from %s order by lower(%s)"
		sql%=(column,cls.__name__,column)
		cursor=cls._connect().cursor()
		cursor.execute(sql)
		return [r[0] for r in cursor.fetchall()]
	def __str__(self):
		return '<'+self.__class__.__name__+': '+", ".join(
				["%s=%s"%(displayname, getattr(self,column))
				for column,displayname
				in self.displaynames.items()])+'>'
	def __repr__(self):
		return self.__class__.__name__+"(id="+str(self.id)+")"
	def __setattr__(self,name,value):
		if name in self.validators :
			if not self.validatorsname:
				raise AttributeError(
					"assignment to "+name+" does not 
validate")
		object.__setattr__(self,name,value)
	def __init__(self,**kw): if 'id' in kw:
				if len(kw)>1 :
						raise AttributeError('extra keywords besides 
id')
				sql = 'select * from %s where id = ?'
				sql%= self.__class__.__name__
				cursor = self._connect().cursor()
				cursor.execute(sql,(kw['id'],))
				r=cursor.fetchone()
				for c in self.columns:
						setattr(self,c,r[c])
				self.id = kw['id']
			else:
				rels={}
				attr={}
				for col in kw:
						if not col in self.columns:
								rels[col]=kw[col]
						else:
								attr[col]=kw[col]
				name = self.__class__.__name__
				cols = ",".join(attr.keys())
				qmarks = ",".join(['?']*len(attr))
				if len(cols):
						sql = 'insert into %s (%s) values (%s)'
						sql%= (name,cols,qmarks)
				else:
						sql = 'insert into %s default values'%name
				with self._connect() as conn:
						cursor = conn.cursor()
						cursor.execute(sql,tuple(attr.values())) self.id = cursor.lastrowid
	def delete(self):
		sql = 'delete from %s where id = ?'
		sql%= self.__class__.__name__
		with self._connect() as conn:
						cursor = conn.cursor()
						cursor.execute(sql,(self.id,))
	def update(self,**kw):
		for k,v in kw.items():
				setattr(self,k,v)
		sets = []
		vals = []
		for c in self.columns:
				if not c == 'id':
						sets.append(c+'=?')
						vals.append(getattr(self,c))
		table = self.__class__.__name__
		sql = 'update %s set %s where id = ?'
		sql%=(table,",".join(sets))
		vals.append(self.id)
		with self._connect() as conn:
					cursor = conn.cursor()
					cursor.execute(sql,vals)

刚才发生了什么

AbstractEntity提供了一些方法来提供 CRUD 功能:

  • 一个构造函数,用于引用数据库中的现有实体或创建新的实体

  • list()listids(),用于查找符合某些标准的实例

  • update(),用于同步实体的更改属性与数据库

  • delete(),用于从数据库中删除实体

它还定义了特殊的 Python 方法__str__(), __repr__(), 和__setattr__(),以便以可读的方式呈现实体并验证对属性的赋值。

显然,AbstractEntity指的是MetaEntity元类(突出显示)。它还定义了一个_local类变量,该变量引用线程局部存储。MetaEntity类将确保这个引用(但不包括其内容)被复制到所有子类以实现快速访问。通过在这里定义它,我们将确保所有子类都引用相同的线程局部存储,更重要的是,每个线程将使用相同的数据库连接,而不是为每个不同的实体使用单独的数据库连接。

listids()类方法将返回符合其pattern参数中指定标准的实体 ID 列表,或者在没有给出标准时返回所有实体的 ID。它将使用sortorder参数来按所需顺序返回 ID 列表。sortorderpattern都是一个元组列表,每个元组的第一项是列名。第二项将是用于与pattern参数匹配的字符串,或者对于sortorder参数是ascdesc,分别表示升序或降序排序。

构建检索任何匹配 ID 的 SQL 语句时,首先创建select部分(突出显示),因为这将不受任何附加限制的影响。接下来,我们检查是否有任何pattern组件被指定,如果有,则为每个pattern项添加一个where子句。我们指定的匹配使用 SQL 的like运算符,这通常仅定义在字符串上,但如果我们使用 like 运算符,SQLite 会将任何参数转换为字符串。使用 like 运算符将允许我们使用 SQL 通配符(例如,%)。

下一个阶段是检查是否在sortorder参数中指定了任何排序项。如果没有,我们使用sortorder类变量中存储的默认排序顺序(在我们的当前实现中仍然将是None)。如果有指定了项,我们将添加一个按顺序子句并为每个排序项添加指定。一个典型的 SQL 语句可能看起来像select id from atable where col1 like ? and col2 like ? order by col1 asc

最后,我们使用由元类添加的_connect()方法来检索一个数据库连接(如果需要,则建立连接),我们可以使用它来执行 SQL 查询并检索 ID 列表。

list()方法与listids()方法非常相似,并接受相同的参数。然而,它将通过使用每个 ID 作为参数调用实体的构造函数,返回实体实例的列表,而不是仅返回 ID 列表。如果这些 ID 本身就是我们想要处理的,这将很方便,但通常 ID 列表更容易操作。因此,我们提供了这两种方法。

能够检索实体列表是件好事,但我们还必须有一种方法来创建一个新的实例,并检索与已知 ID 记录相关联的所有信息。这就是实体构造函数,即__init__()方法的作用所在。

注意

严格来说,__init__()不是一个构造函数,而是一个在实例构建后初始化实例的方法。

如果向__init__()传递单个 ID 关键字参数(突出显示),将检索与该 ID 匹配的记录的所有列,并使用setattr()内置函数设置相应的参数(即与列名称相同的属性)。

如果向__init__()传递了多个关键字参数,则每个参数应该是已定义列的名称。如果不是,将引发异常。否则,使用关键字及其值来构造插入语句。如果定义的列数多于提供的关键字数,这将导致插入默认值。默认值通常是NULL,除非在定义Entity时为该列指定了default参数。如果NULL是默认值且列有non null约束,将引发异常。

因为 ID 列被定义为autoincrement列,我们没有指定显式的 ID 值,所以 ID 值将等于rowid,这是我们作为游标对象的lastrowid属性检索的值(突出显示)。

尝试一次性检索实例的英雄

首先检索 ID 列表,然后实例化实体意味着我们必须使用单独的 SQL 语句检索每个实体的所有属性。如果实体列表很大,这可能会对性能产生负面影响。

创建一个list()方法的变体,它不会逐个将选定的 ID 转换为实体实例,而是使用单个选择语句检索所有属性,并使用这些属性来实例化实体。

关系

定义实体之间的关系应该与定义实体本身一样方便,而且有了元类的力量,我们可以使用相同的概念。但让我们首先看看我们如何使用这样的实现。

定义新关系的行动时间:它应该看起来如何

以下示例代码显示了如何使用Relation类(也在relation.py中可用):`

Chapter7/relation.py

from os import unlink
db="/tmp/abcr.db"
try:
	unlink(db)
except:
	pass
class Entity(AbstractEntity):
	database=db
class Relation(AbstractRelation):
	database=db
class A(Entity): pass
class B(Entity): pass
class AB(Relation):
	a=A
	b=B
a1=A()
a2=A()
b1=B()
b2=B()
a1.add(b1)
a1.add(b2)
print(a1.get(B))
print(b1.get(A))

刚才发生了什么?

在定义了一些实体之后,定义这些实体之间的关系遵循相同的模式:我们定义一个 Relation 类,它是 AbstractRelation 的子类,以建立对将使用的数据库的引用。

然后,我们通过子类化 Relation 并定义两个类变量 ab 来定义两个实体之间的实际关系,这两个变量分别引用构成关系每一半的 Entity 类。

如果我们实例化了一些实体,我们可以通过使用 add() 方法在这些实例之间定义一些关系,并通过 get() 方法检索相关实体。

注意,这些方法是在 Entity 实例上调用的,这比在 Relation 类中使用类方法要自然得多。这些 add()get() 方法是由 MetaRelation 元类添加到这些实体类中的,在下一节中,我们将看到这是如何实现的。

关系类的类图几乎与实体类的类图相同:

发生了什么?

实现 MetaRelation 和 AbstractRelation 类

AbstractRelation 类的实现非常简约,因为它仅用于创建一些线程局部存储并与 MetaRelation 元类建立关系:

Chapter7/relation.py

class AbstractRelation(metaclass=MetaRelation):
	_local = threading.local()

没有指定任何方法,因为元类将负责向实体类添加适合此关系的方法。

MetaRelation 类有两个目标:创建一个数据库表,该表将保存每个单独关系的记录,并向涉及的实体类添加方法,以便可以创建、删除和查询关系:

Chapter7/relation.py

class MetaRelation(type):
	@staticmethod
	def findattr(classes,attribute):
		a=None
		for c in classes:
			if hasattr(c,attribute):
				a=getattr(c,attribute)
				break
		if a is None:
			for c in classes:
				a = MetaRelation.findattr(c.__bases__,attribute)
				if not a is None:
					break
		return a
	def __new__(metaclass,classname,baseclasses,classdict):
		def connect(cls):
			if not hasattr(cls._local,'conn'):
				cls._local.conn=sqlite.connect(cls._database)
				cls._local.conn.execute('pragma foreign_keys = 1')
				cls._local.conn.row_factory = sqlite.Row
			return cls._local.conn
		def get(self,cls):
			return getattr(self,'get'+cls.__name__)()
		def getclass(self,cls,relname):
			clsname = cls.__name__
			sql = 'select %s_id from %s where %s_id = ?'%(
				clsname,relname,self.__class__.__name__)
			cursor=self._connect().cursor()
			cursor.execute(sql,(self.id,))
			return [cls(id=r[clsname+'_id']) for r in cursor]
		def add(self,entity):
			return getattr(self,
				'add'+entity.__class__.__name__)(entity)
		def addclass(self,entity,Entity,relname):
			if not entity.__class__ == Entity :
				raise TypeError(
					'entity not of the required class')
			sql = 'insert or replace into %(rel)s '
			sql+= '(%(a)s_id,%(b)s_id) values (?,?)'
			sql%= { 'rel':relname,
				'a':self.__class__.__name__,
				'b':entity.__class__.__name__}
			with self._connect() as conn:
				cursor = conn.cursor()
				cursor.execute(sql,(self.id,entity.id))
		relationdefinition = False
		if len(baseclasses):
			if not 'database' in classdict:
				classdict['_database']=MetaRelation.findattr(
					baseclasses,'database')
				if classdict['_database'] is None:
					raise AttributeError(
						'''subclass of AbstractRelation has no
						database class variable''') relationdefinition=True
				if not '_local' in classdict:
					classdict['_local']=MetaRelation.findattr(
						baseclasses,'_local')
				classdict['_connect']=classmethod(connect)
				if relationdefinition:
					a = classdict['a']
					b = classdict['b'] if not issubclass(a,AbstractEntity) :
						raise TypeError('a not an AbstractEntity')
					if not issubclass(a,AbstractEntity) :
						raise TypeError('b not an AbstractEntity')
						sql = 'create table if not exists %(rel)s '
						sql+= '( %(a)s_id references %(a)s '
						sql+= 'on delete cascade, '
						sql+= '%(b)s_id references %(b)s '
						sql+= 'on delete cascade, '
						sql+= 'unique(%(a)s_id,%(b)s_id))'
						sql%= { 'rel':classname,
							'a':a.__name__,
							'b':b.__name__}
						conn = sqlite.connect(classdict['_database'])
						conn.execute(sql) setattr(a,'get'+b.__name__,
							lambda self:getclass(self,b,classname))
						setattr(a,'get',get) setattr(b,'get'+a.__name__,
							lambda self:getclass(self,a,classname))
						setattr(b,'get',get) setattr(a,'add'+b.__name__,
						lambda self,entity:addclass(self,
									entity,b,classname))
						setattr(a,'add',add) setattr(b,'add'+a.__name__,
						lambda self,entity:addclass(self,
									entity,a,classname))
						setattr(b,'add',add)
				return type.__new__(metaclass,
							classname,baseclasses,classdict)

MetaEntity 类的情况一样,MetaRelation 通过其 __new__() 方法执行其魔法。

首先,我们检查是否正在创建 AbstractRelation 的子类,通过检查 baseclasses 参数的长度(记住 MetaRelation 被定义为 AbstractRelation 的元类,这意味着不仅其子类,而且 AbstractRelation 本身也将由元类机制处理,这在实际中并不是必需的)。

如果是子类,我们将数据库和线程局部存储引用复制到类字典中,以便快速访问。

如果没有指定 database 属性,我们知道正在定义的类是 Relation 的子类,即一个特定的关系类,并在 relationdefinition 变量中标记这一点(突出显示)。

如果我们处理的是关系的一个具体定义,我们必须找出涉及哪些实体。这是通过检查类字典中名为 ab 的属性来完成的,这些属性应该是 AbstractEntity 的子类(突出显示)。这两个都是关系的两半,它们的名称用于创建一个桥接表,如果尚未存在的话。

如果我们定义一个像这样的关系:

class Owner(Relation):
	a=Car
	b=User

生成的 SQL 语句将是:

create table if not exists Owner (
	Car_id references Car on delete cascade,
	User_id references User on delete cascade,
	unique(Car_id,User_id)
)

每一列都引用了相应表的主键(因为我们只指定了在引用子句中的表),on delete cascade约束将确保如果一个实体被删除,关系也会被删除。最后的unique约束将确保如果存在特定实例之间的关系,将只有一个记录反映这一点。

向现有类添加新方法

__new__()方法的最后一部分处理在涉及此关系的实体类中插入方法。向其他类添加方法可能听起来像魔法,但在 Python 中,类本身也是对象,并且有包含类属性值的类字典。方法只是恰好具有函数定义值的属性。

因此,我们可以在运行时向任何类添加新方法,只需将一个对合适函数的引用赋给类的属性。MetaEntity类在创建之前只修改了Entity类的类字典。MetaRelation类更进一步,不仅修改了Relation类的类字典,还修改了涉及的Entity类的类字典。

小贴士

在运行时更改类定义不仅限于元类,但应该谨慎使用,因为我们期望类在代码的任何地方都能保持一致的行为。

如果我们有两个类,AB,我们想确保每个类都有自己的 get 和 add 方法。也就是说,我们想确保A类有getB()addB()方法,而B类有getA()addA()方法。因此,我们定义了通用的getclass()addclass()函数,并将这些函数与定制 lambda 函数一起分配给相关类中的命名属性(高亮显示)。

如果我们再次假设实体类被称为AB,我们的关系被称为AB,那么这个赋值:

setattr(a,'get'+b.__name__,lambda self:getclass(self,b,classname))

这意味着 A 类将现在有一个名为getB的方法,如果在这个A类的实例(如a1.getB())上调用该方法,它将导致对getclass的调用,如下所示:

getclass(a1,B,'AB')

我们还创建(或重新定义)了一个get()方法,当给定一个类作为参数时,将找到相应的getXXX方法。

getclass()方法定义如下:

			def getclass(self,cls,relname):
					clsname = cls.__name__
					sql = 'select %s_id from %s where %s_id = 
?'%(clsname,relname,self.__class__.__name__)
					cursor=self._connect().cursor()
					cursor.execute(sql,(self.id,))
					return [cls(id=r[clsname+'_id']) for r in cursor]

首先,它构建一个 SQL 语句。如果getclass()被调用为getclass(a1,B,'AB'),这个语句可能看起来像这样:

select B_id from AB where A_id = ?

然后它使用self.id作为参数执行这个语句。返回的结果是一个包含实例 ID 的列表。

添加功能遵循相同的模式,所以我们只需快速查看addclass()函数。它首先检查我们试图添加的实体是否属于所需的类。注意,如果我们调用a1.addB(b1)这样的函数,它将引用由MetaRelation类插入的函数,然后将以addclass(a1,b1,B,'AB')的方式调用。

随后构建的 SQL 语句可能看起来像这样:

insert or replace into AB (A_id,B_id) values (?,?)

由于我们之前指定的唯一约束,如果第二个插入操作指定了相同的特定关系,可能会失败,在这种情况下,我们将替换记录(这实际上忽略了失败)。这样,我们可以在具有相同参数的情况下调用add()两次,但最终仍然只有一个关系记录。

浏览实体列表

用户与一组实体进行交互的最重要工具之一是表格。表格提供了一种逻辑接口,用于翻页浏览数据列表,并在列中展示相关属性。此类表格界面通常还包含排序选项,即根据一个或多个属性进行排序,以及钻取功能,即仅显示具有特定属性值的实体。

使用基于表格的实体浏览器进行操作的时间

运行browse.py并将您的浏览器指向http://localhost:8080。启动了一个小型示例应用程序,显示了随机数据列表,如下面的图像所示:

使用基于表格的实体浏览器进行操作的时间

这个看起来相当简朴的界面可能缺少大多数视觉装饰,但它仍然完全功能。您可以通过点击底部的按钮栏中的相应按钮来翻页浏览数据列表,通过点击一个或多个标题来更改列表的排序顺序(这将循环通过升序、降序或完全不排序,但目前没有任何视觉反馈)或通过点击列中的一个值来减少显示的项目列表,这将导致显示具有此列相同值的项的列表。通过点击清除按钮,可以再次显示所有项目。

刚才发生了什么?

browse模块(作为browse.py提供)包含的不仅仅是示例应用程序。它还定义了一个可重用的Browse类,可以用对Entity的引用进行初始化,并用作 CherryPy 应用程序。Browse类还可以接受参数,指定是否以及哪些列应该显示。

它的最佳用途可以通过查看示例应用程序来展示:

第七章/browse.py

from random import randint
import os
current_dir = os.path.dirname(os.path.abspath(__file__))
class Entity(AbstractEntity):
	database='/tmp/browsetest.db'
class Number(Entity):
	n = Attribute(displayname="Size")
n=len(Number.listids())
if n<100:
	for i in range(100-n):
		Number(n=randint(0,1000000))
root = Browse(Number, columns=['id','n'],
	sortorder=[('n','asc'),('id','desc')])
cherrypy.quickstart(root,config={
	'/':
	{ 'log.access_file' :
			os.path.join(current_dir,"access.log"),
	'log.screen': False,
	'tools.sessions.on': True
	}
})

它初始化了一个Browse类的实例,该实例以一个必选参数作为Entity的子类,在本例中是Number。它还接受一个columns参数,该参数接受一个列表,指定要在表格列中显示哪些属性及其顺序。它还接受一个sortorder参数,该参数是一个元组列表,指定要在哪些列上排序以及排序方向。

然后,将此Browse类的实例传递给 CherryPy 的quickstart()函数,以向客户端提供功能。同样简单的方法是挂载两个不同的Browse实例,每个实例在自定义根应用程序中服务于不同的Entity类。

所有这些是如何实现的?让我们首先看看__init__()方法:

第七章/browse.py

class Browse:
	def __init__(self,entity,columns=None,
		sortorder=None,pattern=None,page=10,show="show"): if not issubclass(entity,AbstractEntity) :
				raise TypeError()
		self.entity = entity
		self.columns = entity.columns if columns is None else 
columns
		self.sortorder = [] if sortorder is None else sortorder
		self.pattern = [] if pattern is None else pattern
		self.page = page
		self.show = show
		self.cache= {}
		self.cachelock=threading.Lock()
		self.cachesize=3
		for c in self.columns:
			if not (c in entity.columns or c == 'id') and not (
						hasattr(self.entity,'get'+c.__name__)) :
					raise ValueError('column %s not defined'%c)
		if len(self.sortorder) > len(self.columns) :
			raise ValueError()
		for s in self.sortorder:
			if s[0] not in self.columns and s[0]!='id':
					raise ValueError(
						'sorting on column %s not 
possible'%s[0])
			if s[1] not in ('asc','desc'):
					raise ValueError(
						'column %s, %s is not a valid sort 
order'%s)
		for s in self.pattern:
			if s[0] not in self.columns and s[0]!='id':
					raise ValueError(
						'filtering on column %s not 
possible'%s[0])
		if self.page < 5 :
					raise ValueError()

__init__()方法接受相当多的参数,其中只有entity参数是必需的。它应该是AbstractEntity的子类,并在高亮代码中进行检查。

如果缺少参数,所有参数都会被存储并初始化为合适的默认值。

columns参数默认为为实体定义的所有列的列表,我们验证我们想要显示的任何列实际上都为实体定义了。

同样,我们验证sortorder参数(一个包含列名及其排序方向的元组列表)包含的项不超过列的数量(因为对同一列进行多次排序是不合理的),并且指定的排序方向是ascdesc(分别表示升序和降序)。

pattern参数,一个包含列名和要过滤的值的元组列表,以类似的方式处理,以查看是否只过滤了定义的列。请注意,过滤或排序在自身未显示的列上是完全有效的。这样,我们可以在不担心太多列的情况下显示大型数据集的子集。

最后的合理性检查是在page参数上进行的,该参数指定了每页要显示的行数。行数过少会显得尴尬,而负值没有意义,所以我们决定每页至少显示五行:

第七章 browse.py

@cherrypy.expose
def index(self, _=None, start=0,
	pattern=None, sortorder=None, cacheid=None,
	next=None,previous=None, first=None, last=None,
	clear=None):
	if not clear is None :
		pattern=None
	if sortorder is None :
		sortorder = self.sortorder
	elif type(sortorder)==str:
		sortorder=[tuple(sortorder.split(','))]
	elif type(sortorder)==list:
		sortorder=[tuple(s.split(',')) for s in sortorder]
	else:
		sortorder=None
	if pattern is None :
		pattern = self.pattern
	elif type(pattern)==str:
		pattern=[tuple(pattern.split(','))]
	elif type(pattern)==list:
		pattern=[tuple(s.split(',',1)) for s in pattern]
	else:
		pattern=None
	ids = self.entity.listids(
		pattern=pattern,sortorder=sortorder)
	start=int(start) if not next is None :
		start+=self.page
	elif not previous is None :
		start-=self.page
	elif not first is None :
		start=0
	elif not last is None :
		start=len(ids)-self.page
	if start >= len(ids) :
		start=len(ids)-1
	if start<0 :
		start=0
	yield '<table class="entitylist" start="%d" page="%d">\
n'%(start,self.page)
	yield '<thead><tr>'
	for col in self.columns:
		if type(col) == str :
			sortclass="notsorted"
			for s in sortorder:
				if s[0]==col :
					sortclass='sorted-'+s[1]
					break
			yield '<th class="%s">'%sortclass+self.entity.
displaynames[col]+'</th>'
			else :
				yield '<th>'+col.__name__+'</th>'
			yield '</tr></thead>\n<tbody>\n' entities = [self.entity(id=i)
					for i in ids[start:start+self.page]]
			for e in entities:
				vals=[]
				for col in self.columns:
					if not type(col) == str:
						vals.append(
							"".join(
							['<span class="related" entity="%s" >%s</span> ' % (r.__
class__.__name__, r.primary) for r in e.get(col)]))
					else:
						vals.append(str(getattr(e,col)))
				yield ('<tr id="%d"><td>'
					+ '</td><td>'.join(vals)+'</td></tr>\n')%(e.id,)
			yield '</tbody>\n</table>\n'
			yield '<form method="GET" action=".">'
			yield '<div class="buttonbar">'
			yield '<input name="start" type="hidden" value="%d">\n'%start
			for s in sortorder:
				yield '<input name="sortorder" type="hidden" value="%s,%s">\n'%s
			for f in pattern:
				yield '<input name="pattern" type="hidden" value="%s,%s">\n'%f
			yield '<input name="cacheid" type="hidden" value="%s">'%cacheid
			yield '<p class="info">items %d-%d/%d</p>'%(start+1,start+len 
(entities),len(ids))
			yield '<button name="first" type="submit">First</button>\n'
			yield '<button name="previous" type="submit">Previous</button>\n'
			yield '<button name="next" type="submit">Next</button>\n'
			yield '<button name="last" type="submit">Last</button>\n'
			yield '<button name="clear" type="submit">Clear</button>\n'
			yield '</div>'
			yield '</form>'
			# no name attr on the following button otherwise it may be sent as 
an argument!
			yield '<form method="GET" action="add"><button type="submit">Add 
new</button></form>'

表的初始显示以及分页、排序和过滤都由同一个index()方法处理。要了解它可能接受的全部参数,查看它为我们示例应用程序生成的 HTML 标记可能会有所帮助。

注意

Browse类的index()方法不是唯一一个需要向客户端发送大量 HTML 的地方。这可能会变得难以阅读,因此也难以维护,使用模板可能是一个更好的解决方案。选择与 CherryPy 兼容的模板解决方案的好起点是www.cherrypy.org/wiki/ChoosingATemplatingLanguage

检查 HTML 标记的行动时间

让我们看看index()方法生成的 HTML 标记是什么样的:

<table class="entitylist" start="0" page="10">
	<thead>
		<tr>
				<th class="sorted-desc">id</th>
				<th class="sorted-asc">Size</th>
		</tr>
	</thead>
	<tbody>
		<tr id="86"><td>86</td><td>7702</td></tr>
		<tr id="14"><td>14</td><td>12331</td></tr>
		<tr id="72"><td>72</td><td>17013</td></tr>
		<tr id="7"><td>7</td><td>26236</td></tr>
		<tr id="12"><td>12</td><td>48481</td></tr>
		<tr id="10"><td>10</td><td>63060</td></tr>
		<tr id="15"><td>15</td><td>64824</td></tr>
		<tr id="85"><td>85</td><td>69352</td></tr>
		<tr id="8"><td>8</td><td>84442</td></tr>
		<tr id="53"><td>53</td><td>94749</td></tr>
	</tbody>
</table>
<form method="GET" action=".">
	<div class="buttonbar">
		<input name="start" type="hidden" value="0">
		<input name="sortorder" type="hidden" value="n,asc">
		<input name="sortorder" type="hidden" value="id,desc">
		<input name="cacheid" type="hidden"
				value="57ec8e0a53e34d428b67dbe0c7df6909">
		<p class="info">items 1-10/100</p>
		<button name="first" type="submit">First</button>
		<button name="previous" type="submit">Previous</button>
		<button name="next" type="submit">Next</button>
		<button name="last" type="submit">Last</button>
		<button name="clear" type="submit">Clear</button>
	</div>
</form>
<form method="GET" action="add">
	<button type="submit">Add new</button>
</form>

除了实际的表格之外,我们还有一个包含相当多的<button><input>元素的<form>元素,尽管大多数元素的类型属性被设置为隐藏。

<form>元素有一个动作属性 "."(一个单独的点),这将导致表单中的所有信息都提交到生成此表单的同一 URL,因此数据将由我们现在正在检查的同一个index()方法处理。当点击任何具有type属性等于submit<button>元素时,将触发提交,在这种情况下,不仅会发送<input>元素,还会发送被点击的按钮的名称。

注意

注意,任何需要发送到服务器的<input>元素都应该有一个name属性。省略name属性会导致它被遗漏。《input>元素如果type等于hidden并且有name属性,也会被发送。隐藏的`元素不会显示,但它们在将与表单相关的重要信息保持在一起方面发挥着重要作用。

表单中第一个隐藏的<input>元素存储了当前在表中显示的项目起始索引。通过将其作为隐藏元素添加,我们可以在点击下一页上一页按钮时计算要显示的项目。

我们还希望记住项目是如何排序的。因此,我们包括了一些具有name属性等于sortorder的隐藏输入元素,每个元素都有一个由逗号分隔的列名和排序方向组成的值。

当表单提交时,具有相同名称的输入元素按顺序作为参数添加到action URL 中,CherryPy 将识别此模式并将它们转换为值列表。在这个例子中,Browse类的index()方法接收这个列表作为其sortorder参数。任何pattern值也作为隐藏的<input>元素存在,并以相同的方式处理。

表单中还包含一个包含有关项目数量和当前页面上实际显示的项目信息的info<p>元素。表单的最后部分是一系列提交按钮。

刚才发生了什么?

index()方法可以不带任何参数调用,也可以调用它显示的表单的任何或所有内容。如果客户端 JavaScript 代码想要在防止浏览器缓存的情况下异步调用它,它甚至可以传递一个带有随机值的_(下划线)参数,该参数将被忽略。

其余的参数都是相关的,并且在执行之前会进行检查。

我们希望sortorder变量包含一个由列名和排序方向组成的元组列表,但 CherryPy 将输入元素的值简单地解释为字符串,因此我们必须通过在逗号分隔符上分割这些字符串来将这个字符串列表转换为元组列表。我们既不检查列名的有效性,也不检查排序方向的有效性,因为这将由执行实际工作的代码来完成。

pattern变量以类似的方式处理,但由于我们可能想要根据包含逗号的价值进行筛选,我们在这里不能简单地使用split()方法,而必须传递一个限制为 1 的参数,以限制其分割到遇到的第一个逗号。

接下来,我们将sortorderpattern变量传递给与这个Browse实例一起存储的实体的listids()类方法。它将返回匹配pattern标准(如果没有指定模式,则为所有实例)的实例 ID 列表,并按正确顺序排序。请注意,由于实例的数量可能很大,我们在这里不使用list()方法,因为一次性将所有 ID 转换为实体实例可能会使应用程序无响应。我们将根据起始和页面变量将那些 ID 转换为我们将实际在页面上显示的实例。

为了计算新的起始索引,我们必须检查我们是否正在操作分页按钮之一(突出显示),如果我们在下一页上一页按钮上点击,则添加或减去页面长度。如果点击了第一页按钮,我们将起始索引设置为 0。如果点击了最后一页按钮,我们将起始索引设置为项目总数减去页面长度。如果这些计算中的任何一个导致起始索引小于零,我们将它设置为 0。

下一步是生成实际输出,每次生成一行,从<table>元素开始。我们的表格由一个头部和一个主体组成,头部由一个包含<th>元素的单一行组成,每个<th>元素包含我们展示的列的显示名称,如果它代表实体的属性,或者如果它代表相关实体,则包含类的名称。与该列关联的任何排序顺序都表示在其class属性中,因此我们可以使用 CSS 使其对用户可见。

为了显示表格主体的行,我们将选择中的相关 ID 转换为实际实体(突出显示)并为每个属性生成<td>元素。如果列引用相关实体,则显示它们的属性,每个相关实体都封装在其自己的<span>元素中。后者将使我们能够将相关操作与每个单独的项目相关联,例如,当它被点击时显示完整内容。

最终的长列表yield语句用于生成带有许多隐藏输入元素的形式,每个输入元素记录传递给index()方法的参数。

缓存

在典型应用程序中浏览列表的大部分活动是向前和向后翻页。如果我们每次向前翻一页都需要检索实体的完整列表,那么如果列表很大或排序和过滤很复杂,应用程序可能会感觉响应缓慢。因此,实现某种缓存方案可能是明智的。

虽然有几个方面需要考虑:

  • 我们的 CherryPy 应用程序是支持多线程的,因此我们应该意识到这一点,尤其是在将东西存储在缓存中时,因为我们不希望线程破坏共享缓存。

  • 由于资源有限,我们必须制定某种方案来限制缓存的项数。

  • 我们必须克服 HTTP 协议无状态的局限性:每次客户端发出请求时。这个请求应该包含所有必要的信息,以确定我们是否为他缓存了可用的内容,当然我们必须理解每个请求可能由不同的线程来处理。

如果我们更改index()中检索匹配 ID 的行,这些要求就可以得到满足:

第七章/browse.py

			if not (next is None and previous is None
								and first is None and last is None):
					cacheid=self.iscached(cacheid,sortorder,pattern)
			else:
					cacheid=None
			if cacheid is None:
					ids = self.entity.listids(
							pattern=pattern,sortorder=sortorder)
					cacheid = self.storeincache(ids,sortorder,pattern)
			else:
					ids = self.getfromcache(cacheid,sortorder,pattern)
					if ids == None:
							ids = self.entity.listids(
									pattern=pattern,sortorder=sortorder)
							cacheid = self.storeincache(ids,sortorder, 
pattern)

因为我们将在一个隐藏的<input>元素中存储一个唯一的cacheid,所以在表单提交时,它将被作为参数传递。我们使用这个cachidsortorderpattern参数一起,通过iscached()方法检查之前检索到的 ID 列表是否存在于缓存中。传递sortorderpattern参数将使iscached()方法能够确定这些参数是否已更改,并使缓存条目失效。

iscached()如果缓存中存在cacheid,将返回cacheid;如果不存在,将返回None。如果cacheid确实存在,但sortorderpattern参数已更改,iscached()也将返回None

接下来,我们检查cacheid是否为None。这看起来可能是多余的,但如果index()是第一次被调用(即没有参数),则提交按钮的任何参数都不会存在,我们也不会检查缓存。

注意

这是故意的:如果我们稍后再次访问这个列表,我们希望得到一组新的项目,而不是一些旧的缓存项目。毕竟,数据库的内容可能已经改变。

如果cacheidNone,我们将检索一个新的 ID 列表并将其与sortorderpattern参数一起存储在缓存中。storeincache()方法将为我们返回一个新的cacheid,以便我们存储在隐藏的<input>元素中。

如果cacheid不是None,我们使用getfromcache()方法从缓存中检索 ID 列表。我们检查返回的值,因为在我们检查缓存中键的存在和检索相关数据之间,缓存可能已经被清除,在这种情况下,我们仍然调用listids()方法。

iscached()getfromcache()storeincache()方法的实现负责处理所有线程安全问题:

			def chash(self,cacheid,sortorder,pattern):
					return cacheid + '-' + hex(hash(str(sortorder))) + '-' + 
hex(hash(str(pattern)))
			def iscached(self,cacheid,sortorder,pattern):
					h=self.chash(cacheid,sortorder,pattern)
					t=False
					with self.cachelock:
							t = h in self.cache
							if t :
									self.cache[h]=(time(),self.cache[h][1])
					return cacheid if t else None
			def cleancache(self):
					t={}
					with self.cachelock:
							t={v[0]:k for k,v in self.cache.items()}
					if len(t) == 0 :
							return
					limit = time()
					oldest = limit
					limit -= 3600
					key=None
					for tt,k in t.items():
							if tt<limit:
									with self.cachelock:
											del self.cache[k]
							else:
								if tt<oldest:
										oldest = tt
										key = k
					if key:
							with self.cachelock:
								del self.cache[key]
			def storeincache(self,ids,sortorder,pattern):
					cacheid=uuid().hex
					h=self.chash(cacheid,sortorder,pattern)
					if len(self.cache)>self.cachesize :
							self.cleancache()
					with self.cachelock:
							self.cache[h]=(time(),ids)
					return cacheid
			def getfromcache(self,cacheid,sortorder,pattern):
					ids=None
					h=self.chash(cacheid,sortorder,pattern)
					with self.cachelock:
							try:
									ids=self.cache[h][1]
							except KeyError:
									pass
					return ids

所有方法都使用chash()方法从cacheidsortorder以及pattern参数创建一个唯一的键。iscached()等待获取锁以检查这个唯一值是否存在于缓存中。如果存在,它将更新相关值,一个由时间戳和 ID 列表组成的元组。通过在这里更新这个时间戳,我们减少了在检查存在性和实际检索之间,这个项目可能被从缓存中清除的机会。

getfromcache()方法使用与iscached()相同的方式通过chash()方法创建一个唯一的键,并在使用该键从缓存中检索值之前等待获取锁。如果这失败了,将会引发一个KeyError,这将会被捕获,导致返回None值,因为这是 IDs 变量初始化时的值。

storeincache()方法首先使用 Python 的uuid模块中的一个uuid()函数创建一个新的cacheid,本质上创建了一个随机的十六进制字符字符串。结合sortorderpattern参数,这个新的cacheid被用来生成一个唯一的键。

在我们将 ID 列表存储到缓存之前,我们会通过比较缓存中的键数与我们准备接受的最大长度来检查是否有剩余空间。如果没有剩余空间,我们会通过调用cleancache()方法来腾出空间,该方法会删除任何过旧的条目。然后我们在获取锁之后将 ID 与时间戳一起存储,并返回刚刚生成的cacheid

我们缓存机制中的最后一个齿轮是cleancache()方法。在获取锁之后,会构建一个反向映射,将时间戳映射到键上。如果这个映射包含任何项,我们就用它来定位任何超过一小时的旧键。这些键在获取锁后会删除。

注意

尽快获取锁并释放,而不是一次性获取锁并执行所有与缓存相关的业务,确保访问缓存的其它线程不需要等待很长时间,这保持了整个应用程序的响应性。

如果条目的年龄小于一小时,我们会记录下来,看看剩下的哪个是最老的,以便在最后删除它。这样,我们确保我们总是至少退休一个条目,即使没有特别旧的条目。

再次审视书籍应用程序

在有了这么多灵活的代码之后,构建我们书籍应用程序的新版简洁版本变得非常直接。

创建书籍应用程序的时间,再来一次

运行books2.py中的代码,并将您的网络浏览器指向localhost:8080

登录后(将出现默认的用户名/密码组合 admin/admin),您将看到一个可以浏览的实体列表(书籍和作者),点击书籍后,将出现一个屏幕,其外观与一般的浏览应用程序非常相似(页面仍然看起来很简朴,因为此时还没有添加 CSS):

创建书籍应用程序的时间,再来一次

多亏了 JavaScript 的强大功能,我们的浏览屏幕被嵌入到页面中,而不是独立运行,但所有功能都得到了保留,包括向前和向后跳转以及排序。可以通过点击添加新按钮添加新的书籍或作者。

刚才发生了什么?

当我们查看books2.py中的代码时,我们看到其主要部分由实体、关系和特定的Browse实体定义组成,这些实体组合在一起形成 CherryPy 应用程序:

第七章/books2.py

import os
import cherrypy
from entity import AbstractEntity, Attribute
from relation import AbstractRelation
from browse import Browse
from display import Display
from editor import Editor
from logondb import LogonDB
db="/tmp/book2.db"
class Entity(AbstractEntity):
	database = db
class Relation(AbstractRelation):
	database = db
class User(Entity):
	name = Attribute(notnull=True, unique=True,
			displayname="Name")
class Book(Entity):
	title = Attribute(notnull=True, displayname="Title")
	isbn = Attribute(displayname="Isbn")
	published = Attribute(displayname="Published")
class Author(Entity):
	name = Attribute(notnull=True, unique=True,
			displayname="Name", primary=True)
class OwnerShip(Relation):
	a = User
	b = Book
class Writer(Relation):
	a = Book
	b = Author logon = LogonDB()
class AuthorBrowser(Browse):
	display = Display(Author)
	edit = Display(Author, edit=True, logon=logon)
	add = Display(Author, add=True, logon=logon)
class BookBrowser(Browse):
	display = Display(Book)
	edit = Display(Book, edit=True, logon=logon,
			columns=Book.columns+[Author])
	add = Display(Book, add=True, logon=logon,
			columns=Book.columns+[Author])
with open('basepage.html') as f:
	basepage=f.read(-1)
books applicationbooks applicationcreatingclass Root():
	logon = logon
	books = BookBrowser(Book,
			columns=['title','isbn','published',Author])
	authors = AuthorBrowser(Author)
	@cherrypy.expose
	def index(self):
			return Root.logon.index(returnpage='../entities')
	@cherrypy.expose
	def entities(self): username = self.logon.checkauth()
		if username is None :
			raise HTTPRedirect('.')
		user=User.list(pattern=[('name',username)])
		if len(user) < 1 :
			User(name=username)
		return basepage%'''<div class="navigation">
		<a href="books">Books</a>
		<a href="authors">Authors</a>
		</div><div class="content">
		</div>
		<script>
				... Javascript omitted ...
		</script>
		'''
cherrypy.engine.subscribe('start_thread',
	lambda thread_index: Root.logon.connect())
current_dir = os.path.dirname(os.path.abspath(__file__))
cherrypy.quickstart(Root(),config={
		'/':
		{ 'log.access_file' :
				os.path.join(current_dir,"access.log"),
		'log.screen': False,
		'tools.sessions.on': True
		}
	})

在导入所需的模块后,我们定义UserBookAuthor实体以及一个OwnerShip类,以定义书籍与用户之间的关系。同样,我们定义一个Writer类,用于定义书籍与其作者之间的关系。

下一步是创建一个LogonDB类的实例(突出显示),它将在我们的 CherryPy 应用程序的许多部分中使用,以验证用户是否已认证。

CherryPy 应用程序的主体由两个Browse类组成,一个用于书籍,另一个用于作者。每个类都有显示、编辑和添加类变量,这些变量指向由Display实例提供服务的我们应用程序的进一步分支。

我们定义的Root类将所有这些内容串联起来。它引用其logon类变量中创建的LogonDb实例,其booksauthors类变量指向先前定义的Browse实例。它还定义了一个index()方法,如果用户尚未认证,则仅显示登录界面;如果已认证,则将用户重定向到实体页面。为该页面提供服务的是entities()方法,确保数据库中存在相应的用户(突出显示),并展示一个包含导航div和内容div的基本页面,当点击导航部分中的链接之一时,这些div将被填充,并有一些 JavaScript 代码将所有内容串联起来。

在我们检查 JavaScript 之前,看看这个插图可能会有助于了解应用程序树的结构:

路径 方法
/ Root.index()
/entities Root.entities()
/logon LogonDB.index()
/books BooksBrowser.index()
/add Display().index()
/edit Display().index()
/display Display().index()
/authors AuthorBrowser.index()
/add Display().index()
/edit Display().index()
/display Display().index()

(注意,编辑、添加显示分支分别由Display的不同实例提供服务)。

之前我们看到我们创建的Browse类能够独立运行:点击任何按钮都会引用最初提供表单的相同 URL。这种设置使得在应用程序树的不同部分使用不同的Browse实例成为可能,但在这里我们希望通过 AJAX 调用用Browse实例生成的表单替换页面的一部分。问题在于,如果没有action属性,提交表单将导致对当前 URL 的请求,即表单嵌入的页面所引用的 URL,而不是生成表单的 URL。

幸运的是,我们可以通过更改新加载表单的 action 属性来指向提供这些表单的 URL,以使用 jQuery 解决这个问题:

第七章/books2.py

	$.ajaxSetup({cache:false,type:"GET"}); $(".navigation a").click(function (){
		var rel = $(this).attr('href');
		function shiftforms(){
			$(".content form").each(function(i,e){
			$(e).attr('action',
				rel+'/'+$(e).attr('action'));
				$('[type=submit]',e).bind('click',
					function(event){
					var f = $(this).parents('form');
					var n = $(this).attr('name');
					if (n != ''){
						n = '&'+n+'='+$(this).attr('value');}
					$(".content").load(f.attr('action'),
						f.serialize()+n,shiftforms);
					return false;
				});
			});
		};
		// change action attributes of form elements
		$(".content").load($(this).attr('href'),shiftforms);
		return false;
	});

这是通过向导航区域中的链接添加点击事件处理程序来实现的。这不仅会阻止默认操作,还会加载由 href 属性引用的 URL 生成的 HTML,并传递一个回调函数,该函数将改变任何新加载的 <form> 元素的操作属性(突出显示)。

shiftforms() 函数首先将原始的 href 内容添加到 action 属性中,然后为每个具有等于提交的 type 属性的按钮或输入元素绑定一个点击处理程序。

仅向表单添加提交处理程序是不够的,因为我们不希望 <form> 执行其默认操作。当表单提交时,页面内容会被替换,这不是我们想要的。相反,我们想要替换内容 div 的内容,因此我们必须自己 load() 表单的 action 属性中的 URL。

这也意味着我们必须将表单的内容序列化以添加为 URL 的参数,但 jQuery 的 serialize() 函数不会序列化提交按钮。因此,我们最终需要为提交按钮添加点击处理程序,以便能够确定被点击的提交按钮,这样我们就可以构建一个完整的参数列表,包括提交按钮的名称和值。

摘要

我们对迄今为止开发的框架进行了批判性的审视,并对框架进行了改进,使其更具可扩展性和易于开发者使用。

具体来说,我们涵盖了:

  • 如何避免显式数据库和线程初始化。

  • 如何运用 Python 元类的强大功能来同步 Python 类及其对应的数据库表的创建。

  • 如何使用相同的元类来改变现有类的定义,以便在处理关系时创建一个更直观的接口。

  • 如何实现一个 Browse 类,以高效的方式使用缓存在大型实体集合中导航。

  • 如何使用这个重构的框架以更简单的方式重写书籍应用。

我们仍然忽略了几个问题,包括如何显示和编辑实例。在最后三章中,我们将开发一个客户关系管理应用,并填补最后的细节,包括如何限制某些操作给特定人员以及如何允许最终用户自定义应用。

第八章:管理客户关系

实体框架和 CherryPy 应用代码不仅仅是浏览列表。用户必须能够添加新实例并编辑现有实例。

在本章中,我们将:

  • 查看如何显示实例

  • 如何添加和编辑实例

  • 如何为引用其他实体的属性提供自动完成功能

  • 如何实现选择列表

那么,让我们开始吧...

一次批判性回顾

现在我们已经创建了一个以实体和关系模块形式存在的对象关系框架,是时候进行一次批判性的评估了。

一些较大和较小的问题可能会阻碍我们快速原型设计和实现数据库驱动的应用:

  • 我们已经对实体属性的附加属性进行了管理,例如,一个属性是否有验证函数。存储诸如属性值的首选表示这样的信息可能是个好主意。我们还希望有记录允许值的可能性,这样我们就可以实现选择列表

  • 尽管框架足够灵活,可以让开发者快速实现数据库驱动的应用,但它没有让最终用户更改数据库架构的功能。例如,无法向实体添加属性。即使这可能实现,我们仍然需要一个授权方案来限制此功能仅对授权用户可用。

在接下来的章节中,我们将逐一解决这些限制,每一步都会让我们更接近实现最终示例:客户关系管理应用。这个过程的一些部分需要我们执行一些相当复杂的 Python 技巧,但这些部分会被明确标记,并且可以跳过。

设计客户关系管理应用

我们对 CRM 的第一个修订版将从裸骨实现开始。它和书籍应用一样简单,其数据模型在下图中展示:

设计客户关系管理应用

网络应用将服务于单一公司,用户通常是销售代表和后台员工。在这个基本形式中,账户是我们感兴趣的公司,具有一些属性,如名称和业务类型。我们还记录了联系人;这些人与账户相关联。这些联系人具有姓名、性别等属性。账户和联系人都可以有任意数量的地址。

实施基本 CRM 的时间

看看下面的代码(作为crm1.py提供),它将定义前一部分中标识的实体,当运行时,结果将看起来很熟悉:

实施基本 CRM 的时间

我们已经添加了一些 CSS 样式来排列页面上的元素,但在最终修订中,我们将给它一个更加吸引人的外观。点击添加新按钮将允许您添加一个新的实体。

刚才发生了什么?

在实现 CRM 的这些朴素开始中,是通过crm1.py中的代码完成的:

Chapter8/crm1.py

	import os
	import cherrypy
	from entity import AbstractEntity, Attribute, Picklist, 
	AbstractRelation
	from browse import Browse
	from display import Display
	from editor import Editor
	from logondb import LogonDB
	db="/tmp/crm1.db"
	class Entity(AbstractEntity):
		database = db
	class Relation(AbstractRelation):
		database = db
	class User(Entity):
		name = Attribute(notnull=True, unique=True,
				displayname="Name", primary=True)
	class Account(Entity):
		name = Attribute(notnull=True,
				displayname="Name", primary=True)
	class Contact(Entity):
		firstname = Attribute(displayname="First Name")
		lastname = Attribute(displayname="Last Name",
					notnull=True, primary=True) gender = Attribute(displayname="Gender",
			notnull=True,
			validate=Picklist(
						Male=1,
						Female=2,
						Unknown=0))
	telephone = Attribute(displayname="Telephone")
class Address(Entity):
	address = Attribute(displayname="Address",
				notnull=True, primary=True)
	city = Attribute(displayname="City")
	zipcode = Attribute(displayname="Zip")
	country = Attribute(displayname="Country")
	telephone = Attribute(displayname="Telephone")
class OwnerShip(Relation):
	a = User
	b = Account
class Contacts(Relation):
	a = Account
	b = Contact
class AccountAddress(Relation):
	a = Account
	b = Address
class ContactAddress(Relation):
	a = Contact
	b = Address

第一部分是关于根据我们之前绘制的数据模型定义实体及其之间的关系。这个概念与书籍应用程序几乎相同,但有一个重要的细节,即使用下拉列表来限制性别(突出显示)的可选选择。我们将在本章后面详细研究这些下拉列表。

下一个部分创建实际的 CherryPy 应用程序,为每个实体创建一个Browse页面(突出显示):

Chapter8/crm1.py

logon = LogonDB() class AccountBrowser(Browse):
	display = Display(Account)
	edit = Display(Account, edit=True, logon=logon,
								columns=Account.columns+[Address,User])
	add = Display(Account, add=True, logon=logon,
								columns=Account.columns+[Address,User]) class UserBrowser(Browse):
	display = Display(User)
	edit = Display(User, edit=True, logon=logon)
	add = Display(User, add=True, logon=logon) class ContactBrowser(Browse):
	display = Display(Contact)
	edit = Display(Contact, edit=True, logon=logon,
								columns=Contact.
columns+[Account,Address])
	add = Display(Contact, add=True, logon=logon,
								columns=Contact.
columns+[Account,Address]) class AddressBrowser(Browse):
	display = Display(Address)
	edit = Display(Address, edit=True, logon=logon)
	add = Display(Address, add=True, logon=logon)

最后的部分定义了一个带有index()方法的Root类,该方法将强制用户首先进行自我识别(突出显示),然后会将用户重定向到由entities()方法提供的/entities页面。

此方法将提供包含导航部分的基页,用户可以通过它选择实体类型的浏览页面,以及一个最初为空的内容区域,但将作为所选浏览组件或任何编辑或添加页面的容器。

Chapter8/crm1.py

with open('basepage.html') as f:
	basepage=f.read(-1)
class Root():
	logon = logon
	user = UserBrowser(User)
	account = AccountBrowser(Account,
						columns=Account.
columns+[User,Address,Contact])
	contact = ContactBrowser(Contact,
						columns=Contact.columns+[Address,Account])
	address = AddressBrowser(Address)
	@cherrypy.expose
	def index(self): return Root.logon.index(returnpage='../entities')
	@cherrypy.expose
	def entities(self):
		username = self.logon.checkauth()
		if username is None :
				raise HTTPRedirect('.')
		user=User.list(pattern=[('name',username)])
		if len(user) < 1 :
				User(name=username)
		return basepage%'''
		<div class="navigation">
				<a href="user">Users</a>
				<a href="http://account">Accounts</a>
				<a href="contact">Contacts</a>
				<a href="http://address">Addresses</a>
		</div>
		<div class="content">
		</div>
		<script>
		... Javascript omitted ...
		</script>
		'''
cherrypy.config.update({'server.thread_pool':1})
cherrypy.engine.subscribe('start_thread',
	lambda thread_index: Root.logon.connect())
current_dir = os.path.dirname(os.path.abspath(__file__))
cherrypy.quickstart(Root(),config={
		'/':
		{ 'log.access_file' :
				os.path.join(current_dir,"access.log"),
		'log.screen': False,
		'tools.sessions.on': True
		},
		'/browse.js':
		{ 'tools.staticfile.on':True,
		'tools.staticfile.filename':current_dir+"/browse.js"
		},
		'/base.css':
		{ 'tools.staticfile.on':True,
		'tools.staticfile.filename':current_dir+"/base.css"
		}
})

添加和编辑值

到目前为止,我们没有仔细查看Display类,尽管它在用 CherryPy 设置的应用程序的各种形态中都被使用。Display类结合了多个功能。它:

到目前为止,我们没有仔细查看Display类,尽管它在用 CherryPy 设置的应用程序的各种形态中都被使用。Display类结合了多个功能。它:

  • 显示实例的详细值

  • 允许编辑这些值

  • 显示一个表单,允许我们添加一个全新的实例

  • 处理来自编辑和添加表单的输入

将这些功能捆绑在一起的原因有两个:显示标签和值以供阅读、编辑或添加实例共享很多共同逻辑,并且通过在同一个类方法中处理结果,我们可以以允许我们在应用程序树中的任何位置挂载Display类实例的方式引用<form>元素的action属性。

添加实例的行动时间

为了理解Display类,让我们创建一个非常简单的应用程序。输入以下代码并运行它:

Chapter8/crmcontact.py

import os
import cherrypy
from entity import AbstractEntity, Attribute, Picklist
from browse import Browse
from display import Display
from logondb import LogonDB
db="/tmp/crmcontact.db"
class Entity(AbstractEntity):
	database = db
class Contact(Entity):
	firstname = Attribute(displayname="First Name")
	lastname = Attribute(displayname="Last Name",
											notnull=True, 
primary=True)
	gender = Attribute(displayname="Gender",
											notnull=True,
											validate=Picklist(
Male=1,Female=2,Unknown=0))
	telephone = Attribute(displayname="Telephone")
class ContactBrowser(Browse):
	edit = Display(Contact, edit=True)
	add = Display(Contact, add=True)
current_dir = os.path.dirname(os.path.abspath(__file__))
cherrypy.quickstart(ContactBrowser(Contact),config={
	'/':
	{ 'log.access_file' :
			os.path.join(current_dir,"access.log"),
	'log.screen': False,
	'tools.sessions.on': True
	}
})

当您将浏览器指向http://localhost:8080时,您将看到一个空的联系人列表,您可以通过点击添加按钮来扩展它。这将显示以下屏幕:

添加实例的行动时间

在这里你可以输入新值,当你点击添加按钮时,一个新的联系人将被添加到数据库中,之后你将返回到联系人列表,但现在多了一个新添加的联系人。

刚才发生了什么?

在我们构建的应用程序树中,我们挂载了几个Display类的实例,每个实例都有自己的初始化参数。这些参数仅通过__init__()方法存储在实例中,以供以后参考:

第八章/display.py

	def __init__(self, entity, edit=False, add=False,
								logon=None, columns=None):
		self.entity = entity
		self.edit = edit
		self.add = add
		self.logon = logon
		if columns is None:
			self.columns = entity.columns
		else:
			self.columns = columns

最重要的参数是entity。这将是我们希望Display能够添加或编辑的Entity类。

__init__()还接受一个editadd参数,当设置时,将确定此Display实例将执行的活动类型。如果没有给出任何一个,实例将仅显示,没有更改其属性的可能性。在简化的crmcontact.py应用程序中,我们创建了一个ContactBrowser类,该类包含对两个不同的Display类实例的引用。add类变量中的实例使用add属性设置为True创建,而edit变量中的实例使用edit属性设置为True创建。浏览器中的添加新按钮配备了点击处理程序,该处理程序将用由带有add参数创建的Display实例提供的表单替换浏览列表。

编辑实例的时间

我们还希望打开一个表单,允许用户在浏览列表中双击时编辑现有实例。在上一节中创建的简化应用程序中,我们仅创建了ContactBrowser类作为Browse类的子类。如果我们想向浏览列表元素添加一个额外的双击处理程序,我们必须重写index()方法。

ContactBrowser类的定义中,向index()方法的定义中添加以下内容(完整的代码可在crmcontactedit.py中找到):`

第八章/crmcontactedit.py

@cherrypy.expose
def index(self, _=None,
	start=0, pattern=None, sortorder=None, cacheid=None,
	next=None, previous=None, first=None, last=None,
	clear=None):
	s="".join(super().index(_, start, pattern, sortorder,
							cacheid, next,previous, first, last, 
clear))
	s+='''
	<script>
	$("table.entitylist tr").dblclick(function(){
				var id=$(this).attr('id');
				$("body").load('edit/?id='+id);
			});
	</script>
	'''
	return basepage%s

代码只是收集了Browse类原始index()方法的输出(突出显示),并向其中添加了一个<script>元素,该元素将为浏览列表中的每个<tr>元素添加一个双击处理程序。此点击处理程序将用由编辑 URL 提供的表单替换主体,该表单将传递一个id参数,其值等于<tr>元素的id属性。

如果你运行crmcontactedit.py,你将看到与之前相同的联系人列表,如果它是空的,你可能首先需要添加一个或多个联系人。一旦这些联系人存在,你可以双击任何一个来打开编辑屏幕:

编辑实例的时间

这看起来非常类似于添加屏幕,但在这里更改值并点击编辑按钮将修改而不是添加联系人并返回联系人列表。

刚才发生了什么?

让我们看看Display类是如何处理实例编辑的。

Display类的实例的所有交互都由一个单独的方法提供:index()(完整代码可在display.py中找到):`

Chapter8/display.py

@cherrypy.expose
def index(self, id=None, _=None,
		add=None, edit=None, related=None, **kw):
	mount = cherrypy.request.path_info
	if not id is None :
		id = int(id)
	kv=[]
	submitbutton=""
	if edit or add:
		... code to process the results of an edit/add form omitted
	action="display"
	autocomplete=''
	if not id is None:
		e=self.entity(id=id)
			for c in self.columns:
				if c in self.entity.columns:
					kv.append('<label for="%s">%s</label>'%
						(c,self.entity.displaynames[c]))
					if c in self.entity.validators and type(
						self.entity.validators[c])==Picklist:
						kv.append('<select name="%s">'%c)
						kv.extend(['<option %s>%s</option>'%
							("selected" if v==getattr(e,c)
							else "",k)
						for k,v in self.entity.validators[c]
							.list.items()])
						kv.append('</select>')
					else:
					kv.append(
					'<input type="text" name="%s" value="%s">'%
						(c,getattr(e,c)))
					elif issubclass(c,AbstractEntity):
						kv.append(
						'<label for="%s">%s</label>'%
						(c.__name__,c.__name__))
						v=",".join([r.primary for r in e.get(c)])
						kv.append(
						'<input type="text" name="%s" value="%s">'%
						(c.__name__,v))
						autocomplete += '''
	$("input[name=%s]").autocomplete({source:"%s",minLength:2});
						'''%(c.__name__,
							mount+'autocomplete?entity='+c.__name__)
				yield self.related_entities(e) if self.edit:
					action='edit'
					submitbutton='''
					<input type="hidden" name="id" value="%s">
					<input type="hidden" name="related" value="%s,%s">
					<input type="submit" name="edit" value="Edit">
					'''%(id,self.entity.__name__,id)
			elif self.add:
				action='add'
					for c in self.columns:
						if c in self.entity.columns:
							kv.append('<label for="%s">%s</label>'%(
								c,self.entity.displaynames[c]))
							if c in self.entity.validators and type(
								self.entity.validators[c])==Picklist:
							kv.append('<select name="%s">'%c)
							kv.extend(['<option>%s</option>'%v
								for v in self.entity.validators[c].
									list.keys()])
								kv.append('</select>')
							else:
								kv.append('<input type="text" name="%s">'
									%c)
						elif c=="related":
							pass
						elif issubclass(c,AbstractEntity):
							kv.append('<label for="%s">%s</label>'%
								(c.__name__,c.__name__))
							kv.append('<input type="text" name="%s">'%
								c.__name__)
							autocomplete += '''
	$("input[name=%s]").autocomplete({source:"%s",minLength:2});
							'''%(c.__name__,
								mount+'autocomplete?entity='+c.__name__)
			submitbutton='''
			<input type="hidden" name="related" value="%s">
			<input type="submit" name="add" value="Add">
			'''%related
		else:
			yield """cannot happen
			id=%s, edit=%s, add=%s, self.edit=%s, self.add=%s
			"""%(id,edit,add,self.edit,self.add) yield '<form action="%s">'%action
		for k in kv:
			yield k
		yield submitbutton
		yield "</form>"
		yield '<script>'+autocomplete+'</script>'

根据index()方法传递的参数和初始化Display实例时存储的信息,index()执行不同的但相似的操作。

当没有addedit参数被调用时,index()函数被用来显示、编辑或添加一个实例,并且代码的前一部分被跳过。

注意

index()函数的addedit参数与传递给__init__()的参数不同。

首先,我们检查id参数是否存在(突出显示)。如果没有,我们预计将展示一个空表单,让用户输入新实例的属性。然而,如果存在id参数,我们必须显示一个带有值的表单。

为了展示这样的表单,我们检索具有给定 ID 的实体,并检查我们必须显示哪些列,并查看这样的列是否是我们正在处理的实体的属性(突出显示)。如果是这样,我们将一个带有列显示名称的<label>元素和一个<input><select>元素追加到kv列表中,具体取决于我们是否处理一个不受限制的文本字段或一个选择列表。如果我们处理一个选择列表,可用的选项将被添加为<option>元素。

如果要显示的列不是实体的属性而是另一个实体类,我们正在处理一个关系。在这里,我们也添加了一个<label>元素和一个<input>字段,但我们还向autocomplete变量中添加了 JavaScript 代码,当执行时,将这个<input>元素转换成一个自动完成小部件,它将从同一个Display实例的autocomplete()方法中检索其选项。

只有当这个Display实例被初始化为执行编辑功能(突出显示)时,我们才追加一个提交按钮并将action变量设置为编辑(<form>元素的值将被提交到 URL 的最后部分)。我们还添加了一个额外的隐藏输入元素,它包含我们正在编辑的实例的 ID。

构建空表单以添加新实例几乎是一项相同的练习,只是在这次操作中,没有任何<input>元素中填写了值。

代码的最后几行(突出显示)再次被共享并用于根据刚刚创建的组件来传递<form>元素,无论是编辑/显示表单还是空白的添加表单,以及任何用于实现自动完成功能的 JavaScript 代码。一个典型的用于编辑表单生成的 HTML 示例可能如下所示:

<form action="edit">
	<label for="firstname">First Name</label>
	<input name="firstname" value="Eva" type="text">
	<label for="lastname">Last Name</label>
	<input name="lastname" value="Johnson" type="text">
	<label for="gender">Gender</label>
	<select name="gender">
			<option selected="selected">Unknown</option>
			<option>Male</option>
			<option>Female</option>
	</select>
	<label for="telephone">Telephone</label>
	<input name="telephone" value="" type="text">
	<label for="Account">Account</label>
	<input name="Account" value="" type="text">
	<label for="Address">Address</label>
	<input name="Address" value="" type="text">
	<input name="id" value="2" type="hidden">
	<input name="edit" value="Edit" type="submit">
</form>
<script>
$("input[name=Account]").autocomplete({source:"autocomplete?entity=
Account",minLength:2});
$("input[name=Address]").autocomplete({source:"autocomplete?entity=
Address",minLength:2});
</script>

如果Display类的index()方法被调用时带有addedit参数(通常是点击生成的编辑或添加表单中的提交按钮的结果),则会执行不同的代码:

Chapter8/display.py

@cherrypy.expose
def index(self, id=None, _=None,
		add=None, edit=None, related=None, **kw):
	mount = cherrypy.request.path_info
	if not id is None :
		id = int(id)
	kv=[]
	submitbutton=""
	if edit or add:
		if (edit and add):
			raise HTTPError(500)
		if not self.logon is None:
			username=self.logon.checkauth()
			if username is None: raise HTTPRedirect('/')
		if add:
			attr={}
			cols={}
			relations={c.__name__:c for c in self.columns
				if type(c)!=str}
			for k,v in kw.items(): if not k in self.entity.columns:
					attr[k]=v
					if not k in relations :
						raise KeyError(k,
							'not a defined relation')
				else:
					cols[k]=v e=self.entity(**cols)
			for k,v in attr.items():
				if v != None and v != '':
					relentity = relations[k]
					primary = relentity.primaryname
					rels = relentity.listids(
						pattern=[(primary,v)])
					if len(rels):
						r = relentity(id=rels[0])
					else:
						r = relentity(**{primary:v})
					e.add(r)
			if not related is None and related != '':
				r=related.split(',')
				re=e.relclass[r[0]](id=int(r[1]))
				e.add(re)
			redit = sub(Display.finaladd,'',mount)
			raise cherrypy.HTTPRedirect(redit)
		elif edit:
			e=self.entity(id=id)
			e.update(**kw)
			raise cherrypy.HTTPRedirect(
				mount.replace('edit','').replace('//','/'))
				...code to display and instance omitted

只应有一个editadd参数存在;如果两者都存在,我们将引发异常。如果用户未认证,则不允许编辑实例或添加新实例,并且我们将他/她无礼地重定向到主页(突出显示)。

如果存在add参数,我们将创建一个新的实例。首要任务是检查所有传入的参数,看它们是否是我们将要创建的实体的属性(突出显示)或相关实体的名称。如果不是,将引发异常。

下一步是创建新的实体(突出显示)并建立任何关系。

添加关系

在前面的章节中,我们默默地忽略了一件事,那就是定义实体之间关系的功能。当然,Display类的实现确实允许创建新实例,但我们没有解决如何定义关系的问题,尽管Display已经完全能够显示指向相关实体(如作者)的列。

我们本可以将这种行为硬编码到Display的具体实现中,就像我们之前在实现图书应用的第一版时做的那样,但这与创建能够自己处理这些事情的组件的想法不太相符,这会让网络应用的开发者有更少的事情要担心。

之前的relation模块版本并不完全符合这个要求:我们可以定义和管理关系,但我们必须通过明确引用Relation类的一个实例来做这件事。

因为这并不直观,我们创建了一个relation模块的第二版,允许我们使用由创建新关系的元类插入到Entity类定义中的add()方法。我们不必关心细节:如果我们使用add()在两个实体之间建立关系,所有这些都会得到处理。

这意味着我们可以完成Display类的添加功能。对于每个指向另一个实体(例如,书的Author列)的列,我们现在实现了一种让用户进行选择的方式,例如,使用自动完成功能,并以相对简单的方式处理这个选择:如果它是空的,我们不添加关系;如果它指向一个现有实体,添加关系;如果不是,首先创建相关实体,然后再添加它。

现在我们有了通过其主属性引用现有相关项或定义新属性的功能。然而,对于最终用户来说,在引用相关实体的输入字段上实现自动完成可能非常方便。这不仅可能节省时间,还可以防止在输入错误时意外添加新实体。

在前面的章节中,我们已经遇到了使用 jQuery UI 的自动完成小部件的自动完成,并实现了检索可能完成列表的服务器端功能。我们现在需要以独立于实际相关实体的方式提供此功能:

第八章/display.py

@cherrypy.expose
def autocomplete(self, entity, term, _=None): entity={c.__name__:c for c in self.columns
		if type(c)!=str}[entity]
	names=entity.getcolumnvalues(entity.primaryname)
	pat=compile(term,IGNORECASE)
	return json.dumps(list(takewhile(lambda x:pat.match(x),
				dropwhile(lambda x:not pat.match(x),names))))

Display类的index()方法生成的 HTML 和 JavaScript 将确保调用autocomplete()方法,并传入我们想要检索列值的实体名称。

我们正在编辑的实例所引用的任何相关类都存储在self.columns实例变量中,就像常规属性的名称一样。因此,高亮行收集了那些实际上是类的列名称,并创建了一个按名称索引的字典,其中包含相应的类作为值。

当我们使用传递给autocomplete()方法的关联实体名称作为索引时,我们将获取该类。这个类在下一行用于检索标记为主列的所有列值。返回一个 JSON 编码的值列表的最终代码与之前实现的方式相同。

注意

字典推导式是 Python 3.x 的新增功能,因此将示例代码中的高亮行以更传统的方式写出来可能会有所启发:

classmap = {} 
for c in self.columns: 
	if type(c)!=str: 
			classmap[c.__name__] = c 
entity = classmap[entity]

选择列表

当我们检查生成编辑实例表单的代码时,我们没有深入研究实现选择列表的细节。选择列表是一种减少输入错误的好方法。在任何允许有限值列表的地方,我们都可以使用选择列表,从而防止用户意外输入不允许的值。这样做,我们还可以将每个可能的值与一个有意义的标签关联起来。

注意

我们已经有了添加验证函数的可能性,但这个函数只检查输入;它不为我们提供一个可能的选项列表。

实现选择列表的行动时间

我们需要的是一个方法来指示实体属性是一个选择列表。运行以下代码(作为fruit.py提供)并将您的浏览器指向localhost:8080

第八章/fruit.py

import os
import cherrypy
from entity import AbstractEntity, Attribute, Picklist
from browse import Browse
from display import Display
from logondb import LogonDB
db="/tmp/fruits.db"
class Entity(AbstractEntity):
	database = db
class Fruit(Entity):
	name = Attribute(displayname="Name")
	color = Attribute(displayname="Color",
			notnull = True,
			validate= Picklist([('Yellow',1),('Green',2),('Orange',0)]))
	taste = Attribute(displayname="Taste",
			notnull = True,
			validate= Picklist(Sweet=1,Sour=2))
class FruitBrowser(Browse):
	edit = Display(Fruit, edit=True)
	add = Display(Fruit, add=True)
current_dir = os.path.dirname(os.path.abspath(__file__))
cherrypy.quickstart(FruitBrowser(Fruit),config={
			'/':
			{ 'log.access_file' : os.path.join(current_dir,"access.
log"),
			'log.screen': False,
			'tools.sessions.on': True
			}
})

点击添加按钮创建一个新的水果实例。colortaste属性被定义为选择列表,例如,点击颜色属性可能看起来像这样:

实现选择列表的行动时间

刚才发生了什么?

entity.py文件中,我们添加了一个Picklist类来存储可用的选项及其值:

第八章/entity.py

class Picklist:
	def __init__(self,list=None,**kw):
		self.list = collections.OrderedDict(list) if not list is 
None else collections.OrderedDict()
		self.list.update(kw)
	def __getitem__(self,key):
		return self.list[key]

Picklist类主要是一个OrderedDict(突出显示)的容器,可以通过列表或传递任意数量的关键字到__init__()方法来初始化。然而,这些关键字的顺序不会被保留,因此尽管我们使用这个验证参数validate= Picklist(Yellow=1,Green=2,Orange=0)定义了水果实体的颜色属性,但在下拉框中的顺序是橙,绿,黄

因此,尽管传递关键字很方便,但它使得使用OrderedDict变得毫无意义。因此,__init__()方法也接受一个键值对元组的列表,如果存在,则使用此列表初始化字典。现在如果我们使用validate= Picklist([('Yellow',1),('Green',2),('Orange',0)]),顺序将被保留,如下面的截图所示。它还有一个额外的优点,即允许我们指定任何字符串作为键,而不仅仅是有效的 Python 标识符。

发生了什么?

我们已经在Display类的index()方法中看到了如何检索可能的选项列表。Entity本身也需要知道如何处理选择列表类型的属性,例如,当它更新这样的属性时。AbstractEntity类的__setattr__()方法需要如下调整:

第八章/entity.py

def __setattr__(self,name,value):
		if name in self.validators :
			if type(self.validators[name])==Picklist:
				try:
					value=self.validators[name].list[value]
				except:
					# key not known, try value directly
					if not value in list(
				self.validators[name].list.values()):
							raise AttributeError(
		"assignment to "+name+" fails, "+
		str(value)+" not in picklist")
			elif not self.validatorsname:
					raise AttributeError(
					"assignment to "+name+" does not validate")
	object.__setattr__(self,name,value)

添加的行(突出显示)检查是否有任何验证器是Picklist,如果是,则尝试检索与键关联的值。如果失败,它检查输入的值是否是允许的值之一。这样,就可以使用键或值来更新选择列表属性。给定前面定义的Fruit类的fruit实例,以下行是等效的:

fruit.color = 'Green'
fruit.color = 2

摘要

在本章中,我们学到了很多关于如何向最终用户提供表单来操作实例的知识,而无需任何硬编码信息。

具体来说,我们涵盖了如何显示实例、添加和编辑它们,如何为引用其他实体的属性提供自动完成功能,以及如何实现选择列表。

所有这些项目都帮助我们设计和实现了 CRM 应用的第一版。当然,CRM 应用不仅仅是账户和联系人,这就是我们在下一章要探讨的内容。

第九章. 创建完整的 Web 应用:实现实例

Python Web 应用的快速开发框架正在顺利进行,但一些独特的功能仍然缺失。

在本章中,我们将探讨其中的一些,特别是:

  • 如何实现更复杂的关系

  • 如何创建必要的用户界面组件来维护这些关系

  • 以及如何允许对谁可以做什么有更精细的控制

这些是一些有趣的挑战,让我们开始吧。

更多的关系

正如我们所见,使Display能够处理相关实体的引用并不困难。然而,这些关系仅限于查找关系(也称为多对一关系)。例如,Contact实体最多引用一个Account实体,而Display类允许我们在编辑Contact时选择Account

但对于相反的情况,我们还需要什么?一个Account可能有多个Contacts,而AccountContact都可能有很多Addresses。我们需要一种方法让Display显示实体存在的一对多关系,并提供在用户点击此类关系时显示这些实体的手段。

展示一对一关系的行动时间

插图显示了我们可能期望的结果:

展示一对一关系的行动时间

我们已经选择了一个Contact,其详细信息可供编辑,包括对Account的引用。然而,现在在左侧,我们有一个侧边栏显示了可用的单一到多对关系,在这种情况下,适用于Contact的唯一单一到多对关系是Address

刚才发生了什么?

要显示实体列表,我们已经有了一个合适的构建块,即Browse类,它不仅让我们能够以各种方式浏览实体列表,还能够过滤这些实体。在这个例子中,我们只想显示与这个特定联系人相关联的地址。

因此,我们在Display类中添加了一个新方法,该方法将生成一个 HTML 片段和一些 JavaScript,以显示可用的单一到多对关系列表:

Chapter9/display.py

@staticmethod
def related_link(re,e):
	return '<li id="%s" class="%s" ref="%s">%s</li>'%(
		e.id,e.__class__.__name__,re.lower(),re)
def related_entities(self,e):
	r=['<div class="related_entities"><h3>Related</h3><ul>']
	if hasattr(e.__class__,'reltype'):
		r.extend([self.related_link(re,e)
			for re,rt in e.__class__.reltype.items()
			if (rt == '1:N' or rt == 'N:N')])
	r.append('</ul></div>')
	r.append('''
	<script>
	$('div.related_entities li').click(function(){
		var rel=$(this).attr("ref");
		var related=$("input[name=related]").val();
		$(".content").load(rel,
			$.param({
			"pattern" : $(this).attr("class") +
				"," + $(this).attr("id"),
			"related": related}),
		function(){shiftforms(rel)});
	});
</script>''')
return "\n".join(r)

要确定哪些关系可用,related_entities()方法引用了reltype类变量(已突出显示),这是一个由MetaRelation类维护的实体名称及其类型的字典,当定义新关系时。对于每个合适的关联,related_link()方法将帮助生成一个<li>元素。

这些<li>元素有一个id属性,它包含引用实体的唯一 ID(在这个例子中是联系人的 ID)和一个class属性,它指示引用实体的类型(在这种情况下是 Contact)。<li>元素还有一个rel属性,它指向由Browse类服务的 URL。目前,我们从这个我们引用的实体的名称(在这种情况下是地址)中派生这个 URL。

最终生成的 HTML 片段是一个<script>元素,它为<li>元素安装了一个事件处理器。这个点击处理器将获取其关联的<li>元素的ref属性来构造一个 URL,随后用于打开一个新窗口。我们将不得不稍微调整DisplayBrowse类的index()方法,以便传递和处理这些属性,正如我们稍后将看到的。

在我们的例子中,生成的 HTML 片段(减去 script 元素)将如下所示:

<div class="related_entities">
	<h3>Related</h3>
	<ul>
		<li ref="address" class="Contact" id="1">Address</li>
	</ul>
</div>

将替换<div>元素内容的load()调用将传递以下 URL,例如:http://127.0.0.1:8080/ address/?_=1295184609212&pattern=Contact,1&related=Contact,1

注意

注意,我们在这里使用 jQuery 的param()函数将包含多个属性的对象转换为适合添加到 URL 的字符串。我们本可以直接在这里传递对象,但那样会导致即使我们配置了所有 AJAX 调用使用 HTTP GET 方法,也会触发 POST 操作。通常情况下,这不会成为问题,但如果 URL 中缺少最后的斜杠,CherryPy 会重定向我们到以斜杠结尾的 URL,并且 AJAX 调用将再次执行,但这次没有附加参数!为了防止这种可能的尴尬情况并帮助调试,我们通过使用param()函数自己构造完整的 URL 来强制使用 GET 方法。

适应 MetaRelation 的时间

Display类中,我们使用了MetaRelation元类存储的关系类型信息。这是必要的,因为当我们认识到存在多种关系类型时,我们需要某种方式来指示当我们定义一个新的关系并创建新类时使用该信息。看看下面的示例代码:

class A(Entity):
	pass
class B(Entity):
	pass
class AhasmanyB(Relation):
	a=A
	b=B

在这里,我们表达AB之间的关系是一对多。如果我们想表达一个A的实例可能只引用一个B实例的概念,我们需要在定义中表明这一点。这样做的一种方式是在变量的类定义中反转赋值:

class AreferstoasingleB(Relation):
	a=B
	b=A

我们之前定义的MetaRelation元类可以对这样的定义进行操作,因为我们安排了被定义的关系的类字典是一个OrderedDict,所以原则上,我们可以对定义的顺序进行操作。

一种更明确的方式来定义这通常是更清晰的,所以我们选择一个可以分配关系类型的字符串的relation_type属性。例如:

class AhasmanyB(Relation):
	a=A
	b=B
	relation_type='1:N'
class AreferstoasingleB(Relation):
	a=A
	b=B
	relation_type='N:1'

如果我们省略了relation_type,则默认为多对一关系。

这到底发生了什么?

让我们看看实现这些语义所需的MetaRelation的更改和添加。我们需要两个更改。第一个是在我们用来管理关系的桥表定义中。我们需要在这里添加一个额外的unique约束,以强制在一对多关系中,方程多边的列中的 ID 是唯一的。

这可能听起来有些反直觉,但假设我们有以下车辆:一辆沃尔沃、一辆雷诺、一辆福特和一辆尼桑。还有两位所有者,John 和 Jill。Jill 拥有沃尔沃和雷诺,而 John 拥有其他车辆。表格可能看起来像这样:

汽车
ID 品牌
--- ---
1 沃尔沃
2 雷诺
3 福特
4 尼桑
所有者
--- ---
ID 名称
--- ---
1 Jill
2 John

反映汽车所有权的表可能看起来像这样:

所有权
汽车 所有者
--- ---
1 1
2 1
3 2
4 2

我们看到,虽然一个单一所有者可能拥有许多辆车,但由于这种关系,汽车列中的数字是唯一的。

为了定义具有那些额外唯一约束条件的表,并使关系类型的信息在构成关系两半的类中可用,我们必须调整MetaRelation元类中的__new__()方法的最后部分:

第九章/entity.py

if relationdefinition or '_meta' in classdict:
		a = classdict['a']
		b = classdict['b'] r = '1:N'0
		if 'relation_type' in classdict: r = classdict['relation_type']
		if not r in ('N:1','1:N'): raise KeyError("unknown relation_
type %s"%r)
		classdict['relation_type'] = r
		if not issubclass(a,AbstractEntity) : raise TypeError('a not 
an AbstractEntity')
		if not issubclass(a,AbstractEntity) : raise TypeError('b not 
an AbstractEntity') runique = ' ,unique(%s_id)'%a.__name__
		if r == '1:N' : runique = ' ,unique(%s_id)'%b.__name__
		sql = 'create table if not exists %(rel)s ( %(a)s_id references %(a)s on delete cascade, %(b)s_id references %(b)s on delete cascade, unique(%(a)s_id,%(b)s_id)%(ru)s)'%{'rel':classname,'a':a.__name__,'b':b.__name__,'ru':runique}
conn = sqlite.connect(classdict['_database'])
		conn.execute(sql)
		setattr(a,'get'+b.__name__,lambda self:getclass(self,b, 
classname))
		setattr(a,'get',get)
		setattr(b,'get'+a.__name__,lambda self:getclass(self,a, 
classname))
		setattr(b,'get',get)
		setattr(a,'add'+b.__name__,lambda self,entity:addclass(self, entity,b,
classname))
		setattr(a,'add',add)
		setattr(b,'add'+a.__name__,lambda self,entity:addclass(self, entity,a,
classname))
		setattr(b,'add',add) reltypes = getattr(a,'reltype',{})
		reltypes[b.__name__]=r
		setattr(a,'reltype',reltypes)
		reltypes = getattr(b,'reltype',{})
		reltypes[a.__name__]={'1:N':'N:1','N:N':'N:N','N:1':'1:N'}[r]
		setattr(b,'reltype',reltypes)
		relclasses = getattr(a,'relclass',{})
		relclasses[b.__name__]=b
		setattr(a,'relclass',relclasses)
		relclasses = getattr(b,'relclass',{})
		relclasses[a.__name__]=a
		setattr(b,'relclass',relclasses)
		joins = getattr(a,'joins',{})
		joins[b.__name__]=classname
		setattr(a,'joins',joins)
		joins = getattr(b,'joins',{})
		joins[a.__name__]=classname
		setattr(b,'joins',joins)
	return type.__new__(metaclass,classname,baseclasses,classdict)

突出显示的行是我们添加的。第一组确保定义了relation_type属性,如果没有,则创建一个具有默认'1:N'值的属性。

突出显示的第二组行确定桥表中哪个列应该接收额外的唯一约束,并构建创建表的 SQL 查询。

突出显示的最后一行代码为两个相关类添加了类属性。所有这些属性都是按实体名称索引的字典。reltype属性持有关系类型,因此在一个Car实体中,我们可能通过以下方式获取与Owner的关系类型:

Car.reltype('Owner')

如果像我们之前的例子那样定义,将产生'N:1'(一辆或多辆车可能有一个单一的所有者)。

同样,我们可以从所有者的角度获取相同关系的信息:

Owner.reltype('Car')

这将产生逆关系,'1:N'(一个所有者可能有多辆车)。

提升显示效果的行动时间

我们需要更改什么来向Display类添加当用户点击相关标签时弹出项目列表的功能?

  • 因为Display类的所有活动都由其index()方法提供服务,所以我们必须在那里做一些更改。

  • index() 方法既显示表单,又在按下提交按钮时处理结果,因此我们必须查看编辑和添加功能的两个方面。

  • 当显示编辑表单时,这始终是从双击 Browse 实例显示的项目列表中触发的,因此会传递一个相关参数。在点击提交按钮时,必须将此参数与表单内容一起传递,以便将其与启动此编辑操作的项关联。

这些问题要求我们对 index() 方法进行一些修改。

刚才发生了什么?

我们必须做的第一件事是向 index() 方法添加一个 related 参数。此参数可以保存实体的名称和特定相关实例的 ID,用逗号分隔:

Chapter9/display.py

@cherrypy.expose
	def index(self,id=None,_=None,add=None,edit=None,related=None,**
kw):

在处理传递给 Display 类的 index() 方法的 related 参数时,涉及添加操作结果的部分必须对 related 参数中的信息进行操作:

Chapter9/display.py

if not related is None and related != '':
	r=related.split(',') re=e.relclass[r[0]](id=int(r[1]))
	e.add(re)

如果方法是在点击项目列表中的 '添加' 按钮后调用的,则 related 参数将不为空,并且我们通过逗号分割它以检索实体的名称及其 ID。

实体的名称用于检索在定义关系时添加到 relclass 字典中的该类,并且使用实例的 ID 调用该类的构造函数以创建对象(突出显示)。随后,通过 add() 方法建立我们正在编辑的项与相关项之间的关系。

同样,最初负责提供添加或编辑表单的部分必须包括一个隐藏的 <input> 元素,该元素保存当用户在 Browse 实例提供的页面上点击 '添加' 按钮时传递给它的相关参数内容:

Chapter9/display.py

							submitbutton='<input type="hidden" name="related" 
value="%s"><input type="submit" name="add" value="Add">'%related

是时候增强 Browse 的功能了。

所有这些相关参数的传递都始于用户在实体列表中点击 '添加' 按钮,而这个列表本身是在编辑或查看项时点击相关标签后显示的。

这些实体列表是由 Browse 类的 index() 方法生成的,因此我们必须确保传递适当的信息(即,与实例 ID 一起列出的实体名称)。

这意味着我们必须:

  • 增强 index() 方法以接收一个相关参数,当点击 '添加' 按钮时可以传递。

  • 将生成与该添加按钮相关表单的代码扩展,添加一个隐藏的 <input> 元素来保存这些信息,以便它可以再次传递给 Display 类的 index() 方法。

如果 DisplayBrowse 之间的连接听起来有点令人困惑,那么设想以下场景可能会有所帮助:

  • 用户从主菜单开始查看所有者列表,并双击某个特定所有者。这将导致由Display类提供的'编辑'表单。因为双击项目不会传递相关参数,所以Display类的index()方法中的此参数将接收其默认值None

  • 编辑表单在标签为相关性的侧边栏中显示了所有者的详情,我们看到一个汽车条目。

  • 当用户点击此汽车条目以显示与该所有者相关的汽车列表时,这将导致调用Car实体的Browse实例的index()方法,同时带有relatedpattern参数Owner,5,例如。

  • 这将导致显示指定所有者的汽车列表,当点击此列表中的'添加'按钮时,再次调用Display类的index()方法,但这次是与Car实体关联的Display实例。它将传递Owner,5related参数。

  • 最后,当用户输入了新的汽车详情并点击'添加'时,将再次调用Display类的index()方法,这次带有related参数Owner,5,同时还有一个add参数。汽车详情将被用来创建一个新的Car实例,而related参数用来识别Owner实例并将新的汽车实例与之关联。

以下一系列截图展示了正在发生的情况。我们从一个所有者列表开始:

增强浏览时间的行动

当我们双击Knut Larsson时,以下 URL 将被发送到服务器:http://127.0.0.1:8080/owner/edit/?id=5&_=1295438048592id=5表示实例,最后的数字是 jQuery 添加到 AJAX 调用中,以防止浏览器缓存)。

结果将是一个用于Knut的编辑表单:

增强浏览时间的行动

点击汽车将导致以下 URL 被发送到服务器:http://127.0.0.1:8080/car/?_=1295438364192&pattern=Owner,5&related=Owner,5

我们识别出Owner,5(即指Knut)的relatedpattern参数。请注意,附加到此 URL 的参数中的逗号将被编码为%2C发送到服务器。

注意

为什么我们发送包含相同信息的related参数和pattern参数?对于将实体添加到另一个实体,这确实是多余的,但如果我们想添加转移所有权以及添加新实体的能力,我们希望过滤出属于其他所有者的汽车,因此我们需要分别提供patternrelated参数。

如果这是我们第一次为Knut添加汽车,相关汽车列表将为空:

增强浏览时间的行动

如果我们现在点击添加新按钮,将构造以下 URL:

http://127.0.0.1:8080/car/add/?_=1295438711800&related=Owner,5,这将导致一个用于添加新汽车的表单:

增强浏览时间的行动

填写详细信息后,点击添加按钮将导致一个新的汽车实例,即使我们因为 URL 中再次传递的相关参数而留空所有者字段,也会与Knut相关联:

http://127.0.0.1:8080/car/add/?_=1295439571424&make=Volvo&model=C30&color=Green&license=124-abc&Owner=&related=Owner,5&add=Add.

增强浏览时间的行动

刚才发生了什么?

要允许Browse实例以与Display实例相同的方式接收和传递related属性,我们需要进行一些小的更改。首先,我们必须更改index()方法的签名:

第九章/browse.py

@cherrypy.expose
	def index(self, _=None, start=0, pattern=None, sortorder=None, 
cacheid=None, next=None,previous=None, first=None, last=None, 
clear=None, related=None):

然后剩下的就是确保点击添加新按钮时,通过在表单中包含一个隐藏的<input>元素来传递这个值:

第九章/browse.py

yield '<form method="GET" action="add">'
			yield '<input name="related" type="hidden" 
value="%s">'%related
			yield '<button type="submit">Add new</button>'
			yield '</form>'

访问控制

在我们迄今为止设计的应用程序中,我们采取了非常简单的方法来处理访问控制。基于某人的登录凭证,我们允许或不允许访问。在书籍应用程序中,我们稍微扩展了这个概念,删除书籍意味着删除书籍与所有者之间的关联,而不是从数据库中完全删除书籍实例。

在许多情况下,需要更精细的权限控制,但如果这种控制被硬编码到应用程序中,维护它将很快变得难以管理。因此,我们需要某种东西,它将允许我们以简单的方式管理访问权限,并且允许轻松扩展。

考虑以下场景:在一个使用我们 CRM 应用程序的公司中,不同的账户由不同的销售人员拥有。由于公司规模较小,所以每个人都允许查看所有账户的信息,但更改账户信息仅限于拥有该账户的销售人员。当然,销售经理,他们的老板,也有权更改这些信息,无论他是否拥有账户。

我们可以通过让Entityupdate()方法检查此实体是否由执行更新的个人拥有账户,如果不是,则该个人是否是销售经理来实现这种策略。

实施访问控制的时间

这种场景在access1.py:中实现

注意

注意:在本章和下一章提供的代码中,logon类不仅使用admin用户(密码为admin)初始化,还使用以下三个用户名/密码组合:eve/eve, john/john, 和 mike/mike

如果你运行这个应用程序并将你的浏览器指向 http://localhost:8080,你会看到一个账户列表。如果你以 johnmike(都是销售人员)的身份登录,你只能更改他们各自拥有的账户。然而,如果你以 eve(销售经理)的身份登录,你可以更改所有账户的信息。

刚才发生了什么?

该应用程序足够简单,遵循一个熟悉的模式。相关的定义如下所示:

Chapter9/access1.py

 from permissions1 import isallowed
class Entity(AbstractEntity):
	database = db def update(self,**kw):
		if isallowed('update', self, logon.checkauth(),
							self.getUser()):
				super().update(**kw)
class Relation(AbstractRelation):
	database = db
class User(Entity):
	name = Attribute(notnull=True, unique=True,
			displayname="Name", primary=True)
class Account(Entity):
	name = Attribute(notnull=True, displayname="Name",
			primary=True)
class OwnerShip(Relation):
	a = User
	b = Account
class AccountBrowser(Browse):
	edit = Display(Account, edit=True, logon=logon,
			columns=Account.columns+[User])
	add = Display(Account, add=True, logon=logon,
			columns=Account.columns+[User])
class UserBrowser(Browse):
	edit = Display(User, edit=True, logon=logon)
	add = Display(User, add=True, logon=logon)

随代码一起分发的数据库(access1.db)已经包含了一些账户,因此代码中不包含创建这些账户的任何行。前述代码中突出显示的部分很重要:它导入了一个包含权限字典的 permissions1 模块。这个字典列出了对于每个实体、动作、所有权和用户名的组合,这是否允许。

我们现在可以覆盖 AbstractEntity 类中的 update() 方法(高亮显示):我们通过调用 checkauth() 方法从当前会话中检索用户名,并将其连同我们想要检查的动作名称(在这种情况下是更新)一起传递给 isallowed() 函数,以及实体和用户列表(所有者)。如果检查无误,我们调用原始的 update() 方法。

如果我们查看 permissions1.py,我们会看到,由于在这个示例中我们只考虑了 Account 实体和更新动作,这个列表相当小:

Chapter9/permissions1.py

import entity1 allowed = {
	'Account' : {
		'create' : {
			'admin' : 'all',
			'eve' : 'all',
			'john' : 'owner',
			'mike' : 'owner'
		},
		'update' : {
			'admin' : 'all',
			'eve' : 'all',
			'john' : 'owner',
			'mike' : 'owner'
		},
		'delete' : {
			'admin' : 'all',
			'eve' : 'all',
		}
	}
} def isallowed(action,entity,user,owner):
if len(owner) < 1 : return True
try:
	privileges = allowed[entity.__class__.__name__][action]
	if not user in privileges :
			return False
	elif privileges[user] == 'all':
			return True
	elif privileges[user] == 'owner' and user == owner[0].name:
			return True
	else:
			return False
except KeyError:
	return True

具有特权的字典本身被称为 allowed(高亮显示),permissions1.py 还定义了一个名为 isallowed() 的函数,如果这个实体没有所有者,它将返回 True。然后它检查这个实体和动作是否有已知的特权。如果不是这种情况,将引发任何异常,因为实体或动作的键不存在。

如果已知特权,我们检查用户是否有特定的特权。如果没有用户键,我们返回 False。如果有,并且特权是 all,我们返回 True:他/她可以在这个实体上执行动作,即使他/她不是所有者。如果特权是所有者,我们只有在用户确实是所有者的情况下才返回 True

由于各种原因,上述方法显得有些繁琐:

  • 如果我们想添加一个新的销售人员,例如,我们必须为每个实体/动作组合添加权限条目。在示例中,我们只考虑了 Account 实体和 update 动作,但在一个更现实的应用程序中,会有数十个实体(如 ContactAddressQuoteLead 等)和相当多的动作需要考虑(例如,deletecreate,以及涉及其他实体的动作,如 更改 所有权或向账户添加地址)。此外,如果那位销售人员被提升为销售经理,我们可能需要再次进行整个练习。

  • 如果我们添加一个新的实体类型,我们就必须为公司中的每个人添加相应的行。

  • 在 Python 模块中管理权限不是你通常期望非技术人员去做的事情,因为这很繁琐,容易出错,并且如果有所改变,需要重新启动应用程序。

最后一个原因是为什么我们将实现数据库中的权限列表。毕竟,我们已经有了一个允许通过网页界面轻松操作数据库条目的框架。其他原因是我们将重新考虑我们的第一个方法,并将实现一个称为基于角色的访问控制的方案。

基于角色的访问控制

基于角色的访问控制中的想法是将一个或多个角色分配给人员,而不是特定的权限。

然后,权限将与角色相关联,如果一个人拥有多个角色,权限将被合并。如果新员工需要一组权限来使用应用程序,或者如果一个人的组织角色发生变化,只需要更改与该人关联的角色列表,而不是必须更改应用程序中每种实体类型的权限。

同样,如果我们想扩展可用的实体类型,我们只需要定义与角色(或角色集)关联的权限将应用于这个新实体,而不是为每个人定义这些权限。

注意

了解更多关于此内容的良好起点是这篇维基百科文章:en.wikipedia.org/wiki/Role-based_access_control

上述概念可以在这个数据模型中体现:

基于角色的访问控制

在我们的简化模型中,一个用户可以有一个角色,但一个角色可以有一个或多个权限。一个权限由几个属性组成,包括实体、操作和级别,这些属性共同描述了在什么条件下允许做某事。

实施基于角色的访问控制的时机

运行access2.py中提供的示例应用程序,并以admin身份登录。你会看到除了用户账户之外,你还看到了角色权限的链接。如果你点击角色,你会看到我们已经定义了几个角色:

实施基于角色的访问控制的时机

如截图所示,我们还定义了一个超级用户角色,以说明可以将基于角色的访问控制的概念扩展到角色和权限的维护。

刚才发生了什么?

使用这种访问控制形式的应用程序只需要稍作调整。看看access2.py

第九章/access2.py

import os
import cherrypy
from rbacentity import AbstractEntity, Attribute, Picklist, 
AbstractRelation
from browse import Browse
from display import Display
from logondb import LogonDB
db="/tmp/access2.db"

与我们之前的例子相比,只有第一部分不同,因为它包括了rbacentity模块而不是entity模块。这个模块提供了与entity模块相同的功能,但在这个模块中定义的AbstractEntity类增加了一些魔法功能,以提供对角色和权限的访问。我们在这里不会详细检查它,但当我们遇到它时,我们会进行注释。

下一个部分是Entity类的定义。我们本可以选择重新定义AbstractEntity类,但在这里我们选择通过添加和覆盖必要的方法来向Entity子类添加功能:

第九章/access2.py

class Entity(AbstractEntity):
	database = db
	userentity = None
	logon = None
	@classmethod
	def setUserEntity(cls,entity):
		cls.userentity = entity
	@classmethod
	def getUserEntity(cls):
		return cls.userentity
	@classmethod
	def setLogon(cls,logon):
		cls.logon = logon
	@classmethod
	def getAuthenticatedUsername(cls):
		if cls.logon :
			return cls.logon.checkauth()
		return None
	def isallowed(self,operation):
		user = self.getUserEntity().list(
			pattern=[('name',self.getAuthenticatedUsername())])[0]
		entity = self.__class__.__name__ if user.name == 'admin' :
			return True
		roles = user.getRole()
		if len(roles):
			role = roles[0]
			permissions = role.getPermission()
			for p in permissions :
				if p.entity == entity:
					if p.operation=='*' or p.operation==operation:
						if p.level == 0 :
							return True
						elif p.level == 1: for owner in self.getUser():
								if user.id == owner.id :
									return True
						return False def update(self,**kw):
						if self.isallowed('update'):
							super().update(**kw)

除了定义一个database类变量之外,我们现在还定义了一个userentity类变量来保存表示用户的实体类的引用,以及一个logon类变量来保存一个登录实例的引用,该实例可以为我们提供已认证用户的名字。

这种区分与前面章节中的例子相同:在我们的主数据库中有一个User实体,我们可以存储与用户相关的各种信息(如全名、电话号码、性别等),还有一个单独的密码数据库,只包含用户名和加密密码。如果用户正确地通过了密码数据库的认证,我们就知道了他的/她的用户名,然后我们可以使用这个用户名来检索包含所有额外关联信息的相应User实例。类方法提供了访问这些类变量的手段。

在这个例子中,我们只覆盖了update()方法(突出显示),但在完整实现中,你可能还希望覆盖其他Entity方法。模式很简单:我们使用一个表示我们想要检查的动作的参数调用isallowed()方法,如果isallowed()返回True,我们就调用原始方法。

isallowed()方法本身首先做的事情是使用getAuthenticatedUsername()类方法检索已认证用户的用户名。然后它使用这个名称来查找User实例。尽管我们可能希望在应用程序中实现基于角色的访问方案,以便允许不同用户管理角色和权限,但我们在这里仍然提供了一个方便的快捷方式(突出显示),以便管理员可以这样做。这样我们就不必在数据库中为管理员用户预先设置角色和权限。当然,对于实际应用,你可能会有不同的选择。

接下来,我们检查是否有任何与用户关联的角色,如果是这样,我们就检索与第一个角色(在这个例子中,用户只有一个角色)关联的所有权限。然后,我们遍历所有这些权限,检查是否有适用于我们感兴趣的实体的权限。如果有,我们检查operation字段。如果这个字段包含星号(*)或者等于我们正在检查的操作,我们就查看level。如果这个level为零,这意味着当前用户即使不是所有者也可以在这个实体上执行此操作。如果级别为一级,他/她只有在拥有实体的情况下才能执行操作,因此我们需要检查用户是否在当前实体的关联用户列表中。

小贴士

每次执行操作时检索角色和权限可能会严重影响性能。可能有必要缓存一些此类信息。但您必须小心,一旦用户的权限集合发生变化,就必须使缓存失效。

如上图所示,access2.py的下一部分说明了我们如何使用这种增强版的Entity类:

第九章/access2.py

class Relation(AbstractRelation):
	database = db
class User(Entity):
	name = Attribute(notnull=True, unique=True, displayname="Name", 
primary=True)
class Account(Entity):
	name = Attribute(notnull=True, displayname="Name", primary=True)
class OwnerShip(Relation):
	a = User
	b = Account
class UserRoles(Relation):
	a = User b = User._rbac().getRole()
	relation_type = "N:1"
logon = LogonDB()
Entity.setUserEntity(User)
Entity.setLogon(logon)

如前所述,我们定义了UserAccount实体,以及它们之间的所有权关系。rbacentity模块将提供RolePermission类,我们可以通过所有AbstractEntity派生类的_rbac()类方法访问它们。这个_rbac()方法返回的对象提供了一个getRole()方法,它返回Role实体的类。我们在这里使用它来创建用户与其角色之间的关系(突出显示)。最后的几行将密码数据库和User类与我们的新Entity类关联起来。

为了提供对角色和权限列表的访问,我们可以使用相同的_rbac()方法提供创建Browse类所需的RolePermission类:

第九章/access2.py

class RoleBrowser(Browse):
		edit = Display(User._rbac().getRole(), edit=True, logon=logon)
		add = Display(User._rbac().getRole(), add=True, logon=logon)
class PermissionBrowser(Browse):
		edit = Display(User._rbac().getPermission(), edit=True,
	logon=logon, columns=User._rbac().getPermission().columns + 
[User._rbac().getRole()])
	add = Display(User._rbac().getPermission(), add=True,
logon=logon, columns=User._rbac().getPermission().columns + [User._
rbac().getRole()])

摘要

在本章中,我们填补了我们框架的一些空白。具体来说,我们学习了如何实现更复杂的关系,例如一对一和多对一关系,如何创建维护这些关系的必要用户界面组件,以及如何实现基于角色的访问控制。

我们还没有完全完成,因为我们缺少让最终用户自定义数据模型的功能,这是下一章的主题。

第十章. 定制 CRM 应用程序

在本章的最后,我们将向我们的框架添加功能,以便进行一些收尾工作。

具体来说,我们将看到:

  • 需要什么来增强用户界面,以便在框架中使用排序和筛选功能

  • 我们如何为最终用户提供自定义应用程序的手段,而无需编程

  • 如何使用这些自定义来增强项目和项目列表的显示

  • 如何使用来自外部来源的信息,如 Google Maps,来增强存储的信息

操作时间排序

当我们在第八章(Chapter 8` 方法中的底层功能时,我们已经处理了排序和筛选。

我们还没有允许任何用户交互来使显示的实体列表可排序。所缺少的是实现这一功能的 JavaScript 代码和一些 CSS。看看下面的截图,注意列表顶部账户旁边的一些表头旁边的小箭头图标:

操作时间排序

当你运行 crm2.py 时,你可以亲自尝试排序选项。

点击带有小双箭头标记的列一次,将按该特定列的升序排序列表。表头将改变其背景颜色以指示列表现在已排序,小图标将变成一个单向上箭头。

再次点击它将按降序排序列表,这将通过一个指向下的小箭头图标来表示。最后一次点击将使项目列表无序,就像点击重置按钮一样(未显示)。

刚才发生了什么?

这种排序行为是通过几个小部分实现的:

  • 与表头关联的 jQuery 点击处理程序

  • 一些 CSS 代码来为这些表头添加合适的图标

  • 对生成表格的 Python 代码进行的一些小修改,以便在浏览器中更容易跟踪排序状态。

首先,让我们看看需要添加到 JavaScript 中的内容(完整的文件作为 browse.js 提供):`

第十章/browse.js

$(".notsorted").live('click',function(){
	$("input[name=sortorder]").remove();
	$(".content form").first() 
	.append('<input type="hidden" name="sortorder" value="' 
			+$("div.colname",this).text()+',asc">');
	$("button[name=first]").click();
}).live('mouseenter mouseleave',function(){
	$(this).toggleClass("ui-state-highlight");
});
$(".sorted-asc").live('click',function(){
	//alert('sorted-asc '+$(this).text())
	$("input[name=sortorder]").remove(); $(".content form").first() 
	.append('<input type="hidden" name="sortorder" value="' 
			+$("div.colname",this).text()+',desc">');
	$("button[name=first]").click();
}).live('mouseenter mouseleave',function(){
	$(this).toggleClass("ui-state-highlight");
});
$(".sorted-desc").live('click',function(){
	//alert('sorted-desc '+$(this).text())
	$("button[name=clear]").click();
}).live('mouseenter mouseleave',function(){
	$(this).toggleClass("ui-state-highlight");
});

安装点击处理程序本身很简单,但它们需要完成的任务稍微复杂一些。

点击处理程序必须首先确定哪个列将用作排序键。被点击的元素作为 this 可用于处理程序,这将使我们能够访问包含列名称的 <div> 元素,该元素位于表头内。这个 <div> 元素没有显示,因为它的 display 属性将被设置为 none。它是添加的,因为我们需要访问列的规范名称。<th> 元素本身只包含列的显示名称,这可能与规范名称不同。

这个排序键必须传递给应用服务器,为此我们将使用现有的机制:如果我们通过导航按钮触发表单的提交并确保传递了正确的排序参数,我们几乎就完成了。这该如何实现呢?

jQuery 提供了方便的方法来将新的 HTML 元素插入到现有的标记中(突出显示)。从列名中,我们通过添加ascdesc来构造一个合适的值,用逗号分隔,并使用这个值作为具有sortorder名称的新隐藏输入元素的值,然后使用append()方法将其插入到第一个<form>元素中。页面中的第一个表单是包含导航按钮的表单元素。

由于这些相同类型的隐藏<input>元素用于在用户翻页浏览项目列表时维护状态,我们首先删除任何具有name属性等于sortorder<input>元素,以确保这些元素反映的是新选定的排序顺序,而不是任何旧的排序顺序。删除操作是通过名为remove()的方法完成的。

最后一步是提交表单。我们可以直接触发提交事件,但由于我们有多个具有type属性等于submit的按钮,我们必须更加具体。

在按钮上无法触发submit事件,只能是在表单上,但可以在按钮上触发click事件,从而模拟用户交互。一旦在具有name属性为first的按钮上触发click事件,表单将连同所有其<input>元素一起提交,包括隐藏的元素,以及表示排序顺序的新增或替换的元素。

对于已经按升序排序并带有sorted-asc类的<th>元素的处理器几乎相同。我们做的唯一改变是将具有name=sortorder的隐藏<input>元素的值改为带有,后缀的列名,而不是asc后缀。

<th>元素已经按降序排序时,点击它将循环回到显示未排序状态,因此这个点击处理器甚至更简单,它只是触发清除按钮的点击处理器,这将导致列表未排序。

Browse类中的index()方法的变化如下(完整代码可在browse.py中找到):`

第十章/browse.py

yield '<thead><tr>'
				for col in self.columns:
					if type(col) == str :
						sortclass="notsorted"
						iconclass="ui-icon ui-icon-triangle-2-n-s"
						for s in sortorder:
								if s[0]==col :
									sortclass='sorted-'+s[1] iconclass=' ui-icon ui-icon-
triangle-1-%s'%({'asc':'n','desc':'s'}[s[1]])
									break
						yield '<th class="%s"><div class="colname" 
style="display:none">%s</div>'%(sortclass,col)+self.entity.
displaynames[col]+'<span class="%s"><span></th>'%iconclass
				else :
						yield '<th>'+col.__name__+'</th>'
		yield '</tr></thead>\n<tbody>\n'

我们应用程序中的 Python 代码几乎不需要改变,以适应这种交互方式。我们只是给列的<th>元素添加一个表示排序状态的类。

它是notsorted, sorted-ascsorted-desc之一。我们还插入一个<div>元素来保存列的真实名称,以及一个带有合适的 jQuery UI 图标类的空<span>元素来保存表示排序状态的图标(突出显示)。

sortorder 列表包含多个元组,每个元组都有一个要排序的列的名称作为第一个元素,以及 ascdesc 作为第二个元素。第二个元素用作字典的索引,该字典将 asc 映射到 n,将 desc 映射到 s,从而选择 ui-icon-triangle-1-nui-icon-triangle-1-s 类。将这些类与 ui-icon 类一起附加,这就是我们需要让 jQuery UI 样式表渲染我们的 <span> 元素并带有有意义图标的全部。

注意

许多类似箭头的图标,在 jQuery UI 中可用,遵循与这里的小三角形相似的命名模式。最后的部分表示一个罗盘方向(这里 n 表示北,或向上)和数字表示图标中描绘了多少个箭头(这里只有一个,但有许多双头变体)。

对于当前按升序排序的名为 time 的列,生成的 HTML 可能看起来像这样:

<th class="sorted-asc">
<div class="colname" style="display:none">time</div>
Time
<span class="ui-icon ui-icon-triangle-1-n"><span>
</th>

除了图标外,我们还在 base.css 中添加了一些额外的样式,使标题更易于识别:

第十章/base.css

th.notsorted { padding-right:1px; border:solid 1px #f0f0f0; }
th.sorted-asc { padding-right:1px; border:solid 1px #f0f0f0; 
background-color: #fff0f0; }
th.sorted-desc { padding-right:1px; border:solid 1px #f0f0f0; 
background-color: #fffff0; }
th span { float:right; }

表头本身只是用浅灰色进行了样式化,但将包含图标的 <span> 元素浮动到右侧是很重要的,否则它会在列标题的下方而不是旁边移动。

动作过滤时间

我们用于排序的几乎相同的方法也可以用于过滤,只是这一次不是点击列标题就能完成,我们必须为用户提供一种输入过滤值的方法。查看以下屏幕截图或通过再次运行 crm2.py 来自己过滤数据:

动作过滤时间

如果你将值插入到表格下方列的任何输入字段中,并点击过滤按钮(带有放大镜图标的按钮),要显示的项目列表将减少到与过滤值匹配的项目。请注意,排序和过滤可以组合使用,并且点击清除按钮将删除排序和过滤设置。

刚才发生了什么?

让我们看看 JavaScript 代码:

第十章/browse.js

$("button[name=search]").button({
	icons: {
		primary: "ui-icon-search"
		},
		text: false
}).click(function(){ $("input[name=pattern]", $(".content form").first()).remove();
		$("input[name=pattern]").each(function(i,e){
			var val=$(e).val();
			var col=$(e).next().text();
			$(".content form").first() 
			.append(
			'<input type="hidden" name="pattern" value="' 
			+col+','+val+'">');
	});
		$("button[name=first]").click();
});

大部分工作是在搜索按钮的点击处理程序中完成的。当点击搜索按钮时,我们必须在第一个表单中构建隐藏的 <input> 元素,其 name 属性等于 pattern,因为正是这些隐藏的过滤输入将在我们触发表单提交时作为参数传递给动作 URL。

注意 jQuery 函数($)的第二个参数,它选择一个<input>元素(突出显示)。提供给用户输入模式值的所有可见<input>元素以及包含导航按钮的表单中的隐藏元素都具有相同的name属性(模式)。我们不希望删除可见的元素,因为它们包含我们感兴趣的模式值。因此,我们将选择限制在第一个表单的上下文中,该表单作为第二个参数传递。

之后,我们只剩下可见的<input>元素,我们使用.each()方法遍历它们。我们收集<input>元素的价值及其下一个兄弟元素的内容,该兄弟元素将是一个包含要过滤的列的真实名称的(隐藏的)<span>元素。这些元素一起用于构建一个新隐藏的<input>元素,该元素将被附加到将要提交的表单中。

在元素插入后,我们通过触发具有等于first属性的name属性的提交按钮的点击处理程序来提交此表单。

第十章/browse.py

			yield '<tfoot><tr>'
			for col in self.columns:
				if type(col)==str:
					filtervalue=dict(pattern).get(col,'')
					yield '''<td><input name="pattern" 
							value="%s"><span 
							style="display:none">%s</span> 
							</td>'''%(filtervalue,col)
			yield '</tr></tfoot>\n'

在我们网络应用程序的 Python 部分中,唯一需要更改的是插入适当的<input>元素,这些元素使用当前的模式值初始化,以向用户提供可见的反馈。当前过滤值为 ABC 的列的 HTML 结果可能如下所示:

<td>
<input name="pattern" value="ABC">
<span style="display:none">name</span>
</td>

定制

无论你的应用程序设计得多好、多么详尽,用户总是希望从中获得更多功能。当然,有了适当的框架和良好的代码文档,更改不应成为大问题,但另一方面,你也不想仅仅因为需要一些小的定制就重新启动应用程序。

许多关于应用程序的请求将关注用户界面的可用性,例如,小部件的不同行为或与显示信息相关的某些附加功能,如检查输入文本的拼写,查找与显示的公司相关的股票市场信息,或屏幕上某个价值的当前汇率以不同货币表示。包括微软、雅虎和谷歌在内的许多公司提供各种免费 API,可用于增强值的显示。

定制实体显示的行动时间

假设我们想通过简单地点击地址旁边的按钮,让最终用户能够通过谷歌地图定位地址。运行crmcustomize.py并添加新地址或编辑现有地址。编辑/添加屏幕将类似于以下内容:

定制实体显示的行动时间

当你点击地图按钮时,将打开一个新窗口,显示该地址的地图,只要谷歌地图能够找到它。

这种功能是由最终用户添加的,无需重新启动服务器。注意,在打开屏幕中,我们有一个新的菜单,自定义。如果选择该菜单,我们会看到一个熟悉的界面,显示为不同实体添加的自定义设置列表。如果我们双击带有 Google Maps 描述的地址,我们会得到一个编辑屏幕,如下面的插图所示:

操作时间:自定义实体显示

快速浏览一下就会显示,这种自定义本身仅仅是 HTML 与一些 JavaScript 的混合,这些 JavaScript 是在每次我们为地址实体打开编辑或添加屏幕时添加到应用程序生成的标记中的。

注意

允许任何最终用户自定义应用程序可能并不总是好主意。你可能希望将一些或所有自定义选项限制为最终用户的一个子集。基于角色的访问控制再次是一种管理权限的合适方式。

刚才发生了什么?

让我们先看看自定义本身,以了解可以完成什么。代码由几行 HTML 和一段嵌入的 JavaScript 组成:

Chapter10/customization.html

<button id="showmap">Map</button>
<script>
$("#showmap").click(function(){
	var url = "http://maps.google.com/maps?f=q&q="
	url +=$("input[name=address]").val()+',';
	url +=$("input[name=city]").val()+',';
	url +=$("input[name=country]").val();
	window.open(url);
});
</script>

由于我们的应用程序本身依赖于 jQuery,任何自定义代码都可以使用这个库,因此在我们定义了一个合适的按钮之后,我们添加了一个点击处理程序(突出显示)来从几个<input>元素的值构建一个 Google Maps URL,这些元素将出现在地址的编辑或添加页面上,特别是地址城市国家。然后,这个 URL 通过window.open()方法传递,以打开一个新屏幕或标签页,显示此查询的结果。

注意

当使用 Google Maps API 时,可能会获得更好的结果,请参阅code.google.com/intl/nl/apis/maps/documentation/javascript

我们需要在我们的框架中更改什么以允许这种简单的最终用户自定义?

我们需要几个相关组件来使这个功能正常工作:

  • Display类需要被修改以生成适合显示实例的定制代码。

  • 我们需要一种方法将自定义存储在数据库中,与应用程序的其他部分一起。

  • 我们需要允许一种方式来编辑这些自定义设置。

让我们详细看看这些要求。最重要的部分是存储这些信息的方法。就像我们为基于角色的访问控制所做的那样,我们实际上可以再次使用我们的框架;这次是通过定义一个custom类。这个custom类将创建一个DisplayCustomization类,并为从AbstractEntity类派生的所有实体提供对其的访问。在实体模块中需要的更改是适度的(完整的代码可在rbacentity.py中找到):

Chapter10/rbacentity.py

class custom:
	def __init__(self,db):
		class CustomEntity(AbstractEntity):
			database=db
		class DisplayCustomization(CustomEntity):
			entity = Attribute(notnull= True, 
					displayname = "Entity")
			description = Attribute(displayname = "Description")
			customhtml = Attribute(displayname = "Custom HTML", 
						htmlescape=True, displayclass="mb-textarea")
		self.DisplayCustomization = DisplayCustomization
	def getDisplayCustomization(self):
		return self.DisplayCustomization
	def getDisplayCustomHTML(self,entity):
		return "".join(dc.customhtml for dc in self.
DisplayCustomization.list(pattern=[('entity',entity)]))

现在我们已经可以访问这个存储自定义功能的地方,任何应用程序都可以使用它,但同时也必须提供一个方式让应用程序用户编辑这些自定义功能。这涉及到定义一个Browse类并添加一个链接来提供访问。这是在crmcustomize应用程序中是如何实现的,如下例所示(仅显示相关更改,完整代码可在crmcustomize.py中找到):

第十章/crmcustomize.py

...
displaycustom = User._custom().getDisplayCustomization()
class DisplayCustomizationBrowser(Browse):
	edit = Display(displaycustom, edit=True, logon=logon)
	add = Display(displaycustom, add=True, logon=logon)
...
class Root():
	logon = logon
	user = UserBrowser(User)
	account = AccountBrowser(Account, 
				columns=Account.columns+[User,Address,Contact])
	contact = ContactBrowser(Contact, 
				columns=Contact.columns+[Address,Account])
	address = AddressBrowser(Address)
	displaycustomization = DisplayCustomizationBrowser(displaycustom, 
		columns=['entity','description'])
	@cherrypy.expose
	def index(self):
			return Root.logon.index(returnpage='../entities')
	@cherrypy.expose
	def entities(self):
		username = self.logon.checkauth()
		if username is None : raise HTTPRedirect('.')
		user=User.list(pattern=[('name',username)])
		if len(user) < 1 : User(name=username)
		return basepage%'''<div class="navigation">
		<a href="user">Users</a>
		<a href="displaycustomization">Customize</a>
		<a href="http://account">Accounts</a>
		<a href="contact">Contacts</a>
		<a href="http://address">Addresses</a>
		</div><div class="content">
		</div>
		<script src="img/browse.js" type="text/javascript"></script>
		'''

最后一步是使用检索和传递这些自定义功能的方法来增强显示模块。这是通过在index()方法的末尾添加几行代码来完成的,如下所示:

第十章/display.py

yield self.entity._custom().getDisplayCustomHTML('*')
yield self.entity._custom().getDisplayCustomHTML(self.entity.__name__)

检索是相当直接的,我们实际上检索了两部分自定义功能:一部分是我们正在显示的特定实体,另一部分是对所有实体都相关的自定义代码。用户可以使用特殊实体名称*(单个星号字符)添加此类自定义。通过在提供的标记中首先放置一般自定义功能,我们可以覆盖为通用情况提供的任何内容,以特定实体的自定义功能来替代。

然而,在Display类的代码中还需要一些技巧。因为自定义代码可能包含 HTML,包括包含 JavaScript 的<script>元素和包含 CSS 的<style>元素,当我们显示用于编辑自定义代码的表单时,这些表单本身就是 HTML,我们可能会遇到麻烦。因此,我们需要某种方法来转义此代码,以防止输入框中的内容被解释为 HTML。

这是以以下方式实现的(以下是对Attribute类的相关更改):

第十章/rbacentity.py

class Attribute:
	def __init__(self,
			unique =False,
			notnull =False,
			default =None,
			affinity =None,
			validate =None,
			displayname =None,
			primary =False,
			displayclass =None,
			htmlescape =False):
			self.unique =unique
			self.notnull =notnull
			self.default =default
			self.affinity=affinity
			self.coldef = ( 
				affinity+' ' if not affinity is None else '') 
				+ ('unique ' if unique else '') 
				+ ('not null ' if notnull else '') 
				+ ('default %s '%default if not default is None else '')
			self.validate = validate?
			self.displayname = displayname
			self.primary = primary
			self.displayclass = displayclass
			self.htmlescape = htmlescape

实体模块中提供的Attribute类被扩展以接受额外的htmlescape参数。如果我们将其设置为True,则表示应在此属性显示在页面之前对其进行转义。

MetaEntity类还需要扩展以在Attribute类的新功能上操作:

第十章/rbacentity.py

classdict['htmlescape']={ k:v.htmlescape 
			for k,v in classdict.items() if type(v) == Attribute}

MetaEntity类被修改为将任何htmlescape属性存储在htmlescape类的属性中,这是一个按属性名称索引的字典。

到目前为止,我们可以创建带有标记为转义的属性的新实体,但Display类本身必须对此信息采取行动。因此,我们在Display类的index()方法中添加以下行:

第十章/display.py

val=getattr(e,c)
if self.entity.htmlescape[c] :
		val=escape(val,{'"':'&quot;','\n':'
'})
		line='''<input type="text" name="%s" 
				value="%s" 
				class="%s">'''%(c,val,displayclass)

Display类的index()方法中,在构建<input>元素之前,我们现在可以检查这个htmlescape字典,看看是否应该转义属性的值,如果是这样,就使用 Python 的xml.sax.saxutils模块中提供的escape()函数来转换可能干扰的任何字符。

注意

注意事项:

允许人们使用 HTML 和 JavaScript 定制应用程序固有的风险。当我们开发 wiki 应用程序时,我们通过清除不想要的 HTML 输入来限制页面上允许的输入。如果你对安全(你应该这样)认真负责,你必须考虑你将允许的定制内容,特别是为了防止跨站脚本(XSS)。例如,检查www.owasp.org/了解更多关于此和其他安全主题的信息。

定制实体列表的操作时间

当然,如果我们为用户提供定制单个实体显示的机会,那么为实体列表提供相同的功能也是有意义的。如果你运行crm4.py并点击联系人菜单项,你会看到一个如下列表:

定制实体列表的操作时间

你会注意到在包含电话号码的列中,以加号开头的那些电话号码以粗体显示。这将给出一个明显的提示,这可能是一个需要电话交换机上特殊代码的外国号码。

刚才发生了什么?

定制本身是一小段 JavaScript 代码,它被插入到显示联系人列表的页面末尾:

第十章/customizationexample3.html

<script>
var re = new RegExp("^\\s*\\+"); $("td:nth-child(4)").each(function(i){
	if($(this).text().match(re)){
		$(this).css({'font-weight':'bold'})
	};
});
</script>

它使用 jQuery 遍历所有<td>元素,这是它们父元素的第四个子元素(一个<tr>元素,代码被突出显示)。如果该元素包含的文本与以可选空白和一个加号开头的文本匹配(正则表达式本身在脚本的第 一行定义),我们将该元素的font-weight CSS 属性设置为bold

就像对Display的定制一样,我们需要添加一些方式来存储这些定制。在实体模块中对custom类的修改是直接的,并且复制了为Display设置的模板(完整的代码可在rbacentity.py中找到):

第十章/rbacentity.py

def __init__(self):
		...
		class BrowseCustomization(CustomEntity):
			entity = Attribute(notnull= True, displayname = "Entity")
			description = Attribute(displayname = "Description")
			customhtml = Attribute(displayname = "Custom HTML", 
				htmlescape=True, displayclass="mb-textarea")
		self.BrowseCustomization = BrowseCustomization
		...
def getBrowseCustomization(self):
		return self.BrowseCustomization
def getBrowseCustomHTML(self,entity):
		return "".join(dc.customhtml 
		for dc in self.BrowseCustomization.list( 
					pattern=[('entity',entity)]))

browse.py中定义的Browse类也需要扩展,以便检索和传递定制(以下是从browse.py中显示的相关行):

第十章/browse.py

yield self.entity._custom().getBrowseCustomHTML('*')
yield self.entity._custom().getBrowseCustomHTML(self.entity.__name__)

最后一步是为用户提供一个编辑定制的链接。这是在主应用程序(作为crm4.py提供)中通过添加这些行来完成的,再次遵循为显示定制设置的模板(与浏览定制相关的行被突出显示):

第十章/crm4.py

displaycustom = User._custom().getDisplayCustomization() browsecustom = User._custom().getBrowseCustomization()
class DisplayCustomizationBrowser(Browse):
	edit = Display(displaycustom, edit=True, logon=logon)
	add = Display(displaycustom, add=True, logon=logon) class BrowseCustomizationBrowser(Browse):
	edit = Display(browsecustom, edit=True, logon=logon)
	add = Display(browsecustom, add=True, logon=logon)
with open('basepage.html') as f:
	basepage=f.read(-1)
class Root():
	logon = logon
	user = UserBrowser(User)
	account = AccountBrowser(Account, 
				columns=Account.columns+[User,Address,Contact])
	contact = ContactBrowser(Contact, 
				columns=Contact.columns+[Address,Account])
	address = AddressBrowser(Address)
	displaycustomization = DisplayCustomizationBrowser( 
				displaycustom,columns=['entity','description']) browsecustomization = BrowseCustomizationBrowser( 
				browsecustom,columns=['entity','description'])
	@cherrypy.expose
	def index(self):
		return Root.logon.index(returnpage='../entities')
	@cherrypy.expose
	def entities(self):
		username = self.logon.checkauth()
	if username is None : raise HTTPRedirect('.')
	user=User.list(pattern=[('name',username)])
	if len(user) < 1 : User(name=username)
	return basepage%'''<div class="navigation">
	<a href="user">Users</a>
	<a href="displaycustomization">Customize Item</a> <a href="http://browsecustomization">Customize List</a>
	<a href="http://account">Accounts</a>
	<a href="contact">Contacts</a>
	<a href="http://address">Addresses</a>
	</div><div class="content">
	</div>
	<script src="img/browse.js" type="text/javascript"></script>
	'''

我们当然不仅限于客户端的操作。因为我们可以利用 jQuery 的所有 AJAX 功能,我们可以做相当复杂的事情。

我们的实体浏览器已经具有在单击一次时将行标记为选中的功能。然而,我们没有为这个选择实现任何有用的操作。

当我们最初实现Display类时,我们添加了一个delete()方法并将其暴露给 CherryPy 引擎。我们并没有以任何方式使用这个方法。现在,由于我们可以自定义实体浏览器,我们可以纠正这一点并实现一些功能,添加一个按钮,当点击时将删除所有选定的条目。请注意,在真实的应用程序中从一开始就提供这样的基本功能可能更有意义,但这确实展示了可能实现的功能。

添加删除按钮的时间

再次运行crm4.py,然后在定制列表菜单中添加一个适用于所有实体(因此标记为*)的项目,如下所示:

添加删除按钮的行动时间

例如,如果我们现在打开联系人列表,我们会看到一个带有垃圾桶图标的新按钮:

添加删除按钮的行动时间

刚才发生了什么?

我们添加的自定义包括一些 HTML 来定义<button>元素和一些 JavaScript 来将其渲染为漂亮的垃圾桶按钮并对其点击进行操作:

第十章/定制示例 4.html

<button class="delete">delete</button>
<script>
$("button.delete").click(function(){ var url = $("form").last().attr('action')+'/delete';
	$("tr.selected").each(function(i){
			var id=$(this).attr('id');
			$.get(url,{'id':id});
	});
	$("input[name=cacheid]").remove();
	$("button[name=first]").click();
	false;
}).button({icons: { primary: "ui-icon-trash" },text: false});
</script>

点击处理程序从实体列表中的最后一个表单中获取action属性(突出显示)。这个表单包含添加按钮,因此这个action属性将指向由Display实例的index()方法服务的 URL。我们只需将其添加delete即可使其指向将由delete()方法服务的 URL。

下一步是遍历所有具有selected类的<tr>元素,并使用 jQuery 的get()方法从添加为参数的<tr>元素中获取具有id属性的 URL。

最后,我们必须重新显示实体列表以显示删除的效果。如果列表已被过滤和/或排序,我们希望保留这些设置,但我们仍然必须删除包含cacheid的隐藏<input>元素,否则我们将展示旧列表。删除它后,我们触发第一个按钮的点击处理程序以启动重新加载。

与几乎每个 jQuery 方法一样,click()方法返回它被调用的选定元素,因此我们可以将button()方法链接到我们的按钮元素上,以添加适当的图标。

摘要

这最后一章全部关于完善我们的 CRM 应用程序。我们增强了用户界面以利用底层框架的排序和过滤功能,重用了框架本身来存储和管理用户自定义,并通过从 Google Maps 检索数据来增强项目显示和项目列表,展示了这些自定义的强大功能。

附录 A.资源参考

在不重复书中给出的每个参考的情况下,本附录列出了一些资源,这些资源提供了关于人们构建 Web 应用程序的各种感兴趣主题的良好和全面的信息。

良好的离线参考书籍

有时候,放下键盘,放松一下,读一些关于我们最喜欢的主题的书(或电子阅读器)是很不错的。以下是一些我经常参考的参考书籍(一些 ISBN 可能反映电子书版本):

尤其对于熟悉 Python 的人来说,拥有一些关于 JavaScript 和 jQuery 库的好书是非常方便的。以下三本书是很好的入门选择:

  • 《学习 JavaScript,第二版》,Shelley Powers 著,O'Reilly 出版社,978-0-596-52187-5

    对 JavaScript 基础的综合介绍。

  • 《jQuery 食谱》,Cody Lindley 著,O'Reilly 出版社,978-0-596-15977-1

    介绍了如何使用 jQuery 解决常见需求的大量实用示例。

  • 《jQuery UI 1.7》,Dan Wellman 著,Packt 出版社,978-1-847199-72-0

    对 jQuery UI 库的所有功能进行逐步解释,包括像拖放这样的高级功能。

Python 在网上有非常好的文档。特别是对与 Python 一起分发的标准模块的覆盖非常出色,但要深入了解语言本身以及 3.0 版本中添加的功能,这本书是一个很好的起点:《Python 3 编程》,Mark Summerfield 著,Addison Wesley 出版社,978-0-32168056-3

以下所有书籍都涵盖了在这本书中扮演重要角色的 Python 主题:

  • 《Python 测试入门指南》,Daniel Arbuckle 著,Packt 出版社,978-1847198-84-6

    测试不必困难,这本书展示了原因。

  • 《CherryPy 基础教程》,Sylvain Hellegouarch 著,Packt 出版社,978-1904811-84-8

    我们在这本书的示例中广泛使用的 CherryPy 应用程序服务器功能强大得多。由其主要开发者撰写,本书涵盖了所有功能,并提供了某些网络应用的实用示例。

  • 《使用 SQLite》,Jay A. Kreibich 著,O'Reilly 出版社,978-0-596-52118-9

    本书展示了 SQLite 的能力,甚至是对数据库设计和 SQL 使用的良好介绍。它不是针对 Python 的(SQLite 在许多地方比 Python 本身使用得更多)。

  • 《精通正则表达式,第三版》,O'Reilly 出版社,978-0-596-52812-6

    这本书涵盖了关于正则表达式的所有知识。它主要不是针对 Python 的,但由于 Python 的正则表达式库与 Perl 中的非常相似,几乎所有示例都可以直接在 Python 中使用。

  • 《CSS 精通》,Andy Budd 著,Friends of Ed 出版社,978-159059-614-2

    当然,jQuery UI 并没有涵盖所有样式问题,CSS 也可能很棘手。这本书是我找到的最易读的之一。

其他网站、维基和博客

关于书中使用的工具和资源的更多信息可以在网上找到。

工具和框架

新闻源

附录 B. 临时测验答案

第二章,创建一个简单的电子表格

使用 CherryPy 提供内容

答案:

index()方法重命名为content()

记住,为了服务由 URL 指定的内容,例如 127.0.0.1/content,CherryPy 会寻找在传递给 quickstart() 函数的对象实例中名为 content() 的方法。稍后,我们将看到也可以构建类层次结构,使 CherryPy 能够服务类似 http://127.0.0.1/app/toplevel/content 的 URL。

向按钮添加图标

答案:

$("button").button({icons: {primary: 'ui-icon-refresh'}})

与许多 jQuery 和 jQuery UI 插件一样,按钮小部件接受一个 options 对象作为参数。这个 options 对象可能有许多属性,其中之一是 icons 属性。这个属性的值本身也是一个对象,它的 primary 属性决定了将在按钮上显示的许多标准图标中的哪一个。请参阅按钮小部件的在线文档以查看所有选项:jqueryui.com/demos/button/,并查看 jQuery UI 的 themeroller 页面 jqueryui.com/themeroller/ 以了解给定主题的所有可用图标。

向 unitconverter 实例添加转换

答案:

$("#example").unitconverter({'cubic feet_litres':1.0/28.3168466 })

修改选项默认值

答案:b

第三章,任务列表 I:持久性

会话 ID

答案 1:

不,CherryPy 只有在准备响应时向会话数据写入某些内容时,才会将会话数据保存到持久存储中。如果收到未知的会话 ID,应用程序无法识别用户,并将此信号发送给客户端,但它不会在会话数据中存储任何内容。

答案 2:

c,因为不存储 cookie 的客户端永远不会发送包含会话 ID 的请求,服务器将生成一个新的。

屏幕元素样式

答案 1:

要么在传递给 button() 函数的 options 对象中省略 text:false,要么显式地使用 text:true 来显示它。

答案 2:

包围 <form> 元素的 <div> 元素可能更宽,不合适的背景颜色可能会在表单未覆盖完整宽度的地方显示。

第四章,任务列表 II:数据库和 AJAX

使用变量选择标准

答案:

cursor.execute('select * from task where user_id = ?',(username,))

一个可工作的实现可以在 taskdb3.py 中找到。请注意,由于查询中可能存在多个占位符,我们传递这些占位符的实际值作为元组。Python 语法的一个特性要求元组必须定义为包含逗号分隔的表达式的括号,即使只包含一个项目,元组也必须包含逗号。因此,(username,) 是一个只有一个项目的元组。

查找错误

答案:

test_number()

它将在第一个断言中失败,输出如下:

python.exe test_factorial.py
.F.
======================================================================
FAIL: test_number (__main__.Test)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_factorial.py", line 7, in test_number
self.assertEqual(24,fac(4))
AssertionError: 24 != 12
----------------------------------------------------------------------
Ran 3 tests in 0.094s
FAILED (failures=1)

代码仍然没有说明哪里出了问题,但现在你知道新的实现并没有正确地计算一个数的阶乘。这次解决方案可能并不难找到:range() 函数应该传递 2 作为其第一个参数,因为代码中只有 01 被视为特殊情况。

第五章,实体和关系

如何检查一个类

答案:

Python 的内置函数 issubclass() 可以提供我们需要的信息。例如,检查 instance_a 属性可能实现如下:

if not issubclass(instance_a, Entity) : raise TypeError()

如何选择有限数量的书籍

答案:

booksdb.list(offset=20,limit=10)

第六章,构建一个维基

突击测验

答案:

id,因为它也是唯一的。

posted @ 2025-09-18 14:37  绝不原创的飞龙  阅读(12)  评论(0)    收藏  举报