Django-1-1-测试和调试教程-全-

Django 1.1 测试和调试教程(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在软件开发过程中,错误是一个耗时的负担。Django 的内置测试框架和调试支持有助于减轻这一负担。本书将教你使用 Django 和 Python 工具快速高效地消除错误,并确保 Django 应用程序正常运行。

本书将逐步引导你开发一个完整的样本 Django 应用程序。你将学习如何最好地测试和调试模型、视图、URL 配置、模板和模板标签。本书将帮助你集成并利用 Python 和 Django 应用程序丰富的外部测试和调试工具环境。

本书从基本的测试概述开始。它将强调测试时需要注意的地方。你将了解到不同类型的测试,每种测试的优缺点,以及 Django 提供的测试扩展的细节,这些扩展简化了测试 Django 应用程序的任务。你将看到外部工具如何集成到 Django 的框架中,提供更复杂的测试功能。

在调试方面,本书说明了如何解释 Django 调试错误页面提供的大量调试信息,以及如何利用日志记录和其他外部工具来了解代码的运行情况。

这本书是一个逐步指南,教你如何使用 Django 的测试支持,并充分利用 Django 和 Python 调试工具。

本书内容

在第一章,“Django 测试概述”中,我们开始开发一个样本 Django 调查应用程序。描述并运行了 Django 自动生成的示例测试。介绍了运行测试的所有选项。

在第二章,“这段代码有效吗?深入了解 doctests”中,开发了样本应用程序使用的模型。通过示例说明了使用 doctests 来测试模型。讨论了 doctests 的优缺点。介绍了在 Django 应用程序中使用 doctests 的特定注意事项。

在第三章,“测试 1, 2, 3:基本单元测试”中,上一章实施的 doctests 被重新实施为单元测试,并根据上一章讨论的 doctests 的优缺点和注意事项进行评估。开发了需要使用测试数据的其他测试。演示了使用 fixture 文件加载此类数据。此外,还开发了一些不适合使用 fixture 文件的测试数据的测试。

在第四章,“变得更加花哨:Django 单元测试扩展”中,我们开始编写为应用程序提供网页的视图。测试的数量开始变得显著,因此本章首先展示了如何用一个 tests 目录替换单个tests.py文件,以便更好地组织测试。然后,开发了用于视图的测试,演示了 Django 提供的单元测试扩展如何简化测试 Web 应用程序的任务。通过开发本章中进行的管理自定义的测试,演示了测试表单行为。

第五章,“填空:集成 Django 和其他测试工具”,展示了 Django 如何支持将其他测试工具集成到其框架中。书中介绍了两个例子。第一个例子说明了如何使用附加应用程序来生成测试覆盖信息,而第二个例子演示了如何将twill测试工具(允许更轻松地测试表单行为)集成到 Django 应用程序测试中。

第六章,“Django 调试概述”,介绍了调试 Django 应用程序的主题。描述了所有与调试相关的设置。介绍了调试错误页面。描述了在调试打开时 Django 维护的数据库查询历史,以及开发服务器的功能,有助于调试。最后,详细介绍了在生产过程中(调试关闭时)发生的错误处理,并提到了确保捕获并发送有关此类错误信息的所有必要设置。

在第七章,“当车轮脱落时:理解 Django 调试页面”,继续开发示例应用程序,在这个过程中犯了一些典型的错误。这些错误导致了 Django 调试页面。描述了这些页面上提供的所有信息,并给出了在什么情况下最有帮助的部分的指导。深入讨论了几种不同类型的调试页面。

第八章,“当问题隐藏时:获取更多信息”,着重介绍了在问题不会导致调试错误页面的情况下如何获取有关代码行为的更多信息。它演示了开发模板标签以在呈现页面中嵌入视图的查询历史的过程,然后展示了如何使用 Django 调试工具栏来获取相同的信息,以及更多信息。最后,开发了一些日志记录工具。

第九章,“当您甚至不知道要记录什么时:使用调试器”,通过示例演示了如何使用 Python 调试器(pdb)来跟踪在没有调试页面出现甚至日志也无法帮助的情况下出现问题。通过示例演示了所有最有用的 pdb 命令。此外,我们还看到了如何使用 pdb 来确保对于受多进程竞争条件影响的代码的正确行为。

第十章,“当一切都失败时:寻求外部帮助”,描述了当迄今为止的技术都未能解决问题时该怎么办。可能是外部代码中的错误:提供了如何搜索以查看其他人是否有相同经历以及是否有任何修复的提示。可能是我们代码中的错误或对某些工作原理的误解;包括了提问的途径和写好问题的提示。

在第十一章,“当是时候上线了:转向生产”,我们将示例应用程序移入生产环境,使用 Apache 和mod_wsgi代替开发服务器。涵盖了在此步骤中遇到的几种常见问题。此外,还讨论了在开发过程中使用 Apache 与mod_wsgi的选项。

本书需要以下内容:

您需要一台运行 Django 1.1 版本的计算机——建议使用最新的 1.1.X 版本。您还需要一个编辑器来编辑代码文件和一个网络浏览器。您可以选择使用您最熟悉的操作系统、编辑和浏览工具,只要选择一个可以运行 Django 的操作系统。有关 Django 要求的更多信息,请参阅docs.djangoproject.com/en/1.1/intro/install/

供您参考,本书中的示例控制台输出和屏幕截图都来自一台运行以下内容的计算机:

  • Ubuntu 8.10

  • Python 2.5.2

  • Django 1.1(书中早期)和 1.1.1(书中后期)

  • Firefox 3.5.7

您可以使用 Django 支持的任何数据库。为了说明的目的,在本书的不同部分使用了不同的数据库(SQLite、MySQL、PostgreSQL)。您可能更愿意选择一个数据库来贯穿使用。

本书在特定的点使用了额外的软件。每当引入一个软件包时,都会包括有关在哪里获取它以进行安装的说明。供您参考,以下是本书中使用的额外软件包及其版本的列表:

  • 第五章 填补空白:集成 Django 和其他测试工具 使用:

  • coverage 3.2

  • django_coverage 1.0.1

  • twill 0.9(和最新的开发级别)

  • 第八章 当问题隐藏时:获取更多信息 使用:

  • django-debug-toolbar 0.8.0

  • 第九章 当你甚至不知道要记录什么:使用调试器 使用:

  • pygooglechart 0.2.0

  • matplotlib 0.98.3

  • 第十一章 当是时候上线了:转向生产 使用:

  • Apache 2.2

  • mod_wsgi 2.3

  • siege 2.6.6

请注意,当您开始阅读本书时,您不需要安装这些额外的软件包中的任何一个,它们可以在您想要开始使用它们的特定点上添加。列出的版本是书中显示的输出所使用的版本;预计稍后的版本也将起作用,尽管如果您使用更新的版本,产生的输出可能会略有不同。

本书的受众

如果您是一名 Django 应用程序开发人员,希望快速创建稳健的应用程序,并且长期易于维护,那么本书适合您。如果您希望聪明地学习如何充分利用 Django 丰富的测试和调试支持,并使开发变得轻松,那么本书是您的不二选择。

假定您具有 Python、Django 和基于数据库的 Web 应用程序的整体结构的基本知识。但是,代码示例已经得到充分解释,以便即使是对这个领域新手的初学者也可以从本书中学到很多知识。如果您是 Django 的新手,建议您在开始阅读本书之前先完成在线 Django 教程。

约定

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

文本中的代码词如下所示:“现在我们有了 Django 项目和应用的基本骨架:一个settings.py文件,一个urls.py文件,manage.py实用程序,以及一个包含模型、视图和测试的.py文件的survey目录。”

代码块设置如下:

__test__ = {"doctest": """
Another way to test that 1 + 1 is equal to 2.

>>> 1 + 1 == 2
True
"""}

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

urlpatterns = patterns('', 
    # Example: 
    # (r'^marketr/', include('marketr.foo.urls')), 

    # Uncomment the admin/doc line below and add # 'django.contrib.admindocs' 
    # to INSTALLED_APPS to enable admin documentation: 
    # (r'^admin/doc/', include('django.contrib.admindocs.urls')), 

    # Uncomment the next line to enable the admin: 
    (r'^admin/', include(admin.site.urls)), 
 (r'', include('survey.urls')), 
) 

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

kmt@lbox:/dj_projects$ django-admin.py startproject marketr

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这样的方式出现在文本中:“此下拉框包含我们可以搜索的所有票据属性的完整列表,例如报告人所有者状态组件。”

注意

警告或重要说明会以这样的方式出现在框中。

提示

提示和技巧会以这样的方式出现。

第一章:Django 测试概述

您如何知道您编写的代码是否按预期工作?好吧,您测试它。但是如何测试?对于 Web 应用程序,您可以通过手动在 Web 浏览器中打开应用程序的页面并验证它们是否正确来测试代码。这不仅涉及快速浏览以查看它们是否具有正确的内容,还必须确保例如所有链接都有效,任何表单都能正常工作等。正如您可以想象的那样,这种手动测试很快就会在应用程序增长到几个简单页面以上时变得不可靠。对于任何非平凡的应用程序,自动化测试是必不可少的。

Django 应用程序的自动化测试利用了 Python 语言内置的基本测试支持:doctests 和单元测试。当您使用manage.py startapp创建一个新的 Django 应用程序时,生成的文件之一包含一个样本 doctest 和单元测试,旨在加速您自己的测试编写。在本章中,我们将开始学习测试 Django 应用程序。具体来说,我们将:

  • 详细检查样本tests.py文件的内容,同时回顾 Python 测试支持的基本知识

  • 查看如何使用 Django 实用程序来运行tests.py中包含的测试

  • 学习如何解释测试的输出,无论测试成功还是失败

  • 审查可以在测试时使用的各种命令行选项的影响

入门:创建一个新应用程序

让我们开始创建一个新的 Django 项目和应用程序。为了在整本书中有一致的工作内容,让我们假设我们打算创建一个新的市场调研类型的网站。在这一点上,我们不需要对这个网站做出太多决定,只需要为 Django 项目和至少一个将包含的应用程序取一些名称。由于market_research有点长,让我们将其缩短为marketr作为项目名称。我们可以使用django-admin.py来创建一个新的 Django 项目:

kmt@lbox:/dj_projects$ django-admin.py startproject marketr

然后,从新的marketr目录中,我们可以使用manage.py实用程序创建一个新的 Django 应用程序。我们市场调研项目的核心应用程序之一将是一个调查应用程序,因此我们将从创建它开始:

kmt@lbox:/dj_projects/marketr$ python manage.py startapp survey

现在我们有了 Django 项目和应用程序的基本框架:settings.py文件,urls.py文件,manage.py实用程序,以及一个包含模型、视图和测试的survey目录。自动生成的模型和视图文件中没有实质性内容,但在tests.py文件中有两个样本测试:一个单元测试和一个 doctest。接下来我们将详细检查每个测试。

理解样本单元测试

单元测试是tests.py中包含的第一个测试,它开始于:

""" 
This file demonstrates two different styles of tests (one doctest and one unittest). These will both pass when you run "manage.py test". 

Replace these with more appropriate tests for your application. 
"""

from django.test import TestCase 

class SimpleTest(TestCase): 
    def test_basic_addition(self): 
        """ 
        Tests that 1 + 1 always equals 2\. 
        """ 
        self.failUnlessEqual(1 + 1, 2) 

单元测试从django.test中导入TestCase开始。django.test.TestCase类基于 Python 的unittest.TestCase,因此它提供了来自基础 Pythonunittest.TestCase的一切,以及对测试 Django 应用程序有用的功能。这些对unittest.TestCase的 Django 扩展将在第三章和第四章中详细介绍。这里的样本单元测试实际上并不需要任何支持,但是将样本测试用例基于 Django 类也没有坏处。

然后,样本单元测试声明了一个基于 Django 的TestCaseSimpleTest类,并在该类中定义了一个名为test_basic_addition的测试方法。该方法包含一条语句:

self.failUnlessEqual(1 + 1, 2)

正如你所期望的那样,该语句将导致测试用例报告失败,除非两个提供的参数相等。按照编码的方式,我们期望该测试会成功。我们将在本章稍后验证这一点,当我们实际运行测试时。但首先,让我们更仔细地看一下示例 doctest。

理解示例 doctest

示例tests.py的 doctest 部分是:

__test__ = {"doctest": """
Another way to test that 1 + 1 is equal to 2.

>>> 1 + 1 == 2
True
"""}

这看起来比单元测试部分更神秘。对于示例 doctest,声明了一个特殊变量__test__。这个变量被设置为包含一个键doctest的字典。这个键被设置为一个类似于包含注释后面的字符串值的 docstring,后面跟着一个看起来像是交互式 Python shell 会话的片段。

看起来像交互式 Python shell 会话的部分就是 doctest 的组成部分。也就是说,以>>>开头的行将在测试期间执行(减去>>>前缀),并且实际产生的输出将与 doctest 中以>>>开头的行下面找到的预期输出进行比较。如果任何实际输出与预期输出不匹配,则测试失败。对于这个示例测试,我们期望在交互式 Python shell 会话中输入1 + 1 == 2会导致解释器产生输出True,所以看起来这个示例测试应该通过。

请注意,doctests 不必通过使用特殊的__test__字典来定义。实际上,Python 的 doctest 测试运行器会查找文件中所有文档字符串中的 doctests。在 Python 中,文档字符串是模块、函数、类或方法定义中的第一条语句。鉴于此,你会期望在tests.py文件顶部的注释中找到的交互式 Python shell 会话片段也会作为 doctest 运行。这是我们开始运行这些测试后可以尝试的另一件事情。

运行示例测试

示例tests.py文件顶部的注释说明了两个测试:当你运行"manage.py test"时都会通过。所以让我们看看如果我们尝试那样会发生什么:

kmt@lbox:/dj_projects/marketr$ python manage.py test 
Creating test database... 
Traceback (most recent call last): 
 File "manage.py", line 11, in <module> 
 execute_manager(settings) 
 File "/usr/lib/python2.5/site-packages/django/core/management/__init__.py", line 362, in execute_manager 
 utility.execute() 
 File "/usr/lib/python2.5/site-packages/django/core/management/__init__.py", line 303, in execute 
 self.fetch_command(subcommand).run_from_argv(self.argv) 
 File "/usr/lib/python2.5/site-packages/django/core/management/base.py", line 195, in run_from_argv 
 self.execute(*args, **options.__dict__) 
 File "/usr/lib/python2.5/site-packages/django/core/management/base.py", line 222, in execute 
 output = self.handle(*args, **options) 
 File "/usr/lib/python2.5/site-packages/django/core/management/commands/test.py", line 23, in handle 
 failures = test_runner(test_labels, verbosity=verbosity, interactive=interactive) 
 File "/usr/lib/python2.5/site-packages/django/test/simple.py", line 191, in run_tests 
 connection.creation.create_test_db(verbosity, autoclobber=not interactive) 
 File "/usr/lib/python2.5/site-packages/django/db/backends/creation.py", line 327, in create_test_db 
 test_database_name = self._create_test_db(verbosity, autoclobber) 
 File "/usr/lib/python2.5/site-packages/django/db/backends/creation.py", line 363, in _create_test_db 
 cursor = self.connection.cursor() 
 File "/usr/lib/python2.5/site-packages/django/db/backends/dummy/base.py", line 15, in complain 
 raise ImproperlyConfigured, "You haven't set the DATABASE_ENGINE setting yet." 
django.core.exceptions.ImproperlyConfigured: You haven't set the DATABASE_ENGINE setting yet.

哎呀,我们似乎有点超前了。我们创建了新的 Django 项目和应用程序,但从未编辑设置文件以指定任何数据库信息。显然,我们需要这样做才能运行测试。

但测试是否会使用我们在settings.py中指定的生产数据库?这可能令人担忧,因为我们可能在某个时候在我们的测试中编写了一些我们不希望对我们的生产数据执行的操作。幸运的是,这不是问题。Django 测试运行器为运行测试创建了一个全新的数据库,使用它来运行测试,并在测试运行结束时删除它。这个数据库的名称是test_后跟settings.py中指定的DATABASE_NAME。因此,运行测试不会干扰生产数据。

为了运行示例tests.py文件,我们需要首先为DATABASE_ENGINEDATABASE_NAMEsettings.py中使用的数据库所需的其他任何内容设置适当的值。现在也是一个好时机将我们的survey应用程序和django.contrib.admin添加到INSTALLED_APPS中,因为我们在继续进行时会需要这两个。一旦这些更改已经在settings.py中进行了,manage.py test就能更好地工作:

kmt@lbox:/dj_projects/marketr$ python manage.py test 
Creating test database... 
Creating table auth_permission 
Creating table auth_group 
Creating table auth_user 
Creating table auth_message 
Creating table django_content_type 
Creating table django_session 
Creating table django_site 
Creating table django_admin_log 
Installing index for auth.Permission model 
Installing index for auth.Message model 
Installing index for admin.LogEntry model 
................................... 
---------------------------------------------------------------------- 
Ran 35 tests in 2.012s 

OK 
Destroying test database...

看起来不错。但到底测试了什么?在最后,它说Ran 35 tests,所以肯定运行了比我们简单的tests.py文件中的两个测试更多的测试。其他 33 个测试来自settings.py中默认列出的其他应用程序:auth、content types、sessions 和 sites。这些 Django“contrib”应用程序附带了它们自己的测试,并且默认情况下,manage.py test会运行INSTALLED_APPS中列出的所有应用程序的测试。

注意

请注意,如果您没有将django.contrib.admin添加到settings.py中的INSTALLED_APPS列表中,则manage.py test可能会报告一些测试失败。对于 Django 1.1,django.contrib.auth的一些测试依赖于django.contrib.admin也包含在INSTALLED_APPS中,以便测试通过。这种相互依赖关系可能会在将来得到修复,但是现在最简单的方法是从一开始就将django.contrib.admin包含在INTALLED_APPS中,以避免可能的错误。无论如何,我们很快就会想要使用它。

可以仅运行特定应用程序的测试。要做到这一点,在命令行上指定应用程序名称。例如,仅运行survey应用程序的测试:

kmt@lbox:/dj_projects/marketr$ python manage.py test survey 
Creating test database... 
Creating table auth_permission 
Creating table auth_group 
Creating table auth_user 
Creating table auth_message 
Creating table django_content_type 
Creating table django_session 
Creating table django_site 
Creating table django_admin_log 
Installing index for auth.Permission model 
Installing index for auth.Message model 
Installing index for admin.LogEntry model 
.. 
---------------------------------------------------------------------- 
Ran 2 tests in 0.039s 

OK 
Destroying test database... 

在这里——Ran 2 tests看起来适合我们的样本tests.py文件。但是关于创建表和安装索引的所有这些消息呢?为什么这些应用程序的表在不进行测试时被创建?这是因为测试运行程序不知道将要测试的应用程序与INSTALLED_APPS中列出的其他不打算进行测试的应用程序之间可能存在的依赖关系。

例如,我们的调查应用程序可能具有一个模型,其中包含对django.contrib.auth User模型的ForeignKey,并且调查应用程序的测试可能依赖于能够添加和查询User条目。如果测试运行程序忽略了对不进行测试的应用程序创建表,这将无法工作。因此,测试运行程序为INSTALLED_APPS中列出的所有应用程序创建表,即使不打算运行测试的应用程序也是如此。

我们现在知道如何运行测试,如何将测试限制在我们感兴趣的应用程序上,以及成功的测试运行是什么样子。但是,测试失败呢?在实际工作中,我们可能会遇到相当多的失败,因此确保我们了解测试输出在发生时的情况是很重要的。因此,在下一节中,我们将引入一些故意的破坏,以便我们可以探索失败的样子,并确保当我们遇到真正的失败时,我们将知道如何正确解释测试运行的报告。

故意破坏事物

让我们首先引入一个单一的简单失败。更改单元测试,期望将1 + 1加上3而不是2。也就是说,更改单元测试中的单个语句为:self.failUnlessEqual(1 + 1, 3)

现在当我们运行测试时,我们会得到一个失败:

kmt@lbox:/dj_projects/marketr$ python manage.py test
Creating test database... 
Creating table auth_permission 
Creating table auth_group 
Creating table auth_user 
Creating table auth_message 
Creating table django_content_type 
Creating table django_session 
Creating table django_site 
Creating table django_admin_log 
Installing index for auth.Permission model
Installing index for auth.Message model 
Installing index for admin.LogEntry model 
...........................F.......
====================================================================== 
FAIL: test_basic_addition (survey.tests.SimpleTest) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests.py", line 15, in test_basic_addition 
 self.failUnlessEqual(1 + 1, 3) 
AssertionError: 2 != 3 

---------------------------------------------------------------------- 
Ran 35 tests in 2.759s 

FAILED (failures=1) 
Destroying test database...

看起来相当简单。失败产生了一块以等号开头的输出,然后是失败的测试的具体内容。失败的方法被识别出来,以及包含它的类。有一个Traceback显示了生成失败的确切代码行,AssertionError显示了失败原因的细节。

注意等号上面的那一行——它包含一堆点和一个F。这是什么意思?这是我们在早期测试输出列表中忽略的一行。如果你现在回去看一下,你会发现在最后一个Installing index消息之后一直有一行点的数量。这行是在运行测试时生成的,打印的内容取决于测试结果。F表示测试失败,点表示测试通过。当有足够多的测试需要一段时间来运行时,这种实时进度更新可以帮助我们在运行过程中了解运行的情况。

最后,在测试输出的末尾,我们看到FAILED (failures=1)而不是之前看到的OK。任何测试失败都会使整体测试运行的结果变成失败,而不是成功。

接下来,让我们看看一个失败的 doctest 是什么样子。如果我们将单元测试恢复到其原始形式,并将 doctest 更改为期望 Python 解释器对1 + 1 == 3作出True的回应,那么运行测试(这次只限制在survey应用程序中进行测试)将产生以下输出:

kmt@lbox:/dj_projects/marketr$ python manage.py test survey 
Creating test database... 
Creating table auth_permission 
Creating table auth_group 
Creating table auth_user 
Creating table auth_message 
Creating table django_content_type 
Creating table django_session 
Creating table django_site 
Creating table django_admin_log 
Installing index for auth.Permission model 
Installing index for auth.Message model 
Installing index for admin.LogEntry model 
.F 
====================================================================== 
FAIL: Doctest: survey.tests.__test__.doctest 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/usr/lib/python2.5/site-packages/django/test/_doctest.py", line 2180, in runTest 
 raise self.failureException(self.format_failure(new.getvalue())) 
AssertionError: Failed doctest test for survey.tests.__test__.doctest 
 File "/dj_projects/marketr/survey/tests.py", line unknown line number, in doctest 

---------------------------------------------------------------------- 
File "/dj_projects/marketr/survey/tests.py", line ?, in survey.tests.__test__.doctest 
Failed example: 
 1 + 1 == 3 
Expected: 
 True 
Got: 
 False 

---------------------------------------------------------------------- 
Ran 2 tests in 0.054s 

FAILED (failures=1) 
Destroying test database... 

失败的 doctest 的输出比单元测试失败的输出稍微冗长,解释起来也没有那么直接。失败的 doctest 被标识为survey.tests.__test__.doctest——这意味着在survey/tests.py文件中定义的__test__字典中的doctest键。输出的Traceback部分不像在单元测试案例中那样有用,因为AssertionError只是指出 doctest 失败了。幸运的是,随后提供了导致失败的原因的详细信息,您可以看到导致失败的行的内容,期望的输出以及执行失败行产生的实际输出。

请注意,测试运行器没有准确定位tests.py中发生失败的行号。它报告了不同部分的未知行号第?行。这是 doctest 的一般问题还是这个特定 doctest 的定义方式的结果,作为__test__字典的一部分?我们可以通过在tests.py顶部的文档字符串中放置一个测试来回答这个问题。让我们将示例 doctest 恢复到其原始状态,并将文件顶部更改为如下所示:

""" 
This file demonstrates two different styles of tests (one doctest and one unittest). These will both pass when you run "manage.py test". 

Replace these with more appropriate tests for your application. 

>>> 1 + 1 == 3 
True
""" 

然后当我们运行测试时,我们得到:

kmt@lbox:/dj_projects/marketr$ python manage.py test survey 
Creating test database... 
Creating table auth_permission 
Creating table auth_group 
Creating table auth_user 
Creating table auth_message 
Creating table django_content_type 
Creating table django_session 
Creating table django_site 
Creating table django_admin_log 
Installing index for auth.Permission model 
Installing index for auth.Message model 
Installing index for admin.LogEntry model 
.F. 
====================================================================== 
FAIL: Doctest: survey.tests 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/usr/lib/python2.5/site-packages/django/test/_doctest.py", line 2180, in runTest 
 raise self.failureException(self.format_failure(new.getvalue())) 
AssertionError: Failed doctest test for survey.tests 
 File "/dj_projects/marketr/survey/tests.py", line 0, in tests 

---------------------------------------------------------------------- 
File "/dj_projects/marketr/survey/tests.py", line 7, in survey.tests 
Failed example: 
 1 + 1 == 3 
Expected: 
 True 
Got: 
 False 

---------------------------------------------------------------------- 
Ran 3 tests in 0.052s 

FAILED (failures=1) 
Destroying test database... 

这里提供了行号。Traceback部分显然标识了包含失败测试行的文档字符串开始的行的上面一行(文档字符串从第 1 行开始,而回溯报告第 0 行)。详细的失败输出标识了导致失败的文件中的实际行,本例中为第 7 行

无法准确定位行号因此是在__test__字典中定义 doctest 的副作用。虽然在我们简单的测试中很容易看出哪一行导致了问题,但在编写更实质性的 doctest 放置在__test__字典中时,这是需要牢记的事情。如果测试中的多行是相同的,并且其中一行导致失败,可能很难确定导致问题的确切行号,因为失败输出不会标识发生失败的具体行号。

到目前为止,我们在样本测试中引入的所有错误都涉及预期输出与实际结果不匹配。这些被报告为测试失败。除了测试失败,有时我们可能会遇到测试错误。接下来描述这些。

测试错误与测试失败

看看测试错误是什么样子,让我们删除上一节介绍的失败的 doctest,并在我们的样本单元测试中引入一种不同类型的错误。假设我们想要测试1 + 1是否等于文字2,而是想要测试它是否等于一个函数sum_args的结果,该函数应该返回其参数的总和。但我们会犯一个错误,忘记导入该函数。所以将self.failUnlessEqual改为:

self.failUnlessEqual(1 + 1, sum_args(1, 1))

现在当运行测试时,我们看到:

kmt@lbox:/dj_projects/marketr$ python manage.py test survey 
Creating test database... 
Creating table auth_permission 
Creating table auth_group 
Creating table auth_user 
Creating table auth_message 
Creating table django_content_type 
Creating table django_session 
Creating table django_site 
Creating table django_admin_log 
Installing index for auth.Permission model 
Installing index for auth.Message model 
Installing index for admin.LogEntry model 
E. 
====================================================================== 
ERROR: test_basic_addition (survey.tests.SimpleTest) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests.py", line 15, in test_basic_addition 
 self.failUnlessEqual(1 + 1, sum_args(1, 1)) 
NameError: global name 'sum_args' is not defined 

---------------------------------------------------------------------- 
Ran 2 tests in 0.041s 

FAILED (errors=1) 
Destroying test database... 

测试运行器在甚至比较1 + 1sum_args的返回值之前就遇到了异常,因为sum_args没有被导入。在这种情况下,错误在于测试本身,但如果sum_args中的代码引起问题,它仍然会被报告为错误,而不是失败。失败意味着实际结果与预期结果不匹配,而错误意味着在测试运行期间遇到了一些其他问题(异常)。错误可能暗示测试本身存在错误,但不一定必须意味着如此。

请注意,在 doctest 中发生的类似错误会报告为失败,而不是错误。例如,我们可以将 doctest 的1 + 1行更改为:

>>> 1 + 1 == sum_args(1, 1) 

然后运行测试,输出将是:

kmt@lbox:/dj_projects/marketr$ python manage.py test survey 
Creating test database... 
Creating table auth_permission 
Creating table auth_group 
Creating table auth_user 
Creating table auth_message 
Creating table django_content_type 
Creating table django_session 
Creating table django_site 
Creating table django_admin_log 
Installing index for auth.Permission model 
Installing index for auth.Message model 
Installing index for admin.LogEntry model 
EF 
====================================================================== 
ERROR: test_basic_addition (survey.tests.SimpleTest) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests.py", line 15, in test_basic_addition 
 self.failUnlessEqual(1 + 1, sum_args(1, 1)) 
NameError: global name 'sum_args' is not defined 

====================================================================== 
FAIL: Doctest: survey.tests.__test__.doctest 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/usr/lib/python2.5/site-packages/django/test/_doctest.py", line 2180, in runTest 
 raise self.failureException(self.format_failure(new.getvalue())) 
AssertionError: Failed doctest test for survey.tests.__test__.doctest 
 File "/dj_projects/marketr/survey/tests.py", line unknown line number, in doctest 

---------------------------------------------------------------------- 
File "/dj_projects/marketr/survey/tests.py", line ?, in survey.tests.__test__.doctest 
Failed example: 
 1 + 1 == sum_args(1, 1) 
Exception raised: 
 Traceback (most recent call last): 
 File "/usr/lib/python2.5/site-packages/django/test/_doctest.py", line 1267, in __run 
 compileflags, 1) in test.globs 
 File "<doctest survey.tests.__test__.doctest[0]>", line 1, in <module> 
 1 + 1 == sum_args(1, 1) 
 NameError: name 'sum_args' is not defined 

---------------------------------------------------------------------- 
Ran 2 tests in 0.044s 

FAILED (failures=1, errors=1) 
Destroying test database... 

因此,对于单元测试所做的错误与失败的区分并不一定适用于 doctests。因此,如果您的测试包括 doctests,则在最后打印的失败和错误计数摘要并不一定反映出产生意外结果的测试数量(单元测试失败计数)或出现其他错误的测试数量(单元测试错误计数)。但是,在任何情况下,都不希望出现失败或错误。最终目标是两者都为零,因此如果它们之间的差异有时有点模糊,那也没什么大不了的。不过,了解在什么情况下报告一个而不是另一个可能是有用的。

我们现在已经了解了如何运行测试,以及整体成功和一些失败和错误的结果是什么样子。接下来,我们将研究manage.py test命令支持的各种命令行选项。

运行测试的命令行选项

除了在命令行上指定要测试的确切应用程序之外,还有哪些控制manage.py test 行为的选项?找出的最简单方法是尝试使用--help选项运行命令:

kmt@lbox:/dj_projects/marketr$ python manage.py test --help
Usage: manage.py test [options] [appname ...]

Runs the test suite for the specified applications, or the entire site if no apps are specified.

Options:
 -v VERBOSITY, --verbosity=VERBOSITY
 Verbosity level; 0=minimal output, 1=normal output,
 2=all output
 --settings=SETTINGS   The Python path to a settings module, e.g.
 "myproject.settings.main". If this isn't provided, the
 DJANGO_SETTINGS_MODULE environment variable will 
 be used.
 --pythonpath=PYTHONPATH
 A directory to add to the Python path, e.g.
 "/home/djangoprojects/myproject".
 --traceback           Print traceback on exception
 --noinput             Tells Django to NOT prompt the user for input of 
 any kind.
 --version             show program's version number and exit
 -h, --help            show this help message and exit

让我们依次考虑每个(除了help,因为我们已经看到它的作用):

冗长度

冗长度是一个介于02之间的数字值。它控制测试产生多少输出。默认值为1,因此到目前为止我们看到的输出对应于指定-v 1--verbosity=1。将冗长度设置为0会抑制有关创建测试数据库和表的所有消息,但不包括摘要、失败或错误信息。如果我们纠正上一节引入的最后一个 doctest 失败,并重新运行指定-v0的测试,我们将看到:

kmt@lbox:/dj_projects/marketr$ python manage.py test survey -v0 
====================================================================== 
ERROR: test_basic_addition (survey.tests.SimpleTest) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests.py", line 15, in test_basic_addition 
 self.failUnlessEqual(1 + 1, sum_args(1, 1)) 
NameError: global name 'sum_args' is not defined 

---------------------------------------------------------------------- 
Ran 2 tests in 0.008s 

FAILED (errors=1) 

将冗长度设置为2会产生更多的输出。如果我们修复这个剩下的错误,并将冗长度设置为最高级别运行测试,我们将看到:

kmt@lbox:/dj_projects/marketr$ python manage.py test survey --verbosity=2 
Creating test database... 
Processing auth.Permission model 
Creating table auth_permission 
Processing auth.Group model 
Creating table auth_group 
 **[...more snipped...]**

**Creating many-to-many tables for auth.Group model** 
**Creating many-to-many tables for auth.User model** 
**Running post-sync handlers for application auth** 
**Adding permission 'auth | permission | Can add permission'** 
**Adding permission 'auth | permission | Can change permission'** 
 ****[...more snipped...]**

**No custom SQL for auth.Permission model** 
**No custom SQL for auth.Group model** 

**[...more snipped...]**
 ****Installing index for auth.Permission model** 
**Installing index for auth.Message model** 
**Installing index for admin.LogEntry model** 
**Loading 'initial_data' fixtures...** 
**Checking '/usr/lib/python2.5/site-packages/django/contrib/auth/fixtures' for fixtures...** 
**Trying '/usr/lib/python2.5/site-packages/django/contrib/auth/fixtures' for initial_data.xml fixture 'initial_data'...** 
**No xml fixture 'initial_data' in '/usr/lib/python2.5/site-packages/django/contrib/auth/fixtures'.** 

**[....much more snipped...]**
**No fixtures found.** 
**test_basic_addition (survey.tests.SimpleTest) ... ok** 
**Doctest: survey.tests.__test__.doctest ... ok** 

**----------------------------------------------------------------------** 
**Ran 2 tests in 0.004s** 

**OK** 
**Destroying test database...****** 

正如您所看到的,以这种详细程度,该命令报告了设置测试数据库所做的一切细节。除了我们之前看到的创建数据库表和索引之外,我们现在看到数据库设置阶段包括:

  1. 运行post-syncdb信号处理程序。例如,django.contrib.auth应用程序使用此信号在安装每个应用程序时自动添加模型的权限。因此,您会看到有关在为INSTALLED_APPS中列出的每个应用程序发送post-syncdb信号时创建权限的消息。

  2. 为数据库中已创建的每个模型运行自定义 SQL。根据输出,似乎INSTALLED_APPS中的任何应用程序都没有使用自定义 SQL。

  3. 加载initial_data fixtures。初始数据 fixtures 是一种自动预先填充数据库的常量数据的方法。我们在INSTALLED_APPS中列出的任何应用程序都没有使用此功能,但是测试运行程序会产生大量输出,因为它寻找初始数据 fixtures,这些 fixtures 可以在几种不同的名称下找到。对于每个被检查的可能文件以及是否找到任何内容,都会有消息。如果测试运行程序找到初始数据 fixtures 时遇到问题,这些输出可能会在某个时候派上用场(我们将在第三章中详细介绍 fixtures),但是目前这些输出并不是很有趣。

****一旦测试运行程序完成初始化数据库,它就会开始运行测试。在2的冗长级别下,我们之前看到的点、Fs 和 Es 的行会被每个测试的更详细的报告所取代。测试的名称被打印出来,然后是三个点,然后是测试结果,可能是okERRORFAIL。如果有任何错误或失败,它们发生的详细信息将在测试运行结束时打印出来。因此,当您观看冗长的测试运行时,设置冗长级别为2,您将能够看到哪些测试遇到了问题,但直到运行完成,您才能得到它们发生原因的详细信息。

设置

****您可以将设置选项传递给test命令,以指定要使用的设置文件,而不是项目默认的设置文件。例如,如果要使用与通常使用的数据库不同的数据库运行测试(无论是为了加快测试速度还是验证代码在不同数据库上是否正确运行),则可以派上用场。

****请注意,此选项的帮助文本说明DJANGO_SETTINGS_MODULE环境变量将用于定位设置文件,如果未在命令行上指定设置选项。当使用django-admin.py实用程序运行test命令时,这才是准确的。当使用manage.py test时,manage.py实用程序负责设置此环境变量以指定当前目录中的settings.py文件。

Pythonpath

****此选项允许您在测试运行期间将附加目录追加到 Python 路径中。当使用django-admin.py时,通常需要将项目路径添加到标准 Python 路径中。manage.py实用程序负责将项目路径添加到 Python 路径中,因此在使用manage.py test时通常不需要此选项。

Traceback

****实际上,test命令并不使用此选项。它作为所有django-admin.py(和manage.py)命令支持的默认选项之一而被继承,但test命令从不检查它。因此,您可以指定它,但它不会产生任何效果。

Noinput

**此选项导致测试运行程序不会提示用户输入,这引发了一个问题:测试运行程序何时需要用户输入?到目前为止,我们还没有遇到过。测试运行程序在测试数据库创建期间会提示用户输入,如果测试数据库名称已经存在。例如,如果在测试运行期间按下Ctrl + C,则测试数据库可能不会被销毁,下次尝试运行测试时可能会遇到类似以下消息:

****kmt@lbox:/dj_projects/marketr$ python manage.py test** 
**Creating test database...** 
**Got an error creating the test database: (1007, "Can't create database 'test_marketr'; database exists")** 
**Type 'yes' if you would like to try deleting the test database 'test_marketr', or 'no' to cancel:**** 

****如果在命令行上传递了--noinput,则不会打印提示,并且测试运行程序将继续进行,就好像用户已经输入了'yes'一样。如果要从无人值守脚本运行测试,并确保脚本不会在等待永远不会输入的用户输入时挂起,这将非常有用。

版本

此选项报告正在使用的 Django 版本,然后退出。因此,当使用--versionmanage.pydjango-admin.py一起使用时,实际上不需要指定test等子命令。实际上,由于 Django 处理命令选项的方式存在错误,在撰写本书时,如果同时指定--version和子命令,版本将被打印两次。这可能会在某个时候得到修复。

****# 摘要

Django 测试的概述现在已经完成。在本章中,我们:

  • 详细查看了在创建新的 Django 应用程序时生成的样本tests.py文件

  • 学习如何运行提供的样本测试

  • 尝试在测试中引入故意的错误,以查看和理解测试失败或遇到错误时提供的信息

  • 最后,我们检查了所有可能与manage.py test一起使用的命令行选项。

我们将在下一章继续建立这些知识,重点关注深入的 doctests。

第二章:这段代码有效吗?深入了解文档测试

在第一章中,我们学习了如何运行manage.py startapp创建的示例测试。虽然我们使用了 Django 实用程序来运行测试,但是示例测试本身与 Django 无关。在本章中,我们将开始详细介绍如何为 Django 应用程序编写测试。我们将:

  • 通过开发一些基本模型来开始编写第一章创建的市场调研项目

  • 尝试向其中一个模型添加文档测试

  • 开始学习哪些测试是有用的,哪些只会给代码增加混乱

  • 发现文档测试的一些优缺点

上一章提到了文档测试和单元测试,而本章的重点将专门放在文档测试上。开发 Django 应用程序的单元测试将是第三章和第四章的重点。

调查应用程序模型

开始开发新的 Django 应用程序的常见地方是从模型开始:这些数据的基本构建块将由应用程序进行操作和存储。我们示例市场调研survey应用程序的基石模型将是Survey模型。

Survey将类似于 Django 教程Poll模型,只是:

  • 教程Poll只包含一个问题,而Survey将有多个问题。

  • Survey将有一个标题用于参考目的。对于教程Poll,可以使用一个单一的问题。

  • Survey只会在有限的时间内(取决于Survey实例)开放回应。虽然Poll模型有一个pub_date字段,但它除了在索引页面上对Polls进行排序之外没有用。因此,Survey将需要两个日期字段,而Poll只有一个,Survey的日期字段将比Poll pub_date字段更常用。

只需这些简单的要求,我们就可以开始为Survey开发 Django 模型。具体来说,我们可以通过将以下内容添加到我们survey应用程序的自动生成的models.py文件中的代码来捕捉这些要求:

class Survey(models.Model): 
    title = models.CharField(max_length=60) 
    opens = models.DateField() 
    closes = models.DateField() 

请注意,由于Survey可能有多个问题,它没有一个问题字段。相反,有一个单独的模型Question,用于保存与其相关的调查实例的问题:

class Question(models.Model): 
    question = models.CharField(max_length=200) 
    survey = models.ForeignKey(Survey) 

我们需要的最终模型(至少是开始时)是一个用于保存每个问题的可能答案,并跟踪调查受访者选择每个答案的次数。这个模型Answer与教程Choice模型非常相似,只是它与Question相关联,而不是与Poll相关联:

class Answer(models.Model): 
    answer = models.CharField(max_length=200) 
    question = models.ForeignKey(Question) 
    votes = models.IntegerField(default=0) 

测试调查模型

如果你和我一样,在这一点上你可能想要开始验证到目前为止是否正确。的确,现在还没有太多的代码,但特别是在项目刚开始的时候,我喜欢确保我到目前为止的东西是有效的。那么,我们如何开始测试?首先,我们可以通过运行manage.py syncdb来验证我们没有语法错误,这也会让我们在 Python shell 中开始尝试这些模型。让我们来做吧。由于这是我们为这个项目第一次运行syncdb,我们将收到关于为INSTALLED_APPS中列出的其他应用程序创建表的消息,并且我们将被问及是否要创建超级用户,我们也可以继续做。

测试调查模型创建

现在,我们可以用这些模型做些什么来在 Python shell 中测试它们?实际上,除了创建每个模型之外,我们并没有太多可做的事情,也许可以验证一下,如果我们没有指定其中一个字段,我们会得到一个错误,或者正确的默认值被分配,并验证我们是否可以遍历模型之间的关系。如果我们首先关注Survey模型以及为了测试其创建而可能做的事情,那么 Python shell 会话可能看起来像这样:

kmt@lbox:/dj_projects/marketr$ python manage.py shell 
Python 2.5.2 (r252:60911, Oct  5 2008, 19:24:49) 
[GCC 4.3.2] on linux2 
Type "help", "copyright", "credits" or "license" for more information. 
(InteractiveConsole) 
>>> from survey.models import Survey 
>>> import datetime 
>>> t = 'First!'
>>> d = datetime.date.today()
>>> s = Survey.objects.create(title=t, opens=d, closes=d) 
>>>

在这里,我们首先导入了我们的Survey模型和 Python 的datetime模块,然后创建了一个变量t来保存一个标题字符串和一个变量d来保存一个日期值,并使用这些值创建了一个Survey实例。没有报告错误,所以看起来很好。

如果我们想验证一下,如果我们尝试创建一个没有关闭日期的Survey,我们会得到一个错误吗,我们将继续进行:

>>> s = Survey.objects.create(title=t, opens=d, closes=None) 
 File "<console>", line 1, in <module> 
 File "/usr/lib/python2.5/site-packages/django/db/models/manager.py", line 126, in create 
 return self.get_query_set().create(**kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/query.py", line 315, in create 
 obj.save(force_insert=True) 
 File "/usr/lib/python2.5/site-packages/django/db/models/base.py", line 410, in save 
 self.save_base(force_insert=force_insert, force_update=force_update) 
 File "/usr/lib/python2.5/site-packages/django/db/models/base.py", line 495, in save_base 
 result = manager._insert(values, return_id=update_pk) 
 File "/usr/lib/python2.5/site-packages/django/db/models/manager.py", line 177, in _insert 
 return insert_query(self.model, values, **kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/query.py", line 1087, in insert_query 
 return query.execute_sql(return_id) 
 File "/usr/lib/python2.5/site-packages/django/db/models/sql/subqueries.py", line 320, in execute_sql 
 cursor = super(InsertQuery, self).execute_sql(None) 
 File "/usr/lib/python2.5/site-packages/django/db/models/sql/query.py", line 2369, in execute_sql 
 cursor.execute(sql, params) 
 File "/usr/lib/python2.5/site-packages/django/db/backends/util.py", line 19, in execute 
 return self.cursor.execute(sql, params) 
 File "/usr/lib/python2.5/site-packages/django/db/backends/sqlite3/base.py", line 193, in execute 
 return Database.Cursor.execute(self, query, params) 
IntegrityError: survey_survey.closes may not be NULL 

在这里,我们尝试创建Survey实例的唯一不同之处是为closes值指定了None,而不是传入我们的日期变量d。结果是一个以IntegrityError结尾的错误消息,因为调查表的关闭列不能为 null。这证实了我们对应该发生的预期,所以到目前为止一切都很好。然后我们可以对其他字段执行类似的测试,并看到相同的回溯报告了其他列的IntegrityError

如果我们想的话,我们可以通过直接从 shell 会话中剪切和粘贴它们到我们的survey/models.py文件中,将这些测试变成我们模型定义的永久部分,就像这样:

import datetime
from django.db import models 

class Survey(models.Model): 
    """ 
    >>> t = 'First!' 
    >>> d = datetime.date.today() 
    >>> s = Survey.objects.create(title=t, opens=d, closes=d) 
    >>> s = Survey.objects.create(title=t, opens=d, closes=None) 
    Traceback (most recent call last): 
    ... 
    IntegrityError: survey_survey.closes may not be NULL 
    >>> s = Survey.objects.create(title=t, opens=None, closes=d) 
    Traceback (most recent call last): 
    ... 
    IntegrityError: survey_survey.opens may not be NULL 
    >>> s = Survey.objects.create(title=None, opens=d, closes=d) 
    Traceback (most recent call last): 
    ... 
    IntegrityError: survey_survey.title may not be NULL 
    """ 
    title = models.CharField(max_length=60) 
    opens = models.DateField() 
    closes = models.DateField()

您可能已经注意到,所显示的结果并不是直接从 shell 会话中剪切和粘贴的。差异包括:

  • import datetime被移出了 doctest,并成为models.py文件中的代码的一部分。这并不是严格必要的——如果作为 doctest 的一部分,它也可以正常工作,但是如果导入在主代码中,那么在 doctest 中就不是必要的。由于models.py中的代码可能需要稍后使用datetime函数,因此现在将导入放在主代码中可以减少稍后的重复和混乱,当主代码需要导入时。

  • 回溯的调用堆栈部分,也就是除了第一行和最后一行之外的所有内容,都被删除并替换为包含三个点的行。这也并不是严格必要的,只是为了去除杂乱,并突出结果的重要部分。doctest 运行器在决定测试成功或失败时会忽略调用堆栈的内容(如果预期输出中存在)。因此,如果调用堆栈具有一些解释价值,可以将其保留在测试中。然而,大部分情况下,最好删除调用堆栈,因为它们会产生大量杂乱,而提供的有用信息并不多。

如果我们现在运行manage.py test survey -v2,输出的最后部分将是:

No fixtures found. 
test_basic_addition (survey.tests.SimpleTest) ... ok 
Doctest: survey.models.Survey ... ok 
Doctest: survey.tests.__test__.doctest ... ok 

---------------------------------------------------------------------- 
Ran 3 tests in 0.030s 

OK 
Destroying test database... 

我们仍然在tests.py中运行我们的样本测试,现在我们还可以看到我们的survey.models.Survey doctest 被列为正在运行并通过。

那个测试有用吗?

但等等;我们刚刚添加的测试有用吗?它实际上在测试什么?实际上并没有什么,除了验证基本的 Django 函数是否按照广告那样工作。它测试我们是否可以创建我们定义的模型的实例,并且我们在模型定义中指定为必需的字段实际上在关联的数据库表中是必需的。看起来这个测试更像是在测试 Django 的底层代码,而不是我们的应用程序。在我们的应用程序中测试 Django 本身并不是必要的:Django 有自己的测试套件,我们可以运行它进行测试(尽管可以相当安全地假设基本功能在任何发布版本的 Django 中都能正确工作)。

可以说,这个测试验证了模型中每个字段是否已经指定了正确和预期的选项,因此这是对应用程序而不仅仅是底层 Django 函数的测试。然而,测试那些通过检查就很明显的事情(对于任何具有基本 Django 知识的人来说)让我觉得有点过分。这不是我通常会在自己写的项目中包含的测试。

这并不是说我在开发过程中不会在 Python shell 中尝试类似的事情:我会的,而且我也会。但是在开发过程中在 shell 中尝试的并不是所有东西都需要成为应用程序中的永久测试。您想要包含在应用程序中的测试类型是那些对应用程序独特行为进行测试的测试。因此,让我们开始开发一些调查应用程序代码,并在 Python shell 中进行测试。当我们的代码工作正常时,我们可以评估哪些来自 shell 会话的测试是有用的。

开发自定义调查保存方法

要开始编写一些特定于应用程序的代码,请考虑对于调查模型,如果在创建模型实例时没有指定closes,我们可能希望允许closes字段假定默认值为opens后的一周。我们不能使用 Django 模型字段默认选项,因为我们想要分配的值取决于模型中的另一个字段。因此,我们通常会通过覆盖模型的保存方法来实现这一点。首次尝试实现这一点可能是:

import datetime
from django.db import models  

class Survey(models.Model): 
    title = models.CharField(max_length=60) 
    opens = models.DateField() 
    closes = models.DateField() 

    def save(self, **kwargs): 
        if not self.pk and not self.closes: 
            self.closes = self.opens + datetime.timedelta(7) 
        super(Survey, self).save(**kwargs) 

也就是说,在调用save并且模型实例尚未分配主键(因此这是对数据库的第一次保存),并且没有指定closes的情况下,我们在调用超类save方法之前将closes赋予一个比opens晚一周的值。然后我们可以通过在 Python shell 中进行实验来测试这是否正常工作:

kmt@lbox:/dj_projects/marketr$ python manage.py shell 
Python 2.5.2 (r252:60911, Oct  5 2008, 19:24:49) 
[GCC 4.3.2] on linux2 
Type "help", "copyright", "credits" or "license" for more information. 
(InteractiveConsole) 
>>> from survey.models import Survey 
>>> import datetime 
>>> t = "New Year's Resolutions" 
>>> sd = datetime.date(2009, 12, 28) 
>>> s = Survey.objects.create(title=t, opens=sd) 
>>> s.closes 
datetime.date(2010, 1, 4) 
>>> 

这与我们之前的测试非常相似,只是我们选择了一个特定的日期来分配给opens,而不是使用今天的日期,并且在创建Survey实例时没有指定closes的值,我们检查了分配给它的值。显示的值比opens晚一周,所以看起来很好。

请注意,故意选择opens日期,其中一周后的值将在下个月和年份是一个明智的选择。测试边界值总是一个好主意,也是一个好习惯,即使(就像这里一样)我们正在编写的代码中没有任何东西负责为边界情况得到正确的答案。

接下来,我们可能希望确保如果我们指定了closes的值,它会被尊重,而不会被默认的一周后的日期覆盖:

>>> s = Survey.objects.create(title=t, opens=sd, closes=sd)
>>> s.opens 
datetime.date(2009, 12, 28) 
>>> s.closes 
datetime.date(2009, 12, 28) 
>>> 

所有看起来都很好,openscloses显示为具有相同的值,就像我们在create调用中指定的那样。我们还可以验证,如果我们在模型已经保存后将closes重置为None,然后尝试再次保存,我们会得到一个错误。在现有模型实例上将closes重置为None将是代码中的错误。因此,我们在这里测试的是我们的save方法重写不会通过悄悄地重新分配一个值给closes来隐藏该错误。在我们的 shell 会话中,我们可以这样继续并查看:

>>> s.closes = None 
>>> s.save() 
Traceback (most recent call last): 
 File "<console>", line 1, in <module> 
 File "/dj_projects/marketr/survey/models.py", line 12, in save 
 super(Survey, self).save(**kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/base.py", line 410, in save 
 self.save_base(force_insert=force_insert, force_update=force_update) 
 File "/usr/lib/python2.5/site-packages/django/db/models/base.py", line 474, in save_base 
 rows = manager.filter(pk=pk_val)._update(values) 
 File "/usr/lib/python2.5/site-packages/django/db/models/query.py", line 444, in _update 
 return query.execute_sql(None) 
 File "/usr/lib/python2.5/site-packages/django/db/models/sql/subqueries.py", line 120, in execute_sql 
 cursor = super(UpdateQuery, self).execute_sql(result_type) 
 File "/usr/lib/python2.5/site-packages/django/db/models/sql/query.py", line 2369, in execute_sql 
 cursor.execute(sql, params) 
 File "/usr/lib/python2.5/site-packages/django/db/backends/util.py", line 19, in execute 
 return self.cursor.execute(sql, params) 
 File "/usr/lib/python2.5/site-packages/django/db/backends/sqlite3/base.py", line 193, in execute 
 return Database.Cursor.execute(self, query, params) 
IntegrityError: survey_survey.closes may not be NULL 
>>> 

同样,这看起来很好,因为这是我们期望的结果。最后,由于我们已经将一些自己的代码插入到基本模型保存处理中,我们应该验证我们没有在create上没有指定titleopens字段的其他预期失败情况中出现问题。如果我们这样做,我们会发现没有指定title的情况下工作正常(我们在数据库标题列上得到了预期的IntegrityError),但如果openscloses都没有指定,我们会得到一个意外的错误:

>>> s = Survey.objects.create(title=t) 
Traceback (most recent call last): 
 File "<console>", line 1, in <module> 
 File "/usr/lib/python2.5/site-packages/django/db/models/manager.py", line 126, in create 
 return self.get_query_set().create(**kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/query.py", line 315, in create 
 obj.save(force_insert=True) 
 File "/dj_projects/marketr/survey/models.py", line 11, in save 
 self.closes = self.opens + datetime.timedelta(7) 
TypeError: unsupported operand type(s) for +: 'NoneType' and 'datetime.timedelta' 
>>> 

在这里,我们用一个相当晦涩的消息来报告我们留下了一个必需的值未指定的错误,而不是一个相当清晰的错误消息。问题是我们在尝试在save方法重写中使用opens之前没有检查它是否有值。为了获得这种情况下的正确(更清晰)错误,我们的save方法应该修改为如下所示:

    def save(self, **kwargs): 
        if not self.pk and self.opens and not self.closes: 
            self.closes = self.opens + datetime.timedelta(7) 
        super(Survey, self).save(**kwargs) 

也就是说,如果opens没有被指定,我们不应该尝试设置closes。在这种情况下,我们直接将save调用转发到超类,并让正常的错误路径报告问题。然后,当我们尝试创建一个没有指定openscloses值的Survey时,我们会看到:

>>> s = Survey.objects.create(title=t) 
Traceback (most recent call last): 
 File "<console>", line 1, in <module> 
 File "/usr/lib/python2.5/site-packages/django/db/models/manager.py", line 126, in create 
 return self.get_query_set().create(**kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/query.py", line 315, in create 
 obj.save(force_insert=True) 
 File "/dj_projects/marketr/survey/models.py", line 12, in save 
 super(Survey, self).save(**kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/base.py", line 410, in save 
 self.save_base(force_insert=force_insert, force_update=force_update) 
 File "/usr/lib/python2.5/site-packages/django/db/models/base.py", line 495, in save_base 
 result = manager._insert(values, return_id=update_pk) 
 File "/usr/lib/python2.5/site-packages/django/db/models/manager.py", line 177, in _insert 
 return insert_query(self.model, values, **kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/query.py", line 1087, in insert_query 
 return query.execute_sql(return_id) 
 File "/usr/lib/python2.5/site-packages/django/db/models/sql/subqueries.py", line 320, in execute_sql 
 cursor = super(InsertQuery, self).execute_sql(None) 
 File "/usr/lib/python2.5/site-packages/django/db/models/sql/query.py", line 2369, in execute_sql 
 cursor.execute(sql, params) 
 File "/usr/lib/python2.5/site-packages/django/db/backends/util.py", line 19, in execute 
 return self.cursor.execute(sql, params) 
 File "/usr/lib/python2.5/site-packages/django/db/backends/sqlite3/base.py", line 193, in execute 
 return Database.Cursor.execute(self, query, params) 
IntegrityError: survey_survey.opens may not be NULL 
>>> 

这样会好得多,因为报告的错误直接指出了问题所在。

决定测试什么

在这一点上,我们相当确定我们的save重写正在按我们的意图工作。在我们为验证目的在 Python shell 中运行的所有测试中,哪些测试有意义地包含在代码中?这个问题的答案涉及判断,并且不同的人可能会有不同的答案。就我个人而言,我倾向于包括:

  • 受代码直接影响的参数的所有测试

  • 在对代码进行初始测试时遇到的任何测试,这些测试在我编写的原始代码版本中没有起作用

因此,我的save重写函数,包括带有注释的 doctests,可能看起来像这样:

    def save(self, **kwargs): 
        """ 
        save override to allow for Survey instances to be created without explicitly specifying a closes date. If not specified, closes will be set to 7 days after opens. 
        >>> t = "New Year's Resolutions" 
        >>> sd = datetime.date(2009, 12, 28) 
        >>> s = Survey.objects.create(title=t, opens=sd) 
        >>> s.closes 
        datetime.date(2010, 1, 4) 

        If closes is specified, it will be honored and not auto-set. 

        >>> s = Survey.objects.create(title=t, opens=sd, closes=sd) 
        >>> s.closes 
        datetime.date(2009, 12, 28) 

        Any changes to closes after initial creation need to be explicit. Changing closes to None on an existing instance will not result in closes being reset to 7 days after opens. 

        >>> s.closes = None 
        >>> s.save() 
        Traceback (most recent call last): 
          ... 
        IntegrityError: survey_survey.closes may not be NULL 

        Making the mistake of specifying neither opens nor closes results in the expected IntegrityError for opens, not any exception in the code here. 

        >>> s = Survey.objects.create(title=t) 
        Traceback (most recent call last): 
          ... 
        IntegrityError: survey_survey.opens may not be NULL 
        """ 
        if not self.pk and self.opens and not self.closes: 
            self.closes = self.opens + datetime.timedelta(7) 
        super(Survey, self).save(**kwargs) 

到目前为止,doctests 的一些优缺点

即使只是通过研究这一个例子方法的经验,我们也可以开始看到 doctests 的一些优缺点。显然,可以很容易地重用在 Python shell 会话中完成的工作(这些工作很可能已经作为编码的一部分而被完成)用于永久测试目的。这使得更有可能为代码编写测试,并且测试本身不需要被调试。这是 doctests 的两个很好的优点。

第三个是 doctests 提供了代码预期行为的明确文档。散文描述可能模糊不清,而以测试形式的代码示例是不可能被误解的。此外,测试作为文档字符串的一部分,使它们可以被所有使用文档字符串自动生成帮助和文档的 Python 工具访问。

在这里包括测试有助于使文档完整。例如,将closes重置为None后的行为可能不明显,一个同样有效的设计是在save期间将closes重置为一周后的日期。在编写文档时很容易忽略这种细节。因此,在 doctest 中详细说明预期的行为是有帮助的,因为它会自动记录下来。

然而,这种测试兼作文档的特性也有一个缺点:您可能希望包括的一些测试实际上可能并不适合作为文档,并且您可能会得到一个对相当简单的代码而言文档过多的情况。考虑我们开发的save重写案例。它有四行代码和超过 30 行的文档字符串。这种比例对于一些具有许多参数或参数以非明显方式相互作用的复杂函数可能是合适的,但是对于这种简单的方法来说,文档比代码多近十倍似乎过多了。

让我们考虑save中的各个测试,重点是它们作为文档的有用性:

  • 第一个测试显示了使用titleopens创建Survey,但没有closes,并验证了在创建后将正确值分配给closes,这是save重写允许调用者执行的示例。这是通过添加的代码启用的特定调用模式,并且因此作为文档是有用的,即使它在很大程度上重复了散文描述。

  • 第二个测试显示了如果指定了closes,它将被遵守,这并不特别适合作为文档。任何程序员都会期望,如果指定了closes,它应该被遵守。这种行为可能适合测试,但不需要记录。

  • 第三个测试展示了在现有的Survey实例上将closes重置为Nonesave的预期行为,出于前面提到的原因,这对于文档来说是有用的。

  • 第四个和最后一个测试说明了添加的代码不会在未指定openscloses的错误情况下引发意外异常。这是另一个需要测试但不需要记录的例子,因为正确的行为是显而易见的。

将我们的文档字符串的一半分类为不适合文档目的是不好的。当人们遇到明显的、冗余的或无用的信息时,他们往往会停止阅读。我们可以通过将这些测试从文档字符串方法移到我们的tests.py文件中来解决这个问题,而不放弃 doctests 的一些优势。如果我们采取这种方法,我们可能会改变tests.py中的__test__字典,使其看起来像这样:

__test__ = {"survey_save": """ 

Tests for the Survey save override method. 

>>> import datetime 
>>> from survey.models import Survey 
>>> t = "New Year's Resolutions" 
>>> sd = datetime.date(2009, 12, 28) 

If closes is specified, it will be honored and not auto-set. 

>>> s = Survey.objects.create(title=t, opens=sd, closes=sd) 
>>> s.closes 
datetime.date(2009, 12, 28) 

Making the mistake of specifying neither opens nor closes results 
in the expected IntegrityError for opens, not any exception in the 
save override code itself. 

>>> s = Survey.objects.create(title=t) 
Traceback (most recent call last): 
  ... 
IntegrityError: survey_survey.opens may not be NULL 
"""} 

在这里,我们将测试的关键字从通用的doctest改为survey_save,这样任何测试输出中报告的测试名称都会给出被测试的提示。然后我们将“非文档”测试(以及现在需要在两个地方都设置的一些变量设置代码)从我们的save覆盖文档字符串中移到这里的键值中,并在顶部添加一般注释,说明测试的目的。

save方法本身的文档字符串中剩下的测试确实具有一定的文档价值:

    def save(self, **kwargs): 
        """ 
        save override to allow for Survey instances to be created without explicitly specifying a closes date. If not specified, closes will be set to 7 days after opens. 
        >>> t = "New Year's Resolutions" 
        >>> sd = datetime.date(2009, 12, 28) 
        >>> s = Survey.objects.create(title=t, opens=sd) 
        >>> s.closes 
        datetime.date(2010, 1, 4) 

        Any changes to closes after initial creation need to be explicit. Changing closes to None on an existing instance will not result in closes being reset to 7 days after opens. 

        >>> s.closes = None 
        >>> s.save() 
        Traceback (most recent call last): 
          ... 
        IntegrityError: survey_survey.closes may not be NULL 

        """ 
        if not self.pk and self.opens and not self.closes: 
            self.closes = self.opens + datetime.timedelta(7) 
        super(Survey, self).save(**kwargs) 

这对于函数的文档字符串来说肯定更容易管理,不太可能会让在 Python shell 中键入help(Survey.save)的人感到不知所措。

这种方法也有其不利之处。代码的测试不再集中在一个地方,很难知道或轻松确定代码被完全测试了多少。如果有人在tests.py中遇到测试,却不知道方法的文档字符串中还有额外的测试,很可能会想知道为什么只测试了这两个边缘情况,为什么忽略了基本功能的直接测试。

此外,当添加测试时,可能不清楚(特别是对于新加入项目的程序员)新测试应该放在哪里。因此,即使项目一开始在文档字符串测试中有一个很好的清晰分割,“适合文档的测试”和“必要但不适合文档的测试”在tests.py文件中,随着时间的推移,这种区别可能很容易变得模糊。

因此,测试选择和放置涉及权衡。并不是每个项目都有“正确”的答案。然而,采用一致的方法是最好的。在选择这种方法时,每个项目团队都应该考虑诸如以下问题的答案:

  • 自动生成的基于文档字符串的文档的预期受众是谁?

如果存在其他文档(或正在编写),预期它们将成为代码“使用者”的主要来源,那么具有不太好的文档功能的 doctests 可能并不是问题。

  • 可能会有多少人在代码上工作?

如果人数相对较少且稳定,让每个人记住测试分散在两个地方可能不是什么大问题。对于一个较大的项目或者如果开发人员流动性较高,教育开发人员关于这种分割可能会成为更大的问题,而且可能更难维护一致的代码。

附加的 doctest 注意事项

Doctests 还有一些我们可能还没有遇到或注意到的额外缺点。其中一些只是我们需要注意的事项,如果我们想确保我们的 doctests 在各种环境中能正常工作,并且在我们的代码周围的代码发生变化时。其他更严重的问题最容易通过切换到单元测试而不是 doctests 来解决,至少对受影响的测试来说是这样。在本节中,我们将列出许多需要注意的额外 doctest 问题,并提供关于如何避免或克服这些问题的指导。

注意环境依赖

doctests 很容易无意中依赖于实际被测试的代码以外的代码的实现细节。我们在save覆盖测试中已经有了一些这样的情况,尽管我们还没有被这个问题绊倒。我们现在所面临的依赖实际上是一种非常特定的环境依赖——数据库依赖。由于数据库依赖本身就是一个相当大的问题,它将在下一节中详细讨论。然而,我们首先将介绍一些其他可能会遇到的次要环境依赖,并看看如何避免将它们包含在我们的测试中。

一种极其常见的环境依赖形式是依赖于对象的打印表示。例如,__unicode__方法是首先在模型类中实现的常见方法。它在之前的Survey模型讨论中被省略,因为那时并不需要,但实际上我们可能会在save覆盖之前实现__unicode__。对于Survey的第一次尝试__unicode__方法可能看起来像这样:

    def __unicode__(self): 
        return u'%s (Opens %s, closes %s)' % (self.title, self.opens, self.closes) 

在这里,我们决定Survey实例的打印表示将由标题值后跟括号中的有关此调查何时开放和关闭的注释组成。鉴于该方法的定义,我们在测试创建实例时正确设置closes时的 shell 会话可能看起来像这样:

>>> from survey.models import Survey 
>>> import datetime 
>>> sd = datetime.date(2009, 12, 28) 
>>> t = "New Year's Resolutions" 
>>> s = Survey.objects.create(title=t, opens=sd) 
>>> s 
<Survey: New Year's Resolutions (Opens 2009-12-28, closes 2010-01-04)> 
>>> 

也就是说,我们可能不是专门检查closes分配的值,而是显示已创建实例的打印表示,因为它包括closes的值。在 shell 会话中进行实验时,自然而然地会以这种方式进行检查,而不是直接询问相关属性。首先,这样做更短(ss.closes更容易输入)。此外,它通常显示的信息比我们可能正在测试的特定部分更多,这在我们进行实验时是有帮助的。

然而,如果我们直接从 shell 会话中复制并粘贴到我们的save覆盖 doctest 中,我们就会使该 doctest 依赖于__unicode__的实现细节。随后,我们可能会决定不想在Survey的可打印表示中包含所有这些信息,甚至只是认为如果“Opens”中的“o”不大写会看起来更好。因此,我们对__unicode__方法的实现进行了微小的更改,突然间一个与其他方法无关的 doctest 开始失败了。

====================================================================== 
FAIL: Doctest: survey.models.Survey.save 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/usr/lib/python2.5/site-packages/django/test/_doctest.py", line 2189, in runTest 
 raise self.failureException(self.format_failure(new.getvalue())) 
AssertionError: Failed doctest test for survey.models.Survey.save 
 File "/dj_projects/marketr/survey/models.py", line 9, in save 

---------------------------------------------------------------------- 
File "/dj_projects/marketr/survey/models.py", line 32, in survey.models.Survey.save 
Failed example: 
 s 
Expected: 
 <Survey: New Year's Resolutions (Opens 2009-12-28, closes 2010-01-04)> 
Got: 
 <Survey: New Year's Resolutions (opens 2009-12-28, closes 2010-01-04)> 

---------------------------------------------------------------------- 
Ran 3 tests in 0.076s 

FAILED (failures=1) 
Destroying test database... 

因此,在从 shell 会话创建 doctests 时,需要仔细考虑会话是否依赖于被测试的代码以外的任何代码的实现细节,并相应地进行调整以消除这种依赖。在这种情况下,使用s.closes来测试closes被赋予了什么值,消除了对Survey模型__unicode__方法实现方式的依赖。

在 doctests 中可能会出现许多其他环境依赖的情况,包括:

  • 任何依赖于文件路径打印表示的测试都可能会遇到问题,因为在基于 Unix 的操作系统上,路径组件由正斜杠分隔,而 Windows 使用反斜杠。如果需要包含依赖于文件路径值的 doctests,可能需要使用实用函数来规范不同操作系统上的文件路径表示。

  • 任何依赖于字典键以特定顺序打印的测试都可能会遇到一个问题,即这个顺序在不同操作系统或 Python 实现中可能是不同的。因此,为了使这些测试在不同平台上更加健壮,可能需要专门查询字典键值,而不仅仅是打印整个字典内容,或者使用一个实用函数,为打印表示应用一致的顺序到键上。

关于这些在 doctests 中经常出现的环境依赖问题,没有什么特别与 Django 相关的内容。然而,在 Django 应用程序中特别容易出现一种环境依赖:数据库依赖。接下来将讨论这个问题。

警惕数据库依赖

Django 的对象关系管理器ORM)非常费力地屏蔽应用程序代码与底层数据库的差异。但是,让所有不同的支持的数据库在所有情况下看起来完全相同对 Django 来说是不可行的。因此,在应用程序级别可能观察到特定于数据库的差异。这些差异可能很容易进入 doctests,使得测试依赖于特定的数据库后端才能通过。

这种依赖已经存在于本章早期开发的save覆盖测试中。因为 SQLite 是最容易使用的数据库(因为它不需要安装或配置),所以到目前为止,示例代码和测试都是使用settings.py中的DATABASE_ENGINE = 'sqlite3'设置开发的。如果我们切换到使用 MySQL(DATABASE_ENGINE = 'mysql')作为数据库,并尝试运行我们的survey应用程序测试,我们将看到失败。有两个失败,但我们首先只关注测试输出中的最后一个:

====================================================================== 
FAIL: Doctest: survey.tests.__test__.survey_save 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/usr/lib/python2.5/site-packages/django/test/_doctest.py", line 2189, in runTest 
 raise self.failureException(self.format_failure(new.getvalue())) 
AssertionError: Failed doctest test for survey.tests.__test__.survey_save 
 File "/dj_projects/marketr/survey/tests.py", line unknown line number, in survey_save 

---------------------------------------------------------------------- 
File "/dj_projects/marketr/survey/tests.py", line ?, in survey.tests.__test__.survey_save 
Failed example: 
 s = Survey.objects.create(title=t) 
Expected: 
 Traceback (most recent call last): 
 ... 
 IntegrityError: survey_survey.opens may not be NULL 
Got: 
 Traceback (most recent call last): 
 File "/usr/lib/python2.5/site-packages/django/test/_doctest.py", line 1274, in __run 
 compileflags, 1) in test.globs 
 File "<doctest survey.tests.__test__.survey_save[6]>", line 1, in <module> 
 s = Survey.objects.create(title=t) 
 File "/usr/lib/python2.5/site-packages/django/db/models/manager.py", line 126, in create 
 return self.get_query_set().create(**kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/query.py", line 315, in create 
 obj.save(force_insert=True) 
 File "/dj_projects/marketr/survey/models.py", line 34, in save 
 super(Survey, self).save(**kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/base.py", line 410, in save 
 self.save_base(force_insert=force_insert, force_update=force_update) 
 File "/usr/lib/python2.5/site-packages/django/db/models/base.py", line 495, in save_base 
 result = manager._insert(values, return_id=update_pk) 
 File "/usr/lib/python2.5/site-packages/django/db/models/manager.py", line 177, in _insert 
 return insert_query(self.model, values, **kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/query.py", line 1087, in insert_query 
 return query.execute_sql(return_id) 
 File "/usr/lib/python2.5/site-packages/django/db/models/sql/subqueries.py", line 320, in execute_sql 
 cursor = super(InsertQuery, self).execute_sql(None) 
 File "/usr/lib/python2.5/site-packages/django/db/models/sql/query.py", line 2369, in execute_sql 
 cursor.execute(sql, params) 
 File "/usr/lib/python2.5/site-packages/django/db/backends/mysql/base.py", line 89, in execute 
 raise Database.IntegrityError(tuple(e)) 
 IntegrityError: (1048, "Column 'opens' cannot be null") 

---------------------------------------------------------------------- 
Ran 3 tests in 0.434s 

FAILED (failures=2) 
Destroying test database... 

这里的问题是什么?在tests.py中的 doctest 中的save调用中没有为opens指定值,预期会出现IntegrityError,而确实出现了IntegrityError,但IntegrityError消息的细节是不同的。SQLite 数据库返回:

 IntegrityError: survey_survey.opens may not be NULL 

MySQL 以稍微不同的方式表达了同样的观点:

 IntegrityError: (1048, "Column 'opens' cannot be null") 

有两种简单的方法可以解决这个问题。一种是在失败的测试上使用 doctest 指令IGNORE_EXCEPTION_DETAIL。使用此选项,doctest 运行程序在确定预期结果是否与实际结果匹配时,只会考虑异常的类型(在本例中为IntegrityError)。因此,不同数据库产生的确切异常消息的差异不会导致测试失败。

通过在包含测试的行上将 doctest 指令指定为单个测试来指定。注释以doctest:开头,后面跟着一个或多个指令名称,前面是+表示打开选项,-表示关闭选项。因此,在这种情况下,我们将更改tests.py中失败的测试行为(请注意,尽管此行在此页面上换行到第二行,但在测试中需要保持在一行上):

>>> s = Survey.objects.create(title=t) # doctest: +IGNORE_EXCEPTION_DETAIL 

另一种修复方法是用省略号替换测试中预期输出的详细消息部分,省略号是一个省略标记。也就是说,将测试更改为:

>>> s = Survey.objects.create(title=t) 
Traceback (most recent call last): 
  ... 
IntegrityError: ... 

这是告诉 doctest 运行器忽略异常消息的具体方法。它依赖于 doctest 选项ELLIPSIS在 doctest 运行时被启用。虽然这个选项在 Python 中默认情况下是不启用的,但是 Django 使用的 doctest 运行器启用了它,所以你不需要在你的测试代码中做任何事情来启用期望输出中的省略号标记。还要注意,ELLIPSIS不仅仅适用于异常消息的细节;它是一种更一般的方法,让你指示 doctest 输出的部分可能因运行而异,而不会导致测试失败。

注意

如果你阅读了ELLIPSIS的 Python 文档,你可能会注意到它是在 Python 2.4 中引入的。因此,如果你正在运行 Python 2.3(这仍然是 Django 1.1 支持的),你可能会期望在你的 Django 应用程序的 doctests 中无法使用省略号标记技术。然而,Django 1.0 和 1.1 附带了一个定制的 doctest 运行器,当你运行你的应用程序的 doctests 时会使用它。这个定制的运行器是基于 Python 2.4 附带的 doctest 模块的。因此,即使你运行的是早期的 Python 版本,你也可以使用 Python 2.4 中的 doctest 选项,比如ELLIPSIS

注意,尽管 Django 使用自己定制的 doctest 运行器的另一面是:如果你运行的 Python 版本比 2.4 更新,你不能在应用程序的 doctests 中使用比 2.4 更晚添加的 doctest 选项。例如,Python 在 Python 2.5 中添加了SKIP选项。在 Django 更新其定制的 doctest 模块的版本之前,你将无法在 Django 应用程序的 doctests 中使用这个新选项。

回想一下,有两次测试失败,我们只看了其中一个的输出(另一个很可能滚动得太快,无法阅读)。然而,考虑到我们检查过的一个失败,我们可能期望另一个也是一样的,因为在models.py的 doctest 中,我们对IntegrityError有一个非常相似的测试:

        >>> s.closes = None 
        >>> s.save() 
        Traceback (most recent call last): 
          ... 
        IntegrityError: survey_survey.closes may not be NULL 

这肯定也需要被修复以忽略异常细节,所以我们可能会同时做这两件事,并且可能会纠正两个测试失败。事实上,当我们在将两个预期的IntegrityErrors都更改为包含省略号标记而不是具体错误消息后再次运行测试时,所有的测试都通过了。

注意

请注意,对于某些 MySQL 的配置,忽略异常细节将无法纠正第二个测试失败。具体来说,如果 MySQL 服务器配置为以“非严格”模式运行,尝试将行更新为包含NULL值的列声明为NOT NULL不会引发错误。相反,该值将设置为列类型的隐式默认值,并发出警告。

很可能,如果你正在使用 MySQL,你会想要配置它以在“严格模式”下运行。然而,如果由于某种原因你不能这样做,并且你需要在你的应用程序中有这样一个测试,并且你需要测试在多个数据库上通过,你将不得不考虑在你的测试中考虑数据库行为的差异。这是可以做到的,但在单元测试中更容易完成,而不是在 doctest 中,所以我们不会讨论如何修复这种情况的 doctest。

现在我们已经让我们的测试在两个不同的数据库后端上通过了,我们可能会认为我们已经准备好了,并且可能会在 Django 支持的所有数据库上获得一个干净的测试运行。我们错了,当我们尝试使用 PostgreSQL 作为数据库运行相同的测试时,我们会发现数据库的差异,这突出了在编写 doctests 时需要注意的下一项内容,并在下一节中进行了介绍。

注意测试之间的相互依赖

如果我们现在尝试使用 PostgreSQL 作为数据库运行我们的测试(在settings.py中指定DATABASE_ENGINE = 'postgresql_psycopg2'),我们会得到一个非常奇怪的结果。从manage.py test survey -v2的输出的末尾,我们看到:

No fixtures found. 
test_basic_addition (survey.tests.SimpleTest) ... ok 
Doctest: survey.models.Survey.save ... ok 
Doctest: survey.tests.__test__.survey_save ... FAIL 

我们仍然在tests.py中有一个样本单元测试运行并通过,然后models.py中的 doctest 也通过了,但我们添加到tests.py中的 doctest 失败了。失败的细节是:

====================================================================== 
FAIL: Doctest: survey.tests.__test__.survey_save 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/usr/lib/python2.5/site-packages/django/test/_doctest.py", line 2189, in runTest 
 raise self.failureException(self.format_failure(new.getvalue())) 
AssertionError: Failed doctest test for survey.tests.__test__.survey_save 
 File "/dj_projects/marketr/survey/tests.py", line unknown line number, in survey_save 

---------------------------------------------------------------------- 
File "/dj_projects/marketr/survey/tests.py", line ?, in survey.tests.__test__.survey_save 
Failed example: 
 s = Survey.objects.create(title=t, opens=sd, closes=sd) 
Exception raised: 
 Traceback (most recent call last): 
 File "/usr/lib/python2.5/site-packages/django/test/_doctest.py", line 1274, in __run 
 compileflags, 1) in test.globs 
 File "<doctest survey.tests.__test__.survey_save[4]>", line 1, in <module> 
 s = Survey.objects.create(title=t, opens=sd, closes=sd) 
 File "/usr/lib/python2.5/site-packages/django/db/models/manager.py", line 126, in create 
 return self.get_query_set().create(**kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/query.py", line 315, in create 
 obj.save(force_insert=True) 
 File "/dj_projects/marketr/survey/models.py", line 34, in save 
 super(Survey, self).save(**kwargs)
 File "/usr/lib/python2.5/site-packages/django/db/models/base.py", line 410, in save 
 self.save_base(force_insert=force_insert, force_update=force_update) 
 File "/usr/lib/python2.5/site-packages/django/db/models/base.py", line 495, in save_base 
 result = manager._insert(values, return_id=update_pk) 
 File "/usr/lib/python2.5/site-packages/django/db/models/manager.py", line 177, in _insert 
 return insert_query(self.model, values, **kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/query.py", line 1087, in insert_query 
 return query.execute_sql(return_id) 
 File "/usr/lib/python2.5/site-packages/django/db/models/sql/subqueries.py", line 320, in execute_sql 
 cursor = super(InsertQuery, self).execute_sql(None) 
 File "/usr/lib/python2.5/site-packages/django/db/models/sql/query.py", line 2369, in execute_sql 
 cursor.execute(sql, params) 
 InternalError: current transaction is aborted, commands ignored until end of transaction block 

---------------------------------------------------------------------- 
File "/dj_projects/marketr/survey/tests.py", line ?, in survey.tests.__test__.survey_save 
Failed example: 
 s.closes 
Exception raised: 
 Traceback (most recent call last): 
 File "/usr/lib/python2.5/site-packages/django/test/_doctest.py", line 1274, in __run 
 compileflags, 1) in test.globs 
 File "<doctest survey.tests.__test__.survey_save[5]>", line 1, in <module> 
 s.closes 
 NameError: name 's' is not defined 
 ****----------------------------------------------------------------------** 
**Ran 3 tests in 0.807s** 
 ****FAILED (failures=1)** 
**Destroying test database...****** 

这次我们需要按顺序检查报告的错误,因为第二个错误是由第一个错误导致的。这种错误的链接是常见的,因此要记住,虽然从测试运行结束时最容易看到的最后一个失败开始可能很诱人,但这可能不是最有效的方法。如果不立即明显导致最后一个失败的原因,通常最好从头开始,找出导致第一个失败的原因。随后的失败原因可能会变得明显。供参考,正在失败的测试的开头是:

**>>> import datetime 
>>> from survey.models import Survey 
>>> t = "New Year's Resolutions" 
>>> sd = datetime.date(2009, 12, 28) 

If closes is specified, it will be honored and not auto-set. 

>>> s = Survey.objects.create(title=t, opens=sd, closes=sd) 
>>> s.closes 
datetime.date(2009, 12, 28)** 

因此,根据测试输出,这个测试中对数据库的第一次访问——也就是尝试创建Survey实例——导致了错误。

****InternalError: current transaction is aborted, commands ignored until end of transaction block****

然后,测试的下一行也会导致错误,因为它使用了应该在上一行中分配的变量s。然而,那一行没有完成执行,所以当测试尝试使用它时,变量s没有被定义。因此,第二个错误是有道理的,考虑到第一个错误,但为什么这个测试中的第一个数据库访问会导致错误呢?

为了理解这一点的解释,我们必须回顾一下紧接在这个测试之前运行的测试。从测试输出中我们可以看到,紧接在这个测试之前的测试是models.py中的 doctest。该测试的结尾是:

 **>>> s.closes = None 
        >>> s.save() 
        Traceback (most recent call last): 
          ... 
        IntegrityError: ... 
        """** 

测试的最后一件事是预期引发数据库错误的事情。在 PostgreSQL 上的一个副作用是,数据库连接进入了一个状态,只允许结束事务块的命令。因此,这个测试结束时,数据库连接处于一个破碎的状态,当下一个 doctest 开始运行时,它仍然处于破碎状态,导致下一个 doctest 在尝试任何数据库访问时立即失败。

这个问题说明了 doctests 之间没有数据库隔离。一个 doctest 对数据库的操作可以被后续运行的 doctest 观察到。这包括在数据库表中创建、更新或删除行的问题,以及在这里看到的问题。这个特定的问题可以通过在故意引起数据库错误的代码后添加一个回滚当前事务的调用来解决。

 **>>> s.closes = None 
        >>> s.save() 
        Traceback (most recent call last): 
          ... 
        IntegrityError: ... 
        >>> from django.db import transaction 
        >>> transaction.rollback() 
        """** 

这将允许测试在 PostgreSQL 上通过,并且在其他数据库后端上是无害的。因此,处理 doctests 中没有数据库隔离的一种方法是编写代码,使它们在自己之后进行清理。这可能是一个可以接受的方法,但如果测试已经在数据库中添加、修改或删除了对象,可能很难将一切恢复到最初的状态。

第二种方法是在每个 doctest 进入时将数据库重置为已知状态。Django 不会为您执行此操作,但您可以通过调用管理命令来手动执行。我通常不建议这种方法,因为随着应用程序的增长,它变得非常耗时。

第三种方法是使 doctests 在数据库状态上相对宽容,这样它们可能会在其他测试是否运行过的情况下正常运行。在这里使用的技术包括:

  • 在测试本身创建测试所需的所有对象。也就是说,不要依赖于任何先前运行的测试创建的对象的存在,因为该测试可能会更改,或被删除,或测试运行的顺序可能会在某个时候更改。

  • 在创建对象时,要防止与其他测试可能创建的相似对象发生冲突。例如,如果一个测试需要创建一个is_superuser字段设置为TrueUser实例,以便测试具有该属性的用户的某些行为,那么给User实例一个username为"superuser"可能是很自然的。然而,如果两个 doctest 都这样做了,那么不幸的是第二个运行的测试会遇到错误,因为User模型的username字段被声明为唯一,所以第二次尝试使用这个username创建User会失败。因此,最好使用在共享模型中不太可能被其他测试使用的唯一字段的值。

所有这些方法和技术都有其缺点。对于这个特定问题,单元测试是一个更好的解决方案,因为它们可以自动提供数据库隔离,而不会产生重置数据库的性能成本(只要在支持事务的数据库上运行)。因此,如果你开始遇到很多 doctest 的测试相互依赖的问题,我强烈建议考虑单元测试作为解决方案,而不是依赖于这里列出的任何方法。

谨防 Unicode

我们将在 doctest 注意事项中涵盖的最后一个问题是 Unicode。如果你在 Django(甚至只是 Python)中使用了比英语更广泛的字符集的数据,你可能已经遇到过UnicodeDecodeErrorUnicodeEncodeError一两次。因此,你可能已经养成了在测试中包含一些非 ASCII 字符的习惯,以确保一切都能正常工作,不仅仅是英语。这是一个好习惯,但不幸的是,在 doctest 中使用 Unicode 值进行测试会出现一些意想不到的故障,需要克服。

先前提到的Survey__unicode__方法可能是我们希望在面对非 ASCII 字符时测试其行为是否正确的一个地方。对此进行测试的第一步可能是:

 **def __unicode__(self): 
        """ 
        >>> t = u'¿Como está usted?' 
        >>> sd = datetime.date(2009, 12, 28) 
        >>> s = Survey.objects.create(title=t, opens=sd) 
        >>> print s 
        ¿Como está usted? (opens 2009-12-28, closes 2010-01-04) 
        """ 
        return u'%s (opens %s, closes %s)' % (self.title, self.opens, self.closes)** 

这个测试与许多保存覆盖测试类似,因为它首先创建了一个Survey实例。在这种情况下,重要的参数是标题,它被指定为 Unicode 文字字符串,并包含非 ASCII 字符。创建了Survey实例后,调用打印它以验证非 ASCII 字符在实例的打印表示中是否正确显示,并且没有引发 Unicode 异常。

这个测试效果如何?不太好。在添加了那段代码后,尝试运行调查测试会导致错误:

****kmt@lbox:/dj_projects/marketr$ python manage.py test survey** 
**Traceback (most recent call last):** 
 **File "manage.py", line 11, in <module>** 
 **execute_manager(settings)** 
 **File "/usr/lib/python2.5/site-packages/django/core/management/__init__.py", line 362, in execute_manager** 
 **utility.execute()** 
 **File "/usr/lib/python2.5/site-packages/django/core/management/__init__.py", line 303, in execute** 
 **self.fetch_command(subcommand).run_from_argv(self.argv)** 
 **File "/usr/lib/python2.5/site-packages/django/core/management/base.py", line 195, in run_from_argv** 
 **self.execute(*args, **options.__dict__)** 
 **File "/usr/lib/python2.5/site-packages/django/core/management/base.py", line 222, in execute** 
 **output = self.handle(*args, **options)** 
 **File "/usr/lib/python2.5/site-packages/django/core/management/commands/test.py", line 23, in handle** 
 **failures = test_runner(test_labels, verbosity=verbosity, interactive=interactive)** 
 **File "/usr/lib/python2.5/site-packages/django/test/simple.py", line 178, in run_tests** 
 **app = get_app(label)** 
 **File "/usr/lib/python2.5/site-packages/django/db/models/loading.py", line 114, in get_app** 
 **self._populate()** 
 **File "/usr/lib/python2.5/site-packages/django/db/models/loading.py", line 58, in _populate** 
 **self.load_app(app_name, True)** 
 **File "/usr/lib/python2.5/site-packages/django/db/models/loading.py", line 74, in load_app** 
 **models = import_module('.models', app_name)** 
 **File "/usr/lib/python2.5/site-packages/django/utils/importlib.py", line 35, in import_module** 
 **__import__(name)** 
 **File "/dj_projects/marketr/survey/models.py", line 40** 
**SyntaxError: Non-ASCII character '\xc2' in file /dj_projects/marketr/survey/models.py on line 41, but no encoding declared; see http://www.python.org/peps/pep-0263.html for details**** 

这个很容易解决;我们只是忘记了声明 Python 源文件的编码。为了做到这一点,我们需要在文件顶部添加一个注释行,指定文件使用的编码。假设我们使用 UTF-8 编码,所以我们应该将以下内容添加为我们的models.py文件的第一行:

**# -*- encoding: utf-8 -*-** 

现在新的测试会起作用吗?还没有,我们仍然失败了:

****======================================================================** 
**FAIL: Doctest: survey.models.Survey.__unicode__** 
**----------------------------------------------------------------------** 
**Traceback (most recent call last):** 
 **File "/usr/lib/python2.5/site-packages/django/test/_doctest.py", line 2180, in runTest** 
 **raise self.failureException(self.format_failure(new.getvalue()))** 
**AssertionError: Failed doctest test for survey.models.Survey.__unicode__** 
 **File "/dj_projects/marketr/survey/models.py", line 39, in __unicode__** 

**----------------------------------------------------------------------** 
**File "/dj_projects/marketr/survey/models.py", line 44, in survey.models.Survey.__unicode__** 
**Failed example:** 
 **print s** 
**Expected:** 
 **¿Como está usted? (opens 2009-12-28, closes 2010-01-04)** 
**Got:** 
 **¿Como está usted? (opens 2009-12-28, closes 2010-01-04)** 

**----------------------------------------------------------------------** 
**Ran 4 tests in 0.084s** 

**FAILED (failures=1)** 
**Destroying test database...**** 

这个有点令人费解。虽然我们在测试中将标题指定为 Unicode 文字字符串u'¿Como está usted?',但打印出来时显然是¿Como está usted?。这种数据损坏是错误地使用了错误的编码将字节字符串转换为 Unicode 字符串的明显迹象。事实上,这里的损坏特性,即原始字符串中的每个非 ASCII 字符在损坏版本中被两个(或更多)字符替换,是实际上以 UTF-8 编码的字符串被解释为如果它是以 ISO-8859-1(也称为 Latin-1)编码的特征。但是这里怎么会发生这种情况,因为我们指定了 UTF-8 作为我们的 Python 文件编码声明?为什么这个字符串会使用其他编码来解释?

此时,我们可能会去仔细阅读我们收到的第一个错误消息中引用的网页,并了解到我们添加的编码声明只影响 Python 解释器从源文件构造 Unicode 文字字符串的方式。然后我们可能会注意到,尽管我们的标题是一个 Unicode 文字字符串,但包含 doctest 的文档字符串却不是。因此,也许这个奇怪的结果是因为我们忽略了将包含 doctest 的文档字符串作为 Unicode 文字字符串。因此,我们下一个版本的测试可能是将整个文档字符串指定为 Unicode 文字字符串。

不幸的是,这也将是不成功的,因为存在 Unicode 文字文档字符串的问题。首先,doctest 运行器无法正确比较预期输出(现在是 Unicode,因为文档字符串本身是 Unicode 文字)和包含非 ASCII 字符的字节串的实际输出。这样的字节串必须转换为 Unicode 以进行比较。当必要时,Python 将自动执行此转换,但问题在于它不知道正在转换的字节串的实际编码。因此,它假定为 ASCII,并且如果字节串包含任何非 ASCII 字符,则无法执行转换。

这种转换失败将导致涉及字节串的比较被假定为失败,进而导致测试被报告为失败。即使预期和接收到的输出是相同的,如果只假定了字节串的正确编码,也没有办法使正确的编码被使用,因此测试将失败。对于Survey模型__unicode__ doctest,这个问题将导致在尝试比较print s的实际输出(这将是一个 UTF-8 编码的字节串)和预期输出时测试失败。

Unicode 文字文档字符串的第二个问题涉及包含非 ASCII 字符的输出的报告,例如在Survey模型__unicode__ doctest 中将发生的失败。doctest 运行器将尝试显示一个消息,显示预期和接收到的输出。然而,当它尝试将预期和接收到的输出合并成一个用于显示的单个消息时,它将遇到与比较期间遇到的相同问题。因此,与其生成一个至少能够显示测试遇到问题的消息,doctest 运行器本身会生成UnicodeDecodeError

Python 的 bug 跟踪器中有一个未解决的 Python 问题报告了这些问题:bugs.python.org/issue1293741。在它被修复之前,最好避免在 doctests 中使用 Unicode 文字文档字符串。

那么,有没有办法在 doctests 中包含一些非 ASCII 数据的测试?是的,这是可能的。使这样的测试起作用的关键是避免在文档字符串中使用 Unicode 文字。而是显式将字符串解码为 Unicode 对象。例如:

 **def __unicode__(self): 
        """ 
        >>> t = '¿Como está usted?'.decode('utf-8') 
        >>> sd = datetime.date(2009, 12, 28) 
        >>> s = Survey.objects.create(title=t, opens=sd) 
        >>> print s 
        ¿Como está usted? (opens 2009-12-28, closes 2010-01-04) 
        """ 
        return u'%s (opens %s, closes %s)' % (self.title, self.opens, self.closes)** 

也就是说,用一个明确使用 UTF-8 解码的字节串替换 Unicode 文字标题字符串,以创建一个 Unicode 字符串。

这样做有用吗?现在运行manage.py test survey -v2,我们在输出的最后看到以下内容:

****No fixtures found.** 
**test_basic_addition (survey.tests.SimpleTest) ... ok** 
**Doctest: survey.models.Survey.__unicode__ ... ok** 
**Doctest: survey.models.Survey.save ... ok** 
**Doctest: survey.tests.__test__.survey_save ... ok** 

**----------------------------------------------------------------------** 
**Ran 4 tests in 0.046s** 

**OK** 
**Destroying test database...**** 

成功!因此,在 doctests 中正确测试非 ASCII 数据是可能的。只需注意避免遇到使用 Unicode 文字文档字符串或在 doctest 中嵌入 Unicode 文字字符串相关的现有问题。

总结

我们对 Django 应用程序的 doctests 的探索现在已经完成。在本章中,我们:

  • 开始为我们的 Djangosurvey应用程序开发一些模型

  • 尝试向其中一个模型添加 doctests——Survey模型

  • 了解了哪些类型的 doctests 是有用的,哪些只是为代码添加了混乱

  • 体验了 doctests 的一些优势,即轻松重用 Python shell 会话工作和方便地将 doctests 用作文档

  • 遇到了许多 doctests 的缺点,并学会了如何避免或克服它们

在下一章中,我们将开始探索单元测试。虽然单元测试可能不提供一些 doctests 的轻松重用功能,但它们也不会受到许多 doctests 的缺点的影响。此外,整体的单元测试框架允许 Django 提供特别适用于 Web 应用程序的便利支持,这将在第四章中详细介绍。

第三章:测试 1, 2, 3:基本单元测试

在上一章中,我们开始通过为Survey模型编写一些 doctests 来学习测试 Django 应用程序。在这个过程中,我们体验了 doctests 的一些优点和缺点。在讨论一些缺点时,提到了单元测试作为避免一些 doctest 陷阱的替代测试方法。在本章中,我们将开始详细学习单元测试。具体来说,我们将:

  • Survey的 doctests 重新实现为单元测试

  • 评估等效的单元测试版本在实现的便利性和对上一章讨论的 doctest 注意事项的敏感性方面与 doctests 相比如何

  • 在扩展现有测试以覆盖其他功能时,开始学习单元测试的一些附加功能

Survey保存覆盖方法的单元测试

回想在上一章中,我们最终实现了对Survey保存覆盖功能的四个单独测试:

  • 对添加的功能进行直接测试,验证如果在创建Survey时未指定closes,则自动设置为opens之后的一周

  • 验证如果在创建时明确指定了closes,则不会执行此自动设置操作的测试

  • 验证只有在初始创建时其值缺失时,才会自动设置closes的测试

  • 验证save覆盖功能在创建时既未指定opens也未指定closes的错误情况下不会引入意外异常的测试

要将这些实现为单元测试而不是 doctests,请在suvery/tests.py文件中创建一个TestCase,替换示例SimpleTest。在新的TestCase类中,将每个单独的测试定义为该TestCase中的单独测试方法,如下所示:

import datetime
from django.test import TestCase 
from django.db import IntegrityError 
from survey.models import Survey 

class SurveySaveTest(TestCase): 
    t = "New Year's Resolutions" 
    sd = datetime.date(2009, 12, 28) 

    def testClosesAutoset(self): 
        s = Survey.objects.create(title=self.t, opens=self.sd) 
        self.assertEqual(s.closes, datetime.date(2010, 1, 4))

    def testClosesHonored(self):
        s = Survey.objects.create(title=self.t, opens=self.sd, closes=self.sd) 
        self.assertEqual(s.closes, self.sd) 

    def testClosesReset(self): 
        s = Survey.objects.create(title=self.t, opens=self.sd) 
        s.closes = None 
        self.assertRaises(IntegrityError, s.save) 

    def testTitleOnly(self): 
        self.assertRaises(IntegrityError, Survey.objects.create, title=self.t) 

这比 doctest 版本更难实现,不是吗?无法直接从 shell 会话中剪切和粘贴,需要添加大量代码开销——在 shell 会话中没有出现的代码。我们仍然可以从 shell 会话中剪切和粘贴作为起点,但是我们必须在粘贴后编辑代码,以将粘贴的代码转换为适当的单元测试。虽然不难,但可能会很乏味。

大部分额外工作包括选择各个测试方法的名称,对剪切和粘贴的代码进行微小编辑以正确引用类变量,如tsd,以及创建适当的测试断言来验证预期结果。其中第一个需要最多的脑力(选择好的名称可能很难),第二个是微不足道的,第三个是相当机械的。例如,在我们的 shell 会话中:

>>> s.closes 
datetime.date(2010, 1, 4) 
>>> 

在单元测试中,我们有一个assertEqual

self.assertEqual(s.closes, datetime.date(2010, 1, 4))

预期的异常类似,但使用assertRaises。例如,在 shell 会话中,我们有:

>>> s = Survey.objects.create(title=t) 
Traceback (most recent call last): 
 [ traceback details snipped ]
IntegrityError: survey_survey.opens may not be NULL 
>>> 

在单元测试中,这是:

self.assertRaises(IntegrityError, Survey.objects.create, title=self.t)

请注意,我们实际上没有在我们的单元测试代码中调用create例程,而是将其留给assertRaises内的代码。传递给assertRaises的第一个参数是预期的异常,后跟可预期引发异常的可调用对象,后跟在调用它时需要传递给可调用对象的任何参数。

单元测试版本的优点

从这项额外工作中我们得到了什么?当以最高详细级别运行时,我们从测试运行器中获得了更多反馈。对于 doctest 版本,manage.py test survey -v2的输出只是:

Doctest: survey.models.Survey.save ... ok 

在单元测试中,我们为每个测试方法报告单独的结果:

testClosesAutoset (survey.tests.SurveySaveTest) ... ok 
testClosesHonored (survey.tests.SurveySaveTest) ... ok 
testClosesReset (survey.tests.SurveySaveTest) ... ok 
testTitleOnly (survey.tests.SurveySaveTest) ... ok 

如果我们再付出一点努力,并为我们的测试方法提供单行文档字符串,我们甚至可以从测试运行器中获得更详细的结果。例如,如果我们这样添加文档字符串:

class SurveySaveTest(TestCase): 
    """Tests for the Survey save override method""" 
    t = "New Year's Resolutions" 
    sd = datetime.date(2009, 12, 28) 

    def testClosesAutoset(self): 
        """Verify closes is autoset correctly""" 
        s = Survey.objects.create(title=self.t, opens=self.sd) 
        self.assertEqual(s.closes, datetime.date(2010, 1, 4)) 

    def testClosesHonored(self): 
        """Verify closes is honored if specified""" 
        s = Survey.objects.create(title=self.t, opens=self.sd, closes=self.sd) 
        self.assertEqual(s.closes, self.sd)

    def testClosesReset(self): 
        """Verify closes is only autoset during initial create""" 
        s = Survey.objects.create(title=self.t, opens=self.sd) 
        s.closes = None 
        self.assertRaises(IntegrityError, s.save) 

    def testTitleOnly(self): 
        """Verify correct exception is raised in error case""" 
        self.assertRaises(IntegrityError, Survey.objects.create, title=self.t) 

然后,此测试的测试运行器输出将是:

Verify closes is autoset correctly ... ok 
Verify closes is honored if specified ... ok 
Verify closes is only autoset during initial create ... ok 
Verify correct exception is raised in error case ... ok 

这种额外的描述性细节在所有测试通过时可能并不那么重要,但当测试失败时,它可能非常有助于作为测试试图实现的线索。

例如,假设我们已经破坏了save覆盖方法,忽略了向opens添加七天,因此如果未指定closes,它将自动设置为与opens相同的值。使用测试的 doctest 版本,失败将被报告为:

====================================================================== 
FAIL: Doctest: survey.models.Survey.save 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/usr/lib/python2.5/site-packages/django/test/_doctest.py", line 2180, in runTest 
 raise self.failureException(self.format_failure(new.getvalue())) 
AssertionError: Failed doctest test for survey.models.Survey.save 
 File "/dj_projects/marketr/survey/models.py", line 10, in save 

---------------------------------------------------------------------- 
File "/dj_projects/marketr/survey/models.py", line 19, in survey.models.Survey.save 
Failed example: 
 s.closes 
Expected: 
 datetime.date(2010, 1, 4) 
Got: 
 datetime.date(2009, 12, 28) 

这并没有提供有关出了什么问题的详细信息,您真的必须阅读完整的测试代码才能看到正在测试什么。与单元测试报告的相同失败更具描述性,因为FAIL标题包括测试文档字符串,因此我们立即知道问题与closes的自动设置有关:

====================================================================== 
FAIL: Verify closes is autoset correctly 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests.py", line 20, in testClosesAutoset 
 self.assertEqual(s.closes, datetime.date(2010, 1, 4)) 
AssertionError: datetime.date(2009, 12, 28) != datetime.date(2010, 1, 4) 

我们可以进一步迈出一步,通过在调用assertEqual时指定自己的错误消息,使错误消息更友好:

    def testClosesAutoset(self):
        """Verify closes is autoset correctly"""
        s = Survey.objects.create(title=self.t, opens=self.sd)
        self.assertEqual(s.closes, datetime.date(2010, 1, 4), 
            "closes not autoset to 7 days after opens, expected %s, ""actually %s" % 
            (datetime.date(2010, 1, 4), s.closes))

然后报告的失败将是:

====================================================================== 
FAIL: Verify closes is autoset correctly 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests.py", line 22, in testClosesAutoset 
 (datetime.date(2010, 1, 4), s.closes)) 
AssertionError: closes not autoset to 7 days after opens, expected 2010-01-04, actually 2009-12-28 

在这种情况下,自定义错误消息可能并不比默认消息更有用,因为这里save覆盖应该做的事情非常简单。然而,对于更复杂的测试断言,这样的自定义错误消息可能是有价值的,以帮助解释正在测试的内容以及预期结果背后的“为什么”。

单元测试的另一个好处是,它们允许比 doctests 更有选择性地执行测试。在manage.py test命令行上,可以通过TestCase名称标识要执行的一个或多个单元测试。甚至可以指定只运行TestCase中的特定方法。例如:

python manage.py test survey.SurveySaveTest.testClosesAutoset 

在这里,我们指示只想在survey应用程序中找到的SurveySaveTest单元测试中运行testClosesAutoset测试方法。在开发测试时,能够仅运行单个方法或单个测试用例是非常方便的时间节省器。

单元测试版本的缺点

切换到单元测试是否有所损失?有一点。首先,已经提到的实施便利性:单元测试需要比 doctests 更多的工作来实施。虽然通常不是困难的工作,但可能会很乏味。这也是可能出现错误的工作,导致需要调试测试代码。这种增加的实施负担可能会阻止编写全面的测试。

我们还失去了将测试与代码放在一起的好处。在上一章中提到,这是将一些 doctests 从文档字符串移出并放入tests.py中的__test__字典的一个负面影响。由于单元测试通常保存在与被测试的代码分开的文件中,因此通常看不到靠近代码的测试,这可能会阻止编写测试。使用单元测试时,除非采用测试驱动开发等方法,否则“视而不见”效应很容易导致编写测试成为事后想法。

最后,我们失去了 doctest 版本的内置文档。这不仅仅是来自文档字符串的自动生成文档的潜力。Doctests 通常比单元测试更易读,其中只是测试开销的多余代码可能会掩盖测试的意图。但请注意,使用单元测试并不意味着您必须放弃 doctests;在应用程序中同时使用这两种测试是完全可以的。每种测试都有其优势,因此对于许多项目来说,最好是在所有测试中使用一种类型,而不是依赖单一类型。

重新审视 doctest 的注意事项

在上一章中,我们列出了编写文档测试时需要注意的事项。在讨论这些事项时,有时会提到单元测试作为一个不会遇到相同问题的替代方法。但是单元测试是否真的免疫于这些问题,还是只是使问题更容易避免或解决?在本节中,我们重新审视文档测试的警告,并考虑单元测试对相同或类似问题的敏感程度。

环境依赖

讨论的第一个文档测试警告是环境依赖:依赖于实际被测试的代码以外的代码的实现细节。尽管单元测试也可能出现这种依赖,但发生的可能性较小。这是因为这种依赖的非常常见的方式是依赖于对象的打印表示,因为它们在 Python shell 会话中显示。单元测试与 Python shell 相去甚远。在单元测试中需要一些编码工作才能获得对象的打印表示,因此这种形式的环境依赖很少会出现在单元测试中。

第二章中提到的一种常见的环境依赖形式也影响到了单元测试,涉及文件路径名。单元测试和文档测试一样,需要注意跨操作系统的文件路径名约定差异,以防在不同于最初编写测试的操作系统上运行测试时导致虚假的测试失败。因此,尽管单元测试不太容易出现环境依赖问题,但它们并非完全免疫。

数据库依赖

数据库依赖是 Django 应用程序特别常见的一种环境依赖形式。在文档测试中,我们看到测试的初始实现依赖于伴随IntegrityError的消息的具体内容。为了使文档测试在多个不同的数据库上通过,我们需要修改初始测试以忽略此消息的细节。

我们在单元测试版本中没有这个问题。用于检查预期异常的assertRaises已经不考虑异常消息的细节。例如:

self.assertRaises(IntegrityError, s.save)

那里没有包含具体的消息,所以我们不需要做任何事情来忽略来自不同数据库实现的消息差异。

此外,单元测试使处理比消息细节更广泛的差异变得更容易。在上一章中指出,对于 MySQL 的某些配置,忽略消息细节不足以使所有测试通过。在这里出现问题的测试是确保closes仅在初始模型创建期间自动设置的测试。这个测试的单元测试版本是:

def testClosesReset(self): 
    """Verify closes is only autoset during initial create""" 
    s = Survey.objects.create(title=self.t, opens=self.sd) 
    s.closes = None 
    self.assertRaises(IntegrityError, s.save) 

如果在运行在非严格模式下的 MySQL 服务器上运行此测试,则此测试将失败。在此模式下,MySQL 在尝试将行更新为包含在声明为NOT NULL的列中包含NULL值时不会引发IntegrityError。相反,该值将设置为隐式默认值,并发出警告。因此,当我们在配置为在非严格模式下运行的 MySQL 服务器上运行此测试时,我们会看到测试错误:

====================================================================== 
ERROR: Verify closes is only autoset during initial create 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests.py", line 35, in testClosesReset 
 self.assertRaises(IntegrityError, s.save) 
 File "/usr/lib/python2.5/unittest.py", line 320, in failUnlessRaises 
 callableObj(*args, **kwargs) 
 File "/dj_projects/marketr/survey/models.py", line 38, in save 
 super(Survey, self).save(**kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/base.py", line 410, in save 
 self.save_base(force_insert=force_insert, force_update=force_update) 
 File "/usr/lib/python2.5/site-packages/django/db/models/base.py", line 474, in save_base 
 rows = manager.filter(pk=pk_val)._update(values) 
 File "/usr/lib/python2.5/site-packages/django/db/models/query.py", line 444, in _update 
 return query.execute_sql(None) 
 File "/usr/lib/python2.5/site-packages/django/db/models/sql/subqueries.py", line 120, in execute_sql 
 cursor = super(UpdateQuery, self).execute_sql(result_type) 
 File "/usr/lib/python2.5/site-packages/django/db/models/sql/query.py", line 2369, in execute_sql 
 cursor.execute(sql, params) 
 File "/usr/lib/python2.5/site-packages/django/db/backends/mysql/base.py", line 84, in execute 
 return self.cursor.execute(query, args) 
 File "/var/lib/python-support/python2.5/MySQLdb/cursors.py", line 168, in execute 
 if not self._defer_warnings: self._warning_check() 
 File "/var/lib/python-support/python2.5/MySQLdb/cursors.py", line 82, in _warning_check 
 warn(w[-1], self.Warning, 3) 
 File "/usr/lib/python2.5/warnings.py", line 62, in warn 
 globals) 
 File "/usr/lib/python2.5/warnings.py", line 102, in warn_explicit 
 raise message 
Warning: Column 'closes' cannot be null 

在这里,我们看到 MySQL 发出的警告导致引发了一个简单的Exception,而不是IntegrityError,因此测试报告了一个错误。

这里还有一个额外的问题需要考虑:当 MySQL 发出警告时引发Exception的行为取决于 Django 的DEBUG设置。只有在DEBUGTrue时(就像先前运行的测试一样),MySQL 警告才会转换为引发的Exception。如果我们在settings.py中将DEBUG设置为False,我们会看到另一种形式的测试失败:

====================================================================== 
FAIL: Verify closes is only autoset during initial create 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests.py", line 35, in testClosesReset 
 self.assertRaises(IntegrityError, s.save) 
AssertionError: IntegrityError not raised 

在这种情况下,MySQL 允许保存,由于 Django 没有打开DEBUG,因此没有将 MySQL 发出的警告转换为Exception,因此保存工作正常进行。

在这一点上,我们可能会认真质疑是否值得在所有这些不同的情况下让这个测试正常运行,考虑到观察到的行为差异很大。也许我们应该要求,如果代码在 MySQL 上运行,服务器必须配置为严格模式。然后测试就会很好,因为以前的失败都会发出服务器配置问题的信号。但是,让我们假设我们确实需要支持在 MySQL 上运行,但我们不能对 MySQL 施加任何特定的配置要求,我们仍然需要验证我们的代码是否对这个测试行为正常。我们该怎么做呢?

请注意,我们试图在这个测试中验证的是,如果在初始创建后将closes重置为None,我们的代码不会自动将其设置为某个值。起初,似乎只需检查尝试保存时是否出现IntegrityError就可以轻松完成这个任务。然而,我们发现了一个数据库配置,我们在那里没有得到IntegrityError。此外,根据DEBUG设置,即使我们的代码行为正确并在尝试保存期间将closes保持为None,我们也可能不会报告任何错误。我们能写一个测试来报告正确的结果吗?也就是说,我们的代码在所有这些情况下是否表现正常?

答案是肯定的,只要我们能在我们的测试代码中确定正在使用的数据库,它是如何配置的,以及DEBUG设置是什么。然后,我们只需要根据测试运行的环境改变预期的结果。实际上,我们可以通过一些工作测试所有这些事情:

    def testClosesReset(self): 
        """Verify closes is only autoset during initial create""" 
        s = Survey.objects.create(title=self.t, opens=self.sd) 
        s.closes = None 

        strict = True 
        debug = False
        from django.conf import settings 
        if settings.DATABASE_ENGINE == 'mysql': 
            from django.db import connection 
            c = connection.cursor() 
            c.execute('SELECT @@SESSION.sql_mode') 
            mode = c.fetchone()[0] 
            if 'STRICT' not in mode: 
                strict = False; 
                from django.utils import importlib
                debug = importlib.import_module(settings.SETTINGS_MODULE).DEBUG

        if strict: 
            self.assertRaises(IntegrityError, s.save) 
        elif debug: 
            self.assertRaises(Exception, s.save) 
        else: 
            s.save() 
            self.assertEqual(s.closes, None) 

测试代码首先假设我们正在运行在严格模式下操作的数据库,并将本地变量strict设置为True。我们还假设DEBUGFalse并设置一个本地变量来反映这一点。然后,如果正在使用的数据库是 MySQL(通过检查settings.DATABASE_ENGINE的值确定),我们需要进行进一步的检查以查看它是如何配置的。查阅 MySQL 文档显示,这样做的方法是SELECT会话的sql_mode变量。如果返回的值包含字符串STRICT,那么 MySQL 正在严格模式下运行,否则不是。我们发出这个查询并使用 Django 支持将原始 SQL 发送到数据库来获取结果。如果我们确定 MySQL 没有配置为运行在严格模式下,我们将更新我们的本地变量strictFalse

如果我们到达将strict设置为False的地步,那也是settings中的DEBUG值变得重要的时候,因为在这种情况下,MySQL 将发出警告而不是为我们在这里测试的情况引发IntegrityError。如果settings文件中的DEBUGTrue,那么 MySQL 的警告将被 Django 的 MySQL 后端转换为Exceptions。这是通过后端使用 Python 的warnings模块完成的。当后端加载时,如果DEBUGTrue,那么将发出warnings.filterwarnings调用,以强制所有数据库警告转换为Exceptions

不幸的是,在数据库后端加载后,测试代码运行之前的某个时刻,测试运行程序将更改内存设置,以便将DEBUG设置为False。这样做是为了使测试代码的行为尽可能接近在生产中发生的情况。但是,这意味着我们不能仅仅在测试期间测试settings.DEBUG的值,以查看在加载数据库后端时DEBUG是否为True。相反,我们必须重新加载设置模块并检查新加载版本中的值。我们使用django.utils.importlibimport_module函数来实现这一点(这是 Python 2.7 的一个函数,已经被回溯使用 Django 1.1)。

最后,我们知道在运行我们的测试代码时要寻找什么。如果我们已经确定我们正在运行严格模式的数据库,我们断言尝试使用closes设置为None保存我们的模型实例应该引发IntegrityError。否则,如果我们在非严格模式下运行,但在设置文件中DEBUGTrue,那么尝试保存应该导致引发Exception。否则保存应该成功,并且我们通过确保即使在模型实例保存后closes仍然设置为None来测试我们代码的正确行为。

所有这些可能看起来是为了一个相当次要的测试而经历的相当大麻烦,但它说明了如何编写单元测试以适应不同环境中预期行为的显着差异。对于 doctest 版本来说,做同样的事情并不那么简单。因此,虽然单元测试显然不能消除在测试中处理数据库依赖的问题,但它们使得编写能够解决这些差异的测试变得更容易。

测试相互依赖

上一章遇到的下一个 doctest 警告是测试相互依赖。当在 PostgreSQL 上运行 doctests 时,在故意触发数据库错误的第一个测试之后遇到了一个错误,因为该错误导致数据库连接进入一个状态,它不会接受除终止事务之外的任何进一步命令。解决这个问题的方法是记住在故意触发错误后“清理”,在导致这种错误的任何测试步骤之后包括一个事务回滚。

Django 单元测试不会受到这个问题的影响。Django 测试用例类django.test.TestCase确保在调用每个测试方法之前将数据库重置为干净状态。因此,即使testClosesReset方法以尝试触发IntegrityError的模型保存结束,下一个运行的测试方法也不会看到任何错误,因为在此期间,数据库连接被django.test.TestCase代码重置。不仅清理了这种错误情况,任何被测试用例方法添加、删除或修改的数据库行在下一个方法运行之前都会被重置为它们的原始状态。(请注意,在大多数数据库上,测试运行程序可以使用事务回滚调用来非常有效地完成这个任务。)因此,Django 单元测试方法完全与之前运行的测试可能执行的任何数据库更改隔离开来。

Unicode

上一章讨论的最后一个 doctest 警告涉及在 doctests 中使用 Unicode 文字。由于 Python 中与 Unicode docstrings 和 docstrings 中的 Unicode 文字相关的基础问题,这些被观察到无法正常工作。

单元测试没有这个问题。对Survey模型__unicode__方法行为的直接单元测试可以工作。

class SurveyUnicodeTest(TestCase): 
    def testUnicode(self): 
        t = u'¿Como está usted?' 
        sd = datetime.date(2009, 12, 28) 
        s = Survey.objects.create(title=t, opens=sd) 
        self.assertEqual(unicode(s), u'¿Como está usted? (opens 2009-12-28, closes 2010-01-04)') 

请注意,必须像我们在上一章中为survey/models.py做的那样,在survey/tests.py的顶部添加编码声明,但不需要对字节字符串文字进行任何手动解码以构造所需的 Unicode 对象,这在 doctest 版本中是必需的。我们只需要像通常一样设置我们的变量,创建Survey实例,并断言调用该实例的unicode方法的结果是否产生我们期望的字符串。因此,使用单元测试进行非 ASCII 数据的测试比使用 doctests 要简单得多。

为单元测试提供数据

除了不受 doctests 一些缺点的影响外,单元测试为 Django 应用程序提供了一些额外的有用功能。其中之一是在测试运行之前加载测试数据到数据库中。有几种不同的方法可以做到这一点;每种方法在以下各节中都有详细讨论。

在测试装置中提供数据

为单元测试提供测试数据的第一种方法是从文件中加载它们,称为固定装置。我们将首先通过开发一个可以从预加载的测试数据中受益的示例测试来介绍这种方法,然后展示如何创建一个固定装置文件,最后描述如何确保固定装置文件作为测试的一部分被加载。

需要测试数据的示例测试

在深入讨论如何为测试提供预加载数据的细节之前,有一个可以使用这个功能的测试的例子将会有所帮助。到目前为止,我们的简单测试通过在进行时创建它们所需的数据来轻松进行。然而,当我们开始测试更高级的功能时,很快就会遇到情况,测试本身需要为一个良好的测试创建所有需要的数据将变得繁琐。

例如,考虑Question模型:

 class Question(models.Model): 
    question = models.CharField(max_length=200) 
    survey = models.ForeignKey(Survey) 

    def __unicode__(self): 
        return u'%s: %s' % (self.survey, self.question) 

(请注意,我们已经为这个模型添加了一个__unicode__方法。当我们开始使用管理界面创建一些调查应用程序数据时,这将会很方便。)

回想一下,给定Question实例的允许答案存储在一个单独的模型Answer中,它使用ForeignKeyQuestion关联:

class Answer(models.Model): 
    answer = models.CharField(max_length=200) 
    question = models.ForeignKey(Question) 
    votes = models.IntegerField(default=0) 

这个Answer模型还跟踪了每个答案被选择的次数,在它的votes字段中。(我们还没有为这个模型添加__unicode__方法,因为根据我们稍后在本章中将如何配置管理界面,它还不是必需的。)

现在,在分析调查结果时,我们想要了解一个给定的QuestionAnswers中哪个被选择得最多。也就是说,Question模型需要支持的一个功能是返回该Question的“获胜答案”。如果我们仔细考虑一下,我们会意识到可能没有一个单一的获胜答案。可能会有多个答案获得相同数量的票数而并列。因此,这个获胜答案的方法应该足够灵活,可以返回多个答案。同样,如果没有人回答这个问题,最好返回没有获胜答案,而不是整套允许的答案,其中没有一个被选择。由于这个方法(让我们称之为winning_answers)可能返回零个、一个或多个结果,为了保持一致性,最好总是返回类似列表的东西。

甚至在开始实现这个函数之前,我们就已经对它需要处理的不同情况有了一定的了解,以及在开发函数本身和对其进行测试时需要放置哪种类型的测试数据。这个例程的一个很好的测试将需要至少三个不同的问题,每个问题都有一组答案:

  • 一个问题的答案中有一个明显的获胜者,也就是说一个答案的票数比其他所有答案都多,这样winning_answers返回一个单一的答案

  • 一个问题的答案中有平局,所以winning_answers返回多个答案

  • 一个问题根本没有得到任何回答,因此winning_answers不返回任何答案

此外,我们应该测试一个没有与之关联的答案的Question。这显然是一个边缘情况,但我们应该确保winning_answers函数在看起来数据还没有完全准备好分析哪个答案最受欢迎时也能正常运行。因此,实际上测试数据中应该有四个问题,其中三个有一组答案,一个没有答案。

使用管理应用程序创建测试数据

在一个 shell 会话或者甚至一个程序中创建四个问题,其中三个有几个答案,是相当乏味的,所以让我们使用 Django 管理应用程序来代替。在第一章中,我们包含了django.contrib.adminINSTALLED_APPS中,所以它已经加载了。此外,当我们运行manage.py syncdb时,为管理所需的表已经创建。然而,我们仍然需要取消注释urls.py文件中与管理相关的行。当我们这样做时,urls.py应该看起来像这样:

from django.conf.urls.defaults import * 

# Uncomment the next two lines to enable the admin: 
from django.contrib import admin 
admin.autodiscover() 

urlpatterns = patterns('', 
    # Example: 
    # (r'^marketr/', include('marketr.foo.urls')), 

    # Uncomment the admin/doc line below and add # 'django.contrib.admindocs' 
    # to INSTALLED_APPS to enable admin documentation: 
    # (r'^admin/doc/', include('django.contrib.admindocs.urls')), 

    # Uncomment the next line to enable the admin: 
    (r'^admin/', include(admin.site.urls)), 
) 

最后,我们需要为我们的调查应用程序模型提供一些管理定义,并将它们注册到管理应用程序中,以便我们可以在管理中编辑我们的模型。因此,我们需要创建一个类似于这样的survey/admin.py文件:

from django.contrib import admin 
from survey.models import Survey, Question, Answer 

class QuestionsInline(admin.TabularInline): 
    model = Question 
    extra = 4

class AnswersInline(admin.TabularInline): 
    model = Answer 

class SurveyAdmin(admin.ModelAdmin): 
    inlines = [QuestionsInline] 

class QuestionAdmin(admin.ModelAdmin): 
    inlines = [AnswersInline] 

admin.site.register(Survey, SurveyAdmin) 
admin.site.register(Question, QuestionAdmin) 

在这里,我们大部分使用了管理默认值,除了我们定义和指定了一些管理内联类,以便更容易在单个页面上编辑多个内容。我们在这里设置内联的方式允许我们在Survey所属的同一页上编辑Questions,并在与其相关联的Answers的同一页上编辑Answers。我们还指定了当它们内联出现时,我们希望有四个额外的空Questions。这个值的默认值是三,但我们知道我们想要设置四个问题,我们也可能设置一次性添加所有四个问题。

现在,我们可以通过在命令提示符中运行python manage.py runserver来启动开发服务器,并通过在同一台机器上的浏览器中导航到http://localhost:8000/admin/来访问管理应用程序。登录为我们在第一章创建的超级用户后,我们将会看到管理主页面。从那里,我们可以点击链接添加一个Survey添加调查页面将允许我们创建一个包含四个Questions的调查:

使用管理应用程序创建测试数据

在这里,我们为我们的Question实例分配了question值,这些值不是问题,而是我们将用来测试每个问题的指示。请注意,此页面还反映了对Survey模型所做的轻微更改:在closes字段规范中添加了blank=True。没有这个改变,管理将要求在这里为closes指定一个值。有了这个改变,管理应用程序允许字段留空,以便可以使用保存覆盖方法自动分配的值。

一旦我们保存了这份调查,我们可以导航到第一个问题的更改页面,明确的赢家,并添加一些答案:

使用管理应用程序创建测试数据

因此,我们设置了明确的赢家问题有一个答案(最大票数)比其他所有答案都多。同样,我们可以设置2-Way Tie问题有两个答案获得相同数量的票数:

使用管理应用程序创建测试数据

最后,我们设置了无回应的答案,这样我们就可以测试没有任何答案收到任何投票的情况:

使用管理应用程序创建测试数据

我们不需要进一步处理无回应问题,因为这个问题将用于测试问题的答案集为空的情况,就像它刚创建时一样。

编写函数本身

现在我们的数据库已经设置了测试数据,我们可以在 shell 中尝试实现winning_answers函数的最佳方法。因此,我们可能会得出类似以下的结果:

from django.db.models import Max

class Question(models.Model): 
    question = models.CharField(max_length=200) 
    survey = models.ForeignKey(Survey) 
    def winning_answers(self): 
       rv = [] 
       max_votes = self.answer_set.aggregate(Max('votes')).values()[0] 
       if max_votes and max_votes > 0: 
           rv = self.answer_set.filter(votes=max_votes) 
       return rv 

该方法首先通过将本地变量rv(返回值)初始化为空列表。然后,它使用聚合Max函数来检索与此Question实例关联的Answer实例集中存在的votes的最大值。这一行代码在几个方面做了一些事情,为了得出答案,可能需要更多的解释。要了解它是如何工作的,请在 shell 会话中查看每个部分依次返回的内容:

>>> from survey.models import Question 
>>> q = Question.objects.get(question='Clear Winner') 
>>> from django.db.models import Max 
>>> q.answer_set.aggregate(Max('votes')) 
{'votes__max': 8} 

在这里,我们看到将聚合函数Max应用于给定Question关联的answer_setvotes字段会返回一个包含单个键值对的字典。我们只对值感兴趣,因此我们使用.values()从字典中检索值。

>>> q.answer_set.aggregate(Max('votes')).values() 
[8] 

但是,values() 返回一个列表,我们想要列表中的单个项目,因此我们通过请求列表中索引为零的项目来检索它:

>>> q.answer_set.aggregate(Max('votes')).values()[0] 
8 

接下来,代码测试 max_votes 是否存在,以及它是否大于零(至少有一个答案至少被选择了一次)。如果是,rv 将被重置为答案集,只包含那些获得最大投票数的答案。

但是,max_votes 何时不存在呢,因为它刚刚在上一行中设置了?这可能发生在没有答案链接到问题的边缘情况中。在这种情况下,聚合 Max 函数将返回最大投票值的 None,而不是零:

>>> q = Question.objects.get(question='No Answers') 
>>> q.answer_set.aggregate(Max('votes')) 
{'votes__max': None} 

因此,在这种边缘情况下,max_votes 可能被设置为 None,所以最好测试一下,避免尝试将 None0 进行比较。虽然在 Python 2.x 中,这种比较实际上可以工作并返回一个看似合理的答案(None 不大于 0),但在 Python 3.0 开始,尝试的比较将返回 TypeError。现在最好避免这样的比较,以限制在需要将代码移植到 Python 3 下运行时可能出现的问题。

最后,该函数返回 rv,此时希望已经设置为正确的值。(是的,这个函数中有一个 bug。偶尔编写能捕捉到 bug 的测试更有趣。)

编写使用测试数据的测试

现在我们已经有了 winning_answers 的实现,以及用于测试的数据,我们可以开始编写 winning_answers 方法的测试。我们可以从 tests.py 中添加以下测试开始,测试有一个明显的获胜者的情况:

from survey.models import Question
class QuestionWinningAnswersTest(TestCase): 
    def testClearWinner(self): 
        q = Question.objects.get(question='Clear Winner') 
        wa_qs = q.winning_answers() 
        self.assertEqual(wa_qs.count(), 1) 
        winner = wa_qs[0] 
        self.assertEqual(winner.answer, 'Max Votes') 

测试从具有其 question 值设置为 'Clear Winner'Question 中开始。然后,它调用 winning_answers 在该 Question 实例上,以检索获得最多投票的问题的答案的查询集。由于这个问题应该有一个单一的获胜者,测试断言返回的查询集中有一个元素。然后它通过检索获胜答案本身并验证其答案值是否为 'Max Votes' 来进行进一步的检查。如果所有这些都成功,我们可以相当肯定 winning_answers 在答案中有一个单一的“获胜者”的情况下返回了正确的结果。

从数据库中提取测试数据

那么,我们如何对我们通过管理员应用加载到数据库中的测试数据运行该测试呢?当我们运行测试时,它们不会使用我们的生产数据库,而是创建并使用一个最初为空的测试数据库。这就是 fixture 的用武之地。Fixture 只是包含可以加载到数据库中的数据的文件。

因此,第一项任务是将我们加载到生产数据库中的测试数据提取到一个 fixture 文件中。我们可以使用 manage.py dumpdata 命令来做到这一点:

python manage.py dumpdata survey --indent 4 >test_winning_answers.json

除了 dumpdata 命令本身外,那里指定的各种内容是:

  • survey:这将限制转储的数据到调查应用程序。默认情况下,dumpdata 将输出所有已安装应用程序的数据,但是获胜答案测试不需要来自调查以外的任何应用程序的数据,因此我们可以将 fixture 文件限制为只包含调查应用程序的数据。

  • --indent 4:这使得数据输出更容易阅读和编辑。默认情况下,dumpdata 将把数据输出到一行,如果你需要检查或编辑结果,这将很难处理。指定 indent 4 使 dumpdata 格式化数据为多行,四个空格缩进使结构的层次清晰。 (你可以为缩进值指定任何你喜欢的数字,不一定是 4。)

  • >test_winning_answers.json:这将命令的输出重定向到一个文件。dumpdata 的默认输出格式是 JSON,所以我们使用 .json 作为文件扩展名,这样当加载 fixture 时,它的格式将被正确解释。

dumpdata完成时,我们将会有一个test_winning_answers.json文件,其中包含我们测试数据的序列化版本。除了将其作为我们测试的一部分加载(下面将介绍),我们还可以对此或任何装置文件做些什么呢?

首先,我们可以使用manage.py loaddata命令加载装置。因此,dumpdataloaddata一起提供了一种将数据从一个数据库移动到另一个数据库的方法。其次,我们可能有或编写处理序列化数据的程序:有时在包含在平面文件中的数据上执行分析可能比在数据库中执行分析更容易。最后,manage.py testserver命令支持将装置(在命令行上指定)加载到测试数据库中,然后运行开发服务器。在您想要尝试使用这些测试数据来实验真实服务器的行为时,这可能会很方便,而不仅仅是限于使用数据编写的测试的结果。

在测试运行期间加载测试数据

回到我们手头的任务:当运行测试时,我们如何加载刚刚创建的这个装置?一个简单的方法是将其重命名为initial_data.json并将其放在我们调查应用程序目录的fixtures子目录中。如果我们这样做并运行测试,我们将看到装置文件被加载,并且我们的测试清晰获胜的情况运行成功:

kmt@lbox:/dj_projects/marketr$ python manage.py test survey 
Creating test database... 
Creating table auth_permission 
Creating table auth_group 
Creating table auth_user 
Creating table auth_message 
Creating table django_content_type 
Creating table django_session 
Creating table django_site 
Creating table django_admin_log 
Creating table survey_survey 
Creating table survey_question 
Creating table survey_answer 
Installing index for auth.Permission model 
Installing index for auth.Message model 
Installing index for admin.LogEntry model 
Installing index for survey.Question model 
Installing index for survey.Answer model 
Installing json fixture 'initial_data' from '/dj_projects/marketr/survey/fixtures'. 
Installed 13 object(s) from 1 fixture(s) 
......... 
---------------------------------------------------------------------- 
Ran 9 tests in 0.079s 

OK 
Destroying test database... 

然而,这并不是真正正确的方法来加载特定的装置数据。初始数据装置是用于应用程序中应始终存在的常量应用程序数据,而这些数据并不属于这一类别。相反,它是特定于这个特定测试的,并且只需要为这个测试加载。为了做到这一点,将其放在survey/fixtures目录中,使用原始名称test_winning_answers.json。然后,更新测试用例代码,通过在测试用例的fixtures类属性中包含文件名来指定应该为这个测试加载这个装置:

class QuestionWinningAnswersTest(TestCase): 

    fixtures = ['test_winning_answers.json'] 

    def testClearWinner(self): 
        q = Question.objects.get(question='Clear Winner') 
        wa_qs = q.winning_answers() 
        self.assertEqual(wa_qs.count(), 1) 
        winner = wa_qs[0] 
        self.assertEqual(winner.answer, 'Max Votes') 

请注意,manage.py test,至少在 Django 1.1 版本中,对于以这种方式指定的测试装置的加载并没有提供与加载初始数据装置相同的反馈。在先前的测试输出中,当装置被加载为初始数据时,会有关于加载初始数据装置和安装了 13 个对象的消息。当装置作为TestCase的一部分加载时,就没有这样的消息了。

此外,如果您在TestCase fixtures值中犯了错误并指定了错误的文件名,将不会有错误指示。例如,如果您错误地将test_winning_answers的结尾s省略了,那么唯一的问题指示将是测试用例失败:

kmt@lbox:/dj_projects/marketr$ python manage.py test survey 
Creating test database... 
Creating table auth_permission 
Creating table auth_group 
Creating table auth_user 
Creating table auth_message 
Creating table django_content_type 
Creating table django_session 
Creating table django_site 
Creating table django_admin_log 
Creating table survey_survey 
Creating table survey_question 
Creating table survey_answer 
Installing index for auth.Permission model 
Installing index for auth.Message model 
Installing index for admin.LogEntry model 
Installing index for survey.Question model 
Installing index for survey.Answer model 
E........ 
====================================================================== 
ERROR: testClearWinner (survey.tests.QuestionWinningAnswersTest) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests.py", line 67, in testClearWinner 
 q = Question.objects.get(question='Clear Winner') 
 File "/usr/lib/python2.5/site-packages/django/db/models/manager.py", line 120, in get 
 return self.get_query_set().get(*args, **kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/models/query.py", line 305, in get 
 % self.model._meta.object_name) 
DoesNotExist: Question matching query does not exist. 

---------------------------------------------------------------------- 
Ran 9 tests in 0.066s 

FAILED (errors=1) 
Destroying test database... 

可能将来对于这种错误情况提供的诊断可能会得到改进,但与此同时最好记住,像上面的DoesNotExist这样的神秘错误很可能是由于没有加载正确的测试装置而不是测试代码或被测试代码中的某些错误。

现在我们已经加载了测试装置并且第一个测试方法正常工作,我们可以为另外三种情况添加测试:其中一种是答案之间存在两种平局的情况,另一种是没有收到问题的回答,还有一种是没有答案与问题相关联的情况。这些测试可以编写得非常类似于测试清晰获胜情况的现有方法:

    def testTwoWayTie(self): 
        q = Question.objects.get(question='2-Way Tie') 
        wa_qs = q.winning_answers() 
        self.assertEqual(wa_qs.count(), 2) 
        for winner in wa_qs: 
            self.assert_(winner.answer.startswith('Max Votes')) 

    def testNoResponses(self): 
        q = Question.objects.get(question='No Responses') 
        wa_qs = q.winning_answers() 
        self.assertEqual(wa_qs.count(), 0) 

    def testNoAnswers(self): 
        q = Question.objects.get(question='No Answers') 
        wa_qs = q.winning_answers() 
        self.assertEqual(wa_qs.count(), 0) 

区别在于从数据库中检索到的Questions的名称,以及如何测试具体的结果。在2-Way Tie的情况下,测试验证winning_answers返回两个答案,并且两者的answer值都以'Max Votes'开头。在没有回应和没有答案的情况下,所有测试只需要验证winning_answers返回的查询集中没有项目。

如果我们现在运行测试,我们会发现之前提到的错误,因为我们最后两个测试失败了:

====================================================================== 
ERROR: testNoAnswers (survey.tests.QuestionWinningAnswersTest) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests.py", line 88, in testNoAnswers 
 self.assertEqual(wa_qs.count(), 0) 
TypeError: count() takes exactly one argument (0 given) 

====================================================================== 
ERROR: testNoResponses (survey.tests.QuestionWinningAnswersTest) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests.py", line 83, in testNoResponses 
 self.assertEqual(wa_qs.count(), 0) 
TypeError: count() takes exactly one argument (0 given) 

这里的问题是winning_answers在返回时不一致:

def winning_answers(self): 
    rv = [] 
    max_votes = self.answer_set.aggregate(Max('votes')).values()[0] 
    if max_votes and max_votes > 0: 
        rv = self.answer_set.filter(votes=max_votes) 
    return rv 

rv的返回值在函数的第一行初始化为一个列表,但当它在有答案收到投票的情况下被设置时,它被设置为来自filter调用的返回值,它返回一个QuerySet,而不是一个列表。测试方法,因为它们在winning_answers的返回值上使用没有参数的count(),所以期望一个QuerySet

对于winning_answers来说,返回列表还是QuerySet更合适?可能是QuerySet。调用者可能只对集合中答案的计数感兴趣,而不是具体的答案,因此可能不需要从数据库中检索实际的答案。如果winning_answers始终返回一个列表,它将不得不强制从数据库中读取答案以将它们放入列表中。因此,始终返回QuerySet并让调用者的要求决定最终需要从数据库中读取什么可能更有效。 (考虑到我们期望在这个集合中的项目数量很少,可能在这里几乎没有效率可言,但在设计接口时考虑这些事情仍然是一个好习惯。)

winning_answers修复为始终返回QuerySet的一种方法是使用应用于answer_setnone()方法,它将返回一个空的QuerySet

def winning_answers(self):
    max_votes = self.answer_set.aggregate(Max('votes')).values()[0] 
    if max_votes and max_votes > 0:
        rv = self.answer_set.filter(votes=max_votes)
    else:
        rv = self.answer_set.none()
    return rv

进行这一更改后,QuestionWinningAnswersTest TestCase将成功运行。

在测试设置期间创建数据

虽然测试装置非常方便,但有时并不是适合所有工作的正确工具。具体来说,由于装置文件包含所有模型数据的固定、硬编码值,因此装置有时对于所有测试来说并不够灵活。

举个例子,让我们回到“调查”模型,并考虑一些我们可能希望它支持的方法。请记住,调查既有“开放”日期,也有“关闭”日期,因此在任何时间点,特定的“调查”实例可能被认为是“已完成”,“活跃”或“即将到来”,这取决于当前日期与调查的“开放”和“关闭”日期的关系。有易于访问这些不同类别的调查将是有用的。在 Django 中支持这一点的典型方法是为Survey创建一个特殊的模型Manager,该Manager实现了返回适当过滤的查询集的方法。这样的Manager可能如下所示:

import datetime 
from django.db import models 

class SurveyManager(models.Manager): 
    def completed(self): 
        return self.filter(closes__lt=datetime.date.today()) 
    def active(self): 
        return self.filter(opens__lte=datetime.date.today()).\filter(closes__gte=datetime.date.today()) 
    def upcoming(self): 
        return self.filter(opens__gt=datetime.date.today()) 

这个管理器实现了三种方法:

  • completed:这将返回一个经过筛选的SurveyQuerySet,只包括那些closes值早于今天的调查。这些是关闭对任何更多回应的调查。

  • active:这将返回一个经过筛选的SurveyQuerySet,只包括那些opens值早于或等于今天,并且closes晚于或等于今天的调查。这些是可以接收回应的调查。

  • upcoming:这将返回一个经过筛选的SurveyQuerySet,只包括那些opens值晚于今天的调查。这些是尚未开放回应的调查。

要使这个自定义管理器成为Survey模型的默认管理器,将其实例分配给Survey objects属性的值:

 class Survey(models.Model):
    title = models.CharField(max_length=60)
    opens = models.DateField()
    closes = models.DateField(blank=True)

    objects = SurveyManager()

为什么我们可能会在使用装置数据测试这些方法时遇到困难?问题出在这些方法依赖于今天日期的移动目标。对于测试completed来说,这并不是问题,因为我们可以为具有过去closes日期的调查设置测试数据,而这些closes日期将继续保持在过去,无论我们向前移动多少时间。

然而,activeupcoming是一个问题,因为最终,即使我们选择将“关闭”(对于upcoming,“打开”)日期设定在遥远的未来,今天的日期也会(除非发生普遍灾难)在某个时候赶上那些遥远的未来日期。当发生这种情况时,测试将开始失败。现在,我们可能期望我们的软件不会在那个遥远的时间仍在运行。(或者我们可能只是希望到那时我们不再负责维护它。)但这并不是一个好的方法。最好使用一种不会在测试中产生定时炸弹的技术。

如果我们不想使用一个带有硬编码日期的测试装置文件来测试这些例程,那么有什么替代方法呢?我们可以做的与之前的工作非常相似:在测试用例中动态创建数据。正如前面所述,这可能有点乏味,但请注意我们不必为每个测试方法重新创建数据。单元测试提供了一个钩子方法setUp,我们可以使用它来实现任何常见的测试前初始化。测试机制将确保我们的setUp例程在每个测试方法之前运行。因此,setUp是一个很好的地方,用于放置为我们的测试动态创建类似装置的数据的代码。

在对自定义“调查”管理器进行测试时,我们可能会有一个类似于以下的setUp例程:

class SurveyManagerTest(TestCase): 
    def setUp(self): 
        today = datetime.date.today() 
        oneday = datetime.timedelta(1) 
        yesterday = today - oneday 
        tomorrow = today + oneday
        Survey.objects.all().delete()
        Survey.objects.create(title="Yesterday", opens=yesterday, closes=yesterday) 
        Survey.objects.create(title="Today", opens=today, closes=today) 
        Survey.objects.create(title="Tomorrow", opens=tomorrow, closes=tomorrow) 

这种方法创建了三个“调查”:一个昨天打开和关闭的,一个今天打开和关闭的,一个明天打开和关闭的。在创建这些之前,它会删除数据库中的所有“调查”对象。因此,SurveyManagerTest中的每个测试方法都可以依赖于数据库中确切地有三个“调查”,每个处于三种状态之一。

为什么测试首先删除所有“调查”对象?数据库中应该还没有任何“调查”,对吧?那个调用只是为了以防将来调查应用程序获取包含一个或多个“调查”的初始数据装置。如果存在这样的装置,它将在测试初始化期间加载,并且会破坏这些依赖数据库中确切地有三个“调查”的测试。因此,在这里setUp最安全的做法是确保数据库中唯一的“调查”是它创建的。

然后可能会有一个Survey管理器completed函数的测试:

    def testCompleted(self): 
        self.assertEqual(Survey.objects.completed().count(), 1) 
        completed_survey = Survey.objects.get(title="Yesterday") 
        self.assertEqual(Survey.objects.completed()[0], completed_survey) 

        today = datetime.date.today() 
        completed_survey.closes = today 
        completed_survey.save() 
        self.assertEqual(Survey.objects.completed().count(), 0) 

测试首先断言进入时数据库中有一个已完成的“调查”。然后验证completed函数返回的一个“调查”实际上是它期望完成的实际调查,即标题设置为“昨天”的调查。然后测试进一步修改了已完成的“调查”,使其“关闭”日期不再使其符合已完成的资格,并将该更改保存到数据库。完成后,测试断言数据库中现在有零个已完成的“调查”。

通过该例程进行测试可以验证测试是否有效,因此,对于活动调查的类似测试可能会被写成:

    def testActive(self):
        self.assertEqual(Survey.objects.active().count(), 1)
        active_survey = Survey.objects.get(title="Today")
        self.assertEqual(Survey.objects.active()[0], active_survey)
        yesterday = datetime.date.today() - datetime.timedelta(1)
        active_survey.opens = active_survey.closes = yesterday
        active_survey.save()
        self.assertEqual(Survey.objects.active().count(), 0)

这与“已完成”测试非常相似。它断言进入时有一个活动的“调查”,检索活动的“调查”并验证它是否是预期的活动的“调查”,修改它以使其不再符合活动的资格(使其符合关闭的资格),保存修改,最后验证“活动”然后返回没有活动的“调查”。

类似地,一个关于即将到来的调查的测试可能是:

    def testUpcoming(self):
        self.assertEqual(Survey.objects.upcoming().count(), 1)
        upcoming_survey = Survey.objects.get(title="Tomorrow")
        self.assertEqual(Survey.objects.upcoming()[0], upcoming_survey)
        yesterday = datetime.date.today() - datetime.timedelta(1)
        upcoming_survey.opens = yesterday
        upcoming_survey.save()
        self.assertEqual(Survey.objects.upcoming().count(), 0)

但是,所有这些测试不会相互干扰吗?例如,completed的测试使“昨天”的调查似乎是活动的,active的测试使“今天”的调查似乎是关闭的。似乎无论哪个先运行,都会进行更改,从而干扰其他测试的正确操作。

实际上,这些测试并不会相互干扰,因为在运行每个测试方法之前,数据库会被重置,并且测试用例的 setUp 方法会被重新运行。因此 setUp 不是每个 TestCase 运行一次,而是每个 TestCase 中的测试方法运行一次。运行这些测试显示,尽管每个测试都会更新数据库,以一种可能会干扰其他测试的方式,但所有这些测试都通过了,如果其他测试看到了它所做的更改,就会相互干扰:

testActive (survey.tests.SurveyManagerTest) ... ok
testCompleted (survey.tests.SurveyManagerTest) ... ok
testUpcoming (survey.tests.SurveyManagerTest) ... ok

setUp 有一个伴随方法,叫做 tearDown,可以用来在测试方法之后执行任何清理工作。在这种情况下,这并不是必要的,因为 Django 默认的操作会在测试方法执行之间重置数据库,从而撤消测试方法所做的数据库更改。tearDown 例程对于清理任何非数据库更改(例如临时文件创建)可能会被测试所做的更改非常有用。

总结

我们现在已经掌握了对 Django 应用程序进行单元测试的基础知识。在本章中,我们:

  • 将先前编写的 Survey 模型的 doctests 转换为单元测试,这使我们能够直接比较每种测试方法的优缺点

  • 重新审视了上一章的 doctest 注意事项,并检查了单元测试在多大程度上容易受到相同问题的影响

  • 开始学习一些单元测试的附加功能;特别是与加载测试数据相关的功能。

在下一章中,我们将开始研究更多可用于 Django 单元测试的高级功能。

第四章:变得更高级:Django 单元测试扩展

在上一章中,我们开始学习如何使用单元测试来测试 Django 应用程序。这包括学习一些 Django 特定的支持,比如如何将测试数据从装置文件加载到数据库中进行特定的测试。到目前为止,我们的测试重点一直是应用程序的组成部分。我们还没有开始编写用于为我们的应用程序提供网页服务的代码,也没有考虑如何测试页面是否被正确地提供并包含正确的内容。Django 的TestCase类提供了对这种更广泛的测试有用的支持,这将是本章的重点。在本章中,我们将:

  • 首先学习如何使用一个 tests 目录来进行 Django 应用程序的测试,而不是单个的tests.py文件。这将使我们能够逻辑地组织测试,而不是将各种不同的测试混合在一个巨大的文件中。

  • 为调查应用程序开发一些网页。对于每一个,我们将编写单元测试来验证它们的正确操作,途中学习测试 Django 应用程序的TestCase支持的具体细节。

  • 尝试在管理应用程序的Survey模型中添加自定义验证,并查看如何测试这样的定制。

  • 简要讨论一些 Django 测试支持中的方面,在我们的示例测试中没有遇到的。

  • 最后,我们将学习在什么条件下可能需要使用替代的单元测试类TransactionTestCase。这个类的性能不如TestCase,但它支持测试一些使用TestCase不可能的数据库事务行为。

组织测试

在我们开始编写用于为调查应用程序提供网页服务的代码(和测试)之前,让我们先考虑一下我们到目前为止所拥有的测试。如果我们运行manage.py test survey -v2并检查输出的末尾,我们会看到我们已经积累了超过十几个单独的测试:

No fixtures found. 
testClearWinner (survey.tests.QuestionWinningAnswersTest) ... ok 
testNoAnswers (survey.tests.QuestionWinningAnswersTest) ... ok 
testNoResponses (survey.tests.QuestionWinningAnswersTest) ... ok 
testTwoWayTie (survey.tests.QuestionWinningAnswersTest) ... ok 
testActive (survey.tests.SurveyManagerTest) ... ok 
testCompleted (survey.tests.SurveyManagerTest) ... ok 
testUpcoming (survey.tests.SurveyManagerTest) ... ok 
Verify closes is autoset correctly ... ok 
Verify closes is honored if specified ... ok 
Verify closes is only autoset during initial create ... ok 
Verify correct exception is raised in error case ... ok 
testUnicode (survey.tests.SurveyUnicodeTest) ... ok 
Doctest: survey.models.Survey.__unicode__ ... ok 
Doctest: survey.models.Survey.save ... ok 
Doctest: survey.tests.__test__.survey_save ... ok 

---------------------------------------------------------------------- 
Ran 15 tests in 0.810s 

OK 
Destroying test database... 

其中两个,即以survey.models.Survey开头的标签的两个 doctest,来自survey/models.py文件。其余的 13 个测试都在survey/tests.py文件中,该文件已经增长到大约 150 行。这些数字并不算大,但是如果考虑到我们几乎刚刚开始编写这个应用程序,很明显,继续简单地添加到tests.py将很快导致一个难以管理的测试文件。由于我们即将开始从构建和测试调查模型转移到构建和测试提供网页服务的代码,现在是一个比单个文件更好的测试组织的好时机。

幸运的是,这并不难做到。Django 中没有要求测试都驻留在单个文件中;它们只需要在名为tests的 Python 模块中。因此,我们可以在survey中创建一个名为tests的子目录,并将现有的tests.py文件移动到其中。由于这个文件中的测试重点是测试应用程序的模型,让我们也将其重命名为model_tests.py。我们还应该删除marketr/survey中的tests.pyc文件,因为在 Python 代码重组后留下零散的.pyc文件通常会引起混乱。最后,我们需要在tests目录中创建一个__init__.py文件,以便 Python 将其识别为一个模块。

就这些吗?并不完全是。Django 使用unittest.TestLoader.LoadTestsFromModule来查找并自动加载tests模块中的所有TestCase类。然而,我们现在已经将所有的TestCase类移动到了名为model_tests的 tests 子模块中。为了让LoadTestsFromModule找到它们,我们需要使它们在父tests模块中可见,我们可以通过在survey/tests__init__.py文件中添加对model_tests的导入来实现这一点:

from model_tests import *

现在我们准备好了吗?几乎。如果我们现在运行manage.py test survey -v2,我们会发现输出报告显示运行了 14 个测试,而在重新组织之前的运行中报告显示运行了 15 个测试:

No fixtures found. 
testClearWinner (survey.tests.model_tests.QuestionWinningAnswersTest) ... ok 
testNoAnswers (survey.tests.model_tests.QuestionWinningAnswersTest) ... ok

testNoResponses (survey.tests.model_tests.QuestionWinningAnswersTest) ... ok 
testTwoWayTie (survey.tests.model_tests.QuestionWinningAnswersTest) ... ok

testActive (survey.tests.model_tests.SurveyManagerTest) ... ok 
testCompleted (survey.tests.model_tests.SurveyManagerTest) ... ok 
testUpcoming (survey.tests.model_tests.SurveyManagerTest) ... ok 
Verify closes is autoset correctly ... ok 
Verify closes is honored if specified ... ok 
Verify closes is only autoset during initial create ... ok 
Verify correct exception is raised in error case ... ok 
testUnicode (survey.tests.model_tests.SurveyUnicodeTest) ... ok 
Doctest: survey.models.Survey.__unicode__ ... ok 
Doctest: survey.models.Survey.save ... ok 
---------------------------------------------------------------------- 
Ran 14 tests in 0.760s 

OK 
Destroying test database... 

哪个测试丢失了?早期运行的最后一个测试,也就是tests.py中的__test__字典中的 doctest。因为__test__以下划线开头(表示它是一个私有属性),所以它不会被from model_tests import *导入。命名所暗示的私有性并不受 Python 强制执行,因此我们也可以向survey/tests/__init__.py添加对__test__的显式导入:

from model_tests import __test__ 
from model_tests import * 

如果我们这样做并再次运行测试,我们会发现我们又回到了 15 个测试。然而,这是一个很差的解决方案,因为它无法扩展到tests目录中的多个文件。如果我们向tests目录添加另一个文件,比如view_tests.py,并简单地复制用于model_tests.py的导入,我们将会有:

from model_tests import __test__ 
from model_tests import * 
from view_tests import __test__
from view_tests import *

这不会导致任何错误,但也不完全有效。第二次导入__test__完全替换了第一次,因此如果我们这样做,model_tests.py中包含的 doctests 将会丢失。

很容易想出一种方法,可以扩展到多个文件,也许是通过为在单独的测试文件中定义的 doctests 创建我们自己的命名约定。然后,__init__.py中的代码可以通过将定义 doctests 的各个测试文件的字典合并为整个tests模块的__test__字典来实现。但是,出于我们将要研究的示例的目的,这是不必要复杂的,因为我们将要添加的额外测试都是单元测试,而不是 doctests。

实际上,现在在model_tests.py中的 doctests 也已经被重新实现为单元测试,因此它们作为测试是多余的,可以安全地删除。然而,它们确实指出了一个与 doctests 相关的问题,如果您决定在自己的项目中摆脱单文件tests.py方法,这个问题就会出现。我们可以通过简单地将model_tests.py文件中的__test__字典定义移动到survey/tests/__init__.py文件中来保留我们已经拥有的 doctests。然后,如果我们决定额外的 doctests(超出models.py中的 doctests)会很有用,我们可以简单地在survey/tests/__init__.py中添加到这个字典,或者想出一个更复杂的方法,允许将 doctests 以及单元测试拆分到不同的文件中。

请注意,不必将tests目录树限制在单个级别。我们可以为模型测试创建一个子目录,为视图创建一个子目录,并将这些测试进一步细分为单独的文件。使用我们在这里开始的方法,所需的只是在各种__init__.py文件中包含适当的导入,以便测试用例在tests包的顶层可见。将树设置多深以及将单个测试文件设置多小是个人偏好的问题。我们现在将坚持单层。

最后,请注意,您可以通过在应用的models和/或tests模块中定义一个suite()函数来完全控制组成应用测试套件的测试。Django 测试运行程序在这些模块中寻找这样的函数,如果suite()存在,就会调用它来创建测试套件。如果提供,suite()函数必须返回一个适合作为参数传递给unittest.TestSuite.addTest的对象(例如,一个unittest.TestSuite)。

创建调查应用首页

现在是时候把注意力转向为调查应用程序构建一些网页了。首先要考虑的页面是主页,这将是一般用户进行任何与调查相关操作的起点。最终,我们可能计划让这个页面有许多不同的元素,比如标准的页眉和页脚,也可能有一两个侧边栏用于新闻和反馈。我们计划开发全面的样式表,以赋予应用程序漂亮和一致的外观。但所有这些都不是我们现在想要关注的重点,我们现在想要关注的是主页的主要内容。

主页的主要功能将是提供当前调查状态的快照概览,并在适当的情况下提供链接,以允许用户查看各个调查的详细信息。主页将显示分为三类的调查:

  • 首先,将列出当前开放的调查。此列表中的每个调查都将有一个链接,供用户参与调查。

  • 其次,将列出最近完成的调查。这些调查也将有一个链接,但这个链接将带来一个页面,允许用户查看调查结果。

  • 第三,将列出即将开放的调查。此列表中的调查将没有链接,因为用户还不能参与,也没有结果可见。

为了构建和测试这个主页,我们需要做四件事情:

  1. 首先,我们需要定义用于访问主页和任何链接到它的页面的 URL,并在urls.py文件中定义这些 URL 应该如何映射到将提供页面的视图代码。

  2. 其次,我们需要实现用于提供第 1 步中识别的页面的视图代码。

  3. 第三,我们需要定义 Django 模板,用于呈现第 2 步生成的响应。

  4. 最后,我们需要为每个页面编写测试。

接下来的章节将依次关注这些步骤中的每一个。

定义调查应用程序的 URL

从调查主页的描述来看,我们可能需要定义两个或三个不同的 URL。当然,首先是主页本身,最自然地放置在调查应用程序的 URL 树的根目录下。我们可以通过在survey目录中创建urls.py文件来定义这一点:

from django.conf.urls.defaults import * 

urlpatterns = patterns('survey.views', 
    url(r'^$', 'home', name='survey_home'), 
) 

在这里,我们指定了对空(根)URL 的请求应由survey.views模块中的home函数处理。此外,我们给这个 URL 命名为survey_home,我们可以在其他代码中使用这个名称来引用这个 URL。始终使用命名 URL 是一个好的做法,因为它允许通过简单地更改urls.py文件而不需要更改其他代码来更改实际的 URL。

除了主页,还有从主页链接过去的页面需要考虑。首先是从活动调查列表中链接的页面,允许用户参与调查。其次是从最近完成的调查列表中链接的页面,允许用户查看结果。你可能会问,这些是否应该由一个还是两个 URL 来覆盖?

虽然听起来这些可能需要不同的 URL,因为页面将显示非常不同的内容,但从某种意义上说,它们都显示了同一件事情——特定调查的详细信息。只是调查的当前状态将影响其详细页面的显示。因此,我们可以选择将决定显示什么内容的逻辑,基于调查状态,放入处理显示调查详细信息的视图中。然后我们可以用一个 URL 模式来覆盖这两种类型的页面。采用这种方法,survey/urls.py文件变成了:

from django.conf.urls.defaults import * 

urlpatterns = patterns('survey.views', 
    url(r'^$', 'home', name='survey_home'), 
    url(r'^(?P<pk>\d+)/$', 'survey_detail', name='survey_detail'), 
) 

在这里,我们采取了将调查的主键放入 URL 的方法。任何由一个或多个数字(主键)组成的单个路径组件的 URL 将被映射到survey.views模块中的survey_detail函数。该函数将接收主键路径组件作为参数pk,以及标准的请求参数。最后,这个 URL 被命名为survey_detail

这两个 URL 模式足以定义我们到目前为止考虑的调查应用程序页面。但是,我们仍然需要将它们连接到项目的整体 URL 配置中。为此,请编辑项目的根urls.py文件,并为调查 URL 添加一行。然后,urls.py中的urlpatterns变量将被定义如下:

urlpatterns = patterns('', 
    # Example: 
    # (r'^marketr/', include('marketr.foo.urls')), 

    # Uncomment the admin/doc line below and add # 'django.contrib.admindocs' 
    # to INSTALLED_APPS to enable admin documentation: 
    # (r'^admin/doc/', include('django.contrib.admindocs.urls')), 

    # Uncomment the next line to enable the admin: 
    (r'^admin/', include(admin.site.urls)), 
 (r'', include('survey.urls')), 
) 

我们在这里添加的最后一行指定了一个空的 URL 模式r''。所有匹配的 URL 将被测试与survey模块中包含的urls.py文件中找到的模式相匹配。模式r''将匹配每个 URL,并且在测试与survey/urls.py中的 URL 模式相匹配时,不会删除 URL 的任何部分,因此这实质上是将调查urls.py文件挂载到项目的 URL 树的根目录。

开发视图以提供页面

现在我们已经定义了我们的 URL 并指定了应该调用的视图函数来提供它们,是时候开始编写这些函数了。或者,也许我们应该从这些页面的模板开始?两者都需要完成,它们彼此之间是相互依赖的。视图返回的数据取决于模板的需求,而模板的编写方式取决于视图提供的数据的命名和结构。因此,很难知道从哪里开始,有时需要在它们之间交替进行。

然而,我们必须从某个地方开始,我们将从视图开始。实际上,每当您在urls.py文件中添加对视图的引用时,立即编写至少该视图的最小实现是一个好主意。例如,对于我们刚刚添加到survey/urls.py的两个视图,我们可能会立即将以下内容放在survey/views.py中:

from django.http import HttpResponse 

def home(request): 
    return HttpResponse("This is the home page.") 

def survey_detail(request, pk): 
    return HttpResponse("This is the survey detail page for survey, " "with pk=%s" % pk) 

这些视图只是简单地返回描述页面应该显示的HttpResponse。创建这样的占位视图可以确保项目的整体 URL 模式配置保持有效。保持这个配置有效很重要,因为任何尝试执行反向 URL 映射(从名称到实际 URL)都会导致异常,如果在 URL 模式配置的任何部分中存在任何错误(例如引用不存在的函数)。因此,无效的 URL 配置很容易似乎破坏其他完全无辜的代码。

例如,管理应用程序需要使用反向 URL 映射在其页面上生成链接。因此,无效的 URL 模式配置可能导致在用户尝试访问管理页面时引发异常,即使管理代码本身没有错误。这种异常很难调试,因为乍一看似乎问题是由完全与实际错误位置分离的代码引起的。因此,即使您更喜欢在编写视图函数之前编写模板,最好立即为您添加到 URL 模式配置中的任何视图提供至少一个最低限度的实现。

我们可以进一步超越最低限度,至少对于主页视图是这样。如前所述,主页将显示三个不同的调查列表:活动的、最近完成的和即将开放的。模板可能不需要将数据结构化得比简单列表(或QuerySet)更复杂,因此主页的视图编写起来很简单:

import datetime 
from django.shortcuts import render_to_response 
from survey.models import Survey 

def home(request): 
    today = datetime.date.today() 
    active = Survey.objects.active() 
    completed = Survey.objects.completed().filter(closes__gte=today-datetime.timedelta(14)) 
    upcoming = Survey.objects.upcoming().filter(opens__lte=today+datetime.timedelta(7))
    return render_to_response('survey/home.html', 
        {'active_surveys': active, 
         'completed_surveys': completed, 
         'upcoming_surveys': upcoming, 
        })

这个视图设置了三个变量,它们是包含数据库中Surveys适当子集的QuerySets。最近完成的集合限于在过去两周内关闭的调查,即将开放的集合限于在下周将要开放的调查。然后,视图调用render_to_response快捷方式来渲染survey/home.html模板,并传递一个上下文字典,其中包含三个Survey子集,分别是active_surveyscompleted_surveysupcoming_surveys上下文变量。

此时,我们可以继续用一些真实的代码替换占位符survey_detail视图的实现,或者我们可以开始一些模板。编写第二个视图并不能让我们更接近测试我们已经编写的第一个视图,所以继续进行模板的工作会更好。暂时用于第二个视图的占位内容现在也可以。

创建页面模板

要开始编写调查应用程序的模板,首先在survey下创建一个templates目录,然后在templates下创建一个survey目录。将模板放在应用程序目录下的templates目录下,可以使它们被默认启用的app_directories模板加载器自动找到。此外,将模板放在templates下的survey目录下,可以最大程度地减少与其他应用程序使用的模板的名称冲突的机会。

现在,我们需要创建哪些模板?在主页视图中命名的是survey/home.html。我们可以只创建一个文件,并将其作为一个完整的独立 HTTP 文档。但这是不现实的。Django 提供了一个方便的模板继承机制,允许重用常见页面元素并选择性地覆盖已定义的块。至少,我们可能希望使用一个定义了整体文档结构和块组件的通用基础模板,然后将个别页面模板实现为扩展基础模板的子模板。

这是一个最小的base.html模板,我们可以用它来开始:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html >
<head>
<title>{% block title %}Survey Central{% endblock %}</title>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>

这个文档提供了整体的 HTML 结构标签,并定义了两个块:titlecontenttitle块的默认内容是Survey Central,可以被子模板覆盖,或者保持不变。content块最初是空的,因此期望子模板始终提供一些内容来填充页面的主体。

有了基础模板,我们可以将home.html模板编写为一个扩展base.html并为content块提供内容的子模板。我们知道home视图提供了三个上下文变量(active_surveyscompleted_surveysupcoming_surveys),其中包含应该显示的数据。home.html模板的初始实现可能如下所示:

{% extends "survey/base.html" %} 
{% block content %} 
<h1>Welcome to Survey Central</h1> 

{% if active_surveys %} 
<p>Take a survey now!</p> 
<ul> 
{% for survey in active_surveys %} 
<li><a href="{{ survey.get_absolute_url }}">{{ survey.title }}</a></li> 
{% endfor %} 
</ul> 
{% endif %} 

{% if completed_surveys %} 
<p>See how your opinions compared to those of others!</p> 
<ul> 
{% for survey in completed_surveys %} 
<li><a href="{{ survey.get_absolute_url }}">{{ survey.title }}</a></li> 
{% endfor %} 
</ul> 
{% endif %} 

{% if upcoming_surveys %} 
<p>Come back soon to share your opinion!</p> 
<ul> 
{% for survey in upcoming_surveys %} 
<li>{{ survey.title }} opens {{ survey.opens }}</li> 
{% endfor %} 
</ul> 
{% endif %} 
{% endblock content %} 

这可能看起来有点吓人,但它很简单。模板首先指定它扩展了survey/base.html模板。然后继续定义应该放在base.html中定义的content块中的内容。第一个元素是一个一级标题欢迎来到调查中心。然后,如果active_surveys上下文变量不为空,标题后面会跟着一个邀请人们参加调查的段落,然后是活动调查的列表。列表中的每个项目都被指定为一个链接,链接目标值是通过调用 Survey 的get_absolute_url方法获得的(我们还没有实现)。每个链接的可见文本都设置为Surveytitle值。

如果有任何completed_surveys,则会显示一个几乎相同的段落和列表。最后,upcoming_surveys也会以类似的方式处理,只是在它们的情况下不会生成链接。相反,调查标题将与每个调查将开放的日期一起列出。

现在,get_absolute_url方法用于生成活动和已完成调查的链接?这是一个标准的模型方法,我们可以实现它来为我们网站上的模型实例提供 URL。除了在我们自己的代码中使用它之外,如果模型实现了它,管理应用程序也会使用它,在模型实例的更改页面上提供一个在网站上查看链接。

回想一下,在我们的urls.py文件中,我们为调查详情命名了 URL 为survey_detail,这个视图需要一个参数pk,这是要显示有关Survey实例的详细信息的主键。知道了这一点,我们可以在Survey模型中实现这个get_absolute_url方法:

    def get_absolute_url(self): 
        from django.core.urlresolvers import reverse 
        return reverse('survey_detail', args=(self.pk,)) 

这种方法使用了django.core.urlresolvers提供的reverse函数来构造实际的 URL,该 URL 将映射到具有模型实例的主键值作为参数值的 URL 命名为survey_detail

另外,我们可以使用方便的models.permalink装饰器,避免记住reverse函数需要从哪里导入:

    @models.permalink
    def get_absolute_url(self):
        return ('survey_detail', (self.pk,))

这等同于实现get_absolute_url的第一种方式。这种方式只是隐藏了调用reverse函数的细节,因为models.permalink代码已经完成了这个工作。

现在,我们已经创建了首页视图和它使用的模板,并实现了从这些模板调用的所有模型方法,我们实际上可以测试这个视图。确保开发服务器正在运行(或者使用manage.py runserver重新启动),然后从同一台机器上的浏览器中,转到http://localhost:8000/。这应该(假设自上一章创建的Winning Answers Test距今不到一周)会显示一个页面,列出可以参与的调查:

为页面创建模板

如果自从创建调查以来已经超过一周,它应该显示在一个段落下,邀请您查看您的观点与他人的观点相比如何!。如果已经超过三周,调查就不应该出现在首页上,这种情况下,您可能需要返回管理应用程序并更改其closes日期,以便它出现在首页上。

那个Winning Answers Test文本是一个链接,可以点击以验证Surveyget_absolute_url方法是否有效,并且我们设置的 URL 配置是否有效。由于我们仍然只有调查详情视图的占位符视图实现,点击Winning Answers Test链接将显示一个页面,看起来像这样:

为页面创建模板

也许并不是特别令人印象深刻,但它确实验证了我们迄今为止放置的各种部件是否有效。

当然,由于数据库中只有一个调查,我们只验证了视图和模板的一部分。为了进行全面的测试,我们还应该验证所有三个类别中的调查是否正确显示。此外,我们还应该验证数据库中的调查是否不应该出现在首页上,因为它们太旧或太遥远。

我们现在可以通过在管理应用程序中手动添加调查并在进行更改时手动检查首页的内容来完成所有这些工作。然而,我们真正想要学习的是如何编写一个测试来验证我们现在的工作是否正确,并且更重要的是,允许我们在继续开发应用程序时验证它是否保持正确。因此,编写这样的测试是我们接下来要关注的重点。

测试调查首页

在考虑如何编写测试本身之前,让我们考虑一下测试所需的数据以及将这些数据放入数据库进行测试的最佳方法。这个测试将与上一章的SurveyManagerTest非常相似,因为确定正确的行为将取决于当前日期与测试数据中包含的日期的关系。因此,使用一个 fixture 文件来存储这些数据并不是一个好主意;最好在测试的setUp方法中动态添加数据。

因此,我们将首先编写一个setUp方法,为测试主页创建一个适当的数据集。由于我们已经开始测试应用的视图,让我们将其放在一个新文件survey/tests/view_tests.py中。当我们创建该文件时,我们还需要记得在survey/tests__init__.py文件中添加一个import行,以便找到其中的测试。

这是我们主页测试的setUp方法:

import datetime 
from django.test import TestCase 
from survey.models import Survey 

class SurveyHomeTest(TestCase): 
    def setUp(self): 
        today = datetime.date.today() 
        Survey.objects.all().delete() 
        d = today - datetime.timedelta(15) 
        Survey.objects.create(title="Too Old", opens=d, closes=d) 
        d += datetime.timedelta(1) 
        Survey.objects.create(title="Completed 1", opens=d, closes=d) 
        d = today - datetime.timedelta(1) 
        Survey.objects.create(title="Completed 2", opens=d, closes=d) 
        Survey.objects.create(title="Active 1", opens=d) 
        Survey.objects.create(title="Active 2", opens=today) 
        d = today + datetime.timedelta(1) 
        Survey.objects.create(title="Upcoming 1", opens=d) 
        d += datetime.timedelta(6) 
        Survey.objects.create(title="Upcoming 2", opens=d) 
        d += datetime.timedelta(1) 
        Survey.objects.create(title="Too Far Out", opens=d) 

这种方法首先将今天的日期存储在一个本地变量today中。然后删除数据库中所有现有的Surveys,以防初始数据装置加载了任何可能干扰测试用例中的测试方法正确执行的调查。然后创建八个Surveys:三个已完成,两个活跃,三个即将到来的。

已完成调查的截止日期被特别设置,以测试应该出现在主页上的窗口边界。最早的截止日期设置得比过去的时间多一天(15 天),不会出现在主页上。其他两个设置为窗口边缘的极限,应该出现在主页上。即将到来的调查的开放日期也类似地设置,以测试该窗口的极限。一个即将到来的调查开放的时间比未来多一天,不会出现在主页上,而另外两个则在窗口的极限处开放,应该显示为即将到来的调查。最后,有两个活跃的调查,一个是昨天开放的,另一个是今天开放的,每个都有一个默认的截止日期,七天后关闭,所以两者都还在开放中。

现在我们有一个setUp例程来创建测试数据,那么我们如何编写一个测试来检查主页的内容呢?Django 提供了一个类django.test.Client来帮助这里。这个Client类的实例就像一个 Web 浏览器,可以用来请求页面并检查返回的响应。每个django.test.TestCase类都会自动分配一个Client类的实例,可以使用self.client来访问。

要了解如何使用测试Client,让我们来看一下调查应用主页测试的开始部分:

    def testHome(self): 
        from django.core.urlresolvers import reverse 
        response = self.client.get(reverse('survey_home')) 
        self.assertEqual(response.status_code, 200) 

SurveyHomeTest中定义了一个testHome方法。这个方法使用测试的client类实例的get方法来检索调查主页(再次使用reverse来确定正确的 URL,以确保所有 URL 配置信息都被隔离在urls.py中)。get的返回值是由调用来提供请求页面的视图返回的django.http.HttpResponse对象,附带一些额外的信息以便于测试。测试的最后一行通过确保返回的响应的status_code属性为200(HTTP OK)来验证请求是否成功。

请注意,测试Client提供的get方法支持不止我们在这里传递的单个 URL 参数。此外,它支持两个关键字参数datafollow,它们分别默认为空字典和False。最后,还可以提供任意数量的extra关键字参数。

如果data字典不为空,则用于构造请求的查询字符串。例如,考虑这样一个get方法:

response = self.client.get('/survey/', data={'pk': 4, 'type': 'results'})

为了处理这个请求创建的 URL 将是/survey/?pk=4&type=results

请注意,您还可以在传递给get的 URL 路径中包含查询字符串。因此,等效的调用将是:

response = self.client.get('/survey/?pk=4&type=results')

如果提供了data字典和 URL 路径中的查询字符串,则data字典用于处理请求,URL 路径中的查询字符串将被忽略。

getfollow参数可以设置为True,以指示测试客户端跟随响应中的重定向。如果是这样,返回的响应将设置一个redirect_chain属性。这个属性将是一个描述重定向链结束之前访问的中间 URL 的列表。列表中的每个元素将是一个元组,包含中间 URL 路径和触发它被检索的状态代码。

最后,任何extra关键字参数都可以用于在请求中设置任意的 HTTP 标头值。例如:

response = self.client.get('/', HTTP_USER_AGENT='Tester')

这个调用将在请求中将HTTP_USER_AGENT标头设置为Tester

针对我们自己的测试,只提供 URL 路径参数,我们现在可以使用manage.py test survey.SurveyHomeTest来运行它,并验证到目前为止一切看起来都很好。我们可以检索主页,响应返回成功的状态代码。但是如何测试页面的内容呢?我们希望确保应该出现的各种调查都出现了,并且数据库中不应该出现在页面上的两个调查也没有列出。

返回的实际页面内容存储在响应的content属性中。我们可以直接检查这一点,但是 Django TestCase类还提供了两种方法来检查响应中是否包含某些文本。这些方法分别命名为assertContainsassertNotContains

要使用assertContains方法,我们传入response和我们要查找的文本。我们还可以选择指定文本应该出现的次数。如果我们指定了count,则文本必须在响应中出现相同的次数。如果我们没有指定countassertContains只是检查文本是否至少出现一次。最后,我们可以指定响应应该具有的status_code。如果我们没有指定这一点,那么assertContains将验证状态代码是否为 200。

assertNotContains方法与assertContains具有相同的参数,但不包括count。它验证传递的文本是否不出现在响应内容中。

我们可以使用这两种方法来验证主页是否包含CompletedActiveUpcoming各两个实例,并且不包含Too OldToo Far Out。此外,由于这些方法检查状态代码,我们可以从我们自己的测试代码中删除该检查。因此,测试方法变为:

    def testHome(self):
        from django.core.urlresolvers import reverse
        response = self.client.get(reverse('survey_home'))
        self.assertContains(response, "Completed", count=2)
        self.assertContains(response, "Active", count=2)
        self.assertContains(response, "Upcoming", count=2)
        self.assertNotContains(response, "Too Old")
        self.assertNotContains(response, "Too Far Out")

如果我们尝试运行这个版本,我们会看到它可以工作。但是,它并不像我们希望的那样具体。换句话说,它没有验证列出的调查是否出现在页面上的正确位置。例如,当前的测试将通过,即使所有列出的调查都出现在段落现在参与调查!下面。我们如何验证每个调查是否出现在适当的列表中呢?

一种方法是手动检查response.content,找到每个预期字符串的位置,并确保它们按预期顺序出现。但是,这将使测试非常依赖页面的确切布局。将来我们可能决定重新排列列表的呈现方式,这个测试可能会失败,即使每个调查仍然被列在正确的类别中。

我们真正想要做的是验证调查是否包含在传递给模板的适当上下文变量中。实际上我们可以测试这一点,因为client.get返回的响应带有用于呈现模板的上下文的注释。因此,我们可以这样检查已完成的调查列表:

        completed = response.context['completed_surveys'] 
        self.assertEqual(len(completed), 2) 
        for survey in completed: 
            self.failUnless(survey.title.startswith("Completed")) 

这段代码从响应上下文中检索 completed_surveys 上下文变量,验证其中是否有 2 个项目,并进一步验证每个项目是否具有以字符串 Completed 开头的 title。如果我们运行该代码,我们会看到它适用于检查已完成的调查。然后,我们可以将该代码块复制两次,并适当调整,以检查活动和即将开始的调查,或者我们可以变得更加复杂,编写类似于这样的代码:

        context_vars = ['completed_surveys', 'active_surveys', 'upcoming_surveys'] 
        title_starts = ['Completed', 'Active', 'Upcoming'] 
        for context_var, title_start in zip(context_vars, title_starts):
            surveys = response.context[context_var] 
            self.assertEqual(len(surveys), 2) 
            for survey in surveys: 
                self.failUnless(survey.title.startswith(title_start))

在这里,我们通过构建一个要检查的事项列表,然后遍历该列表,避免了基本上三次重复相同的代码块,只是有些微的差异。因此,我们只有一个代码块出现一次,但它循环三次,每次都是为了检查我们想要检查的上下文变量之一。这是一种常用的技术,用于避免多次重复几乎相同的代码。

请注意,当在测试中使用这种技术时,最好在断言检查中包含具体的消息。在代码的原始版本中,直接测试已完成的列表,如果出现错误,比如列表中有太多的调查,测试失败将产生一个相当具体的错误报告:

FAIL: testHome (survey.tests.view_tests.SurveyHomeTest) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests/view_tests.py", line 29, in testHome 
 self.assertEqual(len(completed), 2) 
AssertionError: 3 != 2 

---------------------------------------------------------------------- 

在这里,包含字符串 completed 的代码失败,因此清楚哪个列表出了问题。使用代码的更通用版本,这个报告就不那么有帮助了:

FAIL: testHome (survey.tests.view_tests.SurveyHomeTest) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests/view_tests.py", line 35, in testHome 
 self.assertEqual(len(surveys), 2) 
AssertionError: 3 != 2 

---------------------------------------------------------------------- 

遇到这种失败报告的可怜程序员将无法知道这三个列表中哪一个有太多的项目。然而,通过提供具体的断言错误消息,这一点可以变得清晰。因此,具有描述性错误的完整测试方法的更好版本将是:

    def testHome(self): 
        from django.core.urlresolvers import reverse 
        response = self.client.get(reverse('survey_home')) 
        self.assertNotContains(response, "Too Old") 
        self.assertNotContains(response, "Too Far Out")          
        context_vars = ['completed_surveys', 'active_surveys', 'upcoming_surveys'] 
        title_starts = ['Completed', 'Active', 'Upcoming'] 
        for context_var, title_start in zip(context_vars, title_starts): 
            surveys = response.context[context_var] 
            self.assertEqual(len(surveys), 2, 
                "Expected 2 %s, found %d instead" % 
                (context_var, len(surveys))) 
            for survey in surveys: 
                self.failUnless(survey.title.startswith(title_start), 
                    "%s title %s does not start with %s" % 
                    (context_var, survey.title, title_start)) 

现在,如果在通用代码的检查过程中出现故障,错误消息已经具体到足以指出问题所在:

FAIL: testHome (survey.tests.view_tests.SurveyHomeTest) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests/view_tests.py", line 36, in testHome 
 (context_var, len(surveys))) 
AssertionError: Expected 2 completed_surveys, found 3 instead 

---------------------------------------------------------------------- 

我们现在对我们的调查主页有一个相当完整的测试,或者至少是我们迄今为止实施的部分。是时候把注意力转向调查详细页面了,接下来我们将介绍这部分内容。

创建调查详细页面

我们在项目的 URL 配置中添加的第二个 URL 映射是用于调查详细页面的。实现这个视图比主页视图要复杂一些,因为根据请求的调查状态,需要呈现完全不同的数据。如果调查已完成,我们需要显示结果。如果调查正在进行中,我们需要显示一个表单,允许用户参与调查。如果调查即将开始,我们不希望调查可见。

一次性完成所有这些工作,而不在验证的过程中进行测试以确保我们朝着正确的方向前进,那将是在自找麻烦。最好将任务分解成较小的部分,并在进行测试时逐步进行。我们将在接下来的部分中迈出朝着这个方向的第一步。

完善调查详细视图

首先要做的是用一个视图替换调查详细页面的简单占位符视图,该视图确定请求的调查状态,并适当地路由请求。例如:

import datetime 
from django.shortcuts import render_to_response, get_object_or_404 
from django.http import Http404 
from survey.models import Survey 
def survey_detail(request, pk): 
    survey = get_object_or_404(Survey, pk=pk) 
    today = datetime.date.today() 
    if survey.closes < today: 
        return display_completed_survey(request, survey) 
    elif survey.opens > today: 
        raise Http404 
    else: 
        return display_active_survey(request, survey) 

这个 survey_detail 视图使用 get_object_or_404 快捷方式从数据库中检索请求的 Survey。如果请求的调查不存在,该快捷方式将自动引发 Http404 异常,因此以下代码不必考虑这种情况。然后,视图检查返回的 Survey 实例上的 closes 日期。如果它在今天之前关闭,请求将被发送到名为 display_completed_survey 的函数。否则,如果调查尚未开放,将引发 Http404 异常。最后,如果这些条件都不成立,调查必须是活动的,因此请求将被路由到名为 display_active_survey 的函数。

首先,我们将非常简单地实现这两个新函数。它们不会执行它们的情况所需的任何真正工作,但它们在呈现响应时将使用不同的模板:

def display_completed_survey(request, survey): 
    return render_to_response('survey/completed_survey.html', {'survey': survey}) 

def display_active_survey(request, survey): 
    return render_to_response('survey/active_survey.html', {'survey': survey}) 

只需这么多代码,我们就可以继续测试不同州的调查是否被正确路由。不过,首先,我们需要创建视图代码引入的两个新模板。

调查详细页面的模板

这两个新模板的名称分别是survey/completed_survey.htmlsurvey/active_survey.html。将它们创建在survey/templates目录下。一开始,它们可以非常简单。例如,completed_survey.html可能是:

{% extends "survey/base.html" %} 
{% block content %} 
<h1>Survey results for {{ survey.title }}</h1> 
{% endblock content %} 

同样地,active_survey.html可能是:

{% extends "survey/base.html" %} 
{% block content %} 
<h1>Survey questions for {{ survey.title }}</h1> 
{% endblock content %} 

每个模板都扩展了survey/base.html模板,并为content块提供了最少但描述性的内容。在每种情况下,显示的只是一个一级标题,用标题标识调查,以及页面是否显示结果或问题。

调查详细页面的基本测试

现在考虑如何测试survey_detail中的路由代码是否工作正常。同样,我们需要测试数据,其中至少有一个调查处于三种状态之一。我们在SurveyHomeTestsetUp方法中创建的测试数据就包含了这些。然而,向主页测试用例添加实际测试调查详细页面视图的方法会很混乱。重复非常相似的setUp代码也不太吸引人。

幸运的是,我们不需要做任何一种。我们可以将现有的setUp代码移到一个更一般的测试用例中,比如SurveyTest,然后基于这个新的SurveyTest来构建SurveyHomeTest和我们的新的SurveyDetailTest。通过这种方式,主页测试和详细页面测试将在数据库中由基本的SurveyTest setUp方法创建相同的数据。此外,任何需要类似数据的其他测试也可以继承自SurveyTest

鉴于我们已经有了测试数据,我们可以做些什么来测试我们迄今为止实现的详细视图?即将到来的调查的情况很容易,因为它应该简单地返回一个 HTTP 404(未找到)页面。因此,我们可以从SurveyDetailTest中为这种情况创建一个方法开始:

from django.core.urlresolvers import reverse 
class SurveyDetailTest(SurveyTest): 
    def testUpcoming(self): 
        survey = Survey.objects.get(title='Upcoming 1') 
        response = self.client.get(reverse('survey_detail', args=(survey.pk,))) 
        self.assertEqual(response.status_code, 404) 

testUpcoming方法从数据库中检索一个即将到来的调查,并使用测试client请求包含该调查详细信息的页面。再次使用reverse来构建适当的详细页面的 URL,将我们请求的调查的主键作为args元组中的单个参数传递。通过确保响应的status_code为 404 来测试对这个请求的正确处理。如果我们现在运行这个测试,我们会看到:

ERROR: testUpcoming (survey.tests.view_tests.SurveyDetailTest)
----------------------------------------------------------------------
Traceback (most recent call last):
 File "/dj_projects/marketr/survey/tests/view_tests.py", line 45, in testUpcoming
 response = self.client.get(reverse('survey_detail', args=(survey.pk,)))
 File "/usr/lib/python2.5/site-packages/django/test/client.py", line 281, in get
 response = self.request(**r)
 File "/usr/lib/python2.5/site-packages/django/core/handlers/base.py", line 119, in get_response
 return callback(request, **param_dict)
 File "/usr/lib/python2.5/site-packages/django/views/defaults.py", line 13, in page_not_found
 t = loader.get_template(template_name) # You need to create a 404.html template.
 File "/usr/lib/python2.5/site-packages/django/template/loader.py", line 81, in get_template
 source, origin = find_template_source(template_name)
 File "/usr/lib/python2.5/site-packages/django/template/loader.py", line 74, in find_template_source
 raise TemplateDoesNotExist, name
TemplateDoesNotExist: 404.html

糟糕。为了使survey_detail视图成功引发Http404并导致“页面未找到”响应,项目中必须存在一个404.html模板。我们还没有创建一个,所以这个测试生成了一个错误。为了解决这个问题,我们可以创建一个简单的survey/templates/404.html文件,其中包含:

{% extends "survey/base.html" %}
{% block content %}
<h1>Page Not Found</h1>
<p>The requested page was not found on this site.</p>
{% endblock content %}

同时,我们还应该创建一个survey/templates/500.html文件,以避免在遇到服务器错误的情况下出现类似的无用错误。现在使用的一个简单的500.html文件会很像这个404.html文件,只是将文本更改为指示问题是服务器错误,而不是页面未找到的情况。

有了404.html模板,我们可以尝试再次运行这个测试,这一次,它会通过。

那么如何测试已完成和活动调查的页面呢?我们可以编写测试,检查response.content中我们放置在各自模板中的标题文本。然而,随着我们继续开发,该文本可能不会保持不变——在这一点上,它只是占位文本。最好验证正确的模板是否用于呈现每个响应。TestCase类有一个用于此目的的方法:assertTemplateUsed。因此,我们可以编写这些在长期内可能会继续正常工作的情况的测试,如下所示:

    def testCompleted(self): 
        survey = Survey.objects.get(title='Too Old') 
        response = self.client.get(reverse('survey_detail', args=(survey.pk,))) 
        self.assertTemplateUsed(response, 'survey/completed_survey.html')

    def testActive(self): 
        survey = Survey.objects.get(title='Active 1') 
        response = self.client.get(reverse('survey_detail', args=(survey.pk,))) 
        self.assertTemplateUsed(response, 'survey/active_survey.html') 

每个测试方法都从适当的类别中检索调查,并请求该调查的详细页面。到目前为止,对响应的唯一测试是检查是否使用了预期的模板来呈现响应。同样,我们现在可以运行这些测试并验证它们是否通过。

除了assertTemplateUsed之外,TestCase还提供了一个assertTemplateNotUsed方法。它接受与assertTempalteUsed相同的参数。正如你所期望的那样,它验证指定的模板未被用于呈现响应。

在这一点上,我们将暂停实施survey应用程序页面。下一个单元测试主题是如何测试接受用户输入的页面。我们在调查应用程序中还没有这样的页面,但 Django 管理员应用程序有。因此,在开发测试之前,测试管理员自定义提供了学习如何测试这些页面的更快捷的途径,因为我们需要编写更少的自定义代码。此外,学习如何测试管理员自定义本身也是有用的。

自定义管理员添加和更改调查页面

我们已经看到 Django 管理员应用程序提供了一种方便的方式来检查和操作数据库中的数据。在上一章中,我们对管理员进行了一些简单的自定义,以允许在Surveys中内联编辑Questions和在Questions中内联编辑Answers。除了这些内联自定义之外,我们没有对管理员默认值进行任何更改。

对管理员进行的另一个很好的改变是确保Survey openscloses日期是有效的。显然,对于这个应用程序,拥有一个晚于closesopens日期是没有意义的,但管理员无法知道这一点。在这一部分,我们将自定义管理员以强制执行我们的应用程序对openscloses之间关系的要求。我们还将为此自定义开发一个测试。

开发自定义调查表单

实施此管理员自定义的第一步是为Survey实施一个包括自定义验证的表单。例如:

from django import forms
class SurveyForm(forms.ModelForm): 
    class Meta: 
        model = Survey 
    def clean(self): 
        opens = self.cleaned_data.get('opens') 
        closes = self.cleaned_data.get('closes') 
        if opens and closes and opens > closes: 
            raise forms.ValidationError("Opens date cannot come, " "after closes date.") 
        return self.cleaned_data 

这是Survey模型的标准ModelForm。由于我们想要执行的验证涉及表单上的多个字段,最好的地方是在整体表单的clean方法中进行。这里的方法从表单的cleaned_data字典中检索openscloses的值。然后,如果它们都已提供,它会检查opens是否晚于closes。如果是,就会引发ValidationError,否则一切正常,所以从clean中返回未修改的现有cleaned_data字典。

由于我们将在管理员中使用此表单,并且目前不预期需要在其他地方使用它,我们可以将此表单定义放在现有的survey/admin.py文件中。

配置管理员使用自定义表单

下一步是告诉管理员使用此表单,而不是默认的Survey模型的ModelForm。要做到这一点,将survey/admin.py中的SurveyAdmin定义更改为:

class SurveyAdmin(admin.ModelAdmin):
    form = SurveyForm
    inlines = [QuestionsInline]

通过指定form属性,我们告诉管理员在添加和编辑Survey实例时使用我们的自定义表单。我们可以通过使用管理员编辑我们现有的“获奖答案测试”调查并尝试将其closes日期更改为早于opens的日期来快速验证这一点。如果我们这样做,我们将看到错误报告如下:

配置管理员使用自定义表单

我们能够手动验证我们的自定义是否有效是很好的,但我们真正想要的是自动化测试。下面将介绍这一点。

测试管理员自定义

我们如何为这个管理员自定义编写测试?关于测试在管理员页面上按下“保存”按钮的行为,至少有一些不同于我们迄今为止测试的地方。首先,我们需要发出 HTTP POST 方法,而不是 GET,以进行请求。测试Client提供了一个post方法,用于此目的,类似于get。对于post,我们需要指定要包含在请求中的表单数据值。我们将这些提供为键/值对的字典,其中键是表单字段的名称。由于我们知道管理员正在使用的ModelForm,因此我们知道这里的键值是模型字段的名称。

我们将从编写一个测试开始,用于管理员添加调查页面,因为在这种情况下,我们不需要在数据库中有任何预先存在的数据。让我们在测试目录中创建一个名为admin_tests.py的新文件来测试管理员视图。还要记得将from admin_tests import *添加到tests/__init__.py文件中,以便在运行tests时找到这些测试。

首次尝试实现对管理员应用程序使用我们定制的“调查”表单的测试可能如下所示:

import datetime 
from django.test import TestCase 
from django.core.urlresolvers import reverse 

class AdminSurveyTest(TestCase):    
    def testAddSurveyError(self): 
        post_data = { 
            'title': u'Time Traveling', 
            'opens': datetime.date.today(), 
            'closes': datetime.date.today() - datetime.timedelta(1), 
        } 
        response = self.client.post(reverse('admin:survey_survey_add'), post_data) 
        self.assertContains(response, "Opens date cannot come after closes date.") 

在这里,我们有一个测试方法testAddSurveyError,它使用Survey ModelFormtitleopenscloses值创建一个post_data字典。我们使用测试client将该字典postsurvey应用程序的管理员Survey添加页面(使用该管理员视图的文档名称的reverse)。我们期望返回的response应该包含我们自定义ModelForm的错误消息,因为我们指定了一个晚于closes日期的opens日期。我们使用assertContains来检查预期的错误消息是否在响应中找到。

请注意,与get一样,我们第一个使用post的测试只使用了可以提供给该方法的参数的子集。除了 URLpathdata字典之外,post还接受一个content_type关键字参数。此参数默认为一个值,导致客户端发送mutlipart/form-data。除了content_typepost还支持相同的followextra关键字参数,具有与get相同的默认值和处理行为。

我们对管理员自定义测试的第一次尝试有效吗?不幸的是,不是。如果我们使用manage.py test survey.AdminSurveyTest运行它,我们将看到以下失败:

FAIL: testAddSurveyError (survey.tests.admin_tests.AdminSurveyTest) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests/admin_tests.py", line 13, in testAddSurveyError 
 self.assertContains(response, "Opens date cannot come after closes date.") 
 File "/usr/lib/python2.5/site-packages/django/test/testcases.py", line 345, in assertContains 
 "Couldn't find '%s' in response" % text) 
AssertionError: Couldn't find 'Opens date cannot come after closes date.' in response 

---------------------------------------------------------------------- 

可能出了什么问题?很难说,因为没有看到返回的响应实际包含什么。意识到这一点,我们可能会想要在错误消息中包含响应的文本。然而,响应往往相当长(因为它们通常是完整的网页),通常将它们包含在测试失败输出中通常会增加更多的噪音。因此,通常最好对测试用例进行临时更改以打印响应,以便弄清楚可能发生了什么。

如果我们在这种情况下这样做,我们将看到返回的响应开始(在一些标准的 HTML 样板之后):

<title>Log in | Django site admin</title> 

哦,对了,我们忘了管理员需要登录用户才能访问。我们在测试用例中没有做任何设置和登录用户的操作,因此当测试尝试访问管理员页面时,管理员代码会简单地返回一个登录页面。

因此,我们的测试首先需要创建一个用户,因为测试数据库最初是空的。该用户需要适当的权限来访问管理,并且必须在尝试对管理应用程序执行任何操作之前登录。这种情况适合于测试setUp例程:

import datetime
from django.test import TestCase
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse

class AdminSurveyTest(TestCase):
    def setUp(self):
        self.username = 'survey_admin'
        self.pw = 'pwpwpw'
        self.user = User.objects.create_user(self.username, '', self.pw)
        self.user.is_staff= True
        self.user.is_superuser = True
        self.user.save()
        self.assertTrue(self.client.login(username=self.username, password=self.pw),
            "Logging in user %s, pw %s failed." % (self.username, self.pw))

在这里,setUp例程使用标准django.contrib.auth User模型提供的create_user方法创建一个名为survey_admin的用户。创建用户后,setUp将其is_staffis_superuser属性设置为True,并将用户再次保存到数据库中。这将允许新创建的用户访问管理应用程序中的所有页面。

最后,setUp尝试使用测试Client login方法登录新用户。如果成功,此方法将返回True。在这里,setUp断言login确实返回True。如果没有,断言将提供特定的指示,说明出了什么问题。这应该比如果login调用失败后继续测试更有帮助。

Client login方法有一个伴随方法logout。我们应该在setUp中使用login后,在tearDown方法中使用它:

    def tearDown(self): 
        self.client.logout() 

现在我们的测试工作了吗?不,但它确实更进一步了。这次的错误报告是:

ERROR: testAddSurveyError (survey.tests.admin_tests.AdminSurveyTest) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests/admin_tests.py", line 26, in testAddSurveyError 
 response = self.client.post(reverse('admin:survey_survey_add'), post_data) 
 File "/usr/lib/python2.5/site-packages/django/test/client.py", line 313, in post 
 response = self.request(**r) 
 File "/usr/lib/python2.5/site-packages/django/core/handlers/base.py", line 92, in get_response 
 response = callback(request, *callback_args, **callback_kwargs) 
 File "/usr/lib/python2.5/site-packages/django/contrib/admin/options.py", line 226, in wrapper 
 return self.admin_site.admin_view(view)(*args, **kwargs) 
 File "/usr/lib/python2.5/site-packages/django/views/decorators/cache.py", line 44, in _wrapped_view_func 
 response = view_func(request, *args, **kwargs) 
 File "/usr/lib/python2.5/site-packages/django/contrib/admin/sites.py", line 186, in inner 
 return view(request, *args, **kwargs) 
 File "/usr/lib/python2.5/site-packages/django/db/transaction.py", line 240, in _commit_on_success 
 res = func(*args, **kw) 
 File "/usr/lib/python2.5/site-packages/django/contrib/admin/options.py", line 731, in add_view 
 prefix=prefix) 
 File "/usr/lib/python2.5/site-packages/django/forms/models.py", line 724, in __init__ 
 queryset=qs) 
 File "/usr/lib/python2.5/site-packages/django/forms/models.py", line 459, in __init__ 
 super(BaseModelFormSet, self).__init__(**defaults) 
 File "/usr/lib/python2.5/site-packages/django/forms/formsets.py", line 44, in __init__ 
 self._construct_forms() 
 File "/usr/lib/python2.5/site-packages/django/forms/formsets.py", line 87, in _construct_forms 
 for i in xrange(self.total_form_count()): 
 File "/usr/lib/python2.5/site-packages/django/forms/models.py", line 734, in total_form_count 
 return super(BaseInlineFormSet, self).total_form_count() 
 File "/usr/lib/python2.5/site-packages/django/forms/formsets.py", line 66, in total_form_count 
 return self.management_form.cleaned_data[TOTAL_FORM_COUNT] 
 File "/usr/lib/python2.5/site-packages/django/forms/formsets.py", line 54, in _management_form 
 raise ValidationError('ManagementForm data is missing or has been tampered with') 
ValidationError: [u'ManagementForm data is missing or has been tampered with'] 

---------------------------------------------------------------------- 

起初可能有点困惑,但在 Django 文档中搜索ManagementForm很快就会发现,当使用 formsets 时,这是必需的内容。由于作为我们的管理定制的一部分,我们指定Questions内联显示在Survey页面上,因此Survey的管理页面包含了Questions的 formset。但是,在我们的post_data字典中没有提供所需的ManagementForm值。所需的两个值是question_setTOTAL_FORMSINITIAL_FORMS。由于我们不想在这里测试内联的管理处理,我们可以在我们的数据字典中将这些值设置为0

    def testAddSurveyError(self): 
        post_data = { 
            'title': u'Time Traveling', 
            'opens': datetime.date.today(), 
            'closes': datetime.date.today() - datetime.timedelta(1), 
            'question_set-TOTAL_FORMS': u'0', 
            'question_set-INITIAL_FORMS': u'0', 
        } 
        response = self.client.post(reverse('admin:survey_survey_add'), post_data) 
        self.assertContains(response, "Opens date cannot come after closes date.") 

现在这个测试工作吗?是的,如果我们运行manage.py test survey.AdminSurveyTest.testAddSurveyError,我们会看到测试成功运行。

请注意,TestCase提供了一个比assertContains更具体的断言来检查表单错误的方法,名为assertFormErrorassertFormError的参数是响应、模板上下文中表单的名称、要检查错误的字段的名称(如果错误是非字段错误,则为None),以及要检查的错误字符串(或错误字符串列表)。但是,在测试管理页面时无法使用assertFormError,因为管理页面不会直接在上下文中提供表单。相反,上下文包含一个包含实际表单的包装对象。因此,我们无法将这个特定的测试更改为使用更具体的assertFormError方法。

我们完成了对管理定制的测试吗?几乎。由于在管理中添加和更改操作都使用相同的表单,因此无需测试更改页面。但是,最好添加一个包含有效数据并确保对于该情况没有出现任何问题的测试。

添加一个测试方法很容易,该方法构建一个包含有效数据的数据字典,并将其发布到管理添加视图。但是,响应中应该测试什么?管理代码在成功完成 POST 请求的某些操作后不会返回简单的200 OK响应。相反,它会重定向到另一个页面,以便尝试重新加载 POST 请求的页面不会导致再次尝试 POST 相同的数据。在添加对象的情况下,管理将重定向到已添加模型的更改列表页面。TestCase提供了一个assertRedirects方法来测试这种行为。我们可以这样使用这个方法:

    def testAddSurveyOK(self): 
        post_data = { 
            'title': u'Time Traveling', 
            'opens': datetime.date.today(), 
            'closes': datetime.date.today(), 
            'question_set-TOTAL_FORMS': u'0', 
            'question_set-INITIAL_FORMS': u'0', 
        } 
        response = self.client.post(reverse('admin:survey_survey_add'), post_data) 
        self.assertRedirects(response, reverse('admin:survey_survey_changelist')) 

这个testAddSurveyOK方法为Survey设置了一个有效的数据字典,指定了相同的openscloses日期。然后将这些数据发布到管理员添加调查页面,并保存响应。最后,它断言响应应该重定向到Survey模型的管理员调查应用程序更改列表页面。assertRedirects的两个额外的可选参数是status_codetarget_status_code。它们分别默认为302200,所以我们在这里不需要指定它们,因为这些是我们在这种情况下期望的代码。

额外的测试支持

本章中我们开发的测试提供了如何使用 Django 的TestCase和测试Client类提供的测试支持的相当广泛的概述。然而,这些示例既没有涵盖这些类提供的每一个细节,也没有涵盖Client返回的注释response对象中的附加数据的每一个细节。在本节中,我们简要提到了TestCaseClientresponse对象可用的一些附加功能。我们不会开发使用所有这些功能的示例;它们在这里提到,以便如果您遇到对这种类型的支持有需求,您将知道它的存在。Django 文档提供了所有这些主题的详细信息。

支持额外的 HTTP 方法

我们的示例测试只需要使用 HTTP GET 和 POST 方法。测试Client类还提供了发出 HTTP HEAD、OPTIONS、PUT 和 DELETE 请求的方法。这些方法分别命名为headoptionsputdelete。每个方法都支持与getpost相同的followextra参数。此外,put支持与post相同的content_type参数。

保持持久状态

测试Client维护两个属性,跨请求/响应周期保持持久状态:cookiessessioncookies属性是一个包含已收到的任何 cookie 的 Python SimpleCookie对象。session属性是一个类似字典的对象,包含会话数据。

电子邮件服务

Web 应用程序中的一些视图可能会创建并发送邮件。在测试时,我们不希望实际发送这样的邮件,但能够验证正在测试的代码是否生成并尝试发送邮件是很好的。TestCase类通过在运行测试时将标准的 Python SMTPConnection类(仅在运行测试时)替换为一个不发送邮件而是将其存储在django.core.mail.outbox中的自定义类来支持这一点。因此,测试代码可以检查这个outbox的内容,以验证正在测试的代码是否尝试发送预期的邮件。

提供特定于测试的 URL 配置

在本章开发的示例中,我们小心地确保测试独立于 URL 配置的具体细节,始终使用命名 URL 并使用reverse将这些符号名称映射回 URL 路径值。这是一个很好的技术,但在某些情况下可能不足够。

考虑到您正在开发一个可重用的应用程序,该应用程序的特定安装可能选择部署可选视图。对于测试这样的应用程序,您不能依赖于可选视图实际上包含在项目的 URL 配置中,但您仍希望能够为它们包括测试。为了支持这一点,TestCase类允许实例设置一个urls属性。如果设置了这个属性,TestCase将使用指定模块中包含的 URL 配置,而不是项目的 URL 配置。

响应上下文和模板信息

在测试调查主页时,我们使用简单的字典样式访问检查响应context属性中的值。例如:

completed = response.context['completed_surveys'] 

虽然这样可以工作,但它忽略了在考虑用于呈现响应的上下文时涉及的一些复杂性。回想一下,我们设置了项目,使其具有两级模板层次结构。base.html模板由每个单独的页面模板扩展。用于呈现响应的每个模板都有其自己的关联上下文,因此响应的context属性不是一个简单的字典,而是用于呈现每个模板的上下文的列表。实际上,它是一种称为django.test.utils.ContextList的东西,其中包含许多django.template.context.Context对象。

这个ContextList对象支持字典样式的访问以简化操作,并在它包含的每个上下文中搜索指定的键。我们在本章的早期示例中使用了这种简单的访问方式。但是,如果您需要更具体地检查要在哪个模板上下文中,响应的context属性也支持这一点,因为您还可以通过索引号到ContextList中检索与特定模板相关的完整上下文。

此外,测试Client返回的响应具有一个template属性,该属性是用于呈现响应的模板的列表。我们没有直接使用这个属性,因为我们使用了TestCase提供的assertTemplateUsed方法。

测试事务行为

本章最后要讨论的主题涉及测试事务行为。如果有必要这样做,有一个替代的测试用例类TransactionTestCase,应该使用它来代替TestCase

什么是测试事务行为的意思?假设您有一个视图,它在单个数据库事务中进行一系列数据库更新。此外,假设您需要测试至少一个更新起初有效,但随后失败,应该导致整个更新集被回滚而不是提交的情况。为了测试这种行为,您可能会尝试在测试代码中验证,当收到响应时,最初有效的更新之一在数据库中是不可见的。要成功运行这种测试代码,您需要使用TransactionTestCase而不是TestCase

这是因为TestCase在调用测试方法之间使用事务回滚来将数据库重置为干净状态。为了使这种回滚方法在测试方法之间的清理工作,受测试代码不得允许发出任何数据库提交或回滚操作。因此,TestCase拦截任何此类调用,并简单地返回而不实际将它们转发到数据库。因此,您的测试代码将无法验证应该被回滚的更新是否已被回滚,因为在TestCase下运行时它们将不会被回滚。

TransactionTestCase在测试方法之间不使用回滚来重置数据库。相反,它截断并重新创建所有表。这比回滚方法慢得多,但它确实允许测试代码验证从受测试代码执行成功的任何数据库事务行为。

总结

我们现在已经讨论完了 Django 的单元测试扩展,以支持测试 Web 应用程序。在本章中,我们:

  • 学会了将单元测试组织成单独的文件,而不是将所有内容放入单个 tests.py 文件

  • 开始为调查应用程序开发视图,并学会了如何使用 Django 的单元测试扩展来测试这些视图

  • 学会了如何通过为我们的模型提供自定义验证来定制管理界面,并学会了如何测试该管理定制

  • 简要讨论了 Django 提供的一些单元测试扩展,我们在任何示例测试中都没有遇到

  • 学会了在何时需要使用TransactionTestCase而不是TestCase进行测试

在学习如何测试 Django 应用程序方面,我们已经涵盖了很多内容,但是测试 Web 应用程序还有许多方面我们甚至还没有涉及。其中一些更适合使用 Django 本身以外的工具进行测试。下一章将探讨一些额外的 Web 应用程序测试需求,并展示如何将外部工具集成到 Django 的测试支持中,以满足这些需求。

第五章:填补空白:集成 Django 和其他测试工具

之前的章节已经讨论了 Django 1.1 提供的内置应用程序测试支持。我们首先学习了如何使用 doctests 来测试应用程序的构建模块,然后介绍了单元测试的基础知识。此外,我们还看到了django.test.TestCasedjango.test.Client提供的函数如何帮助测试 Django 应用程序。通过示例,我们学习了如何使用这些函数来测试应用程序的更完整的部分,例如它提供的页面内容和表单处理行为。

然而,Django 本身并没有提供测试支持所需的一切。毕竟,Django 是一个 Web 应用程序框架,而不是一个测试框架。例如,它不提供任何测试覆盖信息,这对于开发全面的测试套件至关重要,也不提供任何支持测试客户端行为的支持,因为 Django 纯粹是一个服务器端框架。存在其他工具来填补这些空白,但通常希望将这些其他工具与 Django 集成,而不是使用完全不同的工具集来构建完整的应用程序测试套件。

在某些情况下,即使 Django 支持某个功能,也可能更喜欢使用其他工具。例如,如果您已经有了使用 Python 测试框架(如nose)的经验,它提供了非常灵活的测试发现机制和强大的测试插件架构,您可能会发现 Django 的测试运行器相当受限制。同样,如果您熟悉twill Web 测试工具,您可能会发现与twill相比,使用 Django 的测试Client来测试表单行为相当麻烦。

在本章中,我们将调查 Django 与其他测试工具的集成。集成有时可以通过使用标准的 Python 单元测试扩展机制来实现,但有时需要更多。本章将涵盖这两种情况。具体来说,我们将:

  • 讨论集成涉及的问题,并了解 Django 提供的用于将其他工具集成到其测试结构中的钩子。

  • 探讨一个问题:我们的代码有多少被我们的测试执行了?我们将看到如何在不对 Django 测试设置进行任何更改的情况下回答这个问题,并利用之前讨论过的钩子。

  • 探索twill工具,并了解如何在我们的 Django 应用程序测试中使用它,而不是 Django 测试Client。对于这种集成,我们不需要使用任何 Django 钩子进行集成,我们只需要使用 Python 的单元测试钩子进行测试设置和拆卸。

集成的问题

为什么 Django 测试与其他工具的集成甚至是一个问题?考虑想要使用nose测试框架的情况。它提供了自己的命令nosetests,用于在项目树中查找并运行测试。然而,在 Django 项目树中尝试运行nosetests而不是manage.py test,很快就会发现一个问题:

kmt@lbox:/dj_projects/marketr$ nosetests 
E 
====================================================================== 
ERROR: Failure: ImportError (Settings cannot be imported, because environment variable DJANGO_SETTINGS_MODULE is undefined.) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/usr/lib/python2.5/site-packages/nose-0.11.1-py2.5.egg/nose/loader.py", line 379, in loadTestsFromName 
 addr.filename, addr.module) 
 File "/usr/lib/python2.5/site-packages/nose-0.11.1-py2.5.egg/nose/importer.py", line 39, in importFromPath 
 return self.importFromDir(dir_path, fqname) 
 File "/usr/lib/python2.5/site-packages/nose-0.11.1-py2.5.egg/nose/importer.py", line 86, in importFromDir 
 mod = load_module(part_fqname, fh, filename, desc) 
 File "/dj_projects/marketr/survey/tests/__init__.py", line 1, in <module> 
 from model_tests import * 
 File "/dj_projects/marketr/survey/tests/model_tests.py", line 2, in <module> 
 from django.test import TestCase 
 File "/usr/lib/python2.5/site-packages/django/test/__init__.py", line 5, in <module> 
 from django.test.client import Client 
 File "/usr/lib/python2.5/site-packages/django/test/client.py", line 24, in <module> 
 from django.db import transaction, close_connection 
 File "/usr/lib/python2.5/site-packages/django/db/__init__.py", line 10, in <module> 
 if not settings.DATABASE_ENGINE: 
 File "/usr/lib/python2.5/site-packages/django/utils/functional.py", line 269, in __getattr__ 
 self._setup() 
 File "/usr/lib/python2.5/site-packages/django/conf/__init__.py", line 38, in _setup 
 raise ImportError("Settings cannot be imported, because environment variable %s is undefined." % ENVIRONMENT_VARIABLE) 
ImportError: Settings cannot be imported, because environment variable DJANGO_SETTINGS_MODULE is undefined. 

---------------------------------------------------------------------- 
Ran 1 test in 0.007s 

FAILED (errors=1) 

问题在于manage.py test所做的一些环境设置缺失。具体来说,没有设置环境,以便在调用 Django 代码时找到适当的设置。可以通过在运行nosetests之前设置DJANGO_SETTINGS_MODULE环境变量来解决这个特定的错误,但nosetests不会走得更远,因为还有更多的东西缺失。

下一个遇到的问题将是需要使用数据库的测试。在运行任何测试之前,manage.py test调用的支持代码会创建测试数据库。nosetests命令对测试数据库的需求一无所知,因此在nosetests下运行需要数据库的 Django 测试用例将失败,因为数据库不存在。简单地在运行nosetests之前设置环境变量无法解决这个问题。

可以采取两种方法来解决这些集成问题。首先,如果其他工具提供了添加功能的钩子,可以使用它们来执行诸如在运行测试之前设置环境和创建测试数据库等操作。这种方法将 Django 测试集成到其他工具中。或者,可以使用 Django 提供的钩子将其他工具集成到 Django 测试中。

第一种选项超出了本书的范围,因此不会详细讨论。但是,对于nose的特定情况,其插件架构当然支持添加必要的功能以使 Django 测试在nose下运行。存在可以用于允许 Django 应用程序测试在从nosetests调用时成功运行的现有 nose 插件。如果这是您想要采用的方法进行自己的测试,您可能希望在构建自己的nose插件之前搜索现有解决方案。

第二个选项是我们将在本节中关注的:Django 提供的允许将其他函数引入到 Django 测试的正常路径中的钩子。这里可以使用两个钩子。首先,Django 允许指定替代测试运行程序。首先将描述指定这一点,测试运行程序的责任以及它必须支持的接口。其次,Django 允许应用程序提供全新的管理命令。因此,可以通过另一个命令来增强manage.py test,该命令可能支持不同的选项,并且可以执行将另一个工具集成到测试路径中所需的任何操作。也将讨论如何执行此操作的详细信息。

指定替代测试运行程序

Django 使用TEST_RUNNER设置来决定调用哪些代码来运行测试。默认情况下,TEST_RUNNER的值是'django.test.simple.run_tests'。我们可以查看该例程的声明和文档字符串,以了解它必须支持的接口:

def run_tests(test_labels, verbosity=1, interactive=True, extra_tests=[]):  
    """ 
    Run the unit tests for all the test labels in the provided list. 
    Labels must be of the form: 
     - app.TestClass.test_method 
        Run a single specific test method 
     - app.TestClass 
        Run all the test methods in a given class 
     - app 
        Search for doctests and unittests in the named application. 

    When looking for tests, the test runner will look in the models and tests modules for the application. 

    A list of 'extra' tests may also be provided; these tests 
    will be added to the test suite. 

    Returns the number of tests that failed. 
    """ 

test_labelsverbosityinteractive参数显然将直接来自manage.py test命令行。extra_tests参数有点神秘,因为没有受支持的manage.py test参数与之对应。实际上,当从manage.py test调用时,extra_tests将永远不会被指定。这个参数是由 Django 用来运行自己的测试套件的runtests.py程序使用的。除非您打算编写一个用于运行 Django 自己的测试的测试运行程序,否则您可能不需要担心extra_tests。但是,自定义运行程序应该实现包括extra_tests在内的定义行为。

测试运行程序需要做什么?这个问题最容易通过查看现有的django.test.simple.run_tests代码并看看它做了什么来回答。简而言之,不逐行进行例程,它:

  • 通过调用django.test.utils.setup_test_environment设置测试环境。这也是自定义测试运行程序应该调用的一个记录方法。它会执行一些操作,以确保例如测试客户端生成的响应具有上一章中提到的contexttemplates属性。

  • DEBUG设置为False

  • 构建包含在指定的test_labels下发现的所有测试的unittest.TestSuite。Django 的简单测试运行程序仅在modelstests模块中搜索测试。

  • 通过调用connection.creation.create_test_db创建测试数据库。这是另一个在 Django 测试文档中记录的例程,供替代测试运行程序使用。

  • 运行测试。

  • 通过调用connection.creation.destroy_test_db销毁测试数据库。

  • 通过调用django.test.utils.teardown_test_environment清理测试环境。

  • 返回测试失败和错误的总和。

注意

请注意,Django 1.2 添加了对指定替代测试运行器的基于类的方法的支持。虽然 Django 1.2 继续支持先前使用的基于函数的方法,并在此处描述,但将来将弃用使用基于函数的替代测试运行器。基于类的方法简化了对测试运行行为进行小改动的任务。您可以实现一个替代测试运行器类,该类继承自默认类,并简单地覆盖实现所需的任何特定方法以实现所需的替代行为。

因此,编写一个测试运行器是相当简单的。但是,仅仅替换测试运行器,我们受到manage.py test命令支持的参数和选项的限制。如果我们的运行器支持一些manage.py test不支持的选项,那么没有明显的方法可以将该选项从命令行传递给我们的测试运行器。相反,manage.py test 将拒绝它不知道的任何选项。

有一种方法可以绕过这个问题。Django 使用 Python 的optparse模块来解析命令行中的选项。在命令行上放置一个裸的---会导致optparse停止处理命令行,因此在裸的---之后指定的选项不会被正在解析的常规 Django 代码看到。但它们仍然可以在sys.argv中被我们的测试运行器访问,因此它们可以被检索并传递给我们正在集成的任何工具。

这种方法有效,但这些选项的存在将对用户隐藏得很好,因为test命令的标准 Django 帮助对它们一无所知。通过使用这种技术,我们扩展了manage.py test支持的接口,而没有任何明显的方式来发布我们所做的扩展,作为test命令的内置帮助的一部分。

因此,指定自定义测试运行器的一个更好的选择可能是提供一个全新的管理命令。创建一个新命令时,我们可以定义它以接受我们喜欢的任何选项,并在用户请求命令的帮助时提供应该显示的每个新选项的帮助文本。下面将讨论这种方法。

创建一个新的管理命令

提供一个新的管理命令很简单。Django 在每个已安装应用程序的目录中的management.commands包中查找管理命令。在已安装应用程序的management.commands包中找到的任何 Python 模块都可以自动用作manage.py的命令指定。

因此,要为我们的调查应用程序创建一个自定义测试命令,比如survey_test,我们在调查目录下创建一个management子目录,并在management下创建一个commands目录。我们在这两个目录中都放置__init__.py文件,以便 Python 将它们识别为模块。然后,我们将survey_test命令的实现放在一个名为survey_test.py的文件中。

survey_test.py需要包含什么?截至 Django 1.1,有关实现管理命令的文档很少。它只说明文件必须定义一个名为Command的类,该类扩展自django.core.management.base.BaseCommand。除此之外,它建议查阅一些现有的管理命令,以了解应该做什么。由于我们希望提供一个增强的测试命令,最简单的方法可能是复制test命令的实现(在django/core/management/commands/test.py中找到)到我们的survey_test.py文件中。

查看该文件,我们看到管理命令实现包含两个主要部分。首先,在必要的导入和类声明之后,为类定义了一些属性。这些属性控制诸如它支持什么选项以及用户请求命令时应显示什么帮助之类的事情:

from django.core.management.base import BaseCommand 
from optparse import make_option 
import sys 

class Command(BaseCommand): 
    option_list = BaseCommand.option_list + ( 
        make_option('--noinput', action='store_false', dest='interactive', default=True, 
            help='Tells Django to NOT prompt the user for ''input of any kind.'), 
    ) 
    help = 'Runs the test suite for the specified applications, or '\ 'the entire site if no apps are specified.' 
    args = '[appname ...]' 

    requires_model_validation = False 

请注意,虽然BaseCommand在官方 Django 1.1 文档中没有记录,但它有一个详尽的文档字符串,因此可以通过查阅源代码或使用 Python shell 的帮助函数来找到这些属性(option_listhelpargsrequires_model_validation)的确切目的。即使不查看文档字符串,我们也可以看到 Python 的标准optparse模块用于构建选项字符串,因此扩展option_list以包括其他参数是很简单的。例如,如果我们想要添加一个--cover选项来打开测试覆盖数据的生成,我们可以将option_list的规范更改为:

     option_list = BaseCommand.option_list + (
        make_option('--noinput', action='store_false',
            dest='interactive', default=True,
            help='Tells Django to NOT prompt the user for '
                'input of any kind.'),
        make_option('--cover', action='store_true',
            dest='coverage', default=False,
            help='Tells Django to generate test coverage data.'),
    ) 

在这里,我们添加了对在命令行上指定--cover的支持。如果指定了,它将导致coverage选项的值为True。如果没有指定,这个新选项将默认为False。除了添加对该选项的支持,我们还可以为它添加帮助文本。

Command实现的声明部分后面是handle函数的定义。这是将被调用来实现我们的survey_test命令的代码。来自test命令的现有代码是:

    def handle(self, *test_labels, **options): 
        from django.conf import settings 
        from django.test.utils import get_runner 

        verbosity = int(options.get('verbosity', 1)) 
        interactive = options.get('interactive', True) 
        test_runner = get_runner(settings) 

        failures = test_runner(test_labels, verbosity=verbosity,  interactive=interactive) 
        if failures: 
            sys.exit(failures) 

正如你所看到的,这执行了一个非常简单的选项检索,使用一个实用函数来找到正确的测试运行器来调用,并简单地使用传递的选项调用运行器。当运行器返回时,如果有任何失败,程序将以设置为失败数量的系统退出代码退出。

我们可以用检索新选项的代码替换最后四行,并打印出它是否已被指定:

        coverage = options.get('coverage', False) 
        print 'Here we do our own thing instead of calling the test '\
            'runner.' 
        if coverage: 
            print 'Our new cover option HAS been specified.' 
        else: 
            print 'Our new cover option HAS NOT been specified.' 

现在,我们可以尝试运行我们的survey_test命令,以验证它是否被找到并且能够接受我们的新选项:

kmt@lbox:/dj_projects/marketr$ python manage.py survey_test --cover 
Here we do our own thing instead of calling the test runner. 
Our new cover option HAS been specified.

我们还可以验证,如果我们在命令行上没有传递--cover,它默认为False

kmt@lbox:/dj_projects/marketr$ python manage.py survey_test 
Here we do our own thing instead of calling the test runner. 
Our new cover 
option HAS NOT been specified. 

最后,我们可以看到我们的选项的帮助包含在新命令的帮助响应中:

kmt@lbox:/dj_projects/marketr$ python manage.py survey_test --help 
Usage: manage.py survey_test [options] [appname ...] 

Runs the test suite for the specified applications, or the entire site if no apps are specified. 

Options: 
 -v VERBOSITY, --verbosity=VERBOSITY 
 Verbosity level; 0=minimal output, 1=normal output, 
 2=all output 
 --settings=SETTINGS   The Python path to a settings module, e.g. 
 "myproject.settings.main". If this isn't provided, the 
 DJANGO_SETTINGS_MODULE environment variable will be used. 
 --pythonpath=PYTHONPATH 
 A directory to add to the Python path, e.g. 
 "/home/djangoprojects/myproject". 
 --traceback           Print traceback on exception 
 --noinput             Tells Django to NOT prompt the user for input of any kind. 
 --cover               Tells Django to generate test coverage data. 
 --version             show program's version number and exit 
 -h, --help            show this help message and exit 

请注意,帮助消息中显示的所有其他选项,如果在我们的option_list中没有指定,都是从BaseCommand继承的。在某些情况下(例如,settingspythonpath参数),在调用它们之前,会为我们适当地处理这些参数;在其他情况下(例如verbosity),我们期望在我们的实现中遵守选项的文档行为。

添加一个新的管理命令很容易!当然,我们实际上并没有实现运行测试和生成覆盖数据,因为我们还不知道如何做到这一点。有现有的软件包提供了这种支持,我们将在下一节中看到它们如何被用来做到这一点。

现在,我们可能会删除这里创建的survey/management树。尝试添加管理命令是一个有用的练习。但实际上,如果我们要提供一个自定义的测试命令来添加诸如记录覆盖数据之类的功能,将这个功能直接绑定到我们的调查应用程序是一个不好的方法。记录覆盖数据的测试命令最好是在一个独立的应用程序中实现。

我们测试了多少代码?

在编写测试时,目标是测试一切。虽然我们可以尝试保持警惕并手动确保我们的代码的每一行都有一个测试,但这是一个非常难以实现的目标,除非有一些自动化分析来验证我们的测试执行了哪些代码行。对于 Python 代码,Ned Batchelder 的coverage模块是一个优秀的工具,用于确定哪些代码行正在执行。在本节中,我们将看到如何使用coverage,首先作为一个独立的实用程序,然后集成到我们的 Django 项目中。

使用独立的覆盖

在使用coverage之前,必须先安装它,因为它既不包含在 Python 中,也不包含在 Django 1.1 中。如果你使用 Linux,你的发行版包管理器可能有coverage可供安装在你的系统上。另外,最新版本的coverage始终可以在 Python 软件包索引(PyPI)的网页上找到,pypi.python.org/pypi/coverage。这里使用的coverage版本是 3.2。

安装完成后,我们可以使用coverage命令的run子命令来运行测试并记录覆盖数据:

kmt@lbox:/dj_projects/marketr$ coverage run manage.py test survey 
Creating test database... 
Creating table auth_permission 
Creating table auth_group 
Creating table auth_user 
Creating table auth_message 
Creating table django_content_type 
Creating table django_session 
Creating table django_site 
Creating table django_admin_log 
Creating table survey_survey 
Creating table survey_question 
Creating table survey_answer 
Installing index for auth.Permission model 
Installing index for auth.Message model 
Installing index for admin.LogEntry model 
Installing index for survey.Question model 
Installing index for survey.Answer model 
..................... 
---------------------------------------------------------------------- 
Ran 21 tests in 11.361s 

OK 
Destroying test database... 

如你所见,测试运行器的输出看起来完全正常。覆盖模块不会影响程序的输出;它只是将覆盖数据存储在名为.coverage的文件中。

.coverage中存储的数据可以使用coveragereport子命令格式化为报告:

kmt@lbox:/dj_projects/marketr$ coverage report
Name                                                 Stmts   Exec  Cover 
------------------------------------------------------------------------- 
/usr/share/pyshared/mod_python/__init__                   2      2   100% 
/usr/share/pyshared/mod_python/util                     330      1     0% 
/usr/share/pyshared/mx/TextTools/Constants/Sets          42     42   100% 
/usr/share/pyshared/mx/TextTools/Constants/TagTables     12     12   100% 
/usr/share/pyshared/mx/TextTools/Constants/__init__      1      1   100% 
/usr/share/pyshared/mx/TextTools/TextTools              259     47    18% 
/usr/share/pyshared/mx/TextTools/__init__                27     18    66% 
/usr/share/pyshared/mx/TextTools/mxTextTools/__init__    12      9    75% 
/usr/share/pyshared/mx/__init__                          2      2   100% 
/usr/share/pyshared/pysqlite2/__init__                  1      1   100% 
/usr/share/pyshared/pysqlite2/dbapi2               41     26    63% 
/usr/share/python-support/python-simplejson/simplejson/__init__      75     20    26% 
/usr/share/python-support/python-simplejson/simplejson/decoder      208    116    55% 
/usr/share/python-support/python-simplejson/simplejson/encoder      215     40    18% 
/usr/share/python-support/python-simplejson/simplejson/scanner       51     46    90% 
__init__                                                1      1   100% 
manage                                                 9      5    55% 
settings                                                 23     23   100% 
survey/__init__                                             1      1   100% 
survey/admin                                                         24     24   100% 
survey/models                                                        38     37    97% 
survey/tests/__init__                                                 4      4   100% 
survey/tests/admin_tests                                             23     23   100% 
survey/tests/model_tests                                             98     86    87% 
survey/tests/view_tests                                              47     47   100% 
survey/urls                                                           2      2   100% 
survey/views                                                         22     22   100% 
urls                                                                  4      4   100% 
------------------------------------------------------------------------------------- 
TOTAL                                                              1575    663    42% 

这比我们实际想要的要多一点。我们只关心我们自己代码的覆盖率,所以首先,对于位于/usr目录下的模块报告的内容并不感兴趣。coverage report--omit选项可用于省略以特定路径开头的模块。此外,-m选项可用于让coverage报告未执行(缺失)的行:

kmt@lbox:/dj_projects/marketr$ coverage report --omit /usr -m 
Name                       Stmts   Exec  Cover   Missing 
-------------------------------------------------------- 
__init__                       1      1   100% 
manage                         9      5    55%   5-8 
settings                      23     23   100% 
survey/__init__                1      1   100% 
survey/admin                  24     24   100% 
survey/models                 38     37    97%   66
survey/tests/__init__          4      4   100% 
survey/tests/admin_tests      23     23   100% 
survey/tests/model_tests      98     86    87%   35-42, 47-51 
survey/tests/view_tests       47     47   100% 
survey/urls                    2      2   100% 
survey/views                  23     23   100% 
urls                           4      4   100% 
-------------------------------------------------------- 
TOTAL                        297    280    94% 

这样就好多了。毫不奇怪,因为我们已经为讨论的每一部分代码开发了测试,几乎所有内容都显示为已覆盖。还有什么缺失的吗?如果你看一下manage.py的 5 到 8 行,它们处理了settings.pyimport引发ImportError的情况。由于这部分代码在成功运行时没有被执行,它们在覆盖报告中显示为缺失。

同样,model_tests中提到的行(35 到 42,47 到 51)来自于testClosesReset方法的替代执行路径,该方法包含从第 34 行开始的代码:

        if settings.DATABASE_ENGINE == 'mysql': 
            from django.db import connection 
            c = connection.cursor() 
            c.execute('SELECT @@SESSION.sql_mode') 
            mode = c.fetchone()[0] 
            if 'STRICT' not in mode: 
                strict = False; 
                from django.utils import importlib 
                debug = importlib.import_module(
                    settings.SETTINGS_MODULE).DEBUG 

        if strict: 
            self.assertRaises(IntegrityError, s.save) 
        elif debug: 
            self.assertRaises(Exception, s.save) 
        else: 
            s.save() 
            self.assertEqual(s.closes, None) 

35 到 42 行没有被执行,因为此次运行使用的数据库是 SQLite,而不是 MySQL。然后,在任何单个测试运行中,if strict/elif debug/else块中的一个分支将执行,因此其他分支将显示为未覆盖的。在这种情况下,if strict分支是被执行的。

最后一个被标记为缺失的行是survey/models.py中的第 66 行。这是Question模型的__unicode__方法实现,我们忽略了为其编写测试。我们可以把这件事放在待办事项清单上。

尽管最后一个是缺失测试的有效指示,但manage.py中的缺失行和我们的测试代码中的缺失行并不是我们真正关心的事情,因为它们并没有报告我们应用代码的缺失覆盖。实际上,如果我们很仔细,我们可能会希望确保我们的测试代码在不同的设置下运行了几次,但让我们暂时假设我们只对我们应用代码的覆盖率感兴趣。coverage模块支持几种不同的方法来排除报告中的代码。一种可能性是在源代码行上注释# pgrama no cover指令,告诉coverage将其排除在覆盖率考虑之外。

另外,coverage提供了一个 Python API,支持指定应自动排除的代码结构的正则表达式,还支持限制报告中包含的模块。这个 Python API 比命令行提供的功能更强大,比手动使用# pragma指令注释源代码更方便。因此,我们可以开始研究如何编写一些coverage实用程序脚本,以便轻松生成我们应用代码的测试覆盖率报告。

然而,在开始这项任务之前,我们可能会想知道是否有人已经做过同样的事情,并提供了一个集成coverage与 Django 测试支持的即插即用的工具。在网上搜索后发现答案是肯定的——有几篇博客文章讨论了这个主题,至少有一个项目打包为 Django 应用程序。接下来将讨论使用这个包。

将覆盖率集成到 Django 项目中

George Song 和 Mikhail Korobov 提供了一个名为django_coverage的 Django 应用程序,支持将coverage集成到 Django 项目的测试中。与基本的coverage包一样,django_coverage可以在 PyPI 上找到:pypi.python.org/pypi/django-coverage。这里使用的版本是 1.0.1。

django_coverage包提供了将coverage与 Django 集成的方法,使用了之前讨论过的两种方法。首先,它提供了一个可以在settings.py中指定的测试运行程序:

TEST_RUNNER = 'django_coverage.coverage_runner.run_tests' 

使用这个选项,每次运行manage.py test时都会生成覆盖信息。

另外,django_coverage也可以包含在INSTALLED_APPS中。当使用这种方法时,django_coverage应用程序提供了一个名为test_coverage的新管理命令。test_coverage命令可以用来代替test来运行测试并生成覆盖信息。由于生成覆盖信息会使测试运行得更慢,我们将使用第二个选项。这样,我们可以选择在对速度要求较高且不关心覆盖率时运行测试。

除了将django_coverage列在INSTALLED_APPS中之外,无需进行任何设置即可使django_coverage与我们的项目一起运行。它带有一个示例settings.py文件,显示了它支持的设置,所有设置都有默认选项和注释描述其作用。我们可以通过在我们自己的设置文件中指定我们喜欢的值来覆盖django_coverage/settings.py中提供的任何默认设置。

不过,我们将首先使用提供的所有默认设置值。当我们运行python manage.py test_coverage survey时,我们将在测试输出的末尾看到覆盖信息:

---------------------------------------------------------------------- 
Ran 21 tests in 10.040s 

OK 
Destroying test database... 
Name            Stmts   Exec  Cover   Missing 
--------------------------------------------- 
survey.admin       21     21   100% 
survey.models      30     30   100% 
survey.views       18     18   100% 
--------------------------------------------- 
TOTAL              69     69   100% 

The following packages or modules were excluded: survey.__init__ survey.tests survey.urls

There were problems with the following packages or modules: survey.templates survey.fixtures 

这有点奇怪。回想一下,在上一节中,coverage包报告说survey.models中的一行代码没有被测试覆盖——Question模型的__unicode__方法。然而,这个报告显示survey.models的覆盖率为 100%。仔细观察这两份报告,我们可以看到列出的模块的语句在django_coverage报告中都比在coverage报告中低。

这种差异是由django_coverage使用的COVERAGE_CODE_EXCLUDES设置的默认值造成的。此设置的默认值导致所有import行、所有__unicode__方法定义和所有get_absolute_url方法定义都被排除在考虑范围之外。这些默认排除导致了这两份报告之间的差异。如果我们不喜欢这种默认行为,我们可以提供自己的替代设置,但现在我们将保持原样。

此外,coverage列出的一些模块在django_coverage报告中完全缺失。这也是默认设置值的结果(在这种情况下是COVERAGE_MODULE_EXCLUDES),输出中有一条消息指出由于此设置而被排除的模块。正如你所看到的,survey中的__init__testsurls模块都被自动排除在覆盖范围之外。

然而,默认情况下不排除templatesfixtures,这导致了一个问题,因为它们实际上不是 Python 模块,所以不能被导入。为了摆脱关于加载这些问题的消息,我们可以在自己的settings.py文件中为COVERAGE_MODULE_EXCLUDES指定一个值,并包括这两个。将它们添加到默认列表中,我们有:

COVERAGE_MODULE_EXCLUDES = ['tests$', 'settings$', 'urls$',
                            'common.views.test', '__init__', 'django',
                            'migrations', 'fixtures$', 'templates$']

如果我们在进行此更改后再次运行test_coverage命令,我们将看到关于加载某些模块存在问题的消息已经消失了。

显示在测试输出中的摘要信息很有用,但更好的是django_coverage可以生成的 HTML 报告。要获得这些报告,我们必须为COVERAGE_REPORT_HTML_OUTPUT_DIR设置指定一个值,默认值为None。因此,我们可以在/dj_projects/marketr中创建一个coverage_html目录,并在settings.py中指定它:

COVERAGE_REPORT_HTML_OUTPUT_DIR = '/dj_projects/marketr/coverage_html'

当代码覆盖率达到 100%时,HTML 报告并不特别有趣。因此,为了看到报告的完整用处,让我们只运行单个测试,比如尝试使用closes日期早于其opens日期的Survey的管理员测试:

python manage.py test_coverage survey.AdminSurveyTest.testAddSurveyError

这一次,由于我们为 HTML 覆盖率报告指定了一个目录,所以在测试运行结束时,我们看到的不是摘要覆盖率信息,而是:

Ran 1 test in 0.337s

OK
Destroying test database...

HTML reports were output to '/dj_projects/marketr/coverage_html'

然后,我们可以使用 Web 浏览器加载放置在coverage_html目录中的index.html文件。它会看起来像这样:

将覆盖率整合到 Django 项目中

由于我们只运行了单个测试,我们只对我们的代码进行了部分覆盖。HTML 报告中的% covered值以颜色编码方式反映了每个模块的覆盖情况。绿色是好的,黄色是一般,红色是差的。在这种情况下,由于我们运行了其中一个管理员测试,只有survey.admin被标记为绿色,而且它并不是 100%。要查看该模块中遗漏的内容,我们可以点击survey.admin链接:

将覆盖率整合到 Django 项目中

这样的报告提供了一种非常方便的方式来确定我们的应用程序代码中哪些部分被测试覆盖,哪些部分没有被测试覆盖。未执行的行会以红色高亮显示。在这里,我们只运行了通过SurveyFrom clean方法的错误路径的测试,所以该方法的成功代码路径以红色显示。此外,import行的颜色编码表明它们被排除了。这是由于默认的COVERAGE_CODE_EXCLUDES设置。最后,文件中的六行空行被忽略了(带有注释的行也会被忽略)。

使用像coverage这样的工具对于确保测试套件正常运行至关重要。未来,Django 可能会提供一些集成的代码覆盖支持。但与此同时,正如我们所看到的,将coverage作为项目的附加组件集成并不困难。在django_coverage的情况下,它提供了使用之前讨论过的 Django 扩展方式的选项。我们将讨论的下一个集成任务既不需要这两种方式,也只需要标准的 Python 钩子来设置和拆卸单元测试。

twill 网络浏览和测试工具

twill是一个支持与网站进行命令行交互的 Python 包,主要用于测试目的。与coveragedjango_coverage包一样,twill 可以在 PyPI 上找到:pypi.python.org/pypi/twill。虽然twill提供了一个用于交互使用的命令行工具,但它提供的命令也可以通过 Python API 使用,这意味着可以在 Django TestCase中使用twill。当我们这样做时,我们实质上是用替代的twill实现替换了 Django 测试Client的使用。

注意

请注意,目前在 PyPI 上可用的twill的最新官方版本(在撰写本文时为 0.9)非常古老。最新的开发版本可在darcs.idyll.org/~t/projects/twill-latest.tar.gz上找到。截至 2010 年 1 月的最新开发版本的输出如本节所示。此处包含的代码也经过了官方的 0.9 版本测试。使用旧的twill代码一切正常,但twill的错误输出略显不足,而且在作为 Django TestCase的一部分运行时,有些twill输出无法被抑制。因此,我建议使用最新的开发版本而不是 0.9 版本。

为什么我们要使用twill而不是 Django 测试Client?为了理解使用twill而不是 Django 测试Client的动机,让我们重新审视上一章的管理员定制测试。回想一下,我们为添加和编辑Survey对象提供了一个自定义表单。这个表单有一个clean方法,对于任何试图保存opens日期晚于其closes日期的Survey都会引发ValidationError。确保在应该引发ValidationError时引发它的测试如下所示:

    def testAddSurveyError(self): 
        post_data = { 
            'title': u'Time Traveling', 
            'opens': datetime.date.today(), 
            'closes': datetime.date.today() - datetime.timedelta(1), 
            'question_set-TOTAL_FORMS': u'0', 
            'question_set-INITIAL_FORMS': u'0', 
        } 
        response = self.client.post(
            reverse('admin:survey_survey_add'), post_data) 
        self.assertContains(response,"Opens date cannot come after closes date.") 

请注意,这个测试向服务器发送了一个包含 POST 数据字典的 POST,而没有发出 GET 请求来获取页面。这最初引起了问题:回想一下,我们最初没有在 POST 字典中包含question_set-TOTAL_FORMSquestion_set-INITIAL_FORMS的值。我们当时专注于测试页面上表单的Survey部分,并没有意识到管理员用于显示Surveys中的Questions的表单集需要这些其他值。当我们发现它们是必需的时,我们有点鲁莽地将它们的值设置为0,并希望这对我们想要测试的内容是可以接受的。

一个更好的方法是首先get调查添加页面。响应将包括一个带有一组初始值的表单,可以用作post回去的字典的基础。在发出post请求之前,我们只需更改我们测试所需的值(titleopenscloses)。因此,当我们发出post调用时,服务器最初在表单中提供的任何其他表单值都将不变地发送回去。我们不必为测试不打算更改的表单部分编制额外的值。

除了更真实地模拟服务器交互场景之外,这种方法还确保服务器正确响应 GET 请求。在这种特殊情况下,测试 GET 路径并不是必要的,因为我们在管理员中添加的额外验证不会影响其对页面的 GET 响应。但是,对于我们自己的视图中提供响应的表单,我们希望测试对getpost的响应。

那么为什么我们不以这种方式编写测试呢?测试Client支持getpost;我们当然可以通过检索包含表单的页面来开始。问题在于返回的响应是 HTML,而 Django 测试Client没有提供任何实用函数来解析 HTML 表单并将其转换为我们可以轻松操作的内容。Django 没有直接的方法来获取响应,更改表单中的一些值,然后将其post回服务器。另一方面,twill包可以轻松实现这一点。

在接下来的章节中,我们将使用twill重新实现AdminSurveyTest。首先,我们将看到如何使用其命令行工具,然后将我们学到的内容转移到 Django TestCase中。

使用 twill 命令行程序

twill包括一个名为twill-sh的 shell 脚本,允许进行命令行测试。这是一种方便的方法,可以进行一些初始测试,并找出测试用例代码需要做什么。从 shell 程序中,我们可以使用go命令访问页面。一旦我们访问了一个页面,我们可以使用showforms命令查看页面上有哪些表单,表单包含哪些字段和初始值。由于我们将使用twill重新实现AdminSurveyTest,让我们看看为我们的测试服务器访问Survey添加页面会产生什么:

kmt@lbox:~$ twill-sh 

 -= Welcome to twill! =- 

current page:  *empty page* 
>> go http://localhost:8000/admin/survey/survey/add/ 
==> at http://localhost:8000/admin/survey/survey/add/ 
current page: http://localhost:8000/admin/survey/survey/add/ 
>> showforms 

Form #1 
## ## __Name__________________ __Type___ __ID________ __Value____________
1     username                 text      id_username 
2     password                 password  id_password 
3     this_is_the_login_form   hidden    (None)       1 
4  1  None                     submit    (None)       Log in 

current page: http://localhost:8000/admin/survey/survey/add/ 
>> 

显然,我们实际上没有到达调查添加页面。由于我们没有登录,服务器响应了一个登录页面。我们可以使用formvalue命令填写登录表单:

>> formvalue 1 username kmt 
current page: http://localhost:8000/admin/survey/survey/add/ 
>> formvalue 1 password secret
current page: http://localhost:8000/admin/survey/survey/add/ 
>> 

formvalue的参数首先是表单编号,然后是字段名称,然后是我们要为该字段设置的值。一旦我们在表单中填写了用户名和密码,我们就可以submit表单了。

>> submit 
Note: submit is using submit button: name="None", value="Log in" 

current page: http://localhost:8000/admin/survey/survey/add/ 

请注意,submit命令还可以选择接受要使用的提交按钮的名称。在只有一个(就像这里)或者如果使用表单上的第一个提交按钮是可以接受的情况下,我们可以简单地使用没有参数的submit。现在我们已经登录,我们可以再次使用showforms来查看我们是否真的检索到了Survey添加页面:

>> showforms 

Form #1 
## ## __Name__________________ __Type___ __ID________ __Value____________
1     title                    text      id_title 
2     opens                    text      id_opens 
3     closes                   text      id_closes 
4     question_set-TOTAL_FORMS hidden    id_quest ... 4 
5     question_set-INITIAL ... hidden    id_quest ... 0 
6     question_set-0-id        hidden    id_quest ... 
7     question_set-0-survey    hidden    id_quest ... 
8     question_set-0-question  text      id_quest ... 
9     question_set-1-id        hidden    id_quest ... 
10    question_set-1-survey    hidden    id_quest ... 
11    question_set-1-question  text      id_quest ... 
12    question_set-2-id        hidden    id_quest ... 
13    question_set-2-survey    hidden    id_quest ... 
14    question_set-2-question  text      id_quest ... 
15    question_set-3-id        hidden    id_quest ... 
16    question_set-3-survey    hidden    id_quest ... 
17    question_set-3-question  text      id_quest ... 
18 1  _save                    submit    (None)       Save 
19 2  _addanother              submit    (None)       Save and add another 
20 3  _continue                submit    (None)       Save and continue editing 

current page: http://localhost:8000/admin/survey/survey/add/ 
>> 

这更像是一个Survey添加页面。确实,我们在第一个测试用例中将question_set-TOTAL_FORMS设置为0是不现实的,因为服务器实际上提供了一个将其设置为4的表单。但它起作用了。这意味着我们不必为这四个内联问题制造值,因此这不是一个致命的缺陷。然而,使用twill,我们可以采取更现实的路径,将所有这些值保持不变,只改变我们感兴趣的字段,再次使用formvalue命令:

>> formvalue 1 title 'Time Traveling' 
current page: http://localhost:8000/admin/survey/survey/add/ 
>> formvalue 1 opens 2009-08-15 
current page: http://localhost:8000/admin/survey/survey/add/ 
>> formvalue 1 closes 2009-08-01 
current page: http://localhost:8000/admin/survey/survey/add/ 

当我们提交该表单时,我们期望服务器会用相同的表单重新显示,并显示来自我们自定义clean方法的ValidationError消息文本。我们可以使用find命令验证返回页面上是否有该文本:

>> submit 
Note: submit is using submit button: name="_save", value="Save" 

current page: http://localhost:8000/admin/survey/survey/add/ 
>> find "Opens date cannot come after closes date." 
current page: http://localhost:8000/admin/survey/survey/add/ 
>>

对于find的响应可能不会立即明显它是否起作用。让我们看看它对于页面上最有可能不存在的内容会做什么:

>> find "lalalala I don't hear you" 

ERROR: no match to 'lalalala I don't hear you' 

current page: http://localhost:8000/admin/survey/survey/add/ 
>> 

好吧,由于twill明显在找不到文本时会抱怨,第一个find必须已经成功地在页面上找到了预期的验证错误文本。现在,我们可以再次使用showforms来查看服务器是否确实发送回我们提交的表单。请注意,初始值是我们提交的值,而不是我们第一次检索页面时的空值。

>> showforms 

Form #1 
## ## __Name__________________ __Type___ __ID________ __Value________________
1     title                    text      id_title     Time Traveling 
2     opens                    text      id_opens     2009-08-15 
3     closes                   text      id_closes    2009-08-01 
4     question_set-TOTAL_FORMS hidden    id_quest ... 4 
5     question_set-INITIAL ... hidden    id_quest ... 0 
6     question_set-0-id        hidden    id_quest ... 
7     question_set-0-survey    hidden    id_quest ... 
8     question_set-0-question  text      id_quest ... 
9     question_set-1-id        hidden    id_quest ... 
10    question_set-1-survey    hidden    id_quest ... 
11    question_set-1-question  text      id_quest ... 
12    question_set-2-id        hidden    id_quest ... 
13    question_set-2-survey    hidden    id_quest ... 
14    question_set-2-question  text      id_quest ... 
15    question_set-3-id        hidden    id_quest ... 
16    question_set-3-survey    hidden    id_quest ... 
17    question_set-3-question  text      id_quest ... 
18 1  _save                    submit    (None)       Save 
19 2  _addanother              submit    (None)     Save and add another 
20 3  _continue                submit    (None)     Save and continue editing 

current page: http://localhost:8000/admin/survey/survey/add/ 
>>

在这一点上,我们可以简单地调整一个日期以使表单有效,并尝试再次提交它:

>> formvalue 1 opens 2009-07-15 
current page: http://localhost:8000/admin/survey/survey/add/ 
>> submit 
Note: submit is using submit button: name="_save", value="Save" 

current page: http://localhost:8000/admin/survey/survey/ 
>> 

当前页面已更改为调查变更列表页面(URL 路径末尾不再有add)。这是一个线索,表明Survey添加这次起作用了,因为服务器在成功保存后会重定向到变更列表页面。有一个名为show的 twill 命令用于显示页面的 HTML 内容。当你有一个可以滚动回去的显示窗口时,这可能很有用。然而,HTML 页面在纸上复制时并不是很有用,所以这里不显示。

twill提供了许多更有用的命令,超出了我们现在所涵盖的范围。这里的讨论旨在简单地展示twill提供了什么,并展示如何在 Django 测试用例中使用它。下面将介绍第二个任务。

在 TestCase 中使用 twill

我们需要做什么来将我们在twill-sh程序中所做的工作转换为TestCase?首先,我们需要在测试代码中使用twill的 Python API。我们在twill-sh中使用的twill命令在twill.commands模块中可用。此外,twill提供了一个浏览器对象(通过twill.get_browser()访问),可能更适合从 Python 调用。命令的浏览器对象版本可能返回一个值,例如,而不是在屏幕上打印一些东西。然而,浏览器对象不直接支持twill.commands中的所有命令,因此通常使用混合twill.commands方法和浏览器方法是常见的。混合使用是可以的,因为twill.commands中的代码在从twill.get_browser()返回的同一个浏览器实例上运行。

其次,出于测试代码的目的,我们希望指示twill直接与我们的 Django 服务器应用程序代码交互,而不是将请求发送到实际服务器。在使用twill-sh代码针对我们正在运行的开发服务器进行测试时,这是可以的,但我们不希望服务器在运行以使我们的测试通过。Django 测试Client会自动执行这一点,因为它是专门编写用于从测试代码中使用的。

使用twill,我们必须调用它的add_wsgi_intercept方法,告诉它将特定主机和端口的请求直接路由到 WSGI 应用程序,而不是将请求发送到网络上。Django 提供了一个支持 WSGI 应用程序接口(名为WSGIHandler)的类,在django.core.handlers.wsgi中。因此,在我们的测试中使用twill的设置代码中,我们可以包含类似这样的代码:

from django.core.handlers.wsgi import WSGIHandler 
import twill 
TWILL_TEST_HOST = 'twilltest'   
twill.add_wsgi_intercept(TWILL_TEST_HOST, 80, WSGIHandler) 

这告诉twill,一个WSGIHandler实例应该用于处理任何发送到名为twilltest的主机的端口 80 的请求。这里使用的实际主机名和端口不重要;它们只是必须与我们的测试代码尝试访问的主机名和端口匹配。

这将我们带到我们的测试代码中必须考虑的第三件事。我们在 Django 测试Client中使用的 URL 没有主机名或端口组件,因为测试Client不基于该信息执行任何路由,而是直接将请求发送到我们的应用程序代码。另一方面,twill接口确实期望在传递给它的 URL 中包含主机(和可选端口)组件。因此,我们需要构建对于twill正确并且将被适当路由的 URL。由于我们通常在测试期间使用 Django 的reverse来创建我们的 URL,因此一个实用函数,它接受一个命名的 URL 并返回将其反转为twill可以正确处理的形式的结果将会很方便。

def reverse_for_twill(named_url): 
    return 'http://' + TWILL_TEST_HOST + reverse(named_url) 

请注意,由于我们在add_wsgi_intercept调用中使用了默认的 HTTP 端口,因此我们不需要在 URL 中包含端口号。

关于使用WSGIHandler应用程序接口进行测试的一件事是,默认情况下,该接口会抑制在处理请求时引发的任何异常。这是在生产环境中使用的相同接口,例如在 Apache 下运行时使用的mod_wsgi模块。在这样的环境中,WSGIHandler暴露异常给其调用者是不可接受的,因此它捕获所有异常并将它们转换为服务器错误(HTTP 500)响应。

尽管在生产环境中抑制异常是正确的行为,但在测试中并不是很有用。生成的服务器错误响应而不是异常完全无助于确定问题的根源。因此,这种行为可能会使诊断测试失败变得非常困难,特别是在被测试的代码引发异常的情况下。

为了解决这个问题,Django 有一个设置DEBUG_PROPAGATE_EXCEPTIONS,可以设置为True,告诉WSGIHandler接口允许异常传播。这个设置默认为False,在生产环境中永远不应该设置为True。然而,我们的twill测试设置代码应该将其设置为True,这样如果在请求处理过程中引发异常,它将在测试运行时被看到,而不是被通用的服务器错误响应替换。

使用 Django 的WSGIHandler接口进行测试时的最后一个问题是保持单个数据库连接用于单个测试发出的多个网页请求。通常,每个请求(获取或提交页面)都使用自己新建立的数据库连接。对于成功请求的处理结束时,数据库连接上的任何打开事务都将被提交,并关闭数据库连接。

然而,正如在第四章的结尾所指出的,TestCase代码会阻止由测试代码发出的任何数据库提交实际到达数据库。因此,在测试数据库中将不会看到通常在请求结束时出现的提交,而是只会看到连接关闭。一些数据库,如具有 InnoDB 存储引擎的 PostgreSQL 和 MySQL,将在这种情况下自动回滚打开的事务。这将对需要发出多个请求并且需要让先前请求所做的数据库更新对后续请求可访问的测试造成问题。例如,任何需要登录的测试都会遇到麻烦,因为登录信息存储在django_session数据库表中。

一种解决方法是将TransactionTestCase用作所有使用twill的测试的基类,而不是TestCase。使用TransactionTestCase,通常在请求处理结束时发生的提交将像往常一样发送到数据库。然而,在每个测试之间将数据库重置为干净状态的过程对于TransactionTestCase来说要比TestCase慢得多,因此这种方法可能会显著减慢我们的测试速度。

另一种解决方案是阻止在请求处理结束时关闭数据库连接。这样,在测试过程中就不会触发数据库在测试中间回滚任何更新。我们可以在测试的setUp方法中将close_connection信号处理程序与request_finished信号断开连接来实现这一点。这不是一个非常干净的解决方案,但这样做是值得的(这也是测试Client用来克服相同问题的方法)。

因此,让我们从为AdminSurveyTest编写一个twill版本的setUp方法开始。前一章中的测试Client版本如下:

class AdminSurveyTest(TestCase):
    def setUp(self):
        self.username = 'survey_admin'
        self.pw = 'pwpwpw'
        self.user = User.objects.create_user(self.username, '', " "self.pw)
        self.user.is_staff= True
        self.user.is_superuser = True
        self.user.save()
        self.assertTrue(self.client.login(username=self.username, password=self.pw),
            "Logging in user %s, pw %s failed." % (self.username, self.pw))

twill版本将需要执行相同的用户创建步骤,但登录步骤会有所不同。我们将用户创建代码提取到一个公共基类(称为AdminTest)中,供AdminSurveyTesttwill版本的AdminSurveyTwillTest使用。对于twill版本的登录,我们可以填写并提交登录表单,如果在登录之前尝试访问任何管理员页面,将返回该表单。因此,twill版本的setUp可能如下所示:

from django.db import close_connection
from django.core import signals
from django.core.handlers.wsgi import WSGIHandler 
from django.conf import settings
import twill 

class AdminSurveyTwillTest(AdminTest): 
    def setUp(self): 
        super(AdminSurveyTwillTest, self).setUp() 
        self.old_propagate = settings.DEBUG_PROPAGATE_EXCEPTIONS
        settings.DEBUG_PROPAGATE_EXCEPTIONS = True
        signals.request_finished.disconnect(close_connection)
        twill.add_wsgi_intercept(TWILL_TEST_HOST, 80, WSGIHandler) 
        self.browser = twill.get_browser() 
        self.browser.go(reverse_for_twill('admin:index')) 
        twill.commands.formvalue(1, 'username', self.username) 
        twill.commands.formvalue(1, 'password', self.pw) 
        self.browser.submit() 
        twill.commands.find('Welcome') 

这个setUp首先调用超类setUp来创建管理员用户,然后保存现有的DEBUG_PROPAGATE_EXCEPTIONS设置,然后将其设置为True。然后,它断开close_connection信号处理程序与request_finished信号的连接。接下来,它调用twill.add_wsgi_intercept来设置twill以将对twilltest主机的请求路由到 Django 的WSGIHandler。为了方便访问,它将twill浏览器对象存储在self.browser中。然后,它使用先前提到的reverse_for_twill实用函数来创建管理员索引页面的适当 URL,并调用浏览器go方法来检索该页面。

返回的页面应该有一个包含用户名密码字段的表单。这些字段设置为由超类setUp创建的用户的值,使用formvalue命令,并使用浏览器submit方法提交表单。如果登录成功,结果应该是管理员索引页面。该页面上将有字符串Welcome,因此这个setUp例程的最后一件事是验证页面上是否找到了文本,这样如果登录失败,错误就会在遇到问题的地方而不是后来引发。

当我们编写setUp时,我们还应该编写相应的tearDown方法来撤消setUp的影响:

    def tearDown(self): 
        self.browser.go(reverse_for_twill('admin:logout')) 
        twill.remove_wsgi_intercept(TWILL_TEST_HOST, 80)
        signals.request_finished.connect(close_connection) 
        settings.DEBUG_PROPAGATE_EXCEPTIONS = self.old_propagate 

在这里,我们go到管理员注销页面以从管理员站点注销,调用remove_wsgi_intercept以删除名为twilltest的主机的特殊路由,重新连接正常的close_connection信号处理程序到request_finished信号,最后恢复DEBUG_PROPAGATE_EXCEPTIONS的旧值。

然后,一个检查closes早于opens的错误情况的twill版本的测试例程将是:

    def testAddSurveyError(self): 
        self.browser.go(reverse_for_twill('admin:survey_survey_add')) 
        twill.commands.formvalue(1, 'title', 'Time Traveling') 
        twill.commands.formvalue(1, 'opens', str(datetime.date.today())) 
         twill.commands.formvalue(1, 'closes',
            str(datetime.date.today()-datetime.timedelta(1)))
        self.browser.submit()
        twill.commands.url(reverse_for_twill(
            'admin:survey_survey_add'))
        twill.commands.find("Opens date cannot come after closes "
            "date.") 

与测试Client版本不同,这里我们首先访问管理员Survey添加页面。我们期望响应包含一个单独的表单,并为其中的titleopenscloses设置值。我们不关心表单中可能还有什么,所以保持不变。然后我们submit表单。

我们期望在错误情况下(鉴于我们将closes设置为比opens早一天,这应该是错误情况),管理员将重新显示相同的页面,并显示错误消息。我们通过首先使用twill url命令来测试当前 URL 是否仍然是Survey添加页面的 URL 来测试这一点。然后,我们还使用twill find命令来验证页面上是否找到了预期的错误消息。(可能只需要执行其中一个检查,但同时执行两个不会有害。因此,这里包括了两个以示例目的。)

如果我们现在使用python manage.py test survey.AdminSurveyTwillTest运行这个测试,我们会看到它可以工作,但即使使用 Python API,twill也有点啰嗦。在测试输出的末尾,我们会看到:

Installing index for survey.Answer model 
==> at http://twilltest/admin/ 
Note: submit is using submit button: name="None", value="Log in" 

==> at http://twilltest/admin/survey/survey/add/ 
Note: submit is using submit button: name="_save", value="Save" 

==> at http://twilltest/admin/logout/ 
. 
---------------------------------------------------------------------- 
Ran 1 test in 0.845s 

OK 
Destroying test database... 

我们不希望twill的输出混乱了我们的测试输出,所以我们希望将这些输出重定向到其他地方。幸运的是,twill提供了一个用于此目的的例程,set_output。因此,我们可以将以下内容添加到我们的setUp方法中:

        twill.set_output(StringIO())

在打印输出的任何twill命令之前放置这个,并记得在引用StringIO之前在导入中包括from StringIO import StringIO。我们还应该在我们的tearDown例程中通过调用twill.commands.reset_output()来撤消这一点。这将恢复twill将输出发送到屏幕的默认行为。做出这些更改后,如果我们再次运行测试,我们会看到它通过了,并且twill的输出不再存在。

然后,最后要编写的是添加一个Survey的测试用例,其中日期不会触发验证错误。它可能看起来像这样:

    def testAddSurveyOK(self): 
        self.browser.go(reverse_for_twill('admin:survey_survey_add')) 
        twill.commands.formvalue(1, 'title', 'Not Time Traveling') 
        twill.commands.formvalue(1, 'opens', str(datetime.date.today())) 
        twill.commands.formvalue(1, 'closes', str(datetime.date.today())) 
        self.browser.submit() 
        twill.commands.url(reverse_for_twill('admin:survey_survey_changelist'))

这与之前的测试非常相似,只是我们尝试验证在预期的成功提交时是否重定向到管理员 changelist 页面。如果我们运行这个测试,它会通过,但实际上是不正确的。也就是说,如果管理员重新显示添加页面而不是重定向到 changelist 页面,它将不会失败。因此,如果我们破坏了某些东西并导致应该成功的提交失败,这个测试将无法捕捉到。

要看到这一点,将这个测试用例中的closes日期更改为opens之前一天。这将触发一个错误,就像testAddSurveyError方法中的情况一样。然而,如果我们进行了这个更改运行测试,它仍然会通过。

这是因为twill url命令以正则表达式作为其参数。它不是检查传递的参数与实际 URL 的精确匹配,而是实际 URL 是否与传递给url命令的正则表达式匹配。我们传递给url方法的 changelist URL 是:

http://twilltest/admin/survey/survey/

在提交时出现错误时,将重新显示添加页面的 URL 将是:

http://twilltest/admin/survey/survey/add/

尝试将添加页面 URL 与 changelist 页面 URL 进行匹配将成功,因为 changelist URL 包含在添加页面 URL 中。因此,twill url命令不会像我们希望的那样引发错误。为了解决这个问题,我们必须在传递给url的正则表达式中指示,我们要求实际 URL 以我们传递的值结束,通过在我们传递的值上包含一个字符串结束标记:

twill.commands.url(reverse_for_twill('admin:survey_survey_changelist') + '$') 

我们还可以在开头包括一个字符串标记,但实际上并不需要修复这个特定问题。如果我们进行这个更改,保留不正确的closes日期设置,我们会看到这个测试用例现在确实失败了,当服务器重新显示添加页面时,而不是成功处理提交:

====================================================================== 
ERROR: testAddSurveyOK (survey.tests.admin_tests.AdminSurveyTwillTest) 
---------------------------------------------------------------------- 
Traceback (most recent call last): 
 File "/dj_projects/marketr/survey/tests/admin_tests.py", line 91, in testAddSurveyOK 
 twill.commands.url(reverse_for_twill('admin:survey_survey_changelist') + '$') 
 File "/usr/lib/python2.5/site-packages/twill/commands.py", line 178, in url 
 """ % (current_url, should_be,)) 
TwillAssertionError: current url is 'http://twilltest/admin/survey/survey/add/'; 
does not match 'http://twilltest/admin/survey/survey/$' 

---------------------------------------------------------------------- 
Ran 2 tests in 1.349s 

FAILED (errors=1) 
Destroying test database... 

一旦我们验证测试在服务器不按预期响应的情况下失败,我们可以将closes日期设置恢复为可接受的保存,并且测试将再次通过。这里的一个教训是在使用twill提供的url命令时要小心。第二个教训是始终尝试验证测试在适当时报告失败。当专注于编写通过的测试时,我们经常忘记验证测试在应该失败时是否能够正确失败。

我们现在已经有了基于twill的工作版本的管理员定制测试。实现这一点并不容易——例如,一些setUp代码的需求并不一定立即显而易见。然而,一旦放置,它可以很容易地被需要比我们在这里需要的更复杂的表单操作的测试所重用。表单操作是 Django 测试框架的一个薄弱点,而且不太可能通过在 Django 中添加重复外部工具中已有的函数的函数来解决这个问题。更有可能的是,在将来,Django 将提供更容易与twill或类似工具集成。因此,投资学习如何使用类似twill的工具可能是一个很好的时间利用。

总结

这使我们结束了讨论 Django 应用程序的测试。在本章中,我们重点介绍了如何通过与其他测试工具集成来填补 Django 中测试函数的任何空白。不可能涵盖与每个工具集成的具体细节,但我们学习了可用的一般机制,并详细讨论了一些例子。这为理解如何一般完成任务提供了坚实的基础。

随着 Django 的不断发展,这样的空白可能会变得更少,但是 Django 不太可能能够在测试支持方面提供每个人都想要的一切。在某些情况下,Python 的类继承结构和单元测试扩展机制允许将其他测试工具直接集成到 Django 测试用例中。在其他情况下,这是不够的。因此,Django 提供了用于添加额外功能的钩子是有帮助的。在本章中,我们:

  • 学习了 Django 为添加测试功能提供的钩子

  • 看到了这些钩子如何被使用的例子,特别是在添加代码覆盖报告的情况下

  • 还探讨了一个例子,在这个例子中,使用这些钩子是不必要的——当将twill测试工具集成到我们的 Django 测试用例中时

在下一章中,我们将从测试转向调试,并开始学习 Django 提供的哪些设施来帮助调试我们的 Django 应用程序。

第六章:Django 调试概述

世界上最好的测试套件也无法使您免受调试问题的困扰。测试只是报告代码是否正常工作。当代码出现问题时,无论是通过失败的测试还是其他方式发现的,都需要进行调试以弄清楚到底出了什么问题。一个良好的测试套件,定期运行,当然可以帮助调试。从失败的测试中得到的错误消息的具体信息,通过测试通过与测试失败提供的聚合信息,以及引入问题的代码更改的知识,都可以为调试提供重要线索。有时这些线索足以弄清楚出了什么问题以及如何解决,但通常需要进行额外的调试。

本章介绍了 Django 的调试支持。它概述了将在随后章节中更深入讨论的主题。具体来说,本章将:

  • 列出控制调试信息收集和呈现的 Django 设置,并简要描述启用调试的影响

  • 在严重代码失败的情况下运行调试时的结果

  • 描述了启用调试时收集的数据库查询历史记录,并显示如何访问它

  • 讨论开发服务器的功能,以帮助调试

  • 描述了在生产过程中如何处理错误,当调试关闭时,以及如何确保适当地报告此类错误的信息

Django 调试设置

Django 有许多设置,用于控制调试信息的收集和呈现。主要设置名为 DEBUG;它广泛地控制服务器是在开发模式(如果 DEBUG 为 True)还是生产模式下运行。

在开发模式下,最终用户预期是站点开发人员。因此,如果在处理请求时出现错误,则在发送到 Web 浏览器的响应中包含有关错误的具体技术信息是有用的。但在生产模式下,当用户预期只是一般站点用户时,这是没有用的。

本节描述了在开发过程中用于调试的三个 Django 设置。在生产过程中使用其他设置来控制应该报告什么错误,以及错误报告应该发送到哪里。这些额外的设置将在处理生产中的问题部分中讨论。

调试和 TEMPLATE_DEBUG 设置

DEBUG 是主要的调试设置。将其设置为 True 的最明显影响之一是,当 Django 在处理请求时出现严重代码问题,例如引发异常时,将生成花哨的错误页面响应。如果 TEMPLATE_DEBUG 也为 True,并且引发的异常与模板错误有关,则花哨的错误页面还将包括有关错误发生位置的信息。

这些设置的默认值都是 False,但由 manage.py startproject 创建的 settings.py 文件通过在文件顶部包含以下行来打开它们:

DEBUG = True 
TEMPLATE_DEBUG = DEBUG 

请注意,当 DEBUG 为 False 时,将 TEMPLATE_DEBUG 设置为 True 是没有用的。如果不显示由 DEBUG 设置控制的花哨错误页面,则打开 TEMPLATE_DEBUG 时收集的额外信息将永远不会显示。同样,当 DEBUG 为 True 时,将 TEMPLATE_DEBUG 设置为 False 也没有什么用。在这种情况下,对于模板错误,花哨的调试页面将缺少有用的信息。因此,保持这些设置彼此相关是有意义的,如之前所示。

关于花哨的错误页面以及它们何时生成将在下一节中介绍。除了生成这些特殊页面之外,打开 DEBUG 还有其他一些影响。具体来说,当 DEBUG 打开时:

  • 将记录发送到数据库的所有查询。记录的详细信息以及如何访问它将在随后的部分中介绍。

  • 对于 MySQL 数据库后端,数据库发出的警告将转换为 PythonExceptions。这些 MySQL 警告可能表明存在严重问题,但警告(仅导致消息打印到stderr)可能会被忽略。由于大多数开发都是在打开DEBUG的情况下进行的,因此对 MySQL 警告引发异常可以确保开发人员意识到可能存在的问题。我们在第三章中遇到了这种行为,测试 1、2、3:基本单元测试,当我们看到testClosesReset单元测试根据DEBUG设置和 MySQL 服务器配置的不同而产生不同的结果时。

  • 管理应用程序对所有注册模型的配置进行了广泛的验证,并且在发现配置中存在错误时,在首次尝试访问任何管理页面时引发ImproperlyConfigured异常。这种广泛的验证相当昂贵,通常不希望在生产服务器启动期间进行,因为管理配置可能自上次启动以来没有更改。但是,在打开DEBUG的情况下,可能会发生管理配置的更改,因此进行显式验证并提供有关检测到问题的具体错误消息是有用且值得成本的。

  • 最后,在 Django 代码中有几个地方,当DEBUG打开时会发生错误,并且生成的响应将包含有关错误原因的特定信息,而当DEBUG关闭时,生成的响应将是一个通用错误页面。

TEMPLATE_STRING_IF_INVALID 设置

在开发过程中进行调试时可能有用的第三个设置是TEMPLATE_STRING_IF_INVALID。此设置的默认值为空字符串。此设置用于控制在模板中插入无效引用(例如,在模板上下文中不存在的引用)的位置。将空字符串的默认值设置为结果中没有任何可见的东西代替这些无效引用,这可能使它们难以注意到。将TEMPLATE_STRING_IF_INVALID设置为某个值可以使跟踪此类无效引用更容易。

然而,Django 附带的一些代码(特别是管理应用程序)依赖于无效引用的默认行为被替换为空字符串。使用非空的TEMPLATE_STRING_IF_INVALID设置运行此类代码可能会产生意外结果,因此此设置仅在您明确尝试跟踪诸如代码中始终确保变量(即使是空变量)在模板上下文中设置的拼写错误模板变量之类的内容时才有用。

调试错误页面

使用DEBUG,Django 在两种情况下生成漂亮的调试错误页面:

  • 当引发django.http.Http404异常时

  • 当引发任何其他异常并且未被常规视图处理代码处理时

在后一种情况下,调试页面包含大量关于错误、引发错误的请求以及发生错误时的环境的信息。解密此页面并充分利用其呈现的信息将在下一章中介绍。Http404异常的调试页面要简单得多,将在此处介绍。

查看Http404调试页面的示例,请考虑第四章中的survey_detail视图:

def survey_detail(request, pk): 
    survey = get_object_or_404(Survey, pk=pk) 
    today = datetime.date.today() 
    if survey.closes < today: 
        return display_completed_survey(request, survey) 
    elif survey.opens > today: 
        raise Http404 
    else: 
        return display_active_survey(request, survey) 

此视图可能引发Http404异常的两种情况:当在数据库中找不到请求的调查时,以及当找到但尚未打开时。因此,我们可以通过尝试访问不存在的调查的调查详细信息,比如调查编号 24,来查看调试 404 页面。结果将如下所示:

调试错误页面

请注意页面中间有一条消息,描述了页面未找到响应的原因:没有符合给定查询的调查。这条消息是由get_object_or_404函数自动生成的。相比之下,在找到调查但尚未开放的情况下,裸露的raise Http404看起来不会有任何描述性消息。为了确认这一点,添加一个将来有开放日期的调查,并尝试访问其详细页面。结果将类似于以下内容:

调试错误页面

这不是一个非常有用的调试页面,因为它缺乏关于搜索内容和为什么无法显示的任何信息。为了使此页面更有用,在引发Http404异常时包含一条消息。例如:

        raise Http404("%s does not open until %s; it is only %s" %  
            (survey.title, survey.opens, today)) 

然后尝试访问此页面将会更有帮助:

调试错误页面

请注意,Http404异常附带的错误消息只会显示在调试 404 页面上;它不会出现在标准的 404 页面上。因此,您可以尽量使这些消息描述性,而不必担心它们会向普通用户泄露私人或敏感信息。

还要注意的一点是,只有在引发Http404异常时才会生成调试 404 页面。如果您手动构造带有 404 状态代码的HttpResponse,它将被返回,而不是调试 404 页面。考虑以下代码:

      return HttpResponse("%s does not open until %s; it is only %s" %
          (survey.title, survey.opens, today), status=404) 

如果使用这段代码来替代raise Http404变体,那么浏览器将简单地显示传递的消息:

调试错误页面

没有显著的页面未找到消息和独特的错误页面格式,这个页面甚至不明显是一个错误报告。还要注意,一些浏览器默认会用所谓的“友好”错误页面替换服务器提供的内容,这些页面往往更加缺乏信息。因此,使用Http404异常而不是手动构建带有状态码 404 的HttpResponse对象既更容易又更有用。

调试 404 页面的最后一个示例非常有用,当 URL 解析失败时会生成。例如,如果我们在 URL 中的调查号之前添加了额外的空格,生成的调试 404 页面将如下所示:

调试错误页面

此页面上的消息包括了解析 URL 失败的所有必要信息。它包括当前 URL,用于解析的基本URLConf的名称,以及按顺序尝试匹配的所有模式。

如果您进行了大量的 Django 应用程序编程,很可能会在某个时候看到此页面,并且会相信其中列出的模式之一应该匹配给定的 URL。你错了。不要浪费精力试图弄清楚 Django 怎么会出现这样的问题。相反,相信错误消息,并集中精力弄清楚为什么你认为应该匹配的模式实际上并没有匹配。仔细查看模式的每个元素,并将其与当前 URL 中的实际元素进行比较:总会有一些不匹配的地方。

在这种情况下,您可能会认为第三个列出的模式应该与当前 URL 匹配。模式中的第一个元素是主键值的捕获,而实际的 URL 值确实包含可能是主键的数字。然而,捕获是使用模式\d+完成的。尝试将其与实际 URL 字符匹配——一个空格后跟着2——失败了,因为\d只匹配数字字符,而空格字符不是数字字符。总会有类似这样的东西来解释为什么 URL 解析失败。

下一章将包括更多导致调试页面的常见错误示例,并深入了解这些页面上提供的所有信息。现在,我们将离开调试页面的主题,学习在DEBUG打开时维护的数据库查询历史的访问。

数据库查询历史

DEBUGTrue时,Django 会保留发送到数据库的所有 SQL 命令的历史记录。这个历史记录保存在名为queries的列表中,位于django.db.connection模块中。查看此列表中保存的内容最简单的方法是从 shell 会话中检查它。

>>> from django.db import connection 
>>> connection.queries 
[] 
>>> from survey.models import Survey 
>>> Survey.objects.count() 
2 
>>> connection.queries 
[{'time': '0.002', 'sql': u'SELECT COUNT(*) FROM "survey_survey"'}] 
>>> 

在这里,我们看到queries在 shell 会话开始时最初是空的。然后,我们检索数据库中Survey对象的数量,结果为2。当我们再次显示queries的内容时,我们看到queries列表中现在有一个查询。列表中的每个元素都是一个包含两个键的字典:timesqltime的值是查询执行所需的时间(以秒为单位)。sql的值是实际发送到数据库的 SQL 查询。

关于connection.queries中包含的 SQL 的一件事:它不包括查询参数的引用。例如,考虑对以Christmas开头的Surveys进行查询时显示的 SQL:

>>> Survey.objects.filter(title__startswith='Christmas') 
[<Survey: Christmas Wish List (opens 2009-11-26, closes 2009-12-31)>] 
>>> print connection.queries[-1]['sql'] 
SELECT "survey_survey"."id", "survey_survey"."title", "survey_survey"."opens", "survey_survey"."closes" FROM "survey_survey" WHERE "survey_survey"."title" LIKE Christmas% ESCAPE '\'  LIMIT 21 
>>>

在显示的 SQL 中,Christmas%需要引用才能使 SQL 有效。然而,在存储在connection.queries中时,我们看到它没有被引用。原因是 Django 实际上并没有以这种形式将查询传递给数据库后端。相反,Django 传递参数化查询。也就是说,传递的查询字符串包含参数占位符,并且参数值是分开传递的。然后,由数据库后端执行参数替换和适当的引用。

对于放置在connection.queries中的调试信息,Django 进行参数替换,但不尝试进行引用,因为这取决于后端。因此,不要担心connection.queries中缺少参数引用:这并不意味着参数在实际发送到数据库时没有正确引用。但是,这意味着connection.queries中的 SQL 不能直接成功地剪切和粘贴到数据库 shell 程序中。如果要在数据库 shell 中使用connection.queries中的 SQL 形式,您需要提供缺失的参数引用。

你可能已经注意到并且可能对前面的 SQL 中包含的LIMIT 21感到好奇。所请求的QuerySet没有包括限制,那么为什么 SQL 包括了限制呢?这是QuerySet repr方法的一个特性,这是 Python shell 调用来显示Survey.objects.filter调用返回的值。

QuerySet可能有许多元素,如果非常大,则在 Python shell 会话中显示整个集合并不特别有用。因此,QuerySet repr最多显示 20 个项目。如果有更多,repr将在末尾添加省略号,以指示显示不完整。因此,对QuerySet进行repr调用的结果的 SQL 将限制结果为 21 个项目,这足以确定是否需要省略号来指示打印的结果是不完整的。

每当您在数据库查询中看到包含LIMIT 21,这表明该查询很可能是对repr的调用的结果。由于应用程序代码不经常调用repr,因此这样的查询很可能是由其他代码(例如 Python shell,或图形调试器变量显示窗口)导致的,这些代码可能会自动显示QuerySet变量的值。牢记这一点可以帮助减少在尝试弄清楚为什么某些查询出现在connection.queries中时的困惑。

关于connection.queries还有一件事要注意:尽管名字是这样,它不仅限于 SQL 查询。所有发送到数据库的 SQL 语句,包括更新和插入,都存储在connection.queries中。例如,如果我们从 shell 会话中创建一个新的Survey,我们将看到生成的 SQL INSERT 存储在connection.queries中。

>>> import datetime
>>> Survey.objects.create(title='Football Favorites',opens=datetime.date.today()) 
<Survey: Football Favorites (opens 2009-09-24, closes 2009-10-01)> 
>>> print connection.queries[-1]['sql'] 
INSERT INTO "survey_survey" ("title", "opens", "closes") VALUES (Football Favorites, 2009-09-24, 2009-10-01) 
>>> 

在这里,我们一直在从 shell 会话中访问connection.queries。然而,通常在请求处理后查看它的内容可能是有用的。也就是说,我们可能想知道在创建页面期间生成了什么数据库流量。然而,在 Python shell 中重新创建视图函数的调用,然后手动检查connection.queries并不特别方便。因此,Django 提供了一个上下文处理器django.core.contextprocessors.debug,它提供了方便的访问从模板中存储在connection.queries中的数据。在第八章问题隐藏时:获取更多信息中,我们将看到如何使用这个上下文处理器将connection.queries中的信息包含在我们生成的页面中。

开发服务器中的调试支持

我们一直在使用的开发服务器自第三章以来,具有几个特点有助于调试。首先,它提供了一个控制台,允许在开发过程中轻松报告 Django 应用程序代码的情况。开发服务器本身向控制台报告其操作的一般信息。例如,开发服务器的典型输出如下:

kmt@lbox:/dj_projects/marketr$ python manage.py runserver 
Validating models... 
0 errors found 

Django version 1.1, using settings 'marketr.settings' 
Development server is running at http://127.0.0.1:8000/ 
Quit the server with CONTROL-C. 
[25/Sep/2009 07:51:24] "GET / HTTP/1.1" 200 480 
[25/Sep/2009 07:51:27] "GET /survey/1/ HTTP/1.1" 200 280 
[25/Sep/2009 07:51:33] "GET /survey/888/ HTTP/1.1" 404 1704 

正如你所看到的,开发服务器首先通过显式验证模型来启动。如果发现任何错误,它们将在服务器启动期间得到突出报告,并且将阻止服务器进入请求处理循环。这有助于确保在开发过程中发现任何错误的模型更改。

服务器然后报告正在运行的 Django 的级别,使用的设置文件,以及它正在侦听的主机地址和端口。其中的第一个在你安装了多个 Django 版本并在它们之间切换时非常有用。例如,如果你在site-packages中安装了最新版本,但也有一个当前主干的 SVN 检出,你可以通过开发服务器报告的版本来确认(或不确认)你当前使用的版本是否是你打算使用的版本。

最后的启动消息指出,你可以通过按Ctrl-C来终止服务器。然后服务器进入请求处理循环,并将继续报告它处理的每个请求的信息。对于每个请求打印的信息是:

  • 请求被处理的日期和时间,用方括号括起来

  • 请求本身,其中包括 HTTP 方法(例如 GET 或 POST)、路径和客户端指定的 HTTP 版本,全部用引号括起来

  • 返回的 HTTP 状态代码

  • 返回响应中的字节数

在前面的示例输出中,我们可以看到服务器已经响应了三个GET请求,所有请求都指定了1.1的 HTTP 版本。首先是根 URL/,导致 HTTP200(OK)状态代码和480字节的响应。对/survey/1/的请求也被成功处理,并产生了280字节的响应,但/survey/888/导致了404的 HTTP 状态和1704字节的响应。返回404状态是因为数据库中不存在主键为888的调查。能够看到开发服务器实际接收到了什么请求,以及返回了什么响应,通常非常有用。

开发服务器处理的一些请求不会显示在控制台上。首先,不会记录对管理员媒体文件(即 CSS、JavaScript 和图像)的请求。如果查看管理员页面的 HTML 源代码,你会看到它在<head>部分包含了 CSS 文件的链接。例如:

<head> 
<title>Site administration | Django site admin</title> 
<link rel="stylesheet" type="text/css" href="/media/css/base.css" /> 
<link rel="stylesheet" type="text/css" href="/media/css/dashboard.css" /> 

接收此文档的 Web 浏览器将继续从生成原始页面的同一服务器检索/media/css/base.css/media/css/dashboard.css。开发服务器将接收并自动提供这些文件,但不会记录这一活动。具体来说,它将提供但不记录以ADMIN_MEDIA_PREFIX设置开头的 URL 的请求。(此设置的默认值为/media/)。

开发服务器不会记录的第二个请求是对/favicon.ico的任何请求。这是许多 Web 浏览器自动请求的文件,以便将图标与书签页面关联,或在地址栏中显示图标。没有必要用这个文件的请求来混淆开发服务器的输出,因此它永远不会被记录。

通常在调试问题时,开发服务器自动记录的非常基本的信息可能不足以弄清楚发生了什么。当发生这种情况时,你可以向应用程序代码添加日志。假设你将添加的日志输出路由到stdoutstderr,它将与开发服务器的正常输出一起显示在控制台上。

请注意,一些生产部署环境不允许将输出发送到stdout。在这种环境中,应用程序代码中错误地留下的调试打印语句可能会导致生产中的服务器故障。为了避免这种情况,始终将调试打印语句路由到stderr而不是stdout

还要注意的是,开发服务器进行的请求日志记录发生在请求处理的最后。记录的信息包括响应的大小,因此在此行出现之前,响应已经完全生成。因此,例如在应用程序视图函数中添加的任何日志都会出现在开发服务器记录的单行之前。不要混淆并认为视图函数中的打印是指上面记录的请求服务所做的工作。有关向应用程序代码添加日志的更多详细信息将在第八章中讨论。

开发服务器的第二个功能是在开发和调试代码时非常有用的,它会自动注意到磁盘上的源代码更改并重新启动,以便始终运行当前的代码。当它重新启动时,会再次打印启动消息,你可以从中得知发生了什么。例如,考虑以下输出:

kmt@lbox:/dj_projects/marketr$ python manage.py runserver 
Validating models... 
0 errors found 

Django version 1.1, using settings 'marketr.settings' 
Development server is running at http://127.0.0.1:8000/ 
Quit the server with CONTROL-C. 
[25/Sep/2009 07:51:24] "GET / HTTP/1.1" 200 480 
[25/Sep/2009 07:51:27] "GET /survey/1/ HTTP/1.1" 200 280 
[25/Sep/2009 07:51:33] "GET /survey/888/ HTTP/1.1" 404 1704 
Validating models... 
0 errors found 

Django version 1.1, using settings 'marketr.settings' 
Development server is running at http://127.0.0.1:8000/ 
Quit the server with CONTROL-C. 
[25/Sep/2009 08:20:15] "GET /admin/ HTTP/1.1" 200 7256 

在这里进行了一些代码更改,导致开发服务器在处理GET /survey/888/GET /admin/请求之间重新启动。

虽然这种自动重新启动行为很方便,但有时也会遇到问题。这种情况最常发生在编辑并保存带有错误的代码时。有时,但并非总是,加载错误的文件会导致开发服务器无法注意到文件的后续更改。因此,即使错误被注意到并修复,修正版本也可能不会自动加载。如果看起来开发服务器没有在应该的时候重新加载,最好手动停止并重新启动它。

开发服务器的这种自动重新加载功能可以通过向runserver传递--noreload选项来关闭。当单独运行开发服务器时,您可能不经常想要指定这一点,但是如果您在调试器下运行它,您可能需要指定这个选项,以便调试器断点能够被正确识别。这是开发服务器的最后一个使其用于调试的特性:很容易在调试器下运行。关于这一点将在第九章中进行详细介绍,当你甚至不知道要记录什么时:使用调试器

处理生产中的问题

在理想的世界中,所有的代码问题都会在开发过程中被发现,并且在代码运行在生产模式时永远不会出错。然而,尽管尽最大努力,这种理想在现实中很少实现。我们必须为代码在生产模式下运行时出现严重问题的情况做好准备,并在发生时安排做一些明智的事情。

做一些明智的事情需要考虑什么?首先,仍然需要向发送引发错误请求的客户端返回一些响应。但是响应应该只是一个一般的错误指示,不包含在DEBUG激活时生成的复杂调试错误页面中找到的具体内部细节。在最好的情况下,Django 调试错误页面可能会让一般的网络用户感到困惑,但在最坏的情况下,从中获取的信息可能会被一些恶意用户用来尝试破坏网站。因此,对于引发错误的请求产生的公共响应应该是一个通用的错误页面。

这些错误的具体细节仍然应该提供给网站管理员,以便分析和修复问题。Django 通过将DEBUG设置为False时遇到的错误详细信息发送到settings.py中指定的电子邮件地址列表来实现这一点。电子邮件中包含的信息并不像调试页面上所找到的那样详尽,但通常足以开始重新创建和修复问题。

本节讨论了处理生产过程中遇到的错误所需的步骤。首先,描述了返回通用错误页面所需的操作,然后讨论了指定发送更详细错误信息的设置。

创建通用错误页面

与复杂的错误页面一样,通用错误页面有两种类型:一种是报告网站上不存在页面的情况,另一种是报告在处理请求时发生了一些内部服务器错误。Django 为这些错误情况提供了默认处理程序,自动加载和呈现名为404.html500.html的模板。依赖于这些错误的默认处理的项目必须提供这些名称的模板以供加载和呈现。manage.py startproject不会创建这些文件的默认值。

当呈现404.html模板时,它会传递一个RequestContext,其中一个名为request_path的变量被设置为引发Http404异常的 URL 路径的值。然后,404.html模板可以使用request_path值和上下文处理器设置的其他变量来定制生成的特定响应。

另一方面,500.html模板是使用空上下文呈现的。当发生内部服务器错误时,服务器代码出现了严重问题。尝试通过上下文处理器处理RequestContext可能会导致另一个异常被引发。为了确保响应能够在没有进一步错误的情况下生成,500.html模板是使用空上下文呈现的。这意味着500.html模板不能依赖于通常由上下文处理器设置的任何上下文变量。

可以通过为这两种错误情况中的任何一种或两种提供自定义错误处理程序来覆盖默认的错误处理。Django 文档提供了如何执行此操作的详细信息;这里没有涵盖,因为默认处理程序对绝大多数情况都很好。

报告生产错误信息

尽管最好避免向一般用户呈现详细的技术错误信息,但完全丢失这些信息也不好。Django 支持在生产中遇到错误时通知站点管理员。与这些通知相关的设置在本节中讨论。第十一章,“当是时候上线:转向生产”,提供了有关转向生产并解决沿途遇到的一些常见问题的更多指导。

内部服务器错误通知

当服务器发生错误时,Django 会向ADMINS设置中列出的所有电子邮件地址发送一封包含生成错误的请求的详细信息和错误的回溯的电子邮件。ADMINS是包含名称和电子邮件地址的元组列表。由manage.py startproject设置的值是:

ADMINS = ( 
    # ('Your Name', 'your_email@domain.com'), 
) 

注释行显示了您应该使用的格式来向此设置添加值。

没有设置来控制是否应发送服务器错误通知:Django 将始终尝试发送这些通知。但是,如果您真的不希望为内部服务器错误生成电子邮件通知,可以将ADMINS设置为空。尽管这不是推荐的做法,因为除非您的用户向您抱怨,否则您将不知道您的网站是否遇到困难。

Django 使用 Python 的 SMTP 支持来发送电子邮件。为了使其工作,Django 必须正确配置以与 SMTP 服务器通信。有几个设置可以控制发送邮件,您可能需要根据您的安装进行自定义:

  • EMAIL_HOST是运行 SMTP 服务器的主机的名称。此设置的默认值为localhost,因此如果在与 Django 服务器相同的机器上没有运行 SMTP 服务器,则需要将其设置为运行 SMTP 服务器的主机,以便用于发送邮件。

  • EMAIL_HOST_USEREMAIL_HOST_PASSWORD可以一起用于对 SMTP 服务器进行身份验证。默认情况下,两者都设置为空字符串。如果其中一个设置为空字符串,那么 Django 将不会尝试对 SMTP 服务器进行身份验证。如果您使用需要身份验证的服务器,则需要将其设置为正在使用的 SMTP 服务器的有效值。

  • EMAIL_USE_TLS指定是否使用安全(传输层安全)连接到 SMTP 服务器。默认值为False。如果您使用需要安全连接的 SMTP 服务器,则需要将其设置为True

  • EMAIL_PORT指定要连接的端口。默认值是默认的 SMTP 端口,25。如果您的 SMTP 服务器在不同的端口上监听(当EMAIL_USE_TLSTrue时很典型),则必须在此处指定。

  • SERVER_EMAIL是将用作发送邮件的From地址的电子邮件地址。默认值为root@localhost。一些电子邮件提供商拒绝接受使用此默认From地址的邮件,因此最好将其设置为电子邮件服务器的有效From地址。

  • EMAIL_SUBJECT_PREFIX是一个字符串,将放在电子邮件的Subject开头。默认值为[Django]。您可能希望将其自定义为特定于站点的内容,以便支持多个站点的管理员可以从电子邮件主题一瞥中知道哪个站点遇到了错误。

一旦您设置了您认为对于正在使用的 SMTP 服务器正确的所有值,最好验证邮件是否成功发送。为此,将ADMINS设置为包括您自己的电子邮件地址。然后将DEBUG=False,并执行会导致服务器错误的操作。实现这一点的一种简单方法是将404.html模板重命名为其他内容,然后尝试访问服务器指定会引发Http404异常的 URL。

例如,尝试访问不存在的调查详细页面或未来的开放日期。这个尝试应该会导致发送一封电子邮件给您。主题将以您服务器的EMAIL_SUBJECT_PREFIX开头,并包括生成错误的请求的 URL 路径。电子邮件的文本将包含错误的回溯,然后是导致错误的请求的详细信息。

未找到页面通知

页面未找到错误比服务器错误要轻得多。实际上,它们可能根本不表示代码中的错误,因为它们可能是用户在浏览器地址栏中错误输入地址导致的。然而,如果它们是用户尝试跟随链接的结果,您可能想知道这一点。这种情况被称为损坏的链接,通常可以通过请求中的 HTTP Referer [sic]标头来区分开第一种情况。Django 支持在检测到用户通过损坏的链接尝试访问不存在的页面时发送电子邮件通知。

与内部服务器错误通知不同,发送损坏的链接通知是可选的。控制 Django 是否发送损坏链接的电子邮件通知的设置是SEND_BROKEN_LINK_EMAILS。此设置的默认值为False;如果要 Django 生成这些电子邮件,则需要将其设置为True。此外,必须启用常见中间件(django.middleware.common.CommonMiddleware)才能发送损坏的链接电子邮件。此中间件默认启用。

此设置生成的电子邮件将发送到MANAGERS设置中找到的电子邮件地址。因此,您可以将这些通知发送给不同的人员组,而不是服务器错误电子邮件。但是,如果您希望将这些发送给接收服务器错误电子邮件的相同人员组,只需在settings.py中的ADMINS设置后设置MANAGERS = ADMINS

除了电子邮件收件人之外,所有相同的电子邮件设置都将用于发送损坏的链接电子邮件,就像用于服务器错误电子邮件一样。因此,如果您已经验证了服务器错误电子邮件成功发送,损坏的链接电子邮件也将成功发送。

损坏的链接电子邮件通知只有在合法问题的报告没有被网页爬虫、机器人和恶意人员的活动淹没时才有用。为了确保发送的通知与有效问题相关,还有一些额外的设置可以用来限制报告为损坏链接的 URL 路径。这些是IGNORABLE_404_STARTSIGNORABLE_404_ENDS。只有不以IGNORABLE_404_STARTS开头且不以IGNORABLE_404_ENDS结尾的请求页面才会发送损坏的链接电子邮件。

IGNORABLE_404_STARTS的默认值是:

('/cgi-bin/', '/_vti_bin', '/_vti_inf')

IGNORABLE_404_ENDS的默认值是:

('mail.pl', 'mailform.pl', 'mail.cgi', 'mailform.cgi', 'favicon.ico', '.php')

您可以根据需要添加这些内容,以确保为损坏的链接生成的电子邮件报告实际问题。

总结

我们现在已经完成了 Django 中的调试支持概述。在本章中,介绍了许多主题,这些主题将在后续章节中得到更深入的介绍。具体来说,我们有:

  • 学习了关于 Django 设置的知识,这些设置控制了调试信息的收集和展示

  • 看到了当调试打开时,会生成特殊的错误页面,这有助于调试问题的任务。

  • 了解了在调试打开时维护的数据库查询历史,并看到如何访问它

  • 讨论了开发服务器的几个特性,在调试时非常有帮助

  • 描述了在生产环境中如何处理错误,以及与确保有用的调试信息发送到正确人员相关的设置

下一章将继续深入探讨 Django 调试页面的细节。

第七章:当车轮脱落:理解 Django 调试页面

当您的代码在生产中运行时,您最不希望发生的事情之一就是遇到一个错误,这个错误严重到只能向客户端返回“对不起,服务器遇到了一个错误,请稍后再试”的消息。然而,在开发过程中,这些服务器错误情况是最糟糕的结果之一。它们通常表示已经引发了异常,当发生这种情况时,有大量信息可用于弄清楚出了什么问题。当DEBUG打开时,这些信息以 Django 调试页面的形式返回,作为导致错误的请求的响应。在本章中,我们将学习如何理解和利用 Django 调试页面提供的信息。

具体来说,在本章中我们将:

  • 继续开发示例调查应用程序,沿途犯一些典型的错误

  • 看看这些错误如何在 Django 调试页面的形式中表现出来

  • 了解这些调试页面提供了哪些信息

  • 对于每个错误,深入研究生成的调试页面上可用的信息,看看它如何被用来理解错误并确定如何修复它

开始调查投票实施

在第四章中,变得更加花哨:Django 单元测试扩展,我们开始开发代码为survey应用程序提供页面。我们实现了主页视图。这个视图生成一个页面,列出了活动和最近关闭的调查,并根据需要提供链接,以便参加活动调查或显示关闭调查的结果。这两种链接都路由到同一个视图函数survey_detail,该函数根据所请求的Survey的状态进一步路由请求:

def survey_detail(request, pk): 
    survey = get_object_or_404(Survey, pk=pk) 
    today = datetime.date.today() 
    if survey.closes < today: 
        return display_completed_survey(request, survey) 
    elif survey.opens > today: 
        raise Http404("%s does not open until %s; it is only %s" %
            (survey.title, survey.opens, today))
    else: 
        return display_active_survey(request, survey) 

然而,我们并没有编写代码来实际显示一个活动的Survey或显示Survey的结果。相反,我们创建了占位符视图和模板,只是简单地说明了这些页面最终打算显示的内容。例如,display_active_survey函数仅保留为:

def display_active_survey(request, survey): 
    return render_to_response('survey/active_survey.html', {'survey': survey}) 

它引用的模板active_survey.html包含:

{% extends "survey/base.html" %} 
{% block content %} 
<h1>Survey questions for {{ survey.title }}</h1> 
{% endblock content %} 

我们现在将从上次离开的地方继续,并开始用处理显示活动“调查”的真实代码替换这个占位符视图和模板。

这需要什么?首先,当请求显示一个活动调查时,我们希望返回一个页面,显示Survey中的问题列表,每个问题都有其相关的可能答案。此外,我们希望以一种方式呈现这些问题和答案数据,以便用户可以参与Survey,并提交他们选择的问题答案。因此,我们需要以 HTML 表单的形式呈现问题和答案数据,并且还需要在服务器上编写代码,处理接收、验证、记录和响应发布的Survey响应。

这一切一次性解决起来很多。我们可以先实现哪个最小的部分,以便我们开始实验并验证我们是否朝着正确的方向前进?我们将从显示一个允许用户查看单个问题并从其相关答案中选择的表单开始。不过,首先让我们在开发数据库中设置一些合理的测试数据来使用。

为投票创建测试数据

由于我们已经有一段时间没有使用这些模型了,我们可能不再有任何活动调查。让我们通过运行manage.py reset survey来从头开始。然后,确保开发服务器正在运行,并使用管理应用程序创建一个新的SurveyQuestionAnswers。这是即将到来的示例中将使用的Survey

为投票创建测试数据

为这个Survey中的一个Question定义的Answers是:

为投票创建测试数据

这就足够开始了。我们可以随后返回并根据需要添加更多数据。现在,我们将继续开发用于显示一个Question并选择其答案的表单。

为投票定义问题表单

Django 的forms包提供了一个方便的框架,用于创建、显示、验证和处理 HTML 表单数据。在 forms 包中,ModelForm类通常用于自动构建代表模型的表单。我们可能最初认为使用ModelForm会对我们的任务有所帮助,但ModelForm不会提供我们所需要的。回想一下,survey应用程序Question模型包含这些字段:

class Question(models.Model): 
    question = models.CharField(max_length=200) 
    survey = models.ForeignKey(Survey) 

此外,Answer模型是:

class Answer(models.Model): 
    answer = models.CharField(max_length=200) 
    question = models.ForeignKey(Question) 
    votes = models.IntegerField(default=0) 

ModelForm包含模型中每个字段的 HTML 输入字段。因此,Question模型的ModelForm将包括一个文本输入,允许用户更改question字段的内容,并包括一个选择框,允许用户选择与之关联的Survey实例。这并不是我们想要的。从Answer模型构建的ModelForm也不是我们要找的。

相反,我们想要一个表单,它将显示question字段的文本(但不允许用户更改该文本),以及与Question实例关联的所有Answer实例,以一种允许用户精确选择列出的答案之一的方式。这听起来像是一个 HTML 单选输入组,其中单选按钮的值由与Question实例关联的Answers集合定义。

我们可以创建一个自定义表单来表示这一点,使用 Django 提供的基本表单字段和小部件类。让我们创建一个新文件,survey/forms.py,并在其中尝试实现将用于显示Question及其关联答案的表单:

from django import forms
class QuestionVoteForm(forms.Form): 
    answer = forms.ModelChoiceField(widget=forms.RadioSelect) 

    def __init__(self, question, *args, **kwargs): 
        super(QuestionVoteForm, self).__init__(*args, **kwargs) 
        self.fields['answer'].queryset = question.answer_set.all() 

这个表单名为QuestionVoteForm,只有一个字段answer,它是一个ModelChoiceField。这种类型的字段允许从QuerySet定义的一组选择中进行选择,由其queryset属性指定。由于此字段的正确答案集将取决于构建表单的特定Question实例,因此我们在字段声明中省略了指定queryset,并在__init__例程中设置它。但是,我们在字段声明中指定,我们要使用RadioSelect小部件进行显示,而不是默认的Select小部件(它在 HTML 选择下拉框中呈现选择)。

在单个answer字段的声明之后,该表单定义了__init__方法的覆盖。这个__init__要求在创建表单实例时传入一个question参数。在首先使用可能提供的其他参数调用__init__超类之后,传递的question用于将answer字段的queryset属性设置为与此Question实例关联的答案集。

为了查看这个表单是否按预期显示,我们需要在display_active_survey函数中创建一个这样的表单,并将其传递给模板进行显示。现在,我们不想担心显示问题列表;我们只会选择一个传递给模板。因此,我们可以将display_active_survey更改为:

from survey.forms import QuestionVoteForm 
def display_active_survey(request, survey): 
    qvf = QuestionVoteForm(survey.question_set.all()[0]) 
    return render_to_response('survey/active_survey.html', {'survey': survey, 'qvf': qvf}) 

现在,这个函数为指定调查的一组问题中的第一个问题创建了一个QuestionVoteForm的实例,并将该表单传递给模板以作为上下文变量qvf进行渲染。

我们还需要修改模板以显示传递的表单。为此,请将active_survey.html模板更改为:

{% extends "survey/base.html" %} 
{% block content %} 
<h1>{{ survey.title }}</h1> 
<form method="post" action="."> 
<div> 
{{ qvf.as_p }} 
<button type="submit">Submit</button> 
</div> 
</form> 
{% endblock content %} 

在这里,我们已经添加了必要的 HTML 元素来包围 Django 表单,并使其成为有效的 HTML 表单。我们使用了as_p方法来显示表单,只是因为它很容易。长期来看,我们可能会用自定义输出来替换它,但是在目前,将表单显示在 HTML 段落元素中就足够了。

现在,我们希望能够测试并查看我们的QuestionVoteForm是否显示我们想要的内容。我们接下来会尝试。

调试页面#1:/处的 TypeError

为了查看QuestionVoteForm目前的样子,我们可以先转到调查主页,然后从那里我们应该能够点击我们拥有的一个活动调查的链接,看看问题和答案选择是如何显示的。效果如何?并不好。由于我们所做的代码更改,我们甚至无法显示主页。相反,尝试访问它会产生一个调试页面:

调试页面#1:/处的 TypeError

天啊,看起来很糟糕。在我们深入了解页面显示的细节之前,让我们先试着理解这里发生了什么。我们添加了一个新的表单,并且更改了用于显示活动调查的视图,以便创建新定义的表单之一。我们还更改了该视图使用的模板。但我们并没有改变主页视图。那么它怎么会出错呢?

答案是主页视图本身并没有出错,但其他地方出了问题。这个出错的其他地方阻止了主页视图的调用。请注意,为了调用主页视图,包含它的模块(survey.views)必须被无错误地导入。因此,survey.views本身以及在导入时它引用的任何内容都必须是无错误的。即使主页视图中没有任何错误,甚至整个survey.views都没有问题,如果在导入survey.views的过程中引入了任何模块的错误,那么在尝试调用主页视图时可能会引发错误。

关键是,在一个地方做出的改变可能会导致最初令人惊讶的故障,而这似乎是完全无关的领域。实际上,另一个领域并不是完全无关的,而是以某种方式(通常通过一系列的导入)与做出改变的领域相连接。在这种情况下,重点放在正确的地方以找到并修复错误是很重要的。

在这种情况下,例如,盯着主页视图代码发呆是没有用的,因为那是我们试图运行的代码,试图弄清楚问题出在哪里也是徒劳的。问题并不在那里。相反,我们需要放下我们对可能在错误发生时运行的代码的任何先入为主的想法,并利用呈现的调试信息来弄清楚实际运行的代码是什么。弄清楚为什么一部分代码最终运行了,而我们本来想运行的是另一些代码,也是有益的,尽管不总是必要的来解决手头的问题。

调试页面的元素

现在让我们把注意力转向我们遇到的调试页面。页面上有很多信息,分成四个部分(截图中只能看到第一个部分和第二个部分的开头)。在本节中,我们重点关注调试页面的每个部分中通常包含的信息,注意我们在这个页面上看到的值只是作为示例。在本章的后面,我们将看到如何使用这个调试页面上呈现的具体信息来修复我们所犯的错误。

基本错误信息

调试页面的顶部包含基本的错误信息。页面标题和页面正文的第一行都说明了遇到的异常类型,以及触发异常的请求中包含的 URL 路径。在我们的情况下,异常类型是TypeError,URL 路径是/。因此,我们在页面上看到TypeError at /作为第一行。

第二行包含异常值。这通常是对导致错误的具体描述。在这种情况下,我们看到 init()至少需要 2 个非关键字参数(给定 1 个)。

在异常值之后是一个包含九个项目的列表:

  • 请求方法:请求中指定的 HTTP 方法。在这种情况下,它是 GET。

  • 请求 URL:请求的完整 URL。在这种情况下,它是 http://localhost:8000/。其中的路径部分是第一行报告的路径的重复。

  • 异常类型:这是在第一行包括的异常类型的重复。

  • 异常值:这是在第二行包括的异常值的重复。

  • 异常位置:异常发生的代码行。在这种情况下,它是/dj_projects/marketr/survey/forms.py 中的 QuestionVoteForm,第 3 行。

  • Python 可执行文件:发生错误时运行的 Python 可执行文件。在这种情况下,它是/usr/bin/python。除非您正在使用不同的 Python 版本进行测试,否则这些信息通常只是有趣的。

  • Python 版本:这标识正在运行的 Python 版本。同样,除非您正在使用不同的 Python 版本进行测试,否则这通常不会引起兴趣。但是,当查看其他人报告的问题时,如果有任何怀疑问题可能取决于 Python 版本,这可能是非常有用的信息。

  • Python 路径:实际生效的完整 Python 路径。当异常类型涉及导入错误时,这通常是最有用的。当安装了不同版本的附加包时,这也可能会派上用场。这加上不正确的路径规范可能会导致使用意外的版本,这可能会导致错误。有可用的完整 Python 路径有助于跟踪这种情况下发生的情况。

  • 服务器时间:这显示了异常发生时服务器的日期、时间和时区。这对于返回与时间相关的结果的任何视图都是有用的。

当出现调试页面时,异常类型、异常值和异常位置是首先要查看的三个项目。这三个项目揭示了出了什么问题,为什么以及发生了什么地方。通常,这就是您需要了解的一切,以便解决问题。但有时,仅凭这些基本信息就不足以理解和解决错误。在这种情况下,了解代码最终运行到哪里可能会有所帮助。对于这一点,调试页面的下一部分是有用的。

回溯

调试页面的回溯部分显示了控制线程如何到达遇到错误的地方。在顶部,它从运行以处理请求的代码的最外层级别开始,显示它调用了下一个更低级别,然后显示下一个调用是如何进行的,最终在底部以导致异常的代码行结束。因此,通常是回溯的最底部(在截图中不可见)最有趣,尽管有时代码走过的路径是理解和修复出了问题的关键。

在回溯中显示的每个调用级别,都显示了三个信息:首先标识代码行,然后显示它,然后有一行带有三角形和文本本地变量。

例如,在调试页面上回溯的顶层的第一部分信息标识了代码行为/usr/lib/python2.5/site-packages/django/core/handlers/base.py in get_response。这显示了包含代码的文件以及在该文件中执行代码的函数(或方法或类)的名称。

接下来是一个背景较暗的带有83. request.path_info)的行。这看起来有点奇怪。左边的数字是文件内的行号,右边是该行的内容。在这种情况下,调用语句跨越了多行,我们只看到了调用的最后一行,这并不是很有信息量。我们只能知道request.path_info作为最后一个参数传递给了某个东西。看到这一行周围的其他代码行可能会更好,这样会更清楚正在调用什么。事实上,我们可以通过单击该行来做到这一点:

Traceback

啊哈!现在,我们可以看到有一个名为resolver.resolve的东西被调用并传递了request.path_info。显然,这个级别的代码是从请求的路径开始,并尝试确定应调用什么代码来处理当前请求。

再次单击显示的代码的任何位置将切换周围代码上下文的显示状态,使得只显示一行。通常,不需要在回溯中看到周围的代码,这就是为什么它最初是隐藏的。但是当需要查看更多内容时,只需单击一下就很方便了。

本地变量包含在每个回溯级别显示的信息的第三个块中。这些变量最初也是隐藏的,因为如果它们被显示出来,可能会占用大量空间并且使页面混乱,从而很难一眼看清控制流是什么样的。单击任何Local vars行会展开该块,显示该级别的本地变量列表和每个变量的值。例如:

Traceback

我们不需要完全理解此处运行的 Django 代码,就可以根据显示的变量的名称和值猜测,代码正在尝试查找处理显示主页的视图。再次单击Local vars行会将该块切换回隐藏状态。

调试页面的回溯部分还有一个非常有用的功能。在Traceback标题旁边有一个链接:切换到剪切和粘贴视图。单击该链接会将回溯显示切换为可以有用地复制和粘贴到其他地方的显示。例如,在本页上,单击该链接会产生一个包含以下内容的文本框:

Environment:

Request Method: GET
Request URL: http://localhost:8000/
Django Version: 1.1
Python Version: 2.5.2
Installed Applications:
['django.contrib.auth',
 'django.contrib.contenttypes',
 'django.contrib.sessions',
 'django.contrib.sites',
 'django.contrib.admin',
 'survey',
 'django_coverage']
Installed Middleware:
('django.middleware.common.CommonMiddleware',
 'django.contrib.sessions.middleware.SessionMiddleware',
 'django.contrib.auth.middleware.AuthenticationMiddleware')

Traceback:
File "/usr/lib/python2.5/site-packages/django/core/handlers/base.py" in get_response
 83\.                     request.path_info)
File "/usr/lib/python2.5/site-packages/django/core/urlresolvers.py" in resolve
 218\.                     sub_match = pattern.resolve(new_path)
File "/usr/lib/python2.5/site-packages/django/core/urlresolvers.py" in resolve
 218\.                     sub_match = pattern.resolve(new_path)
File "/usr/lib/python2.5/site-packages/django/core/urlresolvers.py" in resolve
 125\.             return self.callback, args, kwargs
File "/usr/lib/python2.5/site-packages/django/core/urlresolvers.py" in _get_callback
 131\.             self._callback = get_callable(self._callback_str)
File "/usr/lib/python2.5/site-packages/django/utils/functional.py" in wrapper
 130\.         result = func(*args)
File "/usr/lib/python2.5/site-packages/django/core/urlresolvers.py" in get_callable
 58\.                 lookup_view = getattr(import_module(mod_name), func_name)
File "/usr/lib/python2.5/site-packages/django/utils/importlib.py" in import_module
 35\.     __import__(name)
File "/dj_projects/marketr/survey/views.py" in <module>
 24\. from survey.forms import QuestionVoteForm
File "/dj_projects/marketr/survey/forms.py" in <module>
 2\. class QuestionVoteForm(forms.Form):
File "/dj_projects/marketr/survey/forms.py" in QuestionVoteForm
 3\.     answer = forms.ModelChoiceField(widget=forms.RadioSelect)

Exception Type: TypeError at /
Exception Value: __init__() takes at least 2 non-keyword arguments (1 given)

正如您所看到的,这一块信息包含了基本的回溯以及从调试页面的其他部分提取的一些其他有用信息。它远不及完整调试页面上提供的信息,但通常足以在解决问题时从他人那里获得帮助。如果您发现自己无法解决问题并希望向他人寻求帮助,那么您想要向他人提供的就是这些信息,而不是调试页面的截图。

事实上,剪切和粘贴视图本身底部有一个按钮:在公共网站上共享此回溯。如果您按下该按钮,回溯信息的剪切和粘贴版本将被发布到dpaste.com网站,并且您将被带到该网站,在那里您可以记录分配的 URL 以供参考或删除该条目。

显然,只有在您的计算机连接到互联网并且可以访问dpaste.com时,此按钮才能正常工作。如果您尝试并且无法连接到该网站,您的浏览器将报告无法连接到dpaste.com的错误。单击返回按钮将返回到调试页面。第十章,当一切都失败时:寻求外部帮助,将更详细地介绍解决棘手问题时获取额外帮助的技巧。

单击切换到复制和粘贴视图链接时,该链接会自动替换为另一个链接:切换回交互视图。因此,在回溯信息的两种形式之间切换很容易。

请求信息

在调试页面上的回溯信息部分之后是详细的请求信息。通常情况下,您不需要查看这个部分,但是当错误是由正在处理的请求的一些奇怪特征触发时,这个部分就非常有价值。它分为五个小节,每个小节都在下面描述。

GET

这个部分包含了request.GET字典中所有键和它们的值的列表。或者,如果请求没有 GET 数据,则显示字符串没有 GET 数据

POST

这个部分包含了request.POST字典中所有键和它们的值的列表。或者,如果请求没有 POST 数据,则显示字符串没有 POST 数据

文件

这个部分包含了request.FILES字典中所有键和它们的值的列表。请注意,这里显示的信息只是上传的文件名,而不是实际的文件数据(这可能相当大)。或者,如果请求没有上传文件数据,则显示字符串没有文件数据

Cookies

这个部分包含了浏览器发送的任何 cookie。例如,如果contrib.sessions应用程序在INSTALLED_APPS中列出,您将在这里看到它使用的sessionid cookie。或者,如果浏览器没有在请求中包含任何 cookie,则显示字符串没有 cookie 数据

元数据

这个部分包含了request.META字典中所有键和它们的值的列表。这个字典包含了所有的 HTTP 请求头,以及与 HTTP 无关的其他变量。

例如,如果您在运行开发服务器时查看这个部分的内容,您将看到它列出了在开发服务器运行的命令提示符的环境中导出的所有环境变量。这是因为这个字典最初被设置为 Python os.environ字典的值,然后添加了其他值。因此,这里可能列出了很多无关紧要的信息,但是如果您需要检查 HTTP 头的值,您可以在这里找到它。

设置

调试页面的最后部分是错误发生时生效的所有设置的详尽列表。这是另一个您可能很少需要查看的部分,但当您需要时,将会非常有帮助。

这个部分包括两项内容:安装的应用程序和安装的中间件,它们都包含在前面提到的调试信息的剪切和粘贴版本中,因为它们在分析他人发布的问题时通常是很有帮助的。

如果您浏览调试页面的这个部分,您可能会注意到一些设置的值实际上并没有报告,而是列出了一串星号。这是一种隐藏信息的方式,不应该随意暴露给可能看到调试页面的任何用户。这种隐藏技术适用于任何设置中包含PASSWORDSECRET字符串的设置。

请注意,这种隐藏技术仅适用于调试页面设置部分中报告的值。这并不意味着在生产站点中启用DEBUG是安全的。仍然有可能从调试页面中检索到敏感信息。例如,如果密码设置的值存储在本地变量中,那么当它被用于建立到数据库或邮件服务器的连接时,典型情况下会发生这种情况。如果在连接尝试期间引发异常,密码值可以从页面的回溯部分的本地变量信息中检索出来。

我们现在已经完成了调试页面上可用信息的一般描述。接下来,我们将看到如何使用我们遇到的页面上的具体信息来追踪并修复代码中的错误。

理解和修复 TypeError

导致我们遇到的调试页面出现问题的原因是什么?在这种情况下,基本的错误信息足以识别和修复问题。我们报告了一个TypeError,异常值为init()至少需要 2 个非关键字参数(给出了 1 个)。此外,导致错误的代码的位置是/dj_projects/marketr/survey/forms.py 中的 QuestionVoteForm,第 3 行。看看那一行,我们看到:

    answer = forms.ModelChoiceField(widget=forms.RadioSelect) 

我们没有指定创建ModelChoiceField所需的所有必要参数。如果您是 Python 的新手,错误消息的具体内容可能有点令人困惑,因为代码行中没有引用任何名为__init__的东西,也没有传递任何非关键字参数,但错误消息却说给出了一个。其解释是,__init__是 Python 在创建对象时调用的方法,它和所有对象实例方法一样,自动接收一个对自身的引用作为其第一个位置参数。

因此,已经提供的一个非关键字参数是self。缺少什么?检查文档,我们发现querysetModelChoiceField的一个必需参数。我们省略了它,因为在声明字段时并不知道正确的值,而只有在创建包含该字段的表单的实例时才知道。但我们不能只是将其省略,因此我们需要在声明字段时指定queryset值。应该是什么?因为它将在创建表单的任何实例时立即重置,所以None可能会起作用。所以让我们尝试将那一行改为:

    answer = forms.ModelChoiceField(widget=forms.RadioSelect, queryset=None) 

这样行得通吗?是的,如果我们点击浏览器重新加载页面按钮,我们现在可以得到调查首页:

理解和修复 TypeError

同样,如果您是 Python 的新手,修复方法的有效性可能会有点令人困惑。错误消息说至少需要两个非关键字参数,但我们没有使用修复方法添加非关键字参数。错误消息似乎表明,唯一正确的修复方法可能是将queryset值作为非关键字参数提供:

    answer = forms.ModelChoiceField(None, widget=forms.RadioSelect) 

显然情况并非如此,因为上面显示的替代修复方法确实有效。这样解释的原因是,消息并不是指调用者指定了多少个非关键字参数,而是指目标方法的声明中指定了多少个参数(在这种情况下是ModelChoiceField__init__方法)。调用者可以自由地使用关键字语法传递参数,即使它们在方法声明中没有列为关键字参数,Python 解释器也会正确地将它们匹配起来。因此,第一个修复方法可以正常工作。

现在我们又让首页正常工作了,我们可以继续看看我们是否能够创建和显示我们的新QuestionVoteForm。要做到这一点,请点击电视趋势调查的链接。结果将是:

理解和修复 TypeError

虽然不再出现调试页面很好,但这并不是我们要找的。这里有一些问题。

首先,答案列表的标题是Answer,但我们希望它是问题文本。这里显示的值是分配给ModelChoiceField的标签。任何表单字段的默认标签都是字段的名称,大写并跟着一个冒号。当我们声明ModelChoiceField答案时,我们没有覆盖默认值,所以显示Answer。修复方法是手动设置字段的label属性。与queryset属性一样,特定表单实例的正确值只有在创建表单时才知道,所以我们通过在表单的__init__方法中添加这一行来实现这一点:

        self.fields['answer'].label = question.question 

其次,答案列表包括一个空的第一个选择,显示为破折号列表。这种默认行为对于选择下拉框非常有帮助,以确保用户被迫选择一个有效的值。然而,在使用单选输入组时是不必要的,因为对于单选输入,当表单显示时我们不需要任何单选按钮被初始选择。因此,我们不需要空的选择。我们可以通过在ModelChoiceField声明中指定empty_label=None来摆脱它。

第三,列出的所有选项都显示为Answer object,而不是实际的答案文本。默认情况下,这里显示的值是模型实例的__unicode__方法返回的任何内容。由于我们还没有为Answer模型实现__unicode__方法,所以我们只能看到Answer object。一个修复方法是在Answer中实现一个返回answer字段值的__unicode__方法:

class Answer(models.Model): 
    answer = models.CharField(max_length=200) 
    question = models.ForeignKey(Question) 
    votes = models.IntegerField(default=0) 

    def __unicode__(self): 
        return self.answer 

请注意,如果我们希望Answer模型的__unicode__方法返回其他内容,我们也可以适应。要做到这一点,我们可以对ModelChoiceField进行子类化,并提供label_from_instance方法的覆盖。这是用于在列表中显示选择值的方法,默认实现使用实例的文本表示。因此,如果我们需要在选择列表中显示除模型的默认文本表示之外的其他内容,我们可以采取这种方法,但对于我们的目的,只需让Answer模型的__unicode__方法返回答案文本即可。

第四,答案选择显示为无序列表,并且该列表显示为带有项目符号,这有点丑陋。有各种方法可以解决这个问题,可以通过添加 CSS 样式规范或更改选择列表的呈现方式来解决。然而,项目符号并不是一个功能性问题,去掉它们并不能进一步帮助我们了解 Django 调试页面的任务,所以现在我们将让它们存在。

先前对QuestionVoteForm所做的修复,导致代码现在看起来像这样:

class QuestionVoteForm(forms.Form): 
    answer = forms.ModelChoiceField(widget=forms.RadioSelect, queryset=None, empty_label=None) 

    def __init__(self, question, *args, **kwargs): 
        super(QuestionVoteForm, self).__init__(*args, **kwargs) 
        self.fields['answer'].queryset = question.answer_set.all() 
        self.fields['answer'].label = question.question 

有了这个表单,并在 Answer 模型中实现了__unicode__方法,重新加载我们的调查详情页面会产生一个看起来更好的结果:

理解和修复 TypeError

现在我们有一个显示得相当好的表单,并准备继续实施调查投票的下一步。

处理多个调查问题

我们已经让单个问题表单的显示工作了,还剩下什么要做?首先,我们需要处理与调查相关的任意数量的问题的显示,而不仅仅是一个单独的问题。其次,我们需要处理接收、验证和处理结果。在本节中,我们将专注于第一个任务。

创建多个问题的数据

在编写处理多个问题的代码之前,让我们在我们的测试调查中添加另一个问题,这样我们就能看到新代码的运行情况。接下来的示例将显示这个额外的问题:

创建多个问题的数据

支持多个问题的编码

接下来,更改视图以创建QuestionVoteForms的列表,并将此列表传递到模板上下文中:

def display_active_survey(request, survey): 
    qforms = [] 
    for i, q in enumerate(survey.question_set.all()): 
        if q.answer_set.count() > 1: 
            qforms.append(QuestionVoteForm(q, prefix=i)) 
    return render_to_response('survey/active_survey.html', {'survey': survey, 'qforms': qforms})

我们从一个名为qforms的空列表开始。然后,我们循环遍历与传递的survey相关联的所有问题,并为每个具有多个答案的问题创建一个表单。(具有少于两个答案的Question可能是设置错误。由于最好避免向一般用户呈现他们实际上无法选择答案的问题,我们选择在活动Survey的显示中略过这样的问题。)

请注意,我们在表单创建时添加了传递prefix参数,并将值设置为调查的全部问题集中当前问题的位置。这为每个表单实例提供了一个唯一的prefix值。如果表单中存在prefix值,则在生成 HTML 表单元素的idname属性时将使用它。指定唯一的prefix是必要的,以确保在页面上存在相同类型的多个表单时生成的 HTML 是有效的,就像我们在这里实现的情况一样。

最后,每个创建的QuestionVoteForm都被附加到qforms列表中,并且在函数结束时,qforms列表被传递到上下文中以在模板中呈现。

因此,最后一步是更改模板以支持显示多个问题而不仅仅是一个。为此,我们可以像这样更改active_survey.html模板:

{% extends "survey/base.html" %} 
{% block content %} 
<h1>{{ survey.title }}</h1> 
<form method="post" action="."> 
<div> 
{% for qform in qforms %} 
    {{ qform.as_p }} 
<button type="submit">Submit</button> 
</div> 
</form> 
{% endblock content %} 

与上一个版本唯一的变化是用循环遍历qforms上下文变量中的表单列表的{% for %}块替换了显示单个表单的{{ qvf.as_p }}。每个表单依次显示,仍然使用as_p便利方法。

调试页面#2:TemplateSyntaxError at /1/

这样做效果如何?效果不太好。如果我们尝试重新加载显示此调查问题的页面,我们将看到:

调试页面#2:TemplateSyntaxError at /1/

我们犯了一个错误,并触发了一个略有不同的调试页面。我们看到一个模板错误部分,而不是基本的异常信息后面紧接着回溯部分。对于TemplateSyntaxError类型的异常,当TEMPLATE_DEBUGTrue时,将包括此部分。它显示了导致异常的模板的一些上下文,并突出显示了被识别为导致错误的行。通常对于TemplateSyntaxError,问题是在模板本身中找到的,而不是尝试呈现模板的代码(这将是回溯部分显示的内容),因此调试页面突出显示模板内容是有帮助的。

理解和修复 TemplateSyntaxError

在这种情况下,被识别为导致错误的行可能有些令人困惑。{% endblock content %}行自上一个工作版本的模板以来并没有改变;它肯定不是一个无效的块标签。为什么模板引擎现在报告它是无效的?答案是,模板语法错误,就像许多编程语言中报告的语法错误一样,有时在试图指出错误位置时会产生误导。被识别为错误的点实际上是在识别错误时,而实际上错误可能发生得更早一些。

当漏掉了某些必需的内容时,经常会发生这种误导性的识别。解析器继续处理输入,但最终达到了当前状态下不允许的内容。此时,应该有缺失部分的地方可能相距几行。这就是这里发生的情况。{% endblock content %}被报告为无效,因为在模板中仍然有一个未关闭的{% for %}标签。

在为支持多个问题进行模板更改时,我们添加了一个{% for %}标签,但忽略了关闭它。Django 模板语言不是 Python,它不认为缩进很重要。因此,它不认为{% for %}块是通过返回到先前的缩进级别终止的。相反,我们必须使用{% endfor %}显式关闭新的{% for %}块:

{% extends "survey/base.html" %} 
{% block content %} 
<h1>{{ survey.title }}</h1> 
<form method="post" action="."> 
<div> 
{% for qform in qforms %} 
    {{ qform.as_p }} 
{% endfor %} 
<button type="submit">Submit</button> 
</div> 
</form> 
{% endblock content %} 

一旦我们做出了这个改变,我们可以重新加载页面,看到我们现在在页面上显示了多个问题:

理解和修复 TemplateSyntaxError

随着多个问题的显示,我们可以继续添加处理提交的回答的代码。

记录调查回答

我们已经有测试数据可以用来练习处理调查回答,因此我们不需要为下一步向开发数据库添加任何数据。此外,模板不需要更改以支持提交回答。它已经在 HTML 表单中包含了一个提交按钮,并指定在提交表单时应将表单数据提交为 HTTP POST。现在提交按钮将起作用,因为它可以被按下而不会出现错误,但唯一的结果是页面被重新显示。这是因为视图代码不尝试区分 GET 和 POST,并且将所有请求都视为 GET 请求。因此,我们需要更改视图代码以添加对处理 POST 请求和 GET 请求的支持。

记录调查回答的编码支持

然后,视图代码需要更改以检查请求中指定的方法。处理 GET 请求的方式应该保持不变。然而,如果请求是 POST,那么应该使用提交的 POST 数据构建QuestionVoteForms。然后可以对其进行验证,如果所有的回答都是有效的(在这种情况下,这意味着用户为每个问题选择了一个选项),那么可以记录投票并向用户发送适当的响应。如果有任何验证错误,构建的表单应该重新显示带有错误消息。这方面的初始实现如下:

def display_active_survey(request, survey): 
    if request.method == 'POST': 
        data = request.POST 
    else: 
        data = None 

    qforms = []
    for i, q in enumerate(survey.question_set.all()): 
        if q.answer_set.count() > 1: 
            qforms.append(QuestionVoteForm(q, prefix=i, data=data)) 

    if request.method == 'POST': 
        chosen_answers = [] 
        for qf in qforms: 
            if not qf.is_valid(): 
                break; 
            chosen_answers.append(qf.cleaned_data['answer']) 
        else: 
            from django.http import HttpResponse
            response = "" 
            for answer in chosen_answers: 
                answer.votes += 1 
                response += "Votes for %s is now %d<br/>" % (answer.answer, answer.votes) 
                answer.save() 
            return HttpResponse(response) 

    return render_to_response('survey/active_survey.html', {'survey': survey, 'qforms': qforms})

在这里,我们首先将本地变量data设置为request.POST字典,如果请求方法是POST,或者为None。我们将在表单构建过程中使用它,并且它必须是None(而不是空字典),以便创建未绑定的表单,这是用户在获取页面时所需的。

然后像以前一样构建qforms列表。这里唯一的区别是我们传入data参数,以便在请求为 POST 时将创建的表单绑定到已发布的数据。将数据绑定到表单允许我们稍后检查提交的数据是否有效。

然后我们有一段新的代码块来处理请求为 POST 的情况。我们创建一个空列表来保存选择的答案,然后循环遍历表单,检查每个表单是否有效。如果有任何无效的表单,我们立即跳出for循环。这将导致跳过与循环相关联的else子句(因为只有在for循环中的项目列表耗尽时才执行)。因此,一旦遇到无效的表单,这个程序将跳到return render_to_response行,这将导致页面重新显示,并在无效的表单上显示错误注释。

但是等等——一旦找到第一个无效的表单,我们就会跳出for循环。如果有多个无效的表单,我们不是想在所有表单上显示错误,而不仅仅是第一个吗?答案是是,但我们不需要在视图中显式调用is_valid来实现这一点。当表单在模板中呈现时,如果它被绑定并且尚未经过验证,is_valid将在其值呈现之前被调用。因此,无论视图代码是否显式调用is_valid,模板中都将显示任何表单中的错误。

如果所有表单都有效,for循环将耗尽其列表,并且for循环上的else子句将运行。在这里,我们想记录投票并向用户返回适当的响应。我们已经完成了第一个,通过增加每个选择答案实例的投票数。但是,对于第二个,我们实现了一个开发版本,该版本构建了一个响应,指示所有问题的当前投票值。这不是我们希望一般用户看到的,但我们可以将其用作快速验证答案记录代码是否符合我们的期望。

如果我们现在选择戏剧几乎没有:我已经看了太多电视了!作为答案并提交表单,我们会看到:

为记录调查响应提供编码支持

看起来不错:没有调试页面,所选的投票值是正确的,所以投票记录代码正在工作。现在我们可以用适用于一般用户的生成响应的开发版本替换开发版本。

在响应成功的 POST 请求时,最佳做法是重定向到其他页面,这样用户按下浏览器的重新加载按钮不会导致已发布的数据被重新提交和重新处理。为此,我们可以将 else 块更改为:

        else: 
            from django.http import HttpResponseRedirect 
            from django.core.urlresolvers import reverse 
            for answer in chosen_answers:
                answer.votes += 1
                answer.save()
            return HttpResponseRedirect(reverse('survey_thanks', args=(survey.pk,)))

请注意,这里包含了导入,只是为了显示需要导入的内容;通常情况下,这些内容会放在文件顶部,而不是嵌套在函数中。现在,这段代码不再构建一个注释所有新答案投票值的响应,而是发送一个 HTTP 重定向。为了避免在实际的 urls.py 文件之外的任何地方硬编码 URL 配置,我们在这里使用了 reverse 来生成与新命名的 URL 模式 survey_thanks 对应的 URL 路径。我们传递调查的主键值作为参数,以便生成的页面可以根据提交的调查进行定制。

reverse调用之前,我们需要在survey/urls.py文件中添加一个名为survey_thanks的新模式。我们可以这样添加,以便survey/urls.py中的完整urlpatterns是:

urlpatterns = patterns('survey.views', 
    url(r'^$', 'home', name='survey_home'), 
    url(r'^(?P<pk>\d+)/$', 'survey_detail', name='survey_detail'),
    url(r'^thanks/(?P<pk>\d+/)$', 'survey_thanks', name='survey_thanks'),
) 

添加的survey_thanks模式与survey_detail模式非常相似,只是相关的 URL 路径在包含调查的主键值的段之前有字符串thanks

另外,我们需要在 survey/views.py 中添加一个 survey_thanks 视图函数:

def survey_thanks(request, pk): 
    survey = get_object_or_404(Survey, pk=pk) 
    return render_to_response('survey/thanks.html', {'survey': survey}) 

这个视图使用get_object_or_404查找指定的调查。如果找不到匹配的调查,那么将引发Http404错误,并返回一个未找到页面的响应。如果找到了调查,那么将使用一个新的模板survey/thanks.html来渲染响应。调查被传递到模板中,允许根据提交的调查定制响应。

调试页面#3:/1/处的 NoReverseMatch

在编写新模板之前,让我们检查一下重定向是否有效,因为它只需要对survey/urls.py和视图实现进行更改。如果我们在views.py中提交了带有新重定向代码的响应,会发生什么?并不是我们所希望的:

调试页面#3:/1/处的 NoReverseMatch

NoReverseMatch异常可能是最令人沮丧的异常之一。与正向匹配失败时不同,调试页面不会提供尝试的模式列表以及匹配尝试的顺序。这有时会让我们认为适当的模式甚至没有被考虑。请放心,它已经被考虑了。问题不是适当的模式没有被考虑,而是它没有匹配。

理解和修复 NoReverseMatch 异常

如何找出预期匹配的模式为何不匹配?猜测可能出错的地方并根据这些猜测进行更改有可能奏效,但也很可能会使情况变得更糟。更好的方法是有条不紊地逐一检查事物,通常会导致问题根源的发现。以下是一系列要检查的事物。我们将按顺序进行检查,并考虑它如何适用于我们的模式,其中reverse出现意外失败:

    url(r'^thanks/(?P<pk>\d+/)$', 'survey_thanks', name='survey_thanks'), 

首先,验证异常中标识的名称是否与 URL 模式规范中的名称匹配。在这种情况下,异常引用了survey_thanks,而我们期望匹配的 URL 模式中指定了name='survey_thanks',所以它们是匹配的。

请注意,如果 URL 模式省略了name参数,并且patterns调用是指定了视图prefix的参数,则在指定要反转的名称时,reverse的调用者也必须包括视图prefix。例如,在这种情况下,如果我们没有为survey_thanks视图指定名称,那么成功的reverse调用将需要指定survey.views.survey_thanks作为要反转的名称,因为在survey/urls.py中指定了survey.views作为patterns prefix

其次,确保异常消息中列出的参数数量与 URL 模式中的正则表达式组数量相匹配。在这种情况下,异常中列出了一个参数1L,一个正则表达式组(?P<pk>\d+/),所以数字是匹配的。

第三,如果异常显示指定了关键字参数,请验证正则表达式组是否具有名称。此外,请验证组的名称是否与关键字参数的名称匹配。在这种情况下,reverse调用没有指定关键字参数,因此在这一步没有什么可检查的。

请注意,当在异常中显示了位置参数时,不需要确保 URL 模式中使用了非命名组,因为位置参数可以与 URL 模式中的命名组匹配。因此,在我们的情况下,URL 模式使用了命名组,而reverse调用者指定了位置参数时,就没有问题。

第四,对于每个参数,验证异常中列出的实际参数值的字符串表示是否与 URL 模式中关联的正则表达式组匹配。请注意,异常中显示的值是对参数调用repr的结果,因此它们可能不完全匹配参数的字符串表示。例如,在这里,异常报告参数值为1L,表示 Python 长整型值(该值是长整型,因为这是本例中使用的数据库 MySQL 对整数值的返回方式)。后缀L用于清晰地表示repr中的类型,但它不会出现在值的字符串表示中,它只是简单的1

因此,对于我们的例子,异常消息中显示的参数的字符串表示形式是1。这是否与 URL 模式中关联的正则表达式组匹配?请记住,该组是(?P<pk>\d+/)。括号标识了它是一个组。?P<pk>为该组分配了名称pk。其余部分\d+/是我们试图与1匹配的正则表达式。这些不匹配。正则表达式指定了一个或多个数字,后跟一个斜杠,然而我们实际拥有的值是一个单个数字,没有尾随斜杠。我们在这里犯了一个错别字,并在组内部包括了斜杠,而不是在其后。我们新的survey_thanks视图的正确规范是:

    url(r'^thanks/(?P<pk>\d+)/$', 'survey_thanks', name='survey_thanks'), 

这样的错别字很容易出现在 URL 模式规范中,因为模式规范往往很长,而且充满了具有特殊含义的标点符号。将它们分解成组件,并验证每个组件是否正确,将为您节省大量麻烦。然而,如果这样做不起作用,当所有部分看起来都正确但仍然出现NoReverseMatch异常时,也许是时候从另一个方向解决问题了。

从整体模式的最简单部分开始,并验证reverse是否有效。例如,您可以从reverse调用中删除所有参数以及 URL 模式规范中的所有组,并验证是否可以按名称reverse URL。然后添加一个参数及其相关的 URL 规范中的模式组,并验证是否有效。继续直到出现错误。然后切换回尝试最简单的版本,除了仅导致错误的参数之外。如果有效,则整体模式中将该参数与其他参数组合在一起存在问题,这是一个线索,因此您可以开始调查可能导致该问题的原因。

这种方法是一种通用的调试技术,可以在遇到复杂代码集中的神秘问题时应用。首先,退回到非常简单的有效内容。然后逐一添加内容,直到再次失败。现在您已经确定了与失败有关的一个部分,并且可以开始调查它是否是单独的问题,或者它在隔离状态下是否有效,但仅在与其他部分组合时才会出现问题。

调试页面#4:/thanks/1/处的 TemplateDoesNotExist

现在,让我们回到我们的例子。现在我们已经解决了reverse问题,重定向到我们的调查感谢页面是否有效?还不够。如果我们再次尝试提交我们的调查结果,我们会看到:

调试页面#4:/thanks/1/处的 TemplateDoesNotExist

这个很容易理解;在追踪NoReverseMatch错误时,我们忘记了我们还没有写新视图的模板。修复将很容易,但首先需要注意这个调试页面的一个部分:模板加载程序事后分析。这是另一个可选部分,就像TemplateSyntaxError调试页面中包含的模板错误部分一样,它提供了有助于确定错误确切原因的额外信息。

模板加载程序事后分析部分具体列出了尝试定位模板时尝试的所有模板加载程序。对于每个加载程序,它列出了该加载程序搜索的完整文件名,以及结果。

在这个页面上,我们可以看到 filesystem 模板加载器被首先调用。但是没有任何文件被该加载器尝试加载。filesystem 加载器包含在我们的 settings.py 文件中,因为它是由 django-admin.py startproject 生成的 settings.py 文件中 TEMPLATE_LOADERS 中的第一个加载器,并且我们没有更改该设置。它会在设置 TEMPLATE_DIRS 的所有指定目录中查找。然而,默认情况下 TEMPLATE_DIRS 是空的,我们也没有更改该设置,因此 filesystem 加载器没有地方可以查找以尝试找到 survey/thanks.html

第二个尝试的加载器是 app_directories 加载器。这是我们迄今为止一直依赖的加载器,用于加载我们调查应用程序的模板。它从每个应用程序目录下的 templates 目录加载模板。调试页面显示,它首先尝试在 admin 应用程序的 templates 目录下找到 survey/thanks.html 文件,然后在 survey 应用程序的 templates 目录下找到。在文件名后面,显示了搜索指定文件的结果;在这两种情况下,我们都看到了 文件不存在,这并不奇怪。

有时,这个消息会显示 文件存在,这可能有点令人困惑。如果文件存在,加载器也能看到它存在,为什么加载器没有加载它呢?这经常发生在像 Apache 这样的 Web 服务器上运行时,问题在于 Web 服务器进程没有必要的权限来读取文件。在这种情况下的解决方法是让 Web 服务器进程可以读取文件。处理这种生产时问题将在第十一章中更详细地讨论,当是时候上线:转向生产

理解和修复 TemplateDoesNotExist

在我们的情况下,修复很简单,我们甚至不需要仔细查看错误消息就知道需要做什么,但请注意,本节提供了追踪 TemplateDoesNotExist 错误所需的一切。您将知道您依赖于哪个加载器来加载模板。如果在 Template-loader postmortem 中没有显示该加载器,那么问题很可能是 settings.pyTEMPLATE_LOADERS 设置不正确。

如果加载器被列出,但没有列出尝试加载预期文件,则下一步是弄清楚原因。这一步取决于加载器,因为每个加载器都有自己的规则来查找模板文件。例如,app_directories 加载器会在 INSTALLED_APPS 中列出的每个应用程序的 templates 目录下查找。因此,确保应用程序在 INSTALLED_APPS 中,并且有一个 templates 目录,是在 app_directories 加载器没有按预期搜索文件时要检查的两件事情。

如果加载器被列出,并且预期的文件被列为尝试加载,那么加载器列出的文件状态所暗示的问题。文件不存在是一个明确的状态,有一个简单的解决方法。如果 文件不存在 出现得出乎意料,那么请仔细检查文件名。从调试页面复制并粘贴到命令提示符中,尝试显示文件可能会有所帮助,因为它可能有助于澄清加载器尝试加载的文件名与实际存在的文件名之间的差异。其他状态消息,比如 文件存在,可能不那么直接,但仍然暗示了问题的性质,并指向了解决问题的方向。

对于我们的示例案例,修复很简单:创建我们之前忘记创建的 survey/thanks.html 模板文件。这个模板返回一个基本页面,其中包含一条感谢用户参与调查的消息:

{% extends "survey/base.html" %} 
{% block content %} 
<h1>Thanks</h1> 
<p>Thanks for completing our {{ survey.title }} survey.  Come back soon and check out the full results!</p> 
{% endblock content %} 

survey/templates目录下放置了这个模板后,我们现在可以提交一个调查而不会出错。相反,我们看到:

理解和修复 TemplateDoesNotExist

好!我们现在是否已经完成了显示调查和处理结果?还没有。我们还没有测试提交无效的调查响应会发生什么。接下来我们将尝试。

处理无效的调查提交

我们已经编写了处理调查提交的视图,以便在提交的表单中发现任何错误时,重新显示页面并显示错误,而不是处理结果。在显示方面,由于我们使用了as_p方便的方法来显示表单,它将负责显示表单中的任何错误。因此,我们不需要进行任何代码或模板更改,就可以看到当提交无效的调查时会发生什么。

什么情况下会使调查提交无效?对于我们的QuestionVoteForm来说,唯一可能的错误情况是没有选择答案。那么,如果我们尝试提交一个缺少答案的调查,会发生什么?如果我们尝试,我们会发现结果并不理想:

处理无效的调查提交

这里至少有两个问题。首先,错误消息的放置位置在调查问题上方,这很令人困惑。很难知道页面上的第一个错误消息指的是什么,第二个错误看起来像是与第一个问题相关联的。最好将错误消息移到实际进行选择的地方附近,例如在问题和答案选择列表之间。

其次,错误消息的文本对于这个特定的表单来说并不是很好。从技术上讲,答案选择列表是一个单一的表单字段,但对于一般用户来说,将字段用于选择列表的引用听起来很奇怪。接下来我们将纠正这两个错误。

编写自定义错误消息和放置

更改错误消息很容易,因为 Django 提供了一个钩子。为了覆盖当未提供必填字段时发出的错误消息的值,我们可以在字段声明中作为参数传递的error_messages字典中,指定required键的值作为我们想要的消息。因此,QuestionVoteFormanswer字段的新定义将把错误消息更改为请在下面选择一个答案

class QuestionVoteForm(forms.Form): 
    answer = forms.ModelChoiceField(widget=forms.RadioSelect, 
        queryset=None, 
        empty_label=None, 
        error_messages={'required': 'Please select an answer below:'}) 

更改错误消息的放置位置需要更改模板。我们将尝试显示答案字段的标签、答案字段的错误以及显示选择的答案字段,而不是使用as_p方便的方法。然后在survey/active_survey.html模板中显示调查表单的{% for %}块变为:

{% for qform in qforms %} 
    {{ qform.answer.label }} 
    {{ qform.answer.errors }} 
    {{ qform.answer }} 
{% endfor %} 

这样做有什么效果?比以前好。如果我们现在尝试提交无效的表单,我们会看到:

编写自定义错误消息和放置

虽然错误消息本身得到了改进,放置位置也更好了,但显示的确切形式并不理想。默认情况下,错误显示为 HTML 无序列表。我们可以使用 CSS 样式来去除出现的项目符号(就像我们最终会对选择列表做的那样),但 Django 也提供了一种实现自定义错误显示的简单方法,因此我们可以尝试使用它。

为了覆盖错误消息的显示,我们可以为QuestionVoteForm指定一个替代的error_class属性,并在该类中实现一个__unicode__方法,以返回我们期望的格式的错误消息。对QuestionVoteForm和新类进行这一更改的初始实现可能是:

class QuestionVoteForm(forms.Form): 
    answer = forms.ModelChoiceField(widget=forms.RadioSelect, 
        queryset=None,                            
        empty_label=None,                            
        error_messages={'required': 'Please select an answer below:'}) 

    def __init__(self, question, *args, **kwargs): 
        super(QuestionVoteForm, self).__init__(*args, **kwargs) 
        self.fields['answer'].queryset = question.answer_set.all() 
        self.fields['answer'].label = question.question 
        self.error_class = PlainErrorList 

from django.forms.util import ErrorList 
class PlainErrorList(ErrorList): 
    def __unicode__(self): 
        return u'%s' % ' '.join([e for e in sefl]) 

QuestionVoteForm的唯一更改是在其__init__方法中将其error_class属性设置为PlainErrorListPlainErrorList类基于django.form.util.ErrorList类,并简单地重写__unicode__方法,以字符串形式返回错误,而不进行特殊的 HTML 格式化。这里的实现利用了基本的ErrorList类继承自list,因此对实例本身进行迭代会依次返回各个错误。然后这些错误用空格连接在一起,并返回整个字符串。

请注意,我们只希望这里只会有一个错误,但以防万一我们对这个假设是错误的,最安全的做法是编写多个错误存在的代码。尽管在这种情况下我们的假设可能永远不会错,但可能我们会决定在其他情况下重用这个自定义错误类,而单个可能的错误预期不成立。如果我们根据我们的假设编写代码,并简单地返回列表中的第一个错误,这可能会导致在某些情况下出现混乱的错误显示,因为我们将阻止报告除第一个错误之外的所有错误。如果我们到达那一点,我们可能还会发现,仅用空格分隔的错误列表格式不是一个好的展示方式,但我们可以稍后处理。首先,我们只想简单验证我们对错误列表显示的自定义是否被使用。

调试页面#5:另一个 TemplateSyntaxError

现在我们指定了自定义错误类,如果我们尝试提交一个无效的调查会发生什么?现在尝试提交一个无效的调查会返回:

调试页面#5:另一个 TemplateSyntaxError

哎呀,我们又犯了一个错误。第二行显示的异常值非常清楚地表明我们将self误输入为sefl,由于我们刚刚做的代码更改总共只影响了五行,所以我们不需要花太多时间来找到这个拼写错误。但让我们仔细看看这个页面,因为它看起来与我们遇到的其他TemplateSyntaxError有些不同。

这一页与其他TemplateSyntaxError相比有什么不同?实际上,在结构上并没有什么不同;它包含了所有相同的部分和相同的内容。显著的区别在于异常值不是单行的,而是一个包含原始回溯的多行消息。那是什么?如果我们看一下调试页面的回溯部分,我们会发现它相当长、重复且无信息。通常最有趣的部分是结尾部分,它是:

调试页面#5:另一个 TemplateSyntaxError

在那个回溯中引用的每一行代码都是 Django 代码,而不是我们的应用程序代码。然而,我们可以非常确定这里的问题不是由 Django 模板处理代码引起的,而是由我们刚刚对QuestionVoteForm进行的更改引起的。发生了什么?

这里发生的是在渲染模板时引发了一个异常。渲染期间的异常会被捕获并转换为TemplateSyntaxErrors。异常的大部分堆栈跟踪可能不会对解决问题有趣或有帮助。更有信息的是原始异常的堆栈跟踪,在被捕获并转换为TemplateSyntaxError之前。这个堆栈跟踪作为最终引发的TemplateSyntaxError的异常值的原始回溯部分提供。

这种行为的一个好处是,很可能是非常长的回溯的重要部分在调试页面的顶部被突出显示。一个不幸的方面是,回溯的重要部分在回溯部分本身不再可用,因此调试页面的回溯部分的特殊功能对其不可用。不可能扩展原始回溯中标识的行周围的上下文,也无法看到原始回溯每个级别的局部变量。这些限制不会导致解决这个特定问题时出现任何困难,但对于更晦涩的错误可能会很烦人。

注意

请注意,Python 2.6 对基本的Exception类进行了更改,导致在显示TemplateSyntaxError异常值时省略了此处提到的原始回溯信息。因此,如果您使用的是 Python 2.6 和 Django 1.1.1,您将看不到调试页面上包括原始回溯。这可能会在 Django 的新版本中得到纠正,因为丢失原始回溯中的信息会使调试错误变得非常困难。这个问题的解决方案也可能解决先前提到的一些烦人的问题,与TemplateSyntaxErrors包装其他异常有关。

修复第二个 TemplateSyntaxError

修复这个第二个TemplateSyntaxError很简单:只需在原始回溯中指出的行上纠正sefl拼写错误。当我们这样做并再次尝试提交无效的调查时,我们会看到响应:

修复第二个 TemplateSyntaxError

那不是一个调试页面,所以很好。此外,错误消息不再显示为 HTML 无序列表,这是我们对此更改的目标,所以很好。它们的确切位置可能不完全是我们想要的,我们可能希望添加一些 CSS 样式,使它们更加突出,但现在它们会做到这一点。

总结

我们现在已经完成了调查投票的实施,并对 Django 调试页面进行了深入的覆盖。在本章中,我们:

  • 着手用真正的实现替换活动调查的占位符视图和模板以进行显示

  • 在实施过程中犯了一些典型的错误,导致我们看到了五个不同的 Django 调试页面。

  • 在遇到第一个调试页面时,了解了调试页面的所有不同部分以及每个部分包含的信息

  • 对于每个遇到的调试页面,使用呈现的信息来定位和纠正编码错误

在下一章中,我们将继续学习即使代码没有导致调试页面显示也能收集调试信息的技术。

第八章:当问题隐藏时:获取更多信息

有时代码不会触发显示调试页面,但也不会产生正确的结果。事实上,即使代码似乎在浏览器中显示的可见结果方面工作正常,幕后它可能也在做一些意想不到的事情,这可能会在以后引起麻烦。例如,如果一个页面需要许多(或非常耗时的)SQL 查询,那么在开发过程中它可能看起来运行正常,但在生产环境中很快就会导致服务器超载。

因此,养成检查代码行为的习惯是很好的做法,即使外部结果没有显示任何问题。首先,这种做法可以揭示最好尽早知道的隐藏问题。其次,当问题确实出现时,了解正常的代码路径是非常有价值的。

本章重点介绍了获取有关 Django 应用程序代码正在执行的更多信息的方法。具体来说,在本章中我们将:

  • 开发模板代码,用于在页面本身包含有关渲染页面所需的所有 SQL 查询的信息

  • 学习如何使用 Django 调试工具栏收集类似信息,以及更多

  • 讨论向 Django 应用程序代码添加日志记录的技术

跟踪请求的 SQL 查询

对于典型的 Django 应用程序,数据库交互非常重要。确保所做的数据库查询是正确的有助于确保应用程序的结果是正确的。此外,确保为应用程序生成的数据库查询是高效的有助于确保应用程序能够支持所需数量的并发用户。

Django 通过使数据库查询历史可供检查来支持这一领域。第六章,“Django 调试概述”介绍了这一历史,并展示了如何从 Python shell 会话中访问它。这种访问对于查看由于调用特定模型方法而发出的 SQL 非常有用。然而,它对于了解在处理特定请求期间进行了哪些 SQL 查询并不有用。

本节将展示如何在页面本身包含有关生产页面所需的 SQL 查询的信息。我们将修改现有的调查应用程序模板以包含查询信息,并检查一些现有调查应用程序视图的查询历史。虽然我们不知道现有视图存在任何问题,但在验证它们是否发出我们期望的查询时,我们可能会学到一些东西。

在模板中访问查询历史的设置

在可以从模板中访问查询历史之前,我们需要确保一些必需的设置被正确配置。为了使 SQL 查询信息在模板中可用,需要三个设置。首先,必须在TEMPLATE_CONTEXT_PROCESSORS设置中包含调试上下文处理器django.core.context_processors.debug。这个上下文处理器包含在TEMPLATE_CONTEXT_PROCESSORS的默认值中。我们没有更改该设置;因此,我们不需要在项目中做任何事情来启用这个上下文处理器。

其次,发送请求的机器的 IP 地址必须列在INTERNAL_IPS设置中。这不是我们以前使用过的设置,默认情况下为空,因此我们需要将其添加到设置文件中。在使用与开发服务器运行的相同机器进行测试时,将INTERNAL_IPS设置为包括环回地址就足够了:

# Addresses for internal machines that can see potentially sensitive
# information such as the query history for a request.
INTERNAL_IPS = ('127.0.0.1', ) 

如果您还从其他机器进行测试,您还需要在此设置中包含它们的 IP 地址。

第三,最后,DEBUG必须为True,才能在模板中使用 SQL 查询历史。

当满足这三个设置条件时,SQL 查询历史可能可以通过名为sql_queries的模板变量在模板中使用。这个变量包含一个字典列表。每个字典包含两个键:sqltimesql的值是 SQL 查询本身,time的值是查询执行所花费的秒数。

请注意,sql_queries上下文变量是由调试上下文处理器设置的。只有在使用RequestContext来渲染模板时,上下文处理器才会被调用。到目前为止,我们在调查应用程序视图中没有使用RequestContexts,因为到目前为止代码还不需要。但是为了从模板中访问查询历史,我们需要开始使用RequestContexts。因此,除了修改模板,我们还需要稍微修改视图代码,以便在调查应用程序的生成页面中包含查询历史。

主页的 SQL 查询

让我们首先看看为了生成survey应用程序主页而发出了哪些查询。回想一下主页视图代码是:

def home(request):
    today = datetime.date.today()
    active = Survey.objects.active()
    completed = Survey.objects.completed().filter(closes__gte=today-
                    datetime.timedelta(14))
    upcoming = Survey.objects.upcoming().filter(
                    opens__lte=today+datetime.timedelta(7))
    return render_to_response('survey/home.html',
        {'active_surveys': active,
        'completed_surveys': completed,
        'upcoming_surveys': upcoming,
        }) 

模板中呈现了三个QuerySets,所以我们期望看到这个视图生成三个 SQL 查询。为了检查这一点,我们必须首先更改视图以使用RequestContext

from django.template import RequestContext 
def home(request): 
    today = datetime.date.today() 
    active = Survey.objects.active() 
    completed = Survey.objects.completed().filter(closes__gte=today-datetime.timedelta(14)) 
    upcoming = Survey.objects.upcoming().filter(opens__lte=today+datetime.timedelta(7)) 
    return render_to_response('survey/home.html', 
        {'active_surveys': active, 
         'completed_surveys': completed, 
         'upcoming_surveys': upcoming,}, 
        RequestContext(request)) 

这里唯一的变化是在文件中添加了import后,将RequestContext(request)作为render_to_response的第三个参数添加进去。当我们做出这个改变时,我们可能也会改变其他视图的render_to_response行,以便也使用RequestContexts。这样,当我们到达检查每个查询的 SQL 查询的时候,我们不会因为忘记做出这个小改变而被绊倒。

其次,我们需要在我们的survey/home.html模板中的某个地方显示来自sql_queries的信息。但是在哪里?我们不一定希望这些信息与真实应用程序数据一起显示在浏览器中,因为那可能会让人困惑。将其包含在响应中但不自动显示在浏览器页面上的一种方法是将其放在 HTML 注释中。然后浏览器不会在页面上显示它,但可以通过查看显示页面的 HTML 源代码来看到它。

作为实现这一点的第一次尝试,我们可能会改变survey/home.html的顶部,看起来像这样:

{% extends "survey/base.html" %} 
{% block content %} 
<!-- 
{{ sql_queries|length }} queries 
{% for qdict in sql_queries %} 
{{ qdict.sql }} ({{ qdict.time }} seconds) 
{% endfor %} 
--> 

这个模板代码在survey/home.html提供的content块的开头以 HTML 注释的形式打印出sql_queries的内容。首先,通过length过滤器过滤列表来记录查询的数量。然后代码遍历sql_queries列表中的每个字典,并显示sql,然后跟着每个查询所花费的time的括号注释。

这个方法效果如何?如果我们尝试通过检索调查主页(确保开发服务器正在运行),并使用浏览器菜单项查看页面的 HTML 源代码,我们可能会看到评论块包含类似以下内容:

<!--
1 queries

SELECT `django_session`.`session_key`, `django_session`.`session_data`, `django_session`.`expire_date` FROM `django_session` WHERE (`django_session`.`session_key` = d538f13c423c2fe1e7f8d8147b0f6887  AND `django_session`.`expire_date` &gt; 2009-10-24 17:24:49 ) (0.001 seconds)

--> 

注意

请注意,这里显示的查询数量取决于您正在运行的 Django 版本。这个结果来自 Django 1.1.1;Django 的后续版本可能不会显示任何查询。此外,浏览器与网站的交互历史将影响发出的查询。这个结果来自一个曾用于访问管理应用程序的浏览器,最后一次与管理应用程序的交互是退出登录。如果浏览器曾用于访问管理应用程序但用户未注销,则可能会看到其他查询。最后,使用的数据库也会影响发出的具体查询和其确切格式。这个结果来自一个 MySQL 数据库。

这并不是我们预期的。首先,一个小小的烦恼,但是1 queries是错误的,应该是1 query。也许这不会让你烦恼,特别是在内部或调试信息中,但对我来说会让我烦恼。我会更改显示查询计数的模板代码,以使用正确的复数形式:

{% with sql_queries|length as qcount %} 
{{ qcount }} quer{{ qcount|pluralize:"y,ies" }} 
{% endwith %} 

在这里,由于模板需要多次使用length结果,首先通过使用{% with %}块将其缓存在qcount变量中。然后它被显示,并且它被用作pluralize过滤器的变量输入,该过滤器将根据qcount值在quer的末尾放置正确的字母。现在注释块将显示0 queries1 query2 queries等等。

解决了这个小小的烦恼后,我们可以集中精力解决下一个更大的问题,那就是显示的查询不是我们预期的查询。此外,我们预期的三个查询,用于检索已完成、活动和即将进行的调查列表,都不见了。发生了什么?我们将依次处理每一个。

显示的查询正在访问django_session表。这个表被django.contrib.sessions应用程序使用。尽管调查应用程序不使用这个应用程序,但它在我们的INSTALLED_APPS中列出,因为它包含在settings.py文件中,startproject生成。此外,sessions应用程序使用的中间件在MIDDLEWARE_CLASSES中列出。

sessions应用程序默认将会话标识符存储在名为sessionid的 cookie 中,一旦任何应用程序使用会话,它就会立即发送到浏览器。浏览器将在所有请求中返回该 cookie 给同一服务器。如果请求中存在该 cookie,会话中间件将使用它来检索会话数据。这就是我们之前看到的查询:会话中间件正在检索由浏览器发送的会话 cookie 标识的会话数据。

但是调查应用程序不使用 sessions,那么浏览器是如何首先获得会话 cookie 的呢?答案是管理员应用程序使用 sessions,并且此浏览器先前曾用于访问管理员应用程序。那时,sessionid cookie 在响应中设置,并且浏览器忠实地在所有后续请求中返回它。因此,似乎很可能这个django_session表查询是由于使用管理员应用程序的副作用设置了sessionid cookie。

我们能确认吗?如果我们找到并删除浏览器中的 cookie,然后重新加载页面,我们应该会看到这个 SQL 查询不再列出。没有请求中的 cookie,触发对会话数据的访问的任何代码都不会有任何东西可以查找。而且由于调查应用程序不使用 sessions,它的任何响应都不应包含新的会话 cookie,这将导致后续请求包含会话查找。这种推理正确吗?如果我们尝试一下,我们会看到注释块变成:

<!--

0 queries

--> 

因此,我们似乎在一定程度上确认了在处理调查应用程序响应期间导致django_session表查询的原因。我们没有追踪到哪些确切的代码访问了由 cookie 标识的会话——可能是中间件或上下文处理器,但我们可能不需要知道细节。记住我们的项目中运行的除了我们正在工作的应用程序之外还有其他应用程序,它们可能会导致与我们自己的代码无关的数据库交互就足够了。如果我们观察到的行为看起来可能会对我们的代码造成问题,我们可以进一步调查,但对于这种特殊情况,我们现在将避免使用管理员应用程序,因为我们希望将注意力集中在我们自己的代码生成的查询上。

现在我们了解了列出的查询,那么没有列出的预期查询呢?缺少的查询是由于QuerySets的惰性评估属性和列出sql_queries内容的comment块的确切放置位置的组合。我们将comment块放在主页的content块顶部,以便在查看页面源时轻松找到 SQL 查询信息。模板在视图创建三个QuerySets之后呈现,因此似乎放在顶部的注释应该显示三个QuerySets的 SQL 查询。

然而,QuerySets是惰性的;仅创建QuerySet并不会立即导致与数据库的交互。相反,直到实际访问QuerySet结果之前,将 SQL 发送到数据库是延迟的。对于调查主页,直到循环遍历每个QuerySet的模板部分被渲染之前,这并不会发生。这些部分都在我们放置sql_queries信息的下面,因此相应的 SQL 查询尚未发出。解决此问题的方法是将comment块的放置位置移动到content块的最底部。

当我们这样做时,我们还应该修复查询显示的另外两个问题。首先,请注意上面显示的查询中显示的是&gt;而不是实际发送到数据库的>符号。此外,如果使用的数据库是使用直引号而不是反引号进行引用的数据库(例如 PostgreSQL),查询中的所有反引号都将显示为&quot;。这是由于 Django 自动转义 HTML 标记字符造成的。这在我们的 HTML 注释中是不必要且难以阅读的,因此我们可以通过将sql查询值通过safe过滤器发送来抑制它。

其次,查询非常长。为了避免需要向右滚动才能看到整个查询,我们还可以通过wordwrap过滤器过滤sql值,引入一些换行,使输出更易读。

要进行这些更改,请从survey/home.html模板的content块顶部删除添加的注释块,而是将此模板的底部更改为:

{% endif %} 
<!-- 
{% with sql_queries|length as qcount %} 
{{ qcount }} quer{{ qcount|pluralize:"y,ies" }} 
{% endwith %} 
{% for qdict in sql_queries %} 
{{ qdict.sql|safe|wordwrap:60 }} ({{ qdict.time }} seconds) 
{% endfor %} 
--> 
{% endblock content %} 

现在,如果我们再次重新加载调查主页并查看返回页面的源代码,我们将在底部的注释中看到列出的查询:

<!--

3 queries

SELECT `survey_survey`.`id`, `survey_survey`.`title`,
`survey_survey`.`opens`, `survey_survey`.`closes` FROM
`survey_survey` WHERE (`survey_survey`.`opens` <= 2009-10-25
 AND `survey_survey`.`closes` >= 2009-10-25 ) (0.000 seconds)

SELECT `survey_survey`.`id`, `survey_survey`.`title`,
`survey_survey`.`opens`, `survey_survey`.`closes` FROM
`survey_survey` WHERE (`survey_survey`.`closes` < 2009-10-25
 AND `survey_survey`.`closes` >= 2009-10-11 ) (0.000 seconds)

SELECT `survey_survey`.`id`, `survey_survey`.`title`,
`survey_survey`.`opens`, `survey_survey`.`closes` FROM
`survey_survey` WHERE (`survey_survey`.`opens` > 2009-10-25 
AND `survey_survey`.`opens` <= 2009-11-01 ) (0.000 seconds)

--> 

这很好,看起来正是我们期望在主页查询中看到的内容。现在我们似乎有一些可以显示查询的工作模板代码,我们将考虑打包这个片段,以便可以轻松地在其他地方重用。

打包模板查询显示以便重用

现在我们有了一小块模板代码,可以将其放在任何模板中,以便轻松查看生成页面所需的 SQL 查询。但是,它并不小到可以在需要时轻松重新输入。因此,最好将其打包成一种形式,可以在需要时方便地包含在任何地方。Django 模板{% include %}标签使这一点变得很容易。

这个片段应该放在哪里?请注意,这个模板片段是完全通用的,与调查应用程序没有任何关联。虽然将其简单地包含在调查模板中很容易,但将其放在那里将使其在将来的项目中更难以重用。更好的方法是将其放在一个独立的应用程序中。

为这个片段创建一个全新的应用程序可能看起来有点极端。然而,在开发过程中创建一些不真正属于主应用程序的小型实用函数或模板片段是很常见的。因此,在实际项目的开发过程中,可能会有其他类似的东西,它们在逻辑上应该放在主应用程序之外的某个地方。有一个地方可以放它们是很有帮助的。

让我们创建一个新的 Django 应用程序,用来保存一些通用的实用代码,这些代码在调查应用程序中并不合乎逻辑:

kmt@lbox:/dj_projects/marketr$ python manage.py startapp gen_utils 

由于它的目的是保存通用实用代码,我们将新应用程序命名为gen_utils。它可以作为一个放置任何非调查特定代码的地方,看起来可能在其他地方有重复使用的潜力。请注意,随着时间的推移,如果在这样的应用程序中积累了越来越多的东西,可能会变得明显,其中的一些子集将有用,可以打包成一个独立的、自包含的应用程序,其名称比gen_utils更具描述性。但是现在,开始一个地方放置与调查应用程序没有真正关联的实用代码就足够了。

接下来,我们可以在gen_utils中创建一个templates目录,然后在templates下创建一个gen_utils目录,并创建一个文件showqueries.html来保存模板片段:

{% if sql_queries %}<!-- 
{% with sql_queries|length as qcount %} 
{{ qcount }} quer{{ qcount|pluralize:"y,ies" }} 
{% endwith %} 
{% for qdict in sql_queries %} 
{{ qdict.sql|safe|wordwrap:60 }} ({{ qdict.time }} seconds){% endfor %} 
-->{% endif %} 

我们对之前直接放在survey/home.html模板中的代码进行了一个改变,就是将整个 HTML comment块放在了{% if sql_qureies %}块中。如果sql_queries变量没有包含在模板上下文中,那么就没有理由生成注释。

作为代码重用的一部分,检查并确保代码确实可重用,并且不会在给定意外或异常输入时以奇怪的方式失败也是一个好习惯。看看那个片段,有没有什么可能在任意的sql_queries输入中引起问题的东西?

答案是肯定的。如果 SQL 查询值包含 HTML 注释结束符,则注释块将被提前终止。这可能导致浏览器将本来应该是注释的内容作为用户显示的页面内容的一部分。为了验证这一点,我们可以尝试在主页视图代码中插入一个包含 HTML 注释结束符的模型filter调用,然后查看浏览器显示的内容。

但是 HTML 注释结束符是什么?你可能会猜想是-->,但实际上它只是连续的两个破折号。从技术上讲,<!>被定义为标记声明的开始和结束,而破折号标记注释的开始和结束。因此,包含连续两个破折号的查询应该触发我们在这里担心的行为。为了测试这一点,将这行代码添加到home视图中:

    Survey.objects.filter(title__contains='--').count() 

注意不需要对调用的结果做任何处理;添加的代码只需确保包含两个破折号的查询实际上被发送到数据库。通过检索匹配包含两个破折号的模式的结果计数,添加的代码实现了这一点。有了home视图中的这一行,Firefox 将显示调查主页如下:

打包模板查询以便重用

在 SQL 查询值中连续出现的两个破折号导致 Firefox 过早终止了注释块,我们本打算仍然在注释中的数据出现在了浏览器页面上。为了避免这种情况,我们需要确保 SQL 查询值中不会连续出现两个破折号。

快速浏览内置的 Django 过滤器并没有发现可以用来替换两个破折号的字符串的过滤器。cut过滤器可以用来移除它们,但仅仅移除它们会使sql值具有误导性,因为没有指示这些字符已从字符串中移除。因此,似乎我们需要为此开发一个自定义过滤器。

我们将自定义过滤器放在gen_utils应用程序中。过滤器和模板标签必须放在应用程序的templatetags模块中,因此我们首先需要创建templatetags目录。然后,我们可以将replace_dashes过滤器的实现放入gen_utils/templatetags目录中的名为gentags.py的文件中:

from django import template 

register = template.Library() 

@register.filter 
def replace_dashes(value): 
    return value.replace('--','~~double-dash~~') 
replace_dashes.is_safe = True 

这段代码的主要部分是标准的样板importregister赋值和@register.filter装饰,需要注册replace_dashes函数,以便它可以作为过滤器使用。函数本身只是用~~double-dash~~替换字符串中一对破折号的任何出现。由于没有办法转义破折号,以便它们不被解释为注释的结束,但仍然显示为破折号,我们用描述原内容的字符串替换它们。最后一行将replace_dashes过滤器标记为安全,这意味着它不会引入任何需要在输出中转义的 HTML 标记字符。

我们还需要更改gen_utils/showqueries.html中的模板片段,以加载和使用此过滤器来显示 SQL 查询的值:

{% if sql_queries %}<!-- 
{% with sql_queries|length as qcount %} 
{{ qcount }} quer{{ qcount|pluralize:"y,ies" }} 
{% endwith %} 
{% load gentags %} 
{% for qdict in sql_queries %} 
{{ qdict.sql|safe|replace_dashes|wordwrap:60 }} ({{ qdict.time }} seconds) 
{% endfor %} 
-->{% endif %} 

这里唯一的变化是添加了{% load gentags %}一行,并在应用于qdict.sql的过滤器序列中添加了replace_dashes

最后,我们可以从survey/home.html模板中删除注释片段。相反,我们将把新的通用片段放在survey/base.html模板中,因此变成:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> 
<html > 
<head> 
<title>{% block title %}Survey Central{% endblock %}</title> 
</head> 
<body> 
{% block content %}{% endblock %}
</body> 
{% include "gen_utils/showqueries.html" %} 
</html> 

在基础模板中放置{% include %}将导致每个从基础模板继承的模板自动添加注释块,假设DEBUG被打开,请求的 IP 地址被列在INTERNAL_IPS中,并且响应被使用RequestContext渲染。在将应用程序放入生产环境之前,我们可能想要删除这个功能,但在开发过程中,可以方便地自动访问用于生成任何页面的 SQL 查询。

测试重新打包的模板代码

代码的重新打包版本效果如何?如果我们现在尝试重新加载调查主页,我们会发现我们忘记了一些东西。第一次尝试会弹出一个 Django 调试页面:

测试重新打包的模板代码

这是上一章提到的特殊调试页面的一个实例。这是由于在渲染过程中引发了异常而导致的TemplateSyntaxError。原始异常被捕获并转换为TemplateSyntaxError,原始回溯作为异常值的一部分显示出来。通过查看原始回溯,我们可以看到原始异常是TemplateDoesNotExist。由于某种原因,模板加载器没有找到gen_utils/showqueries.html模板文件。

在这里接收到的调试页面中进一步翻页,我们了解到模板引擎将原始异常包装在TemplateSyntaxError中的行为有时会令人恼火。因为最终引发的异常是TemplateSyntaxError而不是TemplateDoesNotExist,这个调试页面没有模板加载器事后报告,该报告将详细说明尝试了哪些模板加载器,以及它们在搜索gen_utils/showqueries.html时尝试加载了哪些文件。因此,由于TemplateSyntaxError异常用于包装其他异常的方式,我们丢失了一些有用的调试信息。

如果需要的话,我们可以通过尝试直接从视图中渲染它,而不是将其包含在另一个模板中,来强制生成此模板文件的模板加载器事后报告。因此,通过一点工作,我们可以获得这个特定调试页面中不幸未包含的信息。

但在这种情况下并不需要,因为异常的原因并不特别隐晦:我们没有采取任何措施确保新的gen_utils应用程序中的模板能够被找到。我们没有将gen_utils包含在INSTALLED_APPS中,以便应用程序模板加载程序可以搜索其templates目录,也没有将gen_utils 模板目录的路径放入TEMPLATE_DIRS设置中。我们需要做这些事情中的一件,以便找到新的模板文件。由于gen_utils现在也有一个过滤器,并且为了加载该过滤器,gen_utils需要被包含在INSTALLED_APPS中,我们将通过将gen_utils包含在INSTALLED_APPS中来修复TemplateDoesNotExist异常。

一旦我们做出了这个改变,新的代码工作了吗?并没有。尝试重新加载页面现在会出现不同的调试页面:

测试重新打包的模板代码

这个有点神秘。显示的模板是gen_utils/showqueries.html,所以我们比之前的情况更进一步了。但出于某种原因,尝试{% load gentags %}失败了。错误信息显示:

'gentags'不是有效的标签库:无法从 django.templatetags.gentags 加载模板库,没有名为 gentags 的模块

这是一个罕见的情况,你不希望完全相信错误消息似乎在说什么。它似乎在暗示问题是django.templatetags中没有gentags.py文件。一个自然的下一个想法可能是,需要将自定义模板标签和过滤器库放在 Django 自己的源树中。然而,这将是一个非常奇怪的要求,而且文档明确地与之相矛盾,因为它指出自定义标签和过滤器应该放在应用程序的templatetags目录中。我们应该使用除了普通的{% load %}标签以外的东西来强制 Django 搜索其自己的templatetags目录之外的标签库吗?

不,这种情况下错误只是误导。尽管错误消息中只命名了django.templatetags模块,但实际上 Django 代码尝试从INSTALLED_APPS中列出的每个应用程序的templatetags目录中加载gentags。因此问题不在于 Django 为什么未能在gen_utils/templatetags目录下查找gentags,而是为什么从genutils.templatetags加载gentags失败?

我们可以尝试回答这个问题,尝试在 Python shell 会话中运行与{% load %}相同的 Django 代码:

kmt@lbox:/dj_projects/marketr$ python manage.py shell 
Python 2.5.2 (r252:60911, Oct  5 2008, 19:24:49) 
[GCC 4.3.2] on linux2 
Type "help", "copyright", "credits" or "license" for more information. 
(InteractiveConsole) 
>>> from gen_utils.templatetags import gentags 
Traceback (most recent call last): 
 File "<console>", line 1, in <module> 
ImportError: No module named templatetags 
>>> 

果然,尝试从gen_utils.templatetags导入gentags失败了。Python 声称templatetags模块不存在。但这个目录肯定是存在的,gentags.py也存在,那么缺少什么呢?答案是在该目录中创建一个__init__.py文件,使 Python 将其识别为一个模块。创建该文件并从 shell 重新尝试导入将会显示导入现在可以工作。

然而,尝试在浏览器中简单地重新加载页面会导致相同的调试页面重新显示。这也是开发服务器需要手动停止和重新启动才能接受更改的罕见情况之一。完成这些操作后,我们最终可以重新加载调查首页并看到:

测试重新打包的模板代码

我们回到了页面被提供而没有引发异常的情况,也不再有sql_queries的杂散调试信息包含在 HTML 注释中。如果我们进一步查看页面的 HTML 源代码,底部会看到类似以下内容:

<!--

4 queries

SELECT COUNT(*) FROM `survey_survey` WHERE
`survey_survey`.`title` LIKE BINARY %~~double-dash~~%  (0.015 seconds)

SELECT `survey_survey`.`id`, `survey_survey`.`title`,
`survey_survey`.`opens`, `survey_survey`.`closes` FROM
`survey_survey` WHERE (`survey_survey`.`opens` <= 2009-11-01
 AND `survey_survey`.`closes` >= 2009-11-01 ) (0.001 seconds)

SELECT `survey_survey`.`id`, `survey_survey`.`title`,
`survey_survey`.`opens`, `survey_survey`.`closes` FROM
`survey_survey` WHERE (`survey_survey`.`closes` < 2009-11-01
 AND `survey_survey`.`closes` >= 2009-10-18 ) (0.000 seconds)

SELECT `survey_survey`.`id`, `survey_survey`.`title`,
`survey_survey`.`opens`, `survey_survey`.`closes` FROM
`survey_survey` WHERE (`survey_survey`.`opens` > 2009-11-01 
AND `survey_survey`.`opens` <= 2009-11-08 ) (0.000 seconds)

--> 

看起来不错。replace_dashes过滤器成功地去掉了两个连字符,因此浏览器不再认为注释块在预期之前被终止。现在我们可以继续检查生成其他调查页面所需的 SQL 查询。

用于活动调查表单显示页面的 SQL 查询

单击链接到一个活动调查会显示该调查的活动调查页面:

用于活动调查表单显示页面的 SQL 查询

查看此页面的源代码,我们看到需要六个 SQL 查询才能生成它:

<!--

6 queries

SELECT `survey_survey`.`id`, `survey_survey`.`title`,
`survey_survey`.`opens`, `survey_survey`.`closes` FROM
`survey_survey` WHERE `survey_survey`.`id` = 1  (0.000 seconds)

SELECT `survey_question`.`id`, `survey_question`.`question`,
`survey_question`.`survey_id` FROM `survey_question` WHERE
`survey_question`.`survey_id` = 1  (0.000 seconds)

SELECT COUNT(*) FROM `survey_answer` WHERE
`survey_answer`.`question_id` = 1  (0.001 seconds)

SELECT COUNT(*) FROM `survey_answer` WHERE
`survey_answer`.`question_id` = 2  (0.001 seconds)

SELECT `survey_answer`.`id`, `survey_answer`.`answer`,
`survey_answer`.`question_id`, `survey_answer`.`votes` FROM
`survey_answer` WHERE `survey_answer`.`question_id` = 1  (0.024 seconds)

SELECT `survey_answer`.`id`, `survey_answer`.`answer`,
`survey_answer`.`question_id`, `survey_answer`.`votes` FROM
`survey_answer` WHERE `survey_answer`.`question_id` = 2  (0.001 seconds)

-->

我们能否将这些查询与用于生成页面的代码进行匹配?是的,在这种情况下,可以相对容易地看到每个查询来自哪里。第一个查询是根据其主键查找调查,并对应于survey_detail视图中第一行中的get_object_or_404调用:

def survey_detail(request, pk): 
    survey = get_object_or_404(Survey, pk=pk) 

由于这是一个活动调查,控制线程随后转到display_active_survey函数,其中包含以下代码来构建页面的表单:

    qforms = [] 
    for i, q in enumerate(survey.question_set.all()): 
        if q.answer_set.count() > 1: 
            qforms.append(QuestionVoteForm(q, prefix=i, data=data)) 

调用enumerate(survey.question_set.all())负责此页面的第二个 SQL 查询,它检索显示的调查的所有问题。for循环中的q.answer_set.count()解释了第三和第四个 SQL 查询,它们检索了调查中每个问题的答案计数。

然后,最后两个查询检索了调查中每个问题的答案集。我们可能首先认为这些查询是在创建调查中每个问题的QuestionVoteForm时发出的。 QuestionVoteForm__init__例程包含此行,以初始化问题的答案集:

        self.fields['answer'].queryset = question.answer_set.all() 

然而,该行代码并不会导致对数据库的调用。它只是将表单的answer字段的queryset属性设置为QuerySet值。由于QuerySets是惰性的,这不会导致数据库访问。这得到了证实,即请求COUNT(*)的两个查询是在检索实际答案信息的查询之前发出的。如果创建QuestionVoteForm导致检索答案信息,那么最后两个查询将不会是最后的,而是将与COUNT(*)查询交错。然后,触发检索答案信息的查询是在survey/active_survey.html模板中呈现答案值时。

如果我们专注于优化,此时我们可能会尝试看看是否可以减少此页面所需的查询数量。在两个单独的查询中检索答案的计数,然后检索答案信息本身似乎效率低下,与仅检索答案信息并根据返回的信息推导计数相比。看起来我们可以用四个查询而不是六个查询来生成此页面。

然而,由于我们专注于理解当前行为以帮助调试,我们不会在这里转向优化讨论。即使我们正在开发一个真正的项目,在开发的这个阶段,现在不是进行此类优化的好时机。这里的低效并不糟糕到被称为错误,所以最好只是将其记录为将来可能要查看的可能事项,当可以确定应用程序的整体性能的全貌时。在那时,最昂贵的低效将值得花时间进行改进的。

发布调查答案的 SQL 查询

如果我们现在为调查问题选择了一些答案并按下提交按钮,我们会收到感谢页面的响应:

用于发布调查答案的 SQL 查询

查看此页面的源代码,我们发现了一个单独的 SQL 查询,以检索给定主键的survey

<!--

1 query

SELECT `survey_survey`.`id`, `survey_survey`.`title`,
`survey_survey`.`opens`, `survey_survey`.`closes` FROM
`survey_survey` WHERE `survey_survey`.`id` = 1  (0.001 seconds)

-->

与该查询相关的代码行是显而易见的;它是survey_thanks视图中的get_object_or_404

def survey_thanks(request, pk): 
    survey = get_object_or_404(Survey, pk=pk) 
    return render_to_response('survey/thanks.html', 
        {'survey': survey }, 
        RequestContext(request)) 

但是,当表单数据被提交时,处理表单数据所涉及的所有 SQL 查询呢?在调用survey_thanks视图之前很久,必须运行display_active_survey以接收提交的表单数据并更新所选答案的数据库。然而,我们在感谢页面显示的查询中没有看到其中任何需要的 SQL 查询。

原因是因为display_active_survey函数在表单处理成功并更新数据库时,不直接呈现模板,而是返回一个HttpResponseRedirect。Web 浏览器在接收到 HTTP 重定向响应后,会自动获取重定向中标识的位置。

因此,在浏览器上按下“提交”按钮和看到感谢页面出现之间,会发生两个完整的请求/响应周期。感谢页面本身可以显示在其(第二个)请求/响应周期期间执行的 SQL 查询,但不能显示在第一个请求/响应周期中发生的任何查询。

这令人失望。此时,我们已经花了相当多的精力开发了一开始看起来似乎会是一个非常简单的实用程序代码。现在,我们发现它对于应用程序中一些最有趣的视图——实际上更新数据库的视图——不起作用。我们该怎么办?

我们当然不希望放弃查看成功处理提交的数据页面的 SQL 查询。但我们也不希望在这个实用程序代码上花费更多的开发工作。虽然我们在这个过程中学到了一些东西,但我们开始偏离我们的主要应用程序。幸运的是,我们不需要做这两件事。相反,我们可以简单地安装并开始使用一个已经开发好的 Django 应用程序的通用调试工具,即 Django Debug Toolbar。这个工具是下一节的重点。

Django Debug Toolbar

Rob Hudson 的 Django Debug Toolbar 是 Django 应用程序的非常有用的通用调试工具。与我们在本章早些时候开发的代码一样,它可以让您看到生成页面所需的 SQL 查询。然而,正如我们将看到的,它远不止于此,还提供了更多关于 SQL 查询和请求处理的信息的简便访问。此外,调试工具栏有一种更高级的显示信息的方式,而不仅仅是将其嵌入到 HTML 注释中。最好通过示例来展示其功能,因此我们将立即开始安装工具栏。

安装 Django Debug Toolbar

工具栏可以在 Python 软件包索引网站上找到:pypi.python.org/pypi/django-debug-toolbar。安装后,通过添加几个设置即可在 Django 项目中激活调试工具栏。

首先,必须将调试工具栏中间件debug_toolbar.middleware.DebugToolbarMiddleware添加到MIDDLEWARE_CLASSES设置中。工具栏的文档指出,它应该放在任何其他编码响应内容的中间件之后,因此最好将其放在中间件序列的最后。

其次,需要将debug_toolbar应用程序添加到INSTALLED_APPS中。debug_toolbar应用程序使用 Django 模板来呈现其信息,因此需要在INSTALLED_APPS中列出,以便应用程序模板加载程序找到它的模板。

第三,调试工具栏要求将请求的 IP 地址列在INTERNAL_IPS中。由于我们在本章早些时候已经进行了此设置更改,因此现在不需要做任何操作。

最后,只有在DEBUGTrue时才会显示调试工具栏。我们一直在调试模式下运行,所以这里也不需要做任何更改。还要注意调试工具栏允许您自定义调试工具栏显示的条件。因此,可以设置工具栏在请求 IP 地址不在INTERNAL_IPS中或调试未打开时显示,但对于我们的目的,默认配置就可以了,所以我们不会做任何更改。

不需要的一件事是应用程序本身使用RequestContext以便在工具栏中提供 SQL 查询信息等。调试工具栏作为中间件运行,因此不依赖于应用程序使用RequestContext来生成信息。因此,如果我们一开始就使用 Django 调试工具栏,就不需要对调查视图进行更改以在render_to_response调用上指定RequestContext

调试工具栏外观

一旦调试工具栏添加到中间件和已安装应用程序设置中,我们可以通过简单地访问调查应用程序中的任何页面来看看它的外观。让我们从主页开始。返回的页面现在应该看起来像这样:

调试工具栏外观

请注意,此截图显示了调试工具栏 0.8.0 版本的外观。早期版本看起来会有很大不同,所以如果您的结果不像这样,您可能使用的是不同于 0.8.0 版本的版本。您拥有的版本很可能比写作时可用的版本更新,可能有其他工具栏面板或功能没有在这里介绍。

如您所见,调试工具栏出现在浏览器窗口的右侧。它由一系列面板组成,可以通过更改工具栏配置单独启用或禁用。这里显示的是默认启用的面板。

在更仔细地查看一些单独面板之前,请注意工具栏顶部包含一个隐藏选项。如果选择隐藏,工具栏会缩小到一个类似标签的指示,以显示其存在:

调试工具栏外观

这对于工具栏的扩展版本遮挡页面上的应用程序内容的情况非常有用。单击DjDT标签后,工具栏提供的所有信息仍然可以访问;它只是暂时不可见。

大多数面板在单击时会提供详细信息。一些还会在主工具栏显示中提供摘要信息。从调试工具栏版本 0.8.0 开始,列出的第一个面板Django 版本只提供摘要信息。单击它不会提供更详细的信息。如您在截图中所见,这里使用的是 Django 1.1.1 版本。

请注意,调试工具栏的当前最新源版本已经为此面板提供了比 0.8.0 版本更多的信息。自 0.8.0 以来,此面板已更名为版本,可以单击以提供更多详细信息。这些额外的详细信息包括工具栏本身的版本信息以及为提供版本信息的任何其他已安装的 Django 应用程序的版本信息。

显示摘要信息的另外三个面板是时间SQL日志面板。因此,我们可以一眼看出页面的第一次出现使用了 60 毫秒的 CPU 时间(总共用了 111 毫秒的时间),页面需要了四个查询,花费了 1.95 毫秒,请求期间没有记录任何消息。

在接下来的章节中,我们将深入研究每个面板在点击时提供的具体信息。我们将首先从 SQL 面板开始,因为它是最有趣的之一,并且提供了我们在本章前面努力自己获取的相同信息(以及更多信息)。

SQL 面板

如果我们点击调试工具栏的SQL部分,页面将会变成:

SQL 面板

乍一看,这个 SQL 查询页面比我们之前想出的要好得多。查询本身被突出显示,使 SQL 关键字更容易阅读。而且,由于它们不是嵌入在 HTML 注释中,它们的内容不需要以任何方式进行修改——没有必要改变包含双破折号的查询内容,以避免它引起显示问题。(现在可能是一个好时机,在我们忘记为什么添加它之前,删除那个额外的查询。)

还要注意,每个查询所列的时间比 Django 默认查询历史中提供的更具体。调试工具栏用自己的查询记录替换了 Django 的查询记录,并以毫秒为单位提供时间,而不是秒。

显示还包括了每个查询所花费时间的图形表示,以水平条形图的形式出现在每个查询的上方。这种表示使人们很容易看出是否有一个或多个查询比其他查询要昂贵得多。实际上,如果一个查询花费的时间过长,它的条形图将会变成红色。在这种情况下,查询时间没有太大的差异,没有一个特别长,所以所有的条形图长度都差不多,并且呈灰色。

更深入地挖掘,我们在本章前面手动找出的一些信息在这个 SQL 查询显示中只需点击一下就可以得到。具体来说,我们可以得到我们的代码中触发特定 SQL 查询的行号。每个显示的查询都有一个切换堆栈跟踪选项,点击后将显示与查询相关联的堆栈跟踪:

SQL 面板

在这里,我们可以看到所有的查询都是由调查views.py文件中的home方法发起的。请注意,工具栏会过滤掉 Django 本身的堆栈跟踪级别,这就解释了为什么每个查询只显示了一个级别。第一个查询是由第 61 行触发的,其中包含了添加的filter调用,用于测试如果记录了一个包含两个连字符的查询会发生什么。其余的查询都归因于第 66 行,这是home视图中render_to_response调用的最后一行。正如我们之前发现的那样,这些查询都是在模板渲染期间进行的。(您的行号可能与此处显示的行号不同,这取决于文件中各种函数的放置位置。)

最后,这个 SQL 查询显示提供了一些我们甚至还没有想到要的信息。在操作列下面是每个查询的SELECTEXPLAINPROFILE链接。点击SELECT链接会显示数据库在实际执行查询时返回的内容。例如:

SQL 面板

类似地,点击EXPLAINPROFILE会显示数据库在被要求解释或分析所选查询时的报告。确切的显示和结果解释将因数据库而异。(事实上,PROFILE选项并不适用于所有数据库——它恰好受到了这里使用的数据库,MySQL 的支持。)解释EXPLAINPROFILE的结果超出了本文所涵盖的范围,但值得知道的是,如果您需要深入了解查询的性能特征,调试工具栏可以轻松实现这一点。

我们现在已经深入了几页 SQL 查询显示。我们如何返回到实际应用程序页面?单击主页显示右上角的圈起来的“>>”将返回到上一个 SQL 查询页面,并且圈起来的“>>”将变成圈起来的“X”。单击任何面板详细信息页面上的圈起来的“X”将关闭详细信息并返回到显示应用程序数据。或者,再次单击工具栏上当前显示面板的面板区域将产生与在显示区域上单击圈起来的符号相同的效果。最后,如果您更喜欢使用键盘而不是鼠标,按下Esc将产生与单击圈起来的符号相同的效果。

现在我们已经完全探索了 SQL 面板,让我们简要地看一下调试工具栏提供的其他面板。

时间面板

单击“时间”面板会显示有关页面生成期间时间花费的更详细信息:

时间面板

总 CPU 时间分为用户和系统时间,列出了总经过的(挂钟)时间,并显示了自愿和非自愿的上下文切换次数。对于生成时间过长的页面,关于时间花费在哪里的额外细节可以帮助指向原因。

请注意,此面板提供的详细信息来自 Python 的resource模块。这是一个特定于 Unix 的 Python 模块,在非 Unix 类型系统上不可用。因此,在 Windows 上,例如,调试工具栏时间面板只会显示摘要信息,没有更多的详细信息可用。

设置面板

单击“设置”会显示所有生效设置的可滚动显示。用于创建此显示的代码与用于在 Django 调试页面上显示设置的代码相同,因此这里的显示将与您在调试页面上看到的相同。

HTTP 头面板

单击“HTTP 头”会显示请求的所有 HTTP 头:

HTTP 头面板

这是调试页面“META”部分中可用信息的子集。如前一章所述,request.META字典包含请求的所有 HTTP 头,以及与请求无关的其他信息,因为request.META最初是从os.environ字典中复制的。调试工具栏选择过滤显示的信息,以包括仅与 HTTP 请求相关的信息,如屏幕截图所示。

请求变量面板

单击“请求变量”会显示请求的 cookie、会话变量、GET 变量和 POST 数据。由于调查应用程序主页没有任何信息可显示,因此它的“请求变量”显示并不是很有趣。相反,这里是来自管理员应用程序的一个示例,它确实使用了会话,因此实际上有一些东西可以显示:

请求变量面板

在这里,您可以看到由于管理员应用程序使用了django.contrib.sessions应用程序而设置的sessionid cookie,并且还可以看到已在会话中设置的各个会话变量。

模板面板

单击“模板”会显示有关请求的模板处理的信息。以调查主页为例:

模板面板

“模板路径”部分列出了TEMPLATE_DIRS设置中指定的路径;由于我们没有向该设置添加任何内容,因此它为空。

模板部分显示了响应渲染的所有模板。列出了每个模板,显示了应用程序指定的首次渲染的名称。单击此名称将显示实际模板文件内容的显示。在应用程序指定的名称下是模板的完整文件路径。最后,每个模板还有一个切换上下文链接,可用于查看每个已呈现模板使用的上下文的详细信息。

上下文处理器部分显示了所有安装的上下文处理器。在每个下面都有一个切换上下文链接,单击后将显示相关上下文处理器添加到上下文中的上下文变量。

请注意,无论应用程序是否使用RequestContext来呈现响应,上下文处理器都会被列出。因此,它们在此页面上列出并不意味着它们设置的变量被添加到此特定响应的上下文中。

信号面板

单击Signals会显示信号配置的显示:

信号面板

列出了所有定义的 Django 信号。对于每个信号,都显示了提供的参数以及已连接到该信号的接收器。

请注意,此显示不表示当前页面生成过程中实际触发了哪些信号。它只显示信号的配置方式。

日志面板

最后,日志面板显示了在请求处理过程中通过 Python 的logging模块发送的任何消息。由于我们尚未调查在调查应用程序中使用日志记录,并且自 Django 1.1 以来,Django 本身不使用 Python 日志记录模块,因此在此面板上我们没有看到任何内容。

调试工具栏处理重定向

现在回想一下我们开始调查调试工具栏的原因:我们发现我们最初用于跟踪页面的 SQL 查询的方法对于返回 HTTP 重定向而不是呈现模板的页面不起作用。调试工具栏如何更好地处理这个问题?要了解这一点,请单击主页上的Television Trends链接,为两个问题选择答案,然后单击提交。结果将是:

调试工具栏处理重定向

此页面显示了为什么有时需要在工具栏上使用隐藏选项的示例,因为工具栏本身遮挡了页面上的部分消息。隐藏工具栏后,可以看到完整的消息是:

Django 调试工具栏已拦截重定向到上述 URL 以进行调试查看。您可以单击上面的链接以继续进行正常的重定向。如果要禁用此功能,请将 DEBUG_TOOLBAR_CONFIG 字典的键 INTERCEPT_REDIRECTS 设置为 False。

调试工具栏在这里所做的是拦截重定向请求,并用包含原始重定向指定位置的渲染响应替换它。工具栏本身仍然存在,并可用于调查我们可能希望查看有关生成重定向的请求处理的任何信息。例如,我们可以单击SQL部分并查看:

调试工具栏处理重定向

这些是处理传入的表单所需的 SQL 查询。毫不奇怪,前四个与我们首次生成表单时看到的完全相同,因为最初对 GET 和 POST 请求都遵循相同的代码路径。

只有在发出这些查询之后,display_active_survey视图才对 GET 和 POST 有不同的代码路径。具体来说,在 POST 的情况下,代码是:

    if request.method == 'POST': 
        chosen_answers = [] 
        for qf in qforms: 
            if not qf.is_valid(): 
                break; 
            chosen_answers.append(qf.cleaned_data['answer']) 
        else: 
            for answer in chosen_answers: 
                answer.votes += 1 
                answer.save() 
           return HttpResponseRedirect(reverse('survey_thanks', args=(survey.pk,))) 

此页面上列出的第五和第六个查询正在检索在提交的表单上选择的特定答案实例。与 GET 情况不同,在第五和第六个查询中检索了给定问题的所有答案,这些查询还在 SQL WHERE 子句中指定了答案id以及问题id。在 POST 情况下,不需要检索问题的所有答案;只需要检索选择的那个答案即可。

切换这些查询的堆栈跟踪显示它们是由代码的if not qf.is_valid()行导致的。这是有道理的,因为除了验证输入外,is_valid方法还会将发布的数据标准化,然后将其放入表单的cleaned_data属性中。对于ModelChoiceField,标准化值是所选的模型对象实例,因此验证代码需要从数据库中检索所选对象。

在发现两个提交的表单都有效之后,此代码的else部分运行。在这里,每个选择的答案的投票计数都会增加,并且更新的answer实例将保存到数据库中。然后,这段代码必须负责之前显示的最后四个查询。可以通过检查这四个查询的堆栈跟踪来确认:所有指向代码的answer.save()行。

但是为什么需要四个 SQL 语句,两个 SELECT 和两个 UPDATE,来保存两个答案到数据库中?UPDATE 语句是不言自明的,但是在它们之前的 SELECT 语句有点奇怪。在每种情况下,都从survey_answer表中选择常量 1,并使用 WHERE 子句指定与正在保存的survey匹配的主键值。这个查询的目的是什么?

Django 代码在这里所做的是尝试确定正在保存的answer是否已经存在于数据库中,或者是新的。Django 可以通过从 SELECT 返回任何结果来判断在将模型实例保存到数据库时是否需要使用 UPDATE 或 INSERT。选择常量值比实际检索结果更有效,当唯一需要的信息是结果是否存在时。

您可能认为 Django 代码应该知道,仅基于模型实例的主键值已经设置,该实例反映的数据已经在数据库中。但是,Django 模型可以使用手动分配的主键值,因此分配了主键值并不保证模型已经保存到数据库中。因此,在保存数据之前需要额外的 SELECT 来确定模型的状态。

然而,调查应用程序代码肯定知道在处理调查响应时保存的所有answer实例已经保存在数据库中。在保存时,调查代码可以通过在保存调用上指定force_update来指示必须通过 UPDATE 而不是 INSERT 保存实例:

                answer.save(force_update=True) 

如果我们进行更改并尝试提交另一个调查,我们会发现对于这种情况,处理中已经消除了 SELECT 查询,从而将所需的总查询数量从 10 减少到 8:

调试工具栏的重定向处理

(是的,我意识到之前我说现在不是进行优化的时候,但是我还是进行了一次。这次实在是太容易了。)

我们现在已经介绍了 Django Debug Toolbar 默认显示的所有面板,并看到了它默认处理返回重定向的方式,允许调查导致重定向的处理过程。它是一个非常灵活的工具:它支持添加面板,更改显示的面板,更改工具栏显示的时间,以及配置各种其他选项。讨论所有这些超出了本文的范围。希望所介绍的内容让您对这个工具的强大功能有所了解。如果您有兴趣了解如何配置它的更多细节,可以从其主页链接的 README 开始。

现在我们将离开 Django Debug Toolbar,继续讨论如何通过日志跟踪应用程序代码的内部状态。为此,我们首先要看看没有工具栏时日志是如何显示的,因此此时我们应该在settings.py中注释掉工具栏中间件。(请注意,不需要从INSTALLED_APPS中删除debug_toolbar列表,因为这只是必须为应用程序模板加载器找到中间件指定的模板。)

跟踪内部代码状态

有时,即使从像 Django Debug Toolbar 这样的工具中获得的所有信息也不足以弄清楚在处理请求过程中出现错误产生不正确结果的原因。问题可能在应用程序代码的某个地方,但从视觉检查中我们无法弄清楚出了什么问题。为了解决问题,我们需要获取有关应用程序代码内部状态的更多信息。也许我们需要看看应用程序中函数的控制流是什么,或者看看为一些最终导致代码走上错误路径的中间结果计算出了什么值。

我们如何获得这种信息?一种方法是在调试器下运行代码,并逐行执行以查看它在做什么。这种方法将在下一章中详细介绍。这是非常强大的,但可能会耗费时间,在某些情况下并不实用。例如,对于只在生产过程中出现的问题,很难使用。

另一种方法是让代码报告或记录它在做什么。这是本节将要介绍的方法。这种方法并不能提供在调试器下可用的全部信息,但通过选择要记录的内容,它可以提供足够的线索来解决许多问题。它也可以更容易地用于仅在生产过程中出现的问题,而不像在调试器下运行的方法那样。

抵制洒播打印的冲动

在开发服务器下运行时,print的输出会显示在控制台上,因此很容易访问。因此,当面对一些在开发过程中表现不佳的 Django 应用程序代码时,很容易就会诱惑地在关键点添加临时的print语句,试图弄清楚代码内部发生了什么。虽然非常诱人,但通常是一个坏主意。

为什么这是一个坏主意?首先,问题很少会仅凭一个或两个print语句就变得明显。起初似乎只要知道代码是否到达这里或那里,一切都会变得清晰。但事实并非如此,我们最终会添加更多的print语句,也许打印出变量的值,代码本身和开发服务器控制台都变成了临时调试信息的一团糟。

然后,一旦问题解决了,所有那些print语句都需要被移除。我们通常不希望它们在代码或控制台中弄乱输出。移除它们都是一件麻烦事,但是必要的,因为一些生产环境不允许访问sys.stdout。因此,从开发调试中留下的print可能会在生产过程中导致服务器错误。

然后,当出现相同或类似的问题时,如果以前通过“sprinkle print”方法解决了问题,那么几乎所有之前的工作可能需要重新做,以便找出这次出了什么问题。以前的经验可能会给我们一个更好的主意,即在哪里放置print语句,但如果在解决第一个问题后已经删除了它们,那么可能需要重新做基本相同的工作,以解决出现的下一个问题变体。这是一种浪费。

这个序列突出了“sprinkle print”方法在开发调试中的一些主要问题。首先,开发人员需要在添加print的地方立即决定在什么条件下它应该被产生以及输出应该去哪里。可以使用条件语句(如if settings.DEBUG)来给添加的print语句加上括号,这可能允许添加的调试支持长期保留在代码中,但这很麻烦并且会给代码增加杂乱,因此通常不会这样做。也可以在print中指定输出应该被路由到除了默认的sys.stdout之外的其他地方,但同样这需要更多的工作,通常也不会这样做。

这些问题导致了“sprinkle print”语句的出现,当问题解决后立即被删除,使得代码默认情况下不报告其操作。然后,当下一个问题出现时,开发人员必须重新开始添加调试信息的报告。

更好的方法是在开发过程中使用一些有纪律的日志记录,这样,至少在DEBUG被打开时,默认情况下,代码会报告它正在做什么。如果是这样,那么很可能不需要收集额外的调试信息来解决出现的问题。此外,使用日志记录设施允许配置在什么条件下输出消息,以及它们应该去哪里,与实际的日志记录语句分开。

开发的简单日志配置

因此,与print语句相比,一种更好的调试选择是使用 Python 的logging模块。实际的日志调用与print一样容易。例如,用于跟踪对display_active_survey的调用的print可能如下所示:

def display_active_survey(request, survey): 
    print 'display_active_survey called for a %s of survey '\'with pk %s' % (request.method, survey.pk) 

这里的print报告了已被调用的函数;以及request.method和它所传递的调查的主键。在开发服务器控制台上,获取活动调查页面的输出将是:

Django version 1.1.1, using settings 'marketr.settings' 
Development server is running at http://0.0.0.0:8000/ 
Quit the server with CONTROL-C. 
display_active_survey called for a GET of survey with pk 1 
[04/Nov/2009 19:14:10] "GET /1/ HTTP/1.1" 200 2197 

只使用 Python 的logging的等效调用可能是:

import logging 
def display_active_survey(request, survey): 
    logging.debug('display_active_survey called for a %s of ''survey with pk %s', request.method, survey.pk) 

这里使用logging.debug调用来指定传递的字符串是调试级别的消息。级别的概念允许调用代码为消息分配重要性的度量,而不实际在当前情况下做出任何关于消息是否应该输出的决定。相反,这个决定是由日志记录设施基于当前设置的日志记录阈值级别做出的。

Python 的logging模块提供了一组方便的方法来记录消息,具有默认定义的级别。这些级别依次增加:debuginfowarningerrorcritical。因此,只有在logging模块的级别阈值已设置为包括调试级别的消息时,才会输出logging.debug消息。

使用logging.debug语句代替print的唯一问题是,默认情况下,日志模块的级别阈值设置为warning。因此,默认情况下只输出warningerrorcritical消息。我们需要配置logging模块以输出调试级别的语句,以便此消息出现在控制台上。一个简单的方法是在settings.py文件中添加对logging.basicConfig的调用。我们可以使调用依赖于DEBUG是否打开:

import logging
if DEBUG: 
    logging.basicConfig(level=logging.DEBUG)

通过将该代码添加到settings.py中,并在display_active_survey函数中调用logging.debug,开发控制台现在将在进入display_active_survey函数时显示消息。

Django version 1.1.1, using settings 'marketr.settings' 
Development server is running at http://0.0.0.0:8000/ 
Quit the server with CONTROL-C. 
DEBUG:root:display_active_survey called for a GET of survey with pk 1 
[04/Nov/2009 19:24:14] "GET /1/ HTTP/1.1" 200 2197 

请注意,消息上的DEBUG:root:前缀是应用于记录消息的默认格式的结果。DEBUG表示与消息关联的级别,root标识用于记录消息的记录器。由于logging.debug调用没有指定任何特定的记录器,因此使用了root的默认值。

logging.basicConfig的其他参数可用于更改消息的格式,但是在这里我们需要覆盖的 Python 日志的所有功能超出了范围。对于我们的目的,默认格式将很好。

日志配置中可以指定消息的路由。我们在这里没有这样做,因为默认的sys.stderr对于开发调试目的已经足够了。

决定记录什么

通过从print切换到logging,我们消除了开发人员添加日志时需要决定在什么条件下产生记录信息以及应该将记录信息放在何处的需要。开发人员只需要确定与消息相关联的重要性级别,然后日志设施本身将决定如何处理记录的信息。那么,接下来应该记录什么呢?

一般来说,在编写代码时很难知道记录哪些信息最有用。作为开发人员,我们可能会猜测一些,但在实际运行代码时,直到我们对代码有了一些经验,才能确定。然而,正如之前提到的,让代码具有一些内置的基本信息报告可能非常有帮助。因此,在最初编写代码时,最好有一些记录的指南要遵循。

这样的一个指南可能是记录所有“重要”函数的进入和退出。输入日志消息应包括任何关键参数的值,退出日志消息应该给出函数返回的一些指示。只有这种类型的输入和退出日志(假设代码合理地分割为可管理的函数),我们将能够清楚地了解代码的控制流。

然而,手动添加条目和退出日志是一件麻烦事。这也会给代码增加混乱。实际上,很少有指南会愉快地遵循记录所有重要函数的进入和退出,除非它比为display_active_survey输入先前记录的日志消息更容易。

幸运的是,Python 提供了便利设施,使得我们可以轻松地做到我们在这里寻找的事情。函数可以包装在其他函数中,允许包装函数执行诸如记录输入和输出以及参数和返回信息等操作。此外,Python 装饰器语法允许以最少的额外代码混乱来实现这种包装。在下一节中,我们将为现有的调查应用程序代码开发一些简单的日志包装器。

装饰器记录函数的输入和输出

使用通用包装器而不是将输入/输出日志嵌入函数本身的一个缺点是,它使得更难以对记录的参数和返回信息进行精细控制。编写一个记录所有参数或不记录任何参数的通用包装器很容易,但很难或不可能编写一个记录参数的子集的包装器,例如。

为什么不记录所有参数?问题在于 Django 应用程序中一些常用的参数,例如请求对象,具有非常冗长的表示。记录它们的完整值会产生太多的输出。最好从一个不记录任何参数值的通用包装记录器开始,可能还有一个或多个专用包装记录器,用于记录这些参数中的关键信息。

例如,一个用于记录视图函数的进入和退出的专用包装器可能是值得的。视图总是将HttpRequest对象作为其第一个参数。虽然记录完整对象并不有用,但记录请求方法既简短又有用。此外,由于视图函数的其他参数来自请求的 URL,它们可能也不会太冗长。

返回值呢?它们应该被记录吗?对于 Django 应用程序来说,通常不会记录,因为它们经常返回HttpResponse对象。这些对象通常太大,无法在记录时提供帮助。但是,记录返回值的一些信息,例如它们的类型,通常是有用的。

我们首先提出了两个包装器。第一个将被命名为log_call,将记录函数的进入和退出。log_call不会记录任何输入参数信息,但它将记录返回结果的类型。第二个包装器将更加专业化,并且将用于包装视图函数。这个将被命名为log_view。它将记录请求方法和传递给包装视图的任何额外参数,以及其返回值的类型。

这段代码应该放在哪里?再次强调,它与调查应用程序没有任何关联,因此将其放在gen_utils中是有意义的。然后我们将在gen_utils中创建一个名为logutils.py的文件,该文件可以保存任何通用的日志记录实用程序代码。我们将从先前描述的log_call包装器的实现开始:

import logging 

class LoggingDecorator(object): 
    def __init__(self, f): 
        self.f = f 

class log_call(LoggingDecorator): 
    def __call__(self, *args, **kwargs): 
       f = self.f 
       logging.debug("%s called", f.__name__) 
       rv = f(*args, **kwargs) 
       logging.debug("%s returned type %s", f.__name__, type(rv)) 
       return rv 

这个实现使用了基于类的编写包装函数的风格。使用这种风格,包装器被定义为一个实现__init____call__方法的类。__init__方法在包装器创建时被调用,并且传递了它所包装的函数。__call__方法在实际调用包装函数时被调用。__call__的实现负责执行包装函数所需的任何操作,调用包装函数,并返回其结果。

在这里,实现分为两个类:基本的LoggingDecorator实现__init__,然后log_call继承自LoggingDecorator并实现__call__。这种分割的原因是我们可以为多个日志记录包装器共享通用的__init____init__只是保存对稍后在调用__call__时使用的包装函数的引用。

然后,log_call __call__的实现首先记录一个消息,指出函数已被调用。包装函数的名称可以在其__name__属性中找到。然后调用包装函数,并将其返回值保存在rv中。然后记录第二个消息,指出被调用函数返回的类型。最后,返回包装函数返回的值。

log_view包装器与log_call非常相似,只是在记录的细节上有所不同:

class log_view(LoggingDecorator): 
    def __call__(self, *args, **kwargs): 
        f = self.f 
        logging.debug("%s called with method %s, kwargs %s", 
            f.__name__, args[0].method, kwargs) 
        rv = f(*args, **kwargs) 
        logging.debug("%s returned type %s", f.__name__, type(rv)) 
        return rv 

在这里,第一个记录的消息包括包装函数的名称,第一个位置参数的method属性和传递给包装函数的关键字参数。由于这个包装器是用于包装视图函数的,它假定第一个位置参数是一个HttpRequest对象,该对象具有method属性。

此外,此代码假定所有其他参数将作为关键字参数传递。我们知道这将是调查应用程序代码的情况,因为所有调查 URL 模式都指定了命名组。如果要支持 URL 模式配置中使用的非命名组,更通用的视图包装器将需要记录args(除了第一个参数,即HttpRequest对象)。对于调查应用程序,这只会导致记录始终相同的信息,因此在此处已被省略。

将装饰器应用于调查代码

现在让我们将这些装饰器添加到调查视图函数中,并看看浏览的一些典型输出是什么样子。添加装饰器很容易。首先,在views.py中,在文件顶部附近添加装饰器的导入:

from gen_utils.logutils import log_view, log_call 

然后,对于所有实际视图函数,将@log_view添加到函数定义之上。(此语法假定正在使用的 Python 版本为 2.4 或更高版本。)例如,对于主页,视图定义如下:

@log_view 
def home(request): 

对于survey_detailsurvey_thanks也是一样。对于实用函数display_active_surveydisplay_completed_survey,使用@log_call。例如:

@log_call 
def display_active_survey(request, survey): 

现在当我们在调查网站上浏览时,我们将在控制台上记录有关所调用代码的基本信息的消息。例如,我们可能会看到:

DEBUG:root:home called with method GET, kwargs {} 
DEBUG:root:home returned type <class 'django.http.HttpResponse'> 
[05/Nov/2009 10:46:48] "GET / HTTP/1.1" 200 1184 

这显示调用了主页视图,并返回了一个HttpResponse。在调查应用程序的日志消息中,我们看到开发服务器的正常打印输出,指出对/GET返回了一个带有代码200(HTTP OK)和包含1184字节的响应。接下来,我们可能会看到:

DEBUG:root:survey_detail called with method GET, kwargs {'pk': u'1'} 
DEBUG:root:display_active_survey called 
DEBUG:root:display_active_survey returned type <class 'django.http.
HttpResponse'> 
DEBUG:root:survey_detail returned type <class 'django.http.HttpResponse'> 
[05/Nov/2009 10:46:49] "GET /1/ HTTP/1.1" 200 2197 

这显示了使用GET调用survey_detail视图,很可能是从先前响应返回的主页上的链接。此外,我们可以看到所请求的特定调查具有主键1。下一条日志消息揭示了这必须是一个活动调查,因为调用了display_active_survey。它返回了一个HttpResponse,与survey_detail视图一样,最后的调查日志消息后面又是 Django 自己的打印输出,总结了请求及其结果。

接下来,我们可能会看到:

DEBUG:root:survey_detail called with method POST, kwargs {'pk': u'1'} 
DEBUG:root:display_active_survey called 
DEBUG:root:display_active_survey returned type <class 'django.http.HttpResponse'> 
DEBUG:root:survey_detail returned type <class 'django.http.HttpResponse'> 
[05/Nov/2009 10:46:52] "POST /1/ HTTP/1.1" 200 2466 

再次,这看起来像是对先前响应的自然进展:对先前请求检索到的相同调查的POSTPOST表示用户正在提交调查响应。然而,记录的HttpResponse的返回类型表明提交存在问题。(我们知道HttpResponse只有在在display_active_survey中发现表单无效时才会对POST进行响应。)

这可能是我们希望在进入/退出信息之外添加额外日志记录的地方,以跟踪被认为无效的已发布表单的具体原因。在其当前形式中,我们只能知道返回的响应,因为它比原始响应略大(2466 比 2197 字节),很可能包含了一个错误注释,指出需要在表单上修复什么才能使其有效。

接下来,我们可能会看到:

DEBUG:root:survey_detail called with method POST, kwargs {'pk': u'1'} 
DEBUG:root:display_active_survey called 
DEBUG:root:display_active_survey returned type <class 'django.http.HttpResponseRedirect'> 
DEBUG:root:survey_detail returned type <class 'django.http.HttpResponseRedirect'> 
[05/Nov/2009 10:46:56] "POST /1/ HTTP/1.1" 302 0 

这开始是对先前请求的重复,对具有主键1的调查的survey_detail视图的POST。然而,这次返回了一个HttpResponseRedirect,表明用户必须纠正第一次提交中存在的任何问题。

在此之后,我们可能会看到:

DEBUG:root:survey_thanks called with method GET, kwargs {'pk': u'1'} 
DEBUG:root:survey_thanks returned type <class 'django.http.HttpResponse'> 
[05/Nov/2009 10:46:56] "GET /thanks/1/ HTTP/1.1" 200 544 

这显示了浏览器在接收到先前请求返回的重定向时将自动执行的请求。我们看到survey_thanks视图记录了与所有先前请求相同的调查的GET,并返回了一个HttpResponse

因此,我们可以看到,通过很少的努力,我们可以添加一些基本的日志记录,提供对 Django 应用程序代码控制流的概述。请注意,这里定义的日志装饰器并不完美。例如,它们不支持装饰方法而不是函数,即使不需要日志记录,它们也会带来一些开销,并且由于将函数转换为类而产生一些副作用。

所有这些缺点都可以通过在包装器的开发中进行一些小心处理来克服。然而,这些细节超出了我们在这里可以涵盖的范围。这里介绍的方法具有相对简单的理解优势,足够功能,希望能够展示具有易于使用的内置日志记录机制的控制流以及代码中的一些关键参数的有用性。

调试工具栏中的日志记录

回想一下,由于调查应用程序代码中没有日志记录,我们跳过了对调试工具栏的日志面板的任何检查。现在让我们返回调试工具栏,看看添加的日志记录是如何显示的。

首先,让我们添加一个额外的日志消息,以记录导致活动调查的 POST 请求失败的原因。正如在前面的部分中提到的,这可能是有用的信息。因此,在display_active_survey函数中,在找到一个无效的表单后添加一个日志调用:

        for qf in qforms: 
            if not qf.is_valid(): 
                logging.debug("form failed validation: %r", qf.errors) 
                break; 

(请注意,在使用logging之前,还需要添加import logging。)有了额外的日志消息,我们应该能够获取有关为什么特定调查提交被视为无效的具体信息。

现在取消settings.py中调试工具栏的中间件的注释,重新激活调试工具栏,浏览到一个活动的调查页面,并尝试通过提交不完整的调查来强制生成该日志消息。当返回响应时,单击工具栏的日志面板将显示如下页面:

调试工具栏中的日志记录

在这个页面上,我们可以看到除了消息本身及其分配的级别之外,工具栏还报告了它们被记录的日期和时间,以及它们在代码中生成的位置。由于大多数这些日志消息来自包装函数,这里的位置信息并不特别有用。然而,新添加的日志消息正确地匹配了它在代码中的位置。事实上,记录的消息清楚地表明表单的问题是缺少一个答案的选择。

总结

我们现在已经讨论完了如何获取有关 Django 应用程序代码运行情况的更多信息的技术。在本章中,我们:

  • 开发了一些模板实用程序代码,以跟踪在生成页面时进行了哪些 SQL 请求

  • 了解到创建可重用的通用实用程序代码可能会比起初看起来需要更多的工作

  • 学习了 Django 调试工具栏如何可以用更少的工作量获得与我们自己编写的代码中相同的信息,以及更多的信息。

  • 讨论了在代码开发过程中应用通用日志框架的有用性,而不是依赖于临时的“添加print”方法来调试问题

通过使用这些工具和技术,我们能够获取关于代码运行情况的大量信息。当代码正常运行时,对代码行为有很好的理解,这样在出现问题时更容易调试。此外,即使在所有外观上看起来代码正常运行时,检查代码确切的运行情况可能会揭示潜在的问题,这些问题在代码从开发转移到生产过程中可能会变成重大问题。

然而,有时候,即使利用这些技术获得的所有信息也不足以解决手头的问题。在这种情况下,下一步可能是在调试器下运行代码。这是下一章的主题。

第九章:当你甚至不知道要记录什么时:使用调试器

对于开发中遇到的许多问题,调试器是最有效的工具,可以帮助弄清楚发生了什么。调试器可以让您逐步查看代码的确切操作,如果需要的话。它可以让您查看并更改沿途的变量值。有了调试器,甚至可以在对源代码进行更改之前测试潜在的代码修复。

本章重点介绍如何使用调试器来帮助调试 Django 应用程序的开发过程。具体来说,在本章中我们将:

  • 继续开发调查应用程序,看看 Python 调试器 pdb 如何帮助弄清楚出现的任何问题

  • 学习如何使用调试器来验证受多进程竞争条件影响的代码的正确操作

  • 简要讨论使用图形调试器调试 Django 应用程序

实施调查结果显示

调查应用程序还有一个主要部分尚未实施:显示已完成调查的结果。这种显示应该采取什么形式?对于调查中每个问题的每个答案收到的投票,仅以文本形式进行计数将很容易编写,但不太能有效地传达结果。结果的图形表示,如饼图,将更有效地传达投票的分布情况。

在本章中,我们将探讨几种不同的方法来实施调查结果视图,其中包括使用饼图来显示投票分布。在此过程中,我们将遇到一些困难,并看到 Python 调试器如何帮助弄清楚出了什么问题。

在开始实施用于显示调查结果的代码之前,让我们设置一些测试数据,以便在进行结果测试时使用。我们可以使用现有的电视趋势调查,只需调整其数据以反映我们想要测试的内容。首先,我们需要将其“关闭”日期更改为过去两周,这样它将显示为已完成的调查,而不是活动中的调查。

其次,我们需要设置问题答案的“投票”计数,以确保我们测试任何特殊情况。这个“调查”有两个问题,因此我们可以用它来测试答案中有一个明显的单一赢家和答案平局的情况。

我们可以使用管理应用程序在第一个问题上设置获胜者平局:

实施调查结果显示

在这里,我们已经将喜剧戏剧设置为获胜答案的平局。为简单起见,投票总数(5)被保持在较低水平。当扇形应包含总数的五分之一和五分之二时,验证饼图的外观将很容易。

对于第二个问题,我们可以设置数据,以便有一个明显的单一赢家:

实施调查结果显示

对于这个问题,我们的结果显示应该只列出几乎没有:我已经看太多电视了!作为唯一的获胜答案。

使用 pygooglechart 显示结果

一旦我们决定要创建饼图,下一个问题是:我们该如何做到这一点?图表创建并不内置于 Python 语言中。但是,有几个附加库提供了这个功能。我们将首先尝试使用最简单的替代方案之一,即pygooglechart,它是围绕 Google 图表 API 的 Python 包装器。

pygooglechart包可以在 Python 包索引网站pypi.python.org/pypi/pygooglechart上找到。有关基础 Google 图表 API 的信息可以在code.google.com/apis/chart/上找到。本章中使用的pygooglechart版本是 0.2.0。

使用pygooglechart的一个原因非常简单,对于 Web 应用程序来说,构建图表的结果只是一个 URL,可以用来获取图表图像。我们不需要从我们的应用程序生成或提供图像文件。相反,所有的工作都可以推迟到 Google 图表 API,并且我们的应用程序只需包含引用由 Google 提供的图像的 HTML img标签。

然后让我们从显示调查结果的模板开始。当前的模板survey/completed_survey.html的实现只是打印一个标题,指出调查的标题:

{% extends "survey/base.html" %} 
{% block content %} 
<h1>Survey results for {{ survey.title }}</h1> 
{% endblock content %} 

我们现在想要改变这一点,并添加模板代码,循环遍历调查中的问题,并打印出每个问题的结果。请记住,Question模型有一个方法(在第三章中实现,测试 1, 2, 3:基本单元测试),该方法返回获胜的答案:

class Question(models.Model): 
    question = models.CharField(max_length=200) 
    survey = models.ForeignKey(Survey) 

    def winning_answers(self): 
        max_votes = self.answer_set.aggregate(Max('votes')).values()[0]
        if max_votes and max_votes > 0: 
            rv = self.answer_set.filter(votes=max_votes) 
        else: 
            rv = self.answer_set.none() 
        return rv 

然后,在模板中,我们可以使用这个方法来访问获胜的答案(或者在平局的情况下是答案)。对于Survey中的每个Question,我们将打印出问题文本,获胜答案的列表,以及显示每个Answer的投票结果的饼图。执行此操作的模板代码如下:

{% extends "survey/base.html" %} 
{% block content %} 
<h1>Survey results for {{ survey.title }}</h1> 
{% for q in survey.question_set.all %} 
{% with q.winning_answers as winners %} 
{% if winners %} 
<h2>{{ q.question }}</h2> 
<p>Winner{{ winners|length|pluralize }}:</p> 
<ul> 
{% for answer in winners %} 
<li>{{ answer.answer }}</li> 
{% endfor %} 
</ul> 
<p><img src="img/{{ q.get_piechart_url }}" alt="Pie Chart"/></p> 
{% endif %} 
{% endwith %} 
{% endfor %} 
{% endblock content %} 

在这里,我们添加了一个{% for %}块,它循环遍历传递的调查中的问题。对于每个问题,使用winning_answers方法检索获胜答案的列表,并将其缓存在winners模板变量中。然后,如果winners中有任何内容,则显示以下项目:

  • 问题文本,作为二级标题。

  • 获胜者列表的标题段落,根据winners的长度正确使用复数形式。

  • 获胜答案的文本列表,格式为无序列表。

  • 一个嵌入式图像,将是答案投票的饼图分解。使用需要在Question模型上实现的例程检索此图像的 URL:get_piechart_url

请注意,整个项目列表的显示受到{% if winners %}块的保护,以防止尝试为未收到答案的Question显示结果的边缘情况。这可能不太可能,但最好永远不要为用户显示可能看起来奇怪的输出,因此在这里的模板级别上,我们在这种情况下简单地避免显示任何内容。

接下来,我们需要为Question模型实现get_piechart_url方法。在阅读了pygooglechart API 之后,初始实现可能是:

    def get_piechart_url(self): 
        from pygooglechart import PieChart3D 
        answer_set = self.answer_set.all() 
        chart = PieChart3D(500, 230) 
        chart.set_data([a.votes for a in answer_set]) 
        chart.set_pie_labels([a.answer for a in answer_set]) 
        return chart.get_url() 

此代码检索与Question相关联的答案集,并将其缓存在本地变量answer_set中。(这是因为在接下来的代码中,该集合被多次迭代,将其缓存在本地变量中可以确保数据只从数据库中获取一次。)然后,调用pygooglechart API 创建一个三维饼图chart,宽度为 500 像素,高度为 230 像素。然后,为饼图楔设置数据值:这些数据值是集合中每个答案的votes计数。接下来,为每个楔设置标签为answer值。最后,该方法使用get_url方法返回构建图表的 URL。

那效果如何?当我们导航到调查应用程序的主页时,电视趋势调查现在应该(因为它的closes日期已经设置为已经过去)在指示我们可以看到其结果的标题下列出:

使用 pygooglechart 显示结果

现在点击电视趋势链接将显示一个已完成的调查结果页面:

使用 pygooglechart 显示结果

这不太对。虽然获胜答案列表的文本显示正常,但饼图没有出现。相反,浏览器显示了为图像定义的替代文本饼图,这意味着在检索指定图像时出现了问题。

查看页面的 HTML 源代码,我们发现包含图像标签的两个段落看起来像这样:

<p><img src="img/" alt="Pie Chart"/></p>

不知何故,get_piechart_url方法返回了一个空字符串而不是一个值。我们可能首先要在get_piechart_url中添加一些日志,以尝试弄清楚原因:

    def get_piechart_url(self): 
        from pygooglechart import PieChart3D 
        import logging 
        logging.debug('get_piechart_url called for pk=%d', self.pk) 
        answer_set = self.answer_set.all() 
        chart = PieChart3D(500, 230) 
        chart.set_data([a.votes for a in answer_set]) 
        chart.set_pie_labels([a.answer for a in answer_set]) 
        logging.debug('get_piechart_url returning: %s', chart.get_url()) 
        return chart.get_url() 

我们已经在进入时添加了一个日志记录,记录了Question实例的主键,以及在退出之前记录了方法即将返回的内容。然而,重新加载包含日志的页面会在服务器控制台上产生混乱的输出:

DEBUG:root:survey_detail called with method GET, kwargs {'pk': u'1'} 
DEBUG:root:display_completed_survey called 
DEBUG:root:get_piechart_url called for pk=1 
DEBUG:root:get_piechart_url called for pk=2 
DEBUG:root:display_completed_survey returned type <class 'django.http.HttpResponse'> 
DEBUG:root:survey_detail returned type <class 'django.http.HttpResponse'> 
[14/Nov/2009 11:29:08] "GET /1/ HTTP/1.1" 200 2573 

我们可以看到survey_detail调用了display_completed_survey,并且get_piechart_url被调用了两次,但是两次都没有显示它返回了什么消息。发生了什么?在两个logging.debug调用之间的代码中没有分支,那么一个是如何执行的,另一个被跳过的呢?

我们可以尝试添加更多的日志调用,插入到每行代码之间。然而,虽然这可能会揭示方法在意外离开之前执行了多远,但它不会提供任何关于为什么执行停止继续到下一行的线索。即使对于像这样小的方法,每行代码之后都添加日志也是一种麻烦。对于这样的问题,调试器是弄清楚发生了什么的更有效的方法。

使用调试器入门

调试器是一个强大的开发工具,可以让我们在代码运行时查看代码的运行情况。当程序在调试器的控制下运行时,用户可以暂停执行,检查和更改变量的值,灵活地继续执行到下一行或其他明确设置的“断点”,等等。Python 有一个名为 pdb 的内置调试器,它提供了一个用户界面,本质上是一个增强的 Python shell。除了正常的 shell 命令,pdb 还支持各种特定于调试器的命令,其中许多我们将在本章中进行实验,因为我们调试调查结果显示代码。

那么,我们如何使用 pdb 来帮助弄清楚这里发生了什么?我们想进入调试器并逐步执行代码,看看发生了什么。首先要做的任务是进入调试器,可以通过在我们想要调试器控制的地方添加import pdb; pdb.set_trace()来完成。set_trace()调用在我们的程序中设置了一个显式断点,执行将在调试器控制下暂停,以便我们可以调查当前状态并控制代码的执行方式。因此,我们可以像这样更改get_piechart_url方法来在进入时调用调试器:

    def get_piechart_url(self): 
        from pygooglechart import PieChart3D 
        import logging 
        import pdb; pdb.set_trace() 
        logging.debug('get_piechart_url called for pk=%d', self.pk) 
        answer_set = self.answer_set.all() 
        chart = PieChart3D(500, 230) 
        chart.set_data([a.votes for a in answer_set]) 
        chart.set_pie_labels([a.answer for a in answer_set]) 
        logging.debug('get_piechart_url returning: %s', chart.get_url()) 
        return chart.get_url() 

现在,当我们重新加载调查结果页面时,浏览器将在尝试加载页面时出现挂起的情况:

使用调试器入门

当我们切换到包含runserver控制台的窗口时,我们看到:

DEBUG:root:survey_detail called with method GET, kwargs {'pk': u'1'} 
DEBUG:root:display_completed_survey called 
> /dj_projects/marketr/survey/models.py(71)get_piechart_url() 
-> logging.debug('get_piechart_url called for pk=%d', self.pk) 
(Pdb) 

在这里,我们看到对survey_detail视图的另一个调用已经完成,它将请求转发到display_completed_survey函数。然后,由于在get_piechart_url中放置的pdb.set_trace()调用,进入了调试器。进入时,调试器打印出两行标识下一行要执行的代码的位置以及该行的内容。因此,我们可以看到我们正在survey/models.py文件的第 71 行,在get_piechart_url方法中,即将发出调用以记录方法的日志。在两行指出执行停止的地方之后,调试器打印出其提示符(Pdb),并等待用户输入。

在继续逐步执行代码并查看代码运行时发生了什么之前,让我们先看看我们能了解到关于我们所处的位置和当前状态的信息。Pdb 支持许多命令,并不是所有命令都会在这里介绍,而只会演示最常用的一些。我们将从一些有助于了解代码所在位置、如何到达该位置以及传递给当前函数的参数的命令开始。

list 命令

例如,如果调试器进入时提供的单行上下文不足够,可以使用list命令看到更多周围的代码。这个命令,像大多数 pdb 命令一样,可以缩写为它的首字母。在这里使用它我们看到:

(Pdb
) l
 66
 67         def get_piechart_url(self):
 68             from pygooglechart import PieChart3D
 69             import logging
 70             import pdb; pdb.set_trace()
 71  ->         logging.debug('get_piechart_url called for pk=%d', self.pk)
 72             answer_set = self.answer_set.all()
 73             chart = PieChart3D(500, 230)
 74             chart.set_data([a.votes for a in answer_set])
 75             chart.set_pie_labels([a.answer for a in answer_set])
 76             logging.debug('get_piechart_url returning: %s', 
(Pdb)
 77                chart.get_url())
 78             return chart.get_url()
 79
 80     class Answer(models.Model):
 81         answer = models.CharField(max_length=200)
 82         question = models.ForeignKey(Question)
 83         votes = models.IntegerField(default=0)
 84
 85         def __unicode__(self):
 86             return self.answer
 87
(Pdb)

在这里,我们看到list命令的响应首先显示了当前执行行上面的五行,然后是当前执行行(由->前缀标记),然后是当前行之后的五行。在(Pdb)提示符下,然后输入了一个空行,这会导致重复输入的最后一个命令。对于list,重复命令会导致显示比之前显示的多 11 行。

可以传递参数给list命令,以指定要显示的确切行,例如l 1,5将显示当前文件中的前五行。

(Pdb) l 1,5
 1     # -*- encoding: utf-8 -*-
 2
 3     import datetime
 4     from django.db import models
 5     from django.db.models import Max
(Pdb)

list命令最有用,可以看到当前停止执行的代码周围的行。如果需要更多上下文,我发现在编辑器中打开文件比尝试使用带参数的list命令更容易获得文件的更完整的图像。

where 命令

w here命令(可以缩写为w)打印当前的堆栈跟踪。在这种情况下,关于代码如何到达当前位置并没有特别的神秘之处,但检查细节仍然是有益的。

get_piechart_url方法在模板渲染期间被调用,这意味着由于模板节点的递归渲染方式,它将具有很长的堆栈跟踪。起初,响应的长度和打印出的内容密度可能会让人感到不知所措,但通过忽略大部分细节,只关注文件和函数的名称,你可以对整体代码流程有一个很好的了解。例如,在响应的开始,这里的where命令是:

(Pdb) w 
 /usr/lib/python2.5/site-packages/django/core/management/commands/runserver.py(60)inner_run() 
-> run(addr, int(port), handler) 
 /usr/lib/python2.5/site-packages/django/core/servers/basehttp.py(698)run() 
-> httpd.serve_forever() 
 /usr/lib/python2.5/SocketServer.py(201)serve_forever() 
-> self.handle_request() 
 /usr/lib/python2.5/SocketServer.py(222)handle_request() 
-> self.process_request(request, client_address) 
 /usr/lib/python2.5/SocketServer.py(241)process_request() 
-> self.finish_request(request, client_address) 
 /usr/lib/python2.5/SocketServer.py(254)finish_request() 
-> self.RequestHandlerClass(request, client_address, self) 
 /usr/lib/python2.5/site-packages/django/core/servers/basehttp.py(560)__init__() 
-> BaseHTTPRequestHandler.__init__(self, *args, **kwargs) 
 /usr/lib/python2.5/SocketServer.py(522)__init__() 
-> self.handle() 
 /usr/lib/python2.5/site-packages/django/core/servers/basehttp.py(605)handle() 
-> handler.run(self.server.get_app()) 
 /usr/lib/python2.5/site-packages/django/core/servers/basehttp.py(279)run() 
-> self.result = application(self.environ, self.start_response) 
 /usr/lib/python2.5/site-packages/django/core/servers/basehttp.py(651)__call__() 
-> return self.application(environ, start_response) 
 /usr/lib/python2.5/site-packages/django/core/handlers/wsgi.py(241)__call__() 
-> response = self.get_response(request) 
 /usr/lib/python2.5/site-packages/django/core/handlers/base.py(92)get_response() 
-> response = callback(request, *callback_args, **callback_kwargs) 

我们可能并不完全确定所有这些代码在做什么,但像serve_forever()handle_request()process_request()finish_request()get_response()这样的名称,似乎这都是标准服务器请求处理循环的一部分。特别是get_response()听起来像是代码接近完成为请求生成响应的真正工作的地方。接下来,我们看到:

 /dj_projects/marketr/gen_utils/logutils.py(21)__call__() 
-> rv = f(*args, **kwargs) 
 /dj_projects/marketr/survey/views.py(30)survey_detail() 
-> return display_completed_survey(request, survey) 
 /dj_projects/marketr/gen_utils/logutils.py(11)__call__() 
-> rv = f(*args, **kwargs) 
 /dj_projects/marketr/survey/views.py(40)display_completed_survey() 
-> RequestContext(request)) 

实际上,在get_response函数中,在调用callback()的地方,代码从 Django 代码(/usr/lib/python2.5/site-packages/django中的文件)转换为我们自己的代码/dj_projects。然后我们看到我们在跟踪中引入了自己的噪音,使用了日志包装函数——在logutils.py中的__call__的引用。

这些并没有传达太多信息,只是表明正在记录所做的函数调用。但是忽略噪音,我们仍然可以看到survey_detail被调用,然后调用了display_completed_survey,它运行到即将返回的地方(在display_completed_survey中多行调用render_to_response的最后一行是结束)。对render_to_response的调用又回到了 Django 代码:

 /usr/lib/python2.5/site-packages/django/shortcuts/__init__.py(20)render_to_response() 
-> return HttpResponse(loader.render_to_string(*args, **kwargs), **httpresponse_kwargs) 
 /usr/lib/python2.5/site-packages/django/template/loader.py(108)render_to_string() 
-> return t.render(context_instance) 
 /usr/lib/python2.5/site-packages/django/template/__init__.py(178)render() 
-> return self.nodelist.render(context) 
 /usr/lib/python2.5/site-packages/django/template/__init__.py(779)render() 
-> bits.append(self.render_node(node, context)) 
 /usr/lib/python2.5/site-packages/django/template/debug.py(71)render_node() 
-> result = node.render(context) 
 /usr/lib/python2.5/site-packages/django/template/loader_tags.py(97)render() 
-> return compiled_parent.render(context) 

我们可以从这里以及接下来的render()render_node()调用中得到的信息是,Django 代码正在处理模板的渲染。最终,一些略有不同的调用开始出现:

 /usr/lib/python2.5/site-packages/django/template/debug.py(87)render() 
-> output = force_unicode(self.filter_expression.resolve(context)) 
 /usr/lib/python2.5/site-packages/django/template/__init__.py(546)resolve() 
-> obj = self.var.resolve(context) 
 /usr/lib/python2.5/site-packages/django/template/__init__.py(687)resolve() 
-> value = self._resolve_lookup(context) 
 /usr/lib/python2.5/site-packages/django/template/__init__.py(722)_resolve_lookup() 
-> current = current() 
> /dj_projects/marketr/survey/models.py(71)get_piechart_url() 
-> logging.debug('get_piechart_url called for pk=%d', self.pk) 
(Pdb) 

在渲染过程中,代码最终到达需要在模板中渲染{{ q.get_piechart_url }}值的点。最终,这被路由到了Question模型的get_piechart_url方法的调用,我们在那里放置了进入调试器的调用,这就是我们现在所处的位置。

args 命令

args命令,缩写为a,可用于查看传递给当前执行函数的参数的值:

(Pdb) a 
self = Television Trends (opens 2009-09-10, closes 2009-11-10): What is your favorite type of TV show? 
(Pdb) 

whatis 命令

whatis命令显示其参数的类型。例如:

(Pdb) whatis self 
<class 'survey.models.Question'> 
(Pdb) 

回想一下,pdb 也像 Python shell 会话一样运行,因此可以通过获取selftype来获得相同的结果:

(Pdb) type(self) 
<class 'survey.models.Question'> 
(Pdb) 

我们还可以查询变量的单个属性,这可能会有所帮助。这里对于args命令显示的self的值包括了该模型的所有单个属性,但不包括其主键值。我们可以找出它是什么:

(Pdb) self.pk 
1L 
(Pdb) 

print命令,缩写为p,打印变量的表示:

(Pdb) p self 
<Question: Television Trends (opens 2009-09-10, closes 2009-11-10): What is your favorite type of TV show?> 
(Pdb)

对于大型数据结构,如果print的输出跨越了行边界,可能会难以阅读。替代的pp命令使用 Python 的pprint模块对输出进行漂亮打印。这可能会导致更容易阅读的输出。例如:

(Pdb) p locals() 
{'PieChart3D': <class 'pygooglechart.PieChart3D'>, 'self': <Question: Television Trends (opens 2009-09-10, closes 2009-11-10): What is your favorite type of TV show?>, 'logging': <module 'logging' from '/usr/lib/python2.5/logging/__init__.pyc'>, 'pdb': <module 'pdb' from '/usr/lib/python2.5/pdb.pyc'>} 

print输出与pp输出进行对比:

(Pdb) pp locals() 
{'PieChart3D': <class 'pygooglechart.PieChart3D'>, 
 'logging': <module 'logging' from '/usr/lib/python2.5/logging/__init__.pyc'>, 
 'pdb': <module 'pdb' from '/usr/lib/python2.5/pdb.pyc'>, 
 'self': <Question: Television Trends (opens 2009-09-10, closes 2009-11-10): What is your favorite type of TV show?>} 
(Pdb) 

调试 pygooglechart 结果显示

此时我们知道代码处于get_piechart_url方法的处理开始阶段,而self的当前值表明我们被调用的Question实例是询问“你最喜欢的电视节目类型是什么?”这是好事,但我们真正想要了解的是随着执行的继续会发生什么。

步骤和下一步命令

我们现在想要指示调试器继续执行,但保持调试器处于活动状态。通常在这里使用两个命令:step(缩写为s)和next(缩写为n)。

step命令开始执行当前行,并在第一个可用的机会返回到调试器。next命令也开始执行当前行,但直到当前函数中的下一行即将执行时才返回到调试器。因此,如果当前行包含函数或方法调用,step用于进入该函数并跟踪其执行,而next用于执行被调用的函数并在其完成时才返回到调试器。

对于我们现在所处的位置,next是我们想要使用的命令,因为我们不特别想要进入日志记录代码并跟踪其执行过程:

(Pdb) n 
DEBUG:root:get_piechart_url called for pk=1 
> /dj_projects/marketr/survey/models.py(72)get_piechart_url() 
-> answer_set = self.answer_set.all() 
(Pdb) 

在这里,next导致执行logging.debug调用,导致记录的消息被打印到控制台。然后调试器再次停止,就在当前函数中的下一行执行之前。输入 nothing 会再次执行next命令,导致answer_set被赋予self.answer_set.all()的值。我们可以使用print命令查看结果:

(Pdb) 
> /dj_projects/marketr/survey/models.py(73)get_piechart_url() 
-> chart = PieChart3D(500, 230) 
(Pdb) p answer_set 
[<Answer: Comedy>, <Answer: Drama>, <Answer: Reality>] 
(Pdb) 

到目前为止一切看起来都很好,所以我们继续:

(Pdb) n
> /dj_projects/marketr/survey/models.py(74)get_piechart_url() 
-> chart.set_data([a.votes for a in answer_set]) 
(Pdb) 
AttributeError: "'PieChart3D' object has no attribute 'set_data'" 
> /dj_projects/marketr/survey/models.py(74)get_piechart_url() 
-> chart.set_data([a.votes for a in answer_set]) 
(Pdb) 

有一个问题:对chart上的set_data的调用引发了一个属性错误,错误消息指示图表没有这样的属性。我们在实现这个例程时犯了一个错误。虽然pygooglechart的许多方法以set_开头,但设置图表数据的调用实际上命名为add_data。因此,尝试指定图表的数据失败了。但为什么我们没有看到该错误反映为返回的调试页面,而只是从get_piechart_url返回了一个空字符串?我们可以通过继续跟踪代码的执行来得到这个问题的答案:

(Pdb) 
--Return-- 
> /dj_projects/marketr/survey/models.py(74)get_piechart_url()->None 
-> chart.set_data([a.votes for a in answer_set]) 

这表明get_piechart_url方法在引发AttributeError时返回None。由于我们没有将get_piechart_url中的代码包含在try/except块中,因此错误正在向上传播调用堆栈。

(Pdb) 
AttributeError: "'PieChart3D' object has no attribute 'set_data'" 
> /usr/lib/python2.5/site-packages/django/template/__init__.py(722)_resolve_lookup() 
-> current = current() 
(Pdb) 
> /usr/lib/python2.5/site-packages/django/template/__init__.py(723)_resolve_lookup() 
-> except TypeError: # arguments *were* required 
(Pdb) 
> /usr/lib/python2.5/site-packages/django/template/__init__.py(727)_resolve_lookup() 
-> except Exception, e: 

在这里,我们看到调用get_piechart_url的代码被包含在try/except块中,并且正在测试except子句是否与实际引发的异常匹配。第一个子句except TypeError没有匹配AttributeError。第二个子句except Exception匹配,因为AttributeError是从基类Exception派生的。因此,代码应该继续运行此except子句中的任何代码。记住我们可以使用list命令来查看那是什么:

(Pdb) l
722                          current = current()
723                      except TypeError: # arguments *were* required
724                          # GOTCHA: This will also catch any TypeError
725                          # raised in the function itself.
726 
current = settings.TEMPLATE_STRING_IF_INVALID #
 invalid method call
727  ->                  except Exception, e:
728                          if getattr(e, 'silent_variable_failure', False
):
729 
current = settings.TEMPLATE_STRING_IF_INVALID
730                          else:
731                             raise
732                      except (TypeError, AttributeError):

这些except子句似乎在测试特殊情况,其中引发的异常将被抑制,并且产生的结果将被设置为settings.TEMPLATE_STRING_IF_INVALID的值。这暗示了这个异常最终不会在调试页面中反映出来,尽管可能不会立即发生在即将执行的except子句中:

(Pdb) n
> /usr/lib/python2.5/site-packages/django/template/__init__.py(728)_resolve_lookup() 
-> if getattr(e, 'silent_variable_failure', False): 
(Pdb) 
> /usr/lib/python2.5/site-packages/django/template/__init__.py(731)_resolve_lookup() 
-> raise 

实际上,此时代码正在重新引发异常,只是立即再次被捕获:

(Pdb) n
> /usr/lib/python2.5/site-packages/django/template/__init__.py(732)_resolve_lookup() 
-> except (TypeError, AttributeError): 

此时的list命令显示了这个except子句将要做什么:

(Pdb) l
727                                 except Exception, e:
728                                     if getattr(e, 'silent_variable_failure', False):
729                                         current = settings.TEMPLATE_STRING_IF_INVALID
730                                     else:
731                                         raise
732  ->                 except (TypeError, AttributeError):
733                         try: # list-index lookup
734                             current = current[int(bit)]
735                         except (IndexError, # list index out of range
736                                 ValueError, # invalid literal for int()
737                                 KeyError,   # current is a dict without `int(bit)` key
(Pdb)
738                                 TypeError,  # unsubscriptable object
739                                 ):
740                             raise VariableDoesNotExist("Failed lookup for key [%s] in %r", (bit, current)) # missing attribute
741                     except Exception, e:
742                         if getattr(e, 'silent_variable_failure', False):
743                             current = settings.TEMPLATE_STRING_IF_INVALID
744                         else:
745                             raise
746
747             return current
748
(Pdb)

在这里,有必要回想一下在模板渲染期间如何处理{{ q.get_piechart_url }}等结构。Django 模板处理尝试使用以下四种方法按顺序解析点号右侧的值:

  • 字典查找

  • 属性查找

  • 方法调用

  • 列表索引查找

我们在方法调用尝试的中间进入了调试器,前两个选项失败后。尝试方法调用的代码不区分由于方法不存在而导致的AttributeError和由调用方法引发的AttributeError,因此下一步将尝试进行列表索引查找。这也将失败:

(Pdb) n
> /usr/lib/python2.5/site-packages/django/template/__init__.py(733)_resolve_lookup() 
-> try: # list-index lookup 
(Pdb) 
> /usr/lib/python2.5/site-packages/django/template/__init__.py(734)_resolve_lookup() 
-> current = current[int(bit)] 
(Pdb) 
ValueError: "invalid literal for int() with base 10: 'get_piechart_url'" 
> /usr/lib/python2.5/site-packages/django/template/__init__.py(734)_resolve_lookup() 
-> current = current[int(bit)] 

具体来说,列表索引查找尝试引发了ValueError,我们可以从先前的代码中看到,它将被特殊处理并转换为VariableDoesNotExist异常。我们可以继续跟踪代码,但在这一点上很明显会发生什么。无效的变量将被转换为TEMPLATE_STRING_IF_INVALID设置分配的内容。由于调查项目将此设置设置为默认的空字符串,因此空字符串是{{ q.get_piechart_url }}的渲染的最终结果。

继续命令

此时,我们知道问题是什么,问题是如何导致模板中出现空字符串而不是调试页面的问题,我们已经准备好去修复代码。我们可以使用continue命令,缩写为c,告诉调试器退出并让程序执行正常继续。当我们在这里这样做时,我们看到:

(Pdb) c 
> /dj_projects/marketr/survey/models.py(71)get_piechart_url() 
-> logging.debug('get_piechart_url called for pk=%d', self.pk) 
(Pdb)

发生了什么?我们又回到了起点。原因是调查中有两个问题,模板循环遍历它们。get_piechart_url方法为每个问题调用一次。当我们在弄清楚第一个问题发生了什么后退出调试器时,模板处理继续进行,很快又调用了get_piechart_url,再次导致pdb.set_trace()调用进入调试器。我们可以通过看到self现在指的是调查中的第二个问题来确认这一点:

(Pdb) self 
<Question: Television Trends (opens 2009-09-10, closes 2009-11-10): How many new shows will you try this Fall?> 
(Pdb) 

我们可以再次continue并继续修复我们的 Python 源文件,但这实际上提供了一个机会来使用一些额外的调试器命令,所以我们将这样做。

跳转命令

首先,使用next来继续到即将在chart上调用错误方法的代码行:

(Pdb) n 
DEBUG:root:get_piechart_url called for pk=2 
> /dj_projects/marketr/survey/models.py(72)get_piechart_url() 
-> answer_set = self.answer_set.all() 
(Pdb) n 
> /dj_projects/marketr/survey/models.py(73)get_piechart_url() 
-> chart = PieChart3D(700, 230) 
(Pdb) n 
> /dj_projects/marketr/survey/models.py(74)get_piechart_url() 
-> chart.set_data([a.votes for a in answer_set]) 
(Pdb) 

现在,手动发出应该存在的调用,chart.add_data

(Pdb) chart.add_data([a.votes for a in answer_set]) 
0 
(Pdb) 

该调用返回了0,这比引发属性错误要好得多。现在我们想要跳过错误的代码行。我们可以看到set_data调用在models.py的第74行;我们想要跳过第74行,而是直接到第75行。我们可以使用jump命令,可以缩写为j

(Pdb) j 75 
> /dj_projects/marketr/survey/models.py(75)get_piechart_url() 
-> chart.set_pie_labels([a.answer for a in answer_set]) 
(Pdb)

这似乎已经奏效。我们可以通过next继续进行,以确认我们在代码中没有错误地前进:

(Pdb) n 
> /dj_projects/marketr/survey/models.py(75)get_piechart_url() 
-> chart.set_pie_labels([a.answer for a in answer_set]) 
(Pdb) n 
> /dj_projects/marketr/survey/models.py(75)get_piechart_url() 
-> chart.set_pie_labels([a.answer for a in answer_set]) 
(Pdb)

除了我们似乎没有在前进,我们似乎卡在一行上。不过我们并没有。请注意,该行包括一个列表推导式:[a.answer for a in answer_set]next命令将避免跟踪调用的函数,但对于列表推导式却不会。包含推导式的行将对列表中每个项目的添加看起来被执行一次。这可能会变得乏味,特别是对于长列表。在这种情况下,列表只有三个元素,因为集合中只有三个答案,所以我们可以轻松地按回车键继续。但是,也有一种方法可以解决这个问题,我们可能也会学到。

断点命令

break命令,可以缩写为b,在指定的行上设置断点。由于next没有像我们希望的那样快速地将我们超过第 75 行,我们可以在第 76 行设置断点,并使用continue一步到位地通过第 75 行的列表推导:

(Pdb) b 76 
Breakpoint 1 at /dj_projects/marketr/survey/models.py:76 
(Pdb) c 
> /dj_projects/marketr/survey/models.py(76)get_piechart_url() 
-> logging.debug('get_piechart_url returning: %s', chart.get_url()) 
(Pdb) 

这对于跳过除列表推导之外的其他循环结构,或者在代码中快速前进到不需要逐行跟踪的点时,但您确实想要停在稍后的某个地方并查看事物的状态,这将非常有用。

没有参数发出的break命令会打印出当前设置的断点列表,以及它们被触发的次数:

(Pdb) b
Num Type         Disp Enb   Where
1   breakpoint   keep yes   at /dj_projects/marketr/survey/models.py:76
 breakpoint already hit 1 time
(Pdb)

请注意,由pdb.set_trace()产生的断点在此处不包括在内,此显示仅显示通过break命令设置的断点。

break命令还支持除简单行号之外的其他参数。您可以指定函数名称或另一个文件中的行。此外,您还可以指定必须满足的断点触发条件。这里没有详细介绍这些更高级的选项。然而,Python 文档提供了完整的细节。

清除命令

设置断点后,可能会有一段时间你想要清除它。这可以通过clear命令来完成,可以缩写为cl(不是c,因为那是continue):

(Pdb) cl 1 
Deleted breakpoint 1 
(Pdb) 

现在调试器将不再停在models.py的第 76 行。在这一点上,我们可能已经看到了各种调试器命令,只需输入c让代码继续执行:

(Pdb) c 
DEBUG:root:get_piechart_url returning: http://chart.apis.google.com/chart?cht=p3&chs=700x230&chd=s:9UU&chl=Hardly%20any%3A%20I%20already%20watch%20too%20much%20TV%21|Maybe%203-5|I%27m%20a%20TV%20fiend%2C%20I%27ll%20try%20them%20all%20at%20least%20once%21 
DEBUG:root:display_completed_survey returned type <class 'django.http.HttpResponse'> 
DEBUG:root:survey_detail returned type <class 'django.http.HttpResponse'> 
[14/Nov/2009 18:03:38] "GET /1/ HTTP/1.1" 200 2989 

在那里,我们看到代码继续处理,记录了从get_piechart_url返回的值,并退出了display_completed_surveysurvey_detail。最终,对于此请求返回了一个2989字节的响应。切换回到网页浏览器窗口,我们看到浏览器等待了那么长时间才收到响应。此外,我们手动调用了正确的方法并跳过了错误的方法。浏览器显示它能够成功地检索到第二个问题的饼图:

清除命令

不幸的是,尽管图表已经生成,但标签太长无法正确显示。为了解决这个问题,我们可以尝试使用图例而不是标签。我们将尝试这样做,并将set_data更改为add_data

修复 pygooglechart 结果显示

我们似乎已经接近了为结果显示创建饼图的工作实现。我们可以更新get_piechart_url方法,使其如下所示:

    def get_piechart_url(self): 
        import pdb; pdb.set_trace() 
        answer_set = self.answer_set.all() 
        chart = PieChart3D(500, 230) 
        chart.add_data([a.votes for a in answer_set]) 
        chart.set_legend([a.answer for a in answer_set]) 
        return chart.get_url() 

与上一个版本的更改首先是删除了日志调用(因为它们并不特别有用),还删除了日志的导入。PieChart3D的导入已经移动到文件顶部,与其他导入一起。对chart.set_data的错误调用已被正确的chart.add_data替换。最后,对chart.set_pie_labels的调用已被替换为chart.set_legend,希望当答案被安排为图例时,它们将能够适合图表而不会溢出边缘。

这样做效果如何?如果我们重新加载浏览器页面,浏览器似乎又卡住了,因为get_piechart_url方法仍然有pdb.set_trace()调用,这会打断调试器。我们可能已经删除了它以及其他更改,希望相信新版本的代码肯定会起作用,但往往这样的希望都会落空,我们发现自己不得不重新添加调用以弄清楚接下来出了什么问题。在这种情况下,还有一些调试器命令可以尝试,我们接下来会做。

上下命令

当我们切换到runserver控制台窗口时,我们再次发现代码坐在get_piechart_url的开头:

DEBUG:root:survey_detail called with method GET, kwargs {'pk': u'1'} 
DEBUG:root:display_completed_survey called 
> /dj_projects/marketr/survey/models.py(71)get_piechart_url() 
-> answer_set = self.answer_set.all() 
(Pdb) 

我们可以继续看看新代码的行为如何,但是有一些调试器命令我们还没有尝试过,所以让我们先做这个。其中一个是step命令,之前提到过,但从来没有使用过,因为我们一直使用next来逐步执行代码。如果我们在这里尝试step几次,我们会看到:

(Pdb) s 
--Call-- 
> /usr/lib/python2.5/site-packages/django/db/models/fields/related.py(319)__get__() 
-> def __get__(self, instance, instance_type=None): 
(Pdb) 
> /usr/lib/python2.5/site-packages/django/db/models/fields/related.py(320)__get__() 
-> if instance is None: 
(Pdb) 
> /usr/lib/python2.5/site-packages/django/db/models/fields/related.py(323)__get__() 
-> return self.create_manager(instance, 
(Pdb) 
> /usr/lib/python2.5/site-packages/django/db/models/fields/related.py(324)__get__() 
-> self.related.model._default_manager.__class__) 
(Pdb) 
--Call-- 
> /usr/lib/python2.5/site-packages/django/db/models/fields/related.py(346)create_manager() 
-> def create_manager(self, instance, superclass): 
(Pdb) 
> /usr/lib/python2.5/site-packages/django/db/models/fields/related.py(350)create_manager() 
-> rel_field = self.related.field 
(Pdb) 

在这里,我们单步执行了六次,结果现在嵌套了几个调用级别深入到 Django 代码中。我们是故意这样做的,这通常是了解 Django(或其他支持库)代码工作原理的有用方式。但是在调试时,当我们真正只想单步执行我们自己的代码时,很常见的是错误地开始单步执行支持库代码。然后我们突然发现自己可能深入了几个完全陌生的代码层次,我们想要回到逐步执行我们正在开发的代码。

一种实现这一点的方法是使用up命令,可以缩写为uup命令将当前堆栈帧上移一个级别:

(Pdb) u 
> /usr/lib/python2.5/site-packages/django/db/models/fields/related.py(324)__get__() 
-> self.related.model._default_manager.__class__) 
(Pdb) u 
> /dj_projects/marketr/survey/models.py(71)get_piechart_url() 
-> answer_set = self.answer_set.all() 
(Pdb) u 
> /usr/lib/python2.5/site-packages/django/template/__init__.py(722)_resolve_lookup() 
-> current = current() 
(Pdb) 

在这里,我们上移了三个级别。原始的当前堆栈帧是调用create_manager的堆栈帧。第一个up命令将当前堆栈帧切换到__get__的堆栈帧,下一个切换到get_piechart_url,第三个则回到了get_piechart_url的调用者_resolve_lookup的堆栈帧。切换当前堆栈帧不会执行任何代码,它只是改变了命令的上下文。例如,现在当前堆栈帧为_resolve_lookup,我们可以检查存在于该堆栈帧中的变量:


(Pdb) whatis current 
Function get_piechart_url 
(Pdb) 

此外,list现在将显示与当前堆栈帧相关联的代码:

(Pdb) l
717                         if callable(current):
718 
if getattr(current, 'alters_data', False):
719                                 current = settings.TEMPLATE_STRING_IF_INVALID
720                             else:
721                                 try: # method call (assuming no args required)
722  ->                                 current = current()
723                                 except TypeError: # arguments *were* required
724                                     # GOTCHA: This will also catch any TypeError
725                                     # raised in the function itself.
726                                     current = settings.TEMPLATE_STRING_IF_INVALID # invalid method call
727                                 except Exception, e:
(Pdb)

如果我们切换了堆栈帧,并想知道当前堆栈帧相对于当前执行点的位置,where命令会显示出来。在这种情况下,where命令的输出看起来像这样:


 /usr/lib/python2.5/site-packages/django/template/__init__.py(687)resolve() 
-> value = self._resolve_lookup(context) 
> /usr/lib/python2.5/site-packages/django/template/__init__.py(722)_resolve_lookup() 
-> current = current() 
 /dj_projects/marketr/survey/models.py(71)get_piechart_url() 
-> answer_set = self.answer_set.all() 
 /usr/lib/python2.5/site-packages/django/db/models/fields/related.py(324)__get__() 
-> self.related.model._default_manager.__class__) 
 /usr/lib/python2.5/site-packages/django/db/models/fields/related.py(350)create_manager() 
-> rel_field = self.related.field 
(Pdb) 

列出的最后一行始终是当前执行点,而当前堆栈帧由第一列中的>表示。在这里,它表示当前堆栈帧是_resolve_lookup的堆栈帧。

在这种情况下,我们上移了一个堆栈帧,比我们真正想要的多了一个。要回到我们自己的代码,我们需要下移一个级别。这是通过使用down命令(可以缩写为d)来完成的:


(Pdb) d 
> /dj_projects/marketr/survey/models.py(71)get_piechart_url() 
-> answer_set = self.answer_set.all() 
(Pdb) 

现在,如果我们想要继续从这里运行到下一行,我们可以使用next命令:

(Pdb) n 
> /dj_projects/marketr/survey/models.py(72)get_piechart_url() 
-> chart = PieChart3D(500, 230) 
(Pdb) 

现在我们回到了熟悉的领域,可以继续调试我们自己的代码。

返回命令

第二种实现相同功能的方法是使用return命令,可以缩写为r。该命令会继续执行,直到当前函数返回,然后再次进入调试器。让我们来看看它的运行情况,让我们步入PieChart3D的调用:


(Pdb) s 
--Call-- 
> /var/lib/python-support/python2.5/pygooglechart.py(820)__init__() 
-> def __init__(self, *args, **kwargs): 
(Pdb) 
> /var/lib/python-support/python2.5/pygooglechart.py(821)__init__() 
-> assert(type(self) != PieChart)  # This is an abstract class 
(Pdb) 
> /var/lib/python-support/python2.5/pygooglechart.py(822)__init__() 
-> Chart.__init__(self, *args, **kwargs) 
(Pdb) 

我们已经进入了该方法的几个步骤,但只进行了一个调用,因此单个return应该让我们回到我们的调查代码:

(Pdb) r 
--Return-- 
> /var/lib/python-support/python2.5/pygooglechart.py(823)__init__()->None 
-> self.pie_labels = [] 
(Pdb) 

这种方法显然没有显式的返回行,因此显示的代码行是该方法中的最后一行。输出中的->None显示了该方法的返回值。如果我们从这里步进:

(Pdb) s 
> /dj_projects/marketr/survey/models.py(73)get_piechart_url() 
-> chart.add_data([a.votes for a in answer_set]) 
(Pdb) 

现在我们回到了创建饼图后的下一行代码。从这里,我们可以使用 return 来查看get_piechart_url方法将返回什么:

(Pdb) r 
--Return-- 
> /dj_projects/marketr/survey/models.py(75)get_piechart_url()->'http://chart...Drama|Reality' 
-> return chart.get_url() 
(Pdb) 

看起来不错;函数已经完成运行并返回一个值。此外,似乎 pdb 会缩短显示的返回值,如果它们很长,因为显示的值看起来不太对。我们可以用任何一个print命令来确认这一点,这些命令显示实际值要长得多:

(Pdb) pp chart.get_url() 
'http://chart.apis.google.com/chart?cht=p3&chs=500x230&chd=s:99f&chdl=Comedy|Drama|Reality' 
(Pdb)

目前看来一切都很正常,所以我们可以使用continue让程序继续运行,然后当第二个饼图的调试器再次进入时再次使用continue

(Pdb) c 
> /dj_projects/marketr/survey/models.py(71)get_piechart_url() 
-> answer_set = self.answer_set.all() 
(Pdb) c 
DEBUG:root:display_completed_survey returned type <class 'django.http.HttpResponse'> 
DEBUG:root:survey_detail returned type <class 'django.http.HttpResponse'> 
[15/Nov/2009 11:48:07] "GET /1/ HTTP/1.1" 200 3280 

这一切看起来都很好。浏览器显示了什么?切换到它的窗口,我们看到以下内容:

return 命令

这比以前好。从标签切换到图例解决了答案文本溢出图形的问题。然而,饼图本身的大小因答案长度不同而有所不同,这有点令人不安。此外,如果饼图楔形能够用表示每个楔形所代表的总数的百分比进行标记,那就更好了。

在 Google 图表 API 上的更多研究并没有揭示任何控制图例放置的方法,也没有说明如何用信息注释楔形图,比如总百分比。虽然使用起来相当简单和直接,但这个 API 在定制生成的图表方面并没有提供太多功能。因此,我们可能需要调查其他生成图表的替代方法,这将是我们接下来要做的事情。

我们将保留get_piechart_url的当前实现,因为在这一点上我们不知道我们是否真的要切换到另一种方法。在继续下一步之前,最好将该函数中的导入pdb; pdb.set_trace()删除。该例程现在正在运行,如果我们以后返回使用它,最好是它在没有用户干预的情况下完成运行,而不是进入调试器中断。

使用 matplotlib 显示结果

matplotlib库提供了另一种从 Python 生成图表的方法。它可以在 Python 软件包索引网站pypi.python.org/pypi/matplotlib上找到。本章中使用的matplotlib版本是 0.98.3。

使用matplotlib,我们的应用程序不能简单地构造一个 URL 并将生成和提供图像数据的任务交给另一个主机。相反,我们需要编写一个视图来生成和提供图像数据。经过对matplotlibAPI 的一些调查,一个初始实现(在survey/views.py中)可能是:

from django.http import HttpResponse 
from survey.models import Question 
from matplotlib.figure import Figure 
from matplotlib.backends.backend_agg import FigureCanvasAgg as \FigureCanvas 

@log_view 
def answer_piechart(request, pk): 
    q = get_object_or_404(Question, pk=pk) 
    answer_set = q.answer_set.all() 
    x = [a.votes for a in answer_set] 
    labels = [a.answer for a in answer_set] 

    fig = Figure() 
    axes = fig.add_subplot(1, 1, 1) 
    patches, texts, autotexts = axes.pie(x, autopct="%.0f%%") 
    legend = fig.legend(patches, labels, 'lower left') 

    canvas = FigureCanvas(fig) 
    response = HttpResponse(content_type='image/png') 
    canvas.print_png(response) 
    return response 

这比pygooglechart版本要复杂一些。首先,我们需要从matplotlib导入两个内容:基本的Figure类和一个适合用于渲染图形的后端。在这里,我们选择了agg(Anti-Grain Geometry)后端,因为它支持渲染为 PNG 格式。

answer_piechart视图中,前四行很简单。从传递给视图的主键值中检索Question实例。该问题的答案集被缓存在本地变量answer_set中。然后从答案集创建了两个数据数组:x包含每个答案的投票计数值,labels包含答案文本值。

接下来,创建了一个基本的matplotlib Figurematplotlib Figure支持包含多个子图。对于Figure只包含单个图的简单情况,仍然需要调用add_subplot来创建子图,并返回一个Axes实例,用于在图上绘制。add_subplot的参数是子图网格中的行数和列数,然后是要添加到Figure的图的编号。这里的参数1, 1, 1表示 1 x 1 网格中的单个子图。

然后在返回的子图axes上调用pie方法生成饼图图。第一个参数x是饼图楔形的数据值数组。autopct关键字参数用于指定一个格式字符串,用于注释每个饼图楔形的百分比。值%.0f%%指定浮点百分比值应该以小数点后零位数字的格式显示,后跟一个百分号。

pie方法返回三个数据序列。其中第一个patches描述了饼图楔形,需要传递给图例的legend方法,以创建一个与楔形相关联的答案值的图例。在这里,我们指定图例应放置在图的左下角。

pie返回的另外两个序列描述了文本标签(这里将为空,因为在调用pie时未指定labels)和楔形图的autopct注释。这里的代码不需要使用这些序列做任何事情。

有了图例,图就完成了。使用先前导入的agg后端FigureCanvas创建了一个canvas。创建了一个内容类型为image/pngHttpResponse,并使用print_png方法以 PNG 格式将图像写入响应。最后,answer_piechart视图返回此响应。

视图代码完成后,我们需要更新survey/urls.py文件,包括一个映射,将请求路由到该视图:

urlpatterns = patterns('survey.views', 
    url(r'^$', 'home', name='survey_home'), 
    url(r'^(?P<pk>\d+)/$', 'survey_detail', name='survey_detail'), 
    url(r'^thanks/(?P<pk>\d+)/$', 'survey_thanks', name='survey_thanks'),
    url(r'^piechart/(?P<pk>\d+)\.png/$', 'answer_piechart', name='survey_answer_piechart'), 
) 

在这里,我们添加了最后一个模式。这个模式匹配以piechart/开头,后跟一个或多个数字(主键),以.png结尾的 URL 路径。这些 URL 被路由到survey.views.answer_piechart视图,传递捕获的主键值作为参数。该模式被命名为survey_answer_piechart

切换到使用matplotlib而不是pygooglechart所需的最后一步是更新survey/completed_survey.html模板,以使用这个模式生成 URL。唯一需要的更改是更新包含img标签的行:

<p><img src="img/{% url survey_answer_piechart q.pk %}" alt="Pie Chart"/></p> 

在这里,我们用引用新添加的模式的url模板标签替换了对问题的get_piechart_url方法的调用。

这是如何工作的?相当不错。我们没有为图形指定大小,而matplotlib的默认大小比我们为pygooglechart指定的要大一些,所以我们不能在不滚动的情况下看到整个页面。然而,每个单独的图看起来都很不错。例如,第一个看起来像这样:

使用 matplotlib 显示结果

第二个看起来像这样:

使用 matplotlib 显示结果

matplotlib API 支持的定制化远远超出了我们在这里使用的范围。图形的大小可以改变,饼图的位置可以改变,楔形图块的颜色和文本的字体属性也可以改变。获胜答案的饼图楔形可以通过从饼图的其余部分爆炸出来来强调。然而,所有这些项目都是装饰性的,超出了我们将在这里涵盖的范围。回到调试的主题,我们将在下一节中将注意力转向删除一些浪费的重复处理,这是由于切换到matplotlib而引入的。

改进 matplotlib 方法

考虑一下当浏览器请求完成调查的页面时会发生什么。对于调查中的每个问题,返回的完成调查页面都有一个嵌入的图像,当获取时,将触发对answer_piechart视图的调用。该视图动态生成图像,计算成本很高。实际上,根据您的硬件,如果您尝试逐步执行该视图,您可能会观察到在跨越一些matplotlib调用时出现明显的暂停。

现在考虑当许多不同的用户请求相同的完成调查页面时会发生什么。这将触发对计算成本高昂的answer_piechart视图的多次调用。最终,所有用户将得到完全相同的数据,因为在调查关闭之前不会显示结果,因此用于创建饼图的基础投票计数不会发生变化。然而,answer_piechart将一遍又一遍地被调用,以重新做相同数量的工作来产生完全相同的结果。这是对服务器容量的浪费。

我们如何消除这种浪费?有(至少)三种可能的方法:

  • 引入缓存,并缓存answer_piechart视图的结果。

  • 设置一些外部进程,在调查关闭时预先计算所有饼图,并将它们保存在磁盘的某个地方。将完成调查响应模板中的img标签更改为引用这些静态文件,而不是动态生成图像的视图。

  • 当第一次请求完成调查时,动态生成饼图,并将其保存到磁盘上。这与第二种方法本质上是相同的,因为完成调查响应中的img标签现在将引用静态文件,但是图表的计算从某个外部进程移动到了 Web 服务器中。

这些方法各有利弊。我们要追求的是最后一种方法,仅仅是因为它提供了学习一些新东西的最大机会。具体来说,在实现这种第三种方法时,我们将看到如何设置开发服务器以提供静态文件,并且我们将看到如何使用 pdb 来确保代码在面对多进程竞争条件时能够正常运行。

设置静态文件服务

到目前为止,在调查应用程序的开发中,我们完全集中在提供动态内容上。虽然动态内容当然是 Django 应用程序的重点,但实际上,即使是最动态的应用程序也会有一些需要从文件中提供的数据。在这里,调查应用程序遇到了一个情况,我们希望从磁盘中提供图像文件。大多数应用程序还将具有最好直接从磁盘而不是通过 Django 视图代码提供的 CSS 和可能是 JavaScript 文件。

Django 是用于提供动态内容的框架。虽然它不直接支持从文件中提供数据,但有一些设置可以方便地将一些静态文件合并到项目中。这些是MEDIA_ROOTMEDIA_URL

MEDIA_ROOT是文件系统路径,即项目的静态文件所在目录的路径。Django 在内部使用它作为保存上传到包含FileField的模型的文件的基本路径。对于调查应用程序,我们将使用它作为保存动态生成的饼图图像文件的基本路径。

该设置的默认值为空字符串,因此我们现在需要将其设置为其他值:

MEDIA_ROOT = '/dj_projects/marketr/site_media/'

在这里,我们将MEDIA_ROOT设置为指向主marketr项目目录下的site_media目录(我们必须创建)。

MEDIA_URL也默认为空字符串,是用于引用静态文件的基本 URL 路径。Django 在内部使用它来生成FileField模型引用的文件的url属性。

此外,django.core.context_processors.media上下文处理器通过在模板上设置MEDIA_URL,使得该设置的值在模板中可用。此上下文处理器默认启用,因此使用RequestContext渲染的任何模板都可以访问MEDIA_URL

让我们在settings.py中设置MEDIA_URL如下:

MEDIA_URL = '/site_media/' 

请注意,不应将'/media/'用作MEDIA_URL的值。这是ADMIN_MEDIA_PREFIX的默认设置,它定义了管理界面使用的静态文件的根 URL。尝试将两个不同的静态文件树放置在 URL 层次结构中的相同位置是行不通的,最简单的方法是将MEDIA_URL设置为'/media/'以外的其他值。

请注意,尽管这些设置是根据在 URL 路径和磁盘文件之间建立映射的术语定义的,但 Django 不会自动根据该映射来提供文件。在 URL 解析期间,Django 不会测试请求的 URL 是否以MEDIA_URL开头,如果是,则提供MEDIA_ROOT下找到的相应文件。相反,Django 假设指向磁盘上静态文件的 URL 将直接由 Web 服务器提供,而不会通过 Django 代码路由。

然而,到目前为止,在开发过程中,我们除了 Django 自己的开发服务器之外,没有使用任何其他 Web 服务器。如果我们想继续使用开发服务器,我们需要以某种方式让它提供由调查应用程序创建的图像文件。我们该怎么做呢?

Django 确实提供了静态文件服务功能,特别是在开发过程中使用。要使用它,我们需要更新项目的urls.py文件,将以'site_media/'开头的 URL 请求路由到 Django 的静态文件服务视图。因此,我们需要更改urls.py文件以包含:

from django.conf.urls.defaults import * 

# Uncomment the next two lines to enable the admin: 
from django.contrib import admin 
admin.autodiscover() 

from django.conf import settings 

urlpatterns = patterns('', 
    # Example: 
    # (r'^marketr/', include('marketr.foo.urls')), 

    # Uncomment the admin/doc line below and add # 'django.contrib.admindocs' 
    # to INSTALLED_APPS to enable admin documentation: 
    # (r'^admin/doc/', include('django.contrib.admindocs.urls')), 

    # Uncomment the next line to enable the admin: 
    (r'^admin/', include(admin.site.urls)), 
    (r'^site_media/(.*)$', 'django.views.static.serve', 
        {'document_root': settings.MEDIA_ROOT, 'show_indexes': True}), 
    (r'', include('survey.urls')), 
) 

与以前版本的第一个变化是从django.conf中添加settingsimport。第二个是添加引用以site_media/开头的 URL 的模式。这些 URL 被路由到django.views.static.serve。两个参数传递给此视图:document_rootshow_indexes。对于document_root,指定了MEDIA_ROOT设置,这意味着静态服务器将在MEDIA_ROOT下查找请求的文件。对于show_indexes,指定了True,这意味着当请求的 URL 引用目录而不是文件时,静态服务器将返回文件列表。

动态生成图像文件

现在,我们已经设置好了从磁盘提供图像文件的一切,我们可以开始进行必要的代码更改。首先,我们应该从survey/urls.py文件中删除piechart模式,因为它不再需要。

其次,我们可以更新views.py中的display_completed_survey函数,以包含在返回完成的调查响应之前确保为调查中的每个问题生成了饼图图像文件的代码:

@log_call 
def display_completed_survey(request, survey): 
    for q in survey.question_set.all(): 
        q.check_piechart() 
    return render_to_response('survey/completed_survey.html', {'survey': survey}, 
        RequestContext(request)) 

在这里,我们添加了 for 循环,循环遍历调查中的所有问题。对于每个问题,它调用问题的一个新方法 check_piechart。此例程将负责确保饼图文件存在,如有必要则创建它。

接下来,我们可以继续移动到 survey/models.py 文件,并更新 Question 模型以包含 check_piechart 的实现以及支持新方法所需的其他任何内容。还需要什么?为了从模板引用饼图 URL,如果 Question 模型支持返回相对于 MEDIA_URL 的饼图文件的路径,那将会很方便。因此,我们需要在 Question 模型中添加两个新方法:

from survey import pie_utils
class Question(models.Model): 
    [… other code unchanged ...]

    @property 
    def piechart_path(self): 
        if self.pk and self.survey.closes < datetime.date.today():
            return pie_utils.PIE_PATH + '%d.png' % self.pk 
        else: 
            raise AttributeError 

    def check_piechart(self): 
        pie_utils.make_pie_if_necessary(self.piechart_path, self.answer_set.all())

survey/models.py 中,我们选择不直接包含大量文件检查和创建代码,而是将该工作分解到 survey/pie_utils.py 中的一个新的独立模块中。然后,这里实现的两个例程可以保持非常简单。

piechart_path 作为只读属性实现,返回饼图的路径。此值可以与 MEDIA_URL 设置结合使用以创建 URL 路径,或者与 MEDIA_ROOT 设置结合使用以创建文件系统路径。由于从长远来看,我们期望在树中不仅有饼图图像,因此将饼图放在树的根部是不合适的。因此,pie_utils.PIE_PATH 值用于在静态文件树中划出一个子树来容纳饼图。

请注意,如果模型实例尚未保存到数据库,或者引用了尚未关闭的调查,此例程将实现引发 AttributeError。在这些情况下,饼图文件不应存在,因此任何尝试引用它都应触发错误。

check_piechart 方法被实现为将调用转发到 pie_utils make_pie_if_necessary 函数。此函数接受两个参数:饼图的路径和问题的答案集。

在我们继续实现 pie_utils 模块之前,我们可以对 survey/completed_survey.html 模板进行简单更新。包含 img 标签的行需要更改为在创建引用饼图图像的 URL 时使用 Question 模型的 piechart_path

<p><img src="img/{{ MEDIA_URL }}{{ q.piechart_path }}" alt="Pie Chart"/></p> 

在这里,piechart_pathMEDIA_URL(在调用 render_to_response 时,display_completed_survey 指定了 RequestContext,因此在模板中可用)结合起来构建图像的完整 URL。

最后,我们需要实现 survey/pie_utils.py 代码。此模块必须定义 PIE_PATH 的值,并实现 make_pie_if_necessary 函数。第一个任务是微不足道的,并且可以通过以下方式完成:

import os
from django.conf import settings 
PIE_PATH = 'piecharts/' 
if not os.path.exists(settings.MEDIA_ROOT + PIE_PATH): 
    os.mkdir(settings.MEDIA_ROOT + PIE_PATH)    

此代码定义了 PIE_PATH 的值,并确保项目的 MEDIA_ROOT 下的结果子目录存在,如有必要则创建它。有了这段代码和先前提到的 MEDIA_ROOT 设置,调查应用程序的饼图图像文件将放置在 /dj_projects/marketr/site-media/piecharts/ 中。

完成 pie_utils 模块所需的第二部分,make_pie_if_necessary 函数的实现,乍看起来也可能很简单。如果文件已经存在,make_pie_if_necessary 就不需要做任何事情,否则它需要创建文件。然而,当考虑到这段代码的部署环境最终将是一个潜在的多进程多线程的 Web 服务器时,情况就变得更加复杂了。这引入了竞争条件的机会,我们将在下面讨论。

处理竞争条件

make_pie_if_necessary 模块的天真实现可能是:

def make_pie_if_necessary(rel_path, answer_set): 
    fname = settings.MEDIA_ROOT + rel_path 
    if not os.path.exists(fname): 
        create_piechart(fname, answer_set) 

在这里,make_pie_if_necessary通过将传递的相对路径与设置的MEDIA_ROOT值相结合来创建完整的文件路径。然后,如果该文件不存在,它调用create_piechart,传递文件名和答案集,以创建饼图文件。这个例程可以这样实现:

from matplotlib.figure import Figure 
from matplotlib.backends.backend_agg import FigureCanvasAgg as \FigureCanvas 

def create_piechart(f, answer_set): 
    x = [a.votes for a in answer_set] 
    labels = [a.answer for a in answer_set] 

    fig = Figure() 
    axes = fig.add_subplot(1, 1, 1) 
    patches, texts, autotexts = axes.pie(x, autopct="%.0f%%") 
    legend = fig.legend(patches, labels, 'lower left') 

    canvas = FigureCanvas(fig) 
    canvas.print_png(f) 

这段代码基本上是原始matplotlib实现中answer_piechart视图的修改,以考虑直接传递的答案集,以及应该写入图像数据的文件。

这个make_pie_if_necessary的实现,在开发服务器上测试时,可以正常工作。甚至在轻负载的生产环境中,它看起来也可以正常工作。然而,如果考虑到一个高负载的生产环境,其中一个多进程的 Web 服务器可能会几乎同时收到对同一页面的请求,就会出现潜在的问题。没有什么可以阻止几乎同时调用make_pie_if_necessary导致多次几乎同时调用canvas.print_png来创建相同的文件。

很明显,这种情况在多处理器机器上可能会发生,因为很容易看到两个同时的请求可能会分派到不同的处理器,并导致相同的代码同时在每个处理器上运行。两个进程都检查文件是否存在,都发现不存在,并都开始创建文件。

即使在单处理器机器上,由于操作系统的抢占式调度,也可能出现相同的情况。一个进程可能会检查文件是否存在,发现不存在,然后开始创建文件。然而,在这段代码真正开始创建文件之前,操作系统的抢占式调度器将其挂起,并让处理第二个几乎同时的请求的进程运行。这个进程在检查时也找不到文件,并开始创建文件的路径。

如果发生这种情况,最终结果会是什么?会很糟糕吗?也许不会。可能一个进程会完成创建和写入文件的工作,然后第二个进程会覆盖第一个进程的结果。可能会有一些重复的工作,但最终结果可能还不错:磁盘上包含饼图 PNG 图像的文件。

然而,有没有保证两个几乎同时的调用的工作会像那样被串行化?没有。matplotlib API 没有提供任何这样的保证。没有深入研究实现,很难确定,但似乎写出图像文件的任务可能会被拆分成几个不同的单独的写入调用。这为来自引用相同文件的不同进程的随机交错调用提供了充分的机会,最终导致在磁盘上写出损坏的图像文件。

为了防止这种情况发生,我们需要改变make_pie_if_necessary函数,使用原子方法检查文件是否存在,并在必要时创建文件。

import errno
def make_pie_if_necessary(rel_path, answer_set): 
    fname = settings.MEDIA_ROOT + rel_path 
    try: 
        fd = os.open(fname, os.O_WRONLY | os.O_CREAT | os.O_EXCL) 
        try: 
            f = os.fdopen(fd, 'wb') 
            create_piechart(f, answer_set) 
        finally: 
            f.close() 
    except OSError, e: 
        if e.errno == errno.EEXIST: 
            pass 
        else: 
            raise 

这段代码使用传递给os.open例程的标志的组合来原子性地创建文件。os.O_WRONLY指定文件仅用于写入,os.O_CREAT指定如果文件不存在则创建文件,os.O_EXCLos.O_CREAT结合使用,指定如果文件存在则引发错误。即使多个进程同时发出这个os.open调用,底层实现保证只有一个会成功,其他的会引发错误。因此,只有一个进程将继续执行创建饼图的代码。

请注意,在 Windows 上运行时,os.O_BINARY也需要包含在传递给os.open的标志集中。如果没有这个标志,Python 会将文件数据视为文本,并在遇到换行符时自动插入回车符。这种行为会导致无法显示的损坏的 PNG 图像文件。

这个改变引入的一个问题是,os.open返回的文件描述符不能作为 PNG 数据的目标文件传递给matplotlibmatplotlib库接受文件名或 Python 文件对象,但不支持os.open返回的文件描述符。因此,这里的代码使用os.fdopen将文件描述符转换为 Python 文件对象,并将返回的文件传递给create_piechart例程。

os.open调用引发OSError的情况下,将测试异常的errno属性是否等于errno.EEXIST。这是文件已经存在时将引发的特定错误,不应该作为错误反映出来,而应该被忽略。任何其他错误都会反映给make_pie_if_necessary的调用者。

这些更改确保图像文件只会被创建一次,这是好的。然而,还有另一个潜在的问题。考虑一下现在同时进行多个请求会发生什么。只有一个请求会继续创建文件。其他所有请求都会看到文件已经存在,然后简单地发送一个引用它的响应。

但请注意,文件的存在并不能保证图像数据已经被写入其中:首先需要进行相当多的处理来创建图像,然后才会将其写入文件。有没有保证这个处理会在收到和处理文件请求之前完成?没有。根据客户端的速度和图像生成的速度,有可能在图像数据实际写入文件之前,文件的请求已经到达并被处理。

这可能会发生吗?可能不会。如果发生了会有什么影响?可能没有什么可怕的。可能浏览器会显示一个部分图像或者饼图的替代文本。用户可能会尝试重新加载页面,看看第二次是否更好,那时图像文件可能会被正确地提供。

考虑到这种情况发生的可能性似乎很小,而且影响也相当小,我们可能选择不修复这个特定的问题。然而,在某些情况下,可能需要确保文件不仅存在,而且还包含数据。调查修复这个潜在问题可能是值得的。一种方法是修改make_pie_if_necessary如下:

import fcntl
def make_pie_if_necessary(rel_path, answer_set): 
    fname = settings.MEDIA_ROOT + rel_path 
    try: 
        fd = os.open(fname, os.O_WRONLY | os.O_CREAT | os.O_EXCL) 
        try: 
            f = os.fdopen(fd, 'wb') 
            fcntl.flock(f, fcntl.LOCK_EX) 
            create_piechart(f, answer_set) 
        finally: 
            fcntl.flock(f, fcntl.LOCK_UN) 
            f.close() 
    except OSError, e: 
        if e.errno == errno.EEXIST: 
            wait_for_data(fname) 
        else: 
            raise 

这里的第一个改变是在调用create_piechart之前,使用fcntl.flock在文件上获取独占锁。(注意,fcntl是一个仅适用于 Unix 的 Python 模块。因此,这段代码在 Windows 上不起作用。有一些附加包可以在 Windows 上获得文件锁定功能,但具体使用它们的细节超出了本文的范围。)第二,这个文件锁在create_piechart返回后关闭文件之前被释放。第三,在发现文件已经存在的情况下,不是立即返回,而是调用一个新的wait_for_data函数。wait_for_data的实现是:

import time
def wait_for_data(fname): 
    try: 
        fd = os.open(fname, os.O_RDONLY) 
        empty = True 
        while empty: 
            fcntl.flock(fd, fcntl.LOCK_SH) 
            st = os.fstat(fd) 
            if st.st_size > 0: 
                empty = False 
            fcntl.flock(fd, fcntl.LOCK_UN) 
            if empty: 
                time.sleep(.5) 
    finally: 
        if fd: 
            os.close(fd) 

这段代码首先打开文件进行读取。然后假设文件为空,并进入一个循环,只要文件保持为空就会继续进行。在循环中,代码获取文件的共享锁,然后调用os.fstat来确定文件的大小。如果返回的大小不为零,则将empty设置为False,这将在此迭代结束时终止循环。在此之前,文件锁被释放,如果文件实际上为空,代码会在继续下一次循环之前睡眠半秒钟。这个睡眠是为了给另一个进程,可能正忙于创建和写入数据,完成工作的时间。在返回之前,文件被关闭(如果它曾经成功打开)。

这一切看起来都很好,在我们尝试在浏览器中测试时似乎运行良好。然而,仅仅通过对这样的代码进行视觉检查,很难确定它是否完全正确。在这里使用调试器人为地创建我们试图防范的竞争条件可能会有所帮助。我们接下来将这样做。

使用调试器来强制发生竞争情况

仅仅使用开发服务器是无法强制发生竞争条件的:它是单线程和单进程的。然而,我们可以将开发服务器与manage.py shell会话结合使用,通过调试器断点和单步执行,来强制进行任何我们想要测试的多进程交错执行的组合。

例如,我们可以在make_pie_if_necessary函数的顶部附近插入一个断点:

def make_pie_if_necessary(rel_path, answer_set): 
    fname = settings.MEDIA_ROOT + rel_path 
    try: 
        import pdb; pdb.set_trace()
        fd = os.open(fname, os.O_WRONLY | os.O_CREAT | os.O_EXCL) 

现在,我们需要从磁盘中删除任何已经生成的图像文件,这样当这个函数首次被调用时,它将沿着尝试创建文件的路径进行:

rm /dj_projects/marketr/site_media/piecharts/*

接下来,我们确保开发服务器正在运行,并从浏览器中重新加载电视趋势调查的结果页面。浏览器将会出现卡住的情况,在开发服务器控制台中我们将看到调试器已进入:

> /dj_projects/marketr/survey/pie_utils.py(13)make_pie_if_necessary() 
-> fd = os.open(fname, os.O_WRONLY | os.O_CREAT | os.O_EXCL) 
(Pdb) 

如果我们使用next来跳过这个调用,我们将看到:

(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(14)make_pie_if_necessary() 
-> try: 
(Pdb) 

代码执行到了下一行,所以os.open调用是成功的。这个线程现在被冻结在文件已经被创建但尚未写入数据的地方。我们希望验证另一个调用相同函数的进程是否会正确地等待文件数据被写入后再继续。为了测试这一点,我们可以在一个单独的窗口中启动manage.py shell,手动检索适当的问题,并调用它的check_piechart方法:

kmt@lbox:/dj_projects/marketr$ python manage.py shell 
Python 2.5.2 (r252:60911, Oct  5 2008, 19:24:49) 
[GCC 4.3.2] on linux2 
Type "help", "copyright", "credits" or "license" for more information. 
(InteractiveConsole) 
>>> from survey.models import Question 
>>> q = Question.objects.get(pk=1) 
>>> q.check_piechart() 
> /dj_projects/marketr/survey/pie_utils.py(13)make_pie_if_necessary() 
-> fd = os.open(fname, os.O_WRONLY | os.O_CREAT | os.O_EXCL) 
(Pdb) 

make_pie_if_necessary中的断点再次在调用打开文件之前停止执行。在这种情况下,当我们使用 next 来跳过调用时,我们应该看到代码走了不同的路径,因为文件已经存在:

(Pdb) n 
OSError: (17, 'File exists', '/dj_projects/marketr/site_media/piecharts/1.png') 
> /dj_projects/marketr/survey/pie_utils.py(13)make_pie_if_necessary() 
-> fd = os.open(fname, os.O_WRONLY | os.O_CREAT | os.O_EXCL) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(21)make_pie_if_necessary() 
-> except OSError, e: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(22)make_pie_if_necessary() 
-> if e.errno == errno.EEXIST: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(23)make_pie_if_necessary() 
-> wait_for_data(fname) 
(Pdb) 

看起来不错。通过逐步执行代码,我们看到os.open引发了一个OSError,其errno属性为errno.EEXIST,正如预期的那样。然后,shell 线程将继续等待文件有数据。如果我们进入该例程,我们可以看到它是否按我们的预期运行:

(Pdb) s 
--Call-- 
> /dj_projects/marketr/survey/pie_utils.py(43)wait_for_data() 
-> def wait_for_data(fname): 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(44)wait_for_data() 
-> try: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(45)wait_for_data() 
-> fd = os.open(fname, os.O_RDONLY) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(46)wait_for_data() 
-> empty = True 
(Pdb) 

此时,我们已经在这个例程中进行了初步处理。文件现在已经打开,并且empty已经被初始化为True。我们准备进入循环的第一次迭代。应该发生什么?由于另一个控制线程仍然被阻塞,甚至在获得文件的独占锁之前,这个线程应该能够获得文件的共享锁,测试文件大小,并最终因为空文件而睡眠半秒钟。通过逐步执行,我们看到确实发生了这种情况:

(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(47)wait_for_data() 
-> while empty: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(48)wait_for_data() 
-> fcntl.flock(fd, fcntl.LOCK_SH) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(49)wait_for_data() 
-> st = os.fstat(fd) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(50)wait_for_data() 
-> if st.st_size > 0: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(52)wait_for_data() 
-> fcntl.flock(fd, fcntl.LOCK_UN) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(53)wait_for_data() 
-> if empty: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(54)wait_for_data() 
-> time.sleep(.5) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(47)wait_for_data() 
-> while empty: 
(Pdb) 

由于文件尚未被另一个线程锁定,fcntl.flock立即返回。这段代码发现文件大小为零,继续睡眠半秒钟,现在开始第二次循环的迭代。让我们将它推进到它再次获得文件的共享锁的地方:

> /dj_projects/marketr/survey/pie_utils.py(48)wait_for_data() 
-> fcntl.flock(fd, fcntl.LOCK_SH) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(49)wait_for_data() 
-> st = os.fstat(fd) 
(Pdb) 

我们现在将让这个线程在这里被冻结,返回到开发服务器线程,并尝试在其中继续前进:

(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(15)make_pie_if_necessary() 
-> f = os.fdopen(fd, 'wb') 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(16)make_pie_if_necessary() 
-> fcntl.flock(f, fcntl.LOCK_EX) 
(Pdb) n 

这段代码无法继续很远。它确实将文件描述符转换为 Python 文件对象,但接下来的调用是对文件获取独占锁,而该调用已被阻塞——在最后的n命令中没有(Pdb)提示,因此执行已在调用内的某个地方停止。这很好,因为调用获取独占锁不应该在其他线程释放锁之前返回。

我们可以切换回到该线程,并将其推进到释放锁的地方:

(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(50)wait_for_data() 
-> if st.st_size > 0: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(52)wait_for_data() 
-> fcntl.flock(fd, fcntl.LOCK_UN) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(53)wait_for_data() 
-> if empty: 
(Pdb) 

当我们跳过释放锁的调用时,开发服务器控制台立即返回到(Pdb)提示符:

> /dj_projects/marketr/survey/pie_utils.py(17)make_pie_if_necessary() 
-> create_piechart(f, answer_set) 
(Pdb) 

这个线程现在对文件有独占锁,如果我们保持它在这一点上被冻结,我们应该看到另一个线程在尝试获取共享锁时会被阻塞:

(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(54)wait_for_data() 
-> time.sleep(.5) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(47)wait_for_data() 
-> while empty: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(48)wait_for_data() 
-> fcntl.flock(fd, fcntl.LOCK_SH) 
(Pdb) n 

看起来很好,这个线程已经被阻塞。现在它应该无法获得锁,直到开发服务器线程释放它,此时文件将有数据。让我们推进开发服务器线程:

(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(19)make_pie_if_necessary() 
-> fcntl.flock(f, fcntl.LOCK_UN) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(20)make_pie_if_necessary() 
-> f.close() 
(Pdb) 

在这里,我们跳过了创建饼图的调用,以及解锁文件的调用。在那时,shell 线程停止了阻塞:

> /dj_projects/marketr/survey/pie_utils.py(49)wait_for_data() 
-> st = os.fstat(fd) 
(Pdb) 

这个线程现在应该看到文件有数据:

(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(50)wait_for_data() 
-> if st.st_size > 0: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(51)wait_for_data() 
-> empty = False 
(Pdb) 

看起来不错;代码将empty设置为False,这应该在释放共享锁的任务完成后触发循环的结束:

(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(52)wait_for_data() 
-> fcntl.flock(fd, fcntl.LOCK_UN) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(53)wait_for_data() 
-> if empty: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(47)wait_for_data() 
-> while empty: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(56)wait_for_data() 
-> if fd: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(57)wait_for_data() 
-> os.close(fd) 
(Pdb) n 
--Return-- 
> /dj_projects/marketr/survey/pie_utils.py(57)wait_for_data()->None 
-> os.close(fd) 
(Pdb) 

确实,代码继续退出循环,关闭文件并返回。我们可以输入c来继续这里,并获得常规的 shell 提示符。此时我们也可以让开发服务器继续,它将重新进入调试器以处理第二个饼图:

(Pdb) c 
> /dj_projects/marketr/survey/pie_utils.py(13)make_pie_if_necessary() 
-> fd = os.open(fname, os.O_WRONLY | os.O_CREAT | os.O_EXCL) 
(Pdb)

我们完成了吗?或者在这一点上我们可能还想测试其他东西吗?一切看起来都很好,但你可能已经注意到在代码中跟踪时的一件事是,等待文件数据的第二个线程在第一个线程实际关闭文件之前被允许继续。这可能是个问题吗?在没有显式调用将数据刷新到磁盘的情况下,可能会在内存中缓冲数据,并且直到文件关闭才会实际写入。根据这需要多长时间,假设文件现在已经准备好供另一个线程读取,那么可能会遇到麻烦,因为实际上并非所有数据都可以供单独的线程读取。

我们可以测试一下这种情况吗?是的,我们可以使用开发服务器的第二个请求来看看是否可能存在问题。在这种情况下,我们在调用创建文件之前让开发服务器被阻塞,然后从 shell 会话中继续检索第二个问题并调用其check_piechart方法:

>>> q = Question.objects.get(pk=2) 
>>> q.check_piechart() 
> /dj_projects/marketr/survey/pie_utils.py(13)make_pie_if_necessary() 
-> fd = os.open(fname, os.O_WRONLY | os.O_CREAT | os.O_EXCL) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(14)make_pie_if_necessary() 
-> try: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(15)make_pie_if_necessary() 
-> f = os.fdopen(fd, 'wb') 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(16)make_pie_if_necessary() 
-> fcntl.flock(f, fcntl.LOCK_EX) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(17)make_pie_if_necessary() 
-> create_piechart(f, answer_set) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(19)make_pie_if_necessary() 
-> fcntl.flock(f, fcntl.LOCK_UN) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(20)make_pie_if_necessary() 
-> f.close() 
(Pdb) 

在这里,我们在 shell 会话中一直进行到锁定文件、创建饼图和解锁文件。我们还没有关闭文件。现在,如果我们在开发服务器中继续,它将看到文件存在并且有数据:

(Pdb) n 
OSError: (17, 'File exists', '/dj_projects/marketr/site_media/piecharts/2.png') 
> /dj_projects/marketr/survey/pie_utils.py(13)make_pie_if_necessary() 
-> fd = os.open(fname, os.O_WRONLY | os.O_CREAT | os.O_EXCL) 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(21)make_pie_if_necessary() 
-> except OSError, e: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(22)make_pie_if_necessary() 
-> if e.errno == errno.EEXIST: 
(Pdb) n 
> /dj_projects/marketr/survey/pie_utils.py(23)make_pie_if_necessary() 
-> wait_for_data(fname) 
(Pdb) n 
--Return-- 
> /dj_projects/marketr/survey/pie_utils.py(23)make_pie_if_necessary()->None 
-> wait_for_data(fname) 
(Pdb) n 
--Return-- 
(Pdb)

看起来不错;在这种情况下,代码走了正确的路径。但是如果我们从这里继续,仍然没有给 shell 线程关闭文件的机会,那么浏览器对这个图像文件的后续请求是否会成功呢?我们可以通过在这里输入c来测试一下,并检查浏览器对第二个饼图的显示。看起来我们有问题:

使用调试器来强制竞争情况

要么我们破坏了生成饼图的代码,要么这是为了提供一个尚未完全写入磁盘的图像文件的结果。后者似乎更有可能。我们该如何解决这个问题?我们可以更改make_pie_if_necessary函数,在释放独占锁之前将数据刷新到磁盘:

def make_pie_if_necessary(rel_path, answer_set): 
    fname = settings.MEDIA_ROOT + rel_path 
    try: 
        import pdb; pdb.set_trace() 
        fd = os.open(fname, os.O_WRONLY | os.O_CREAT | os.O_EXCL) 
        try: 
            f = os.fdopen(fd, 'wb') 
            fcntl.flock(f, fcntl.LOCK_EX) 
            create_piechart(f, answer_set) 
        finally: 
            f.flush() 
            os.fsync(f.fileno()) 
            fcntl.flock(f, fcntl.LOCK_UN) 
            f.close() 
    except OSError, e: 
       if e.errno == errno.EEXIST: 
            wait_for_data(fname) 
       else: 
            raise 

查阅 Python 文档显示,需要对文件进行flush和调用os.fsync,以确保所有文件数据实际上被写入磁盘,因此我们在解锁文件之前添加了这两个调用。

这样行得通吗?测试它意味着再次删除图像文件,再次强制我们要进行的竞争条件。这里没有包括详细的输出,但确实,如果我们强制一个新的 shell 会话成为创建第二个图像文件的线程,在它关闭文件之前停止它,并让开发服务器线程继续发送完成的调查响应页面,然后提供图像文件,我们会在浏览器中看到完整的第二个图像。因此,添加flushos.fsync的调用似乎可以解决问题。

这个练习展示了编写正确处理竞争条件的代码有多么困难。不幸的是,这种竞争条件通常无法在 Web 应用程序中避免,因为它们通常会部署在多线程、多进程的 Web 服务器中。调试器是确保处理这些条件的代码按预期工作的宝贵工具。

使用图形调试器的注意事项

在本章中,我们专注于使用 Python 命令行调试器 pdb。图形集成开发环境,如 Eclipse、NetBeans 和 Komodo 也提供了可以用于 Django 应用程序代码的调试器(尽管有些需要安装特定插件来支持 Python 代码的开发)。设置和使用这些环境的细节超出了本文的范围,但下面将包括一些关于在 Django 应用程序中使用图形调试器的一般说明。

首先,使用图形调试器有一些明显的优势。通常,图形调试器会提供单独的窗格,显示当前执行的源代码、程序堆栈跟踪、本地变量和程序输出。这可以让您快速地对程序的状态有一个整体的感觉。在 pdb 中做到这一点往往更难,您必须运行单独的命令来获取相同的信息,并且在它们从屏幕上滚动出去后能够记住结果。

图形调试器的第二个优势是,通常可以通过在调试器中选择代码行并选择菜单项来设置断点。因此,您可以轻松地进行调试,而无需更改源代码以包含显式的断点进入调试器。

图形调试器中断点的一个要求是,在调试器中启动开发服务器的runserver命令必须指定--noreload选项。没有这个选项,当检测到磁盘上的运行代码已更改时,开发服务器会自动重新加载自身。这种重新加载机制会干扰图形调试器用于触发断点激活调试器的方法,因此在运行服务器时必须通过指定--noreload来禁用它。

当然,这样做的一个缺点是,集成开发环境中运行的开发服务器在代码更改后不会自动重新加载。如果你已经习惯了从简单命令行运行时的自动重新加载功能,可能很难记住在进行代码更改后需要手动重新启动服务器。

使用图形调试器时需要注意的另一件事是调试器本身可能会触发意外行为。例如,为了显示本地变量的值,调试器必须询问它们的值。对于QuerySets这样的本地变量,这可能意味着调试器会导致数据库交互,而应用程序本身永远不会发起。因此,调试器在尝试显示本地变量的值时,可能会在应用程序本身不会触发的地方触发QuerySets的评估。

QuerySets只是调试器可能引入意外行为的一个例子。基本上,调试器可能需要在幕后运行大量代码才能完成其工作,而这些幕后工作可能会产生副作用。这些副作用可能会干扰或不干扰调试应用程序代码的任务。如果它们干扰了(通常是在调试器下运行时出现意外结果),与其试图弄清楚调试器幕后到底发生了什么,不如换用不同的调试技术可能更有效。

总结

这就是我们讨论开发 Django 应用程序代码时使用调试器的结束。在本章中,我们:

  • 使用pygooglechart实现了显示调查结果的功能,以创建饼图。当我们在这个过程中遇到一些麻烦时,我们看到了 Python 调试器 pdb 如何帮助我们找出问题出在哪里。我们尝试了许多最有用的 pdb 命令。我们学会了查看正在运行的代码的上下文,检查和更改变量的值,并灵活地控制代码在调试器中的执行过程的命令。

  • 使用matplotlib库重新实现了显示调查结果的功能。对于这种替代实现,我们最终需要编写容易受到多进程竞争条件影响的代码。在这里,我们看到了 pdb 如何帮助验证这种类型代码的正确行为,因为它允许我们强制出现问题的竞争条件,然后验证代码对这种情况的行为是否正确。

  • 最后,讨论了使用图形调试器来开发 Django 应用程序代码的一些利弊。

在下一章中,我们将学习在开发过程中遇到问题时该怎么办,而目前讨论的调试技术似乎都无法解决这些问题。

第十章:当一切都失败时:寻求外部帮助

有时我们遇到的问题似乎不是由我们自己的代码引起的。尽管我们按照最好的理解遵循文档,但我们得到的结果与我们的预期不符。构建在 Django 等开源代码上的好处之一是我们可以深入研究其代码,确切地找出问题出在哪里。然而,这可能不是我们时间的最佳利用方式。

追踪此类问题的更好的第一步通常是查阅社区资源。也许其他人已经遇到了我们面临的问题,并找到了解决方法或解决方法。如果是这样,我们可能可以通过利用他们的经验来节省大量时间,而不是找到自己的解决方案。

本章描述了 Django 社区资源,并说明了如何使用它们。具体来说,在本章中我们将:

  • 通过发现 Django 1.1 版本中存在的一个错误的过程,并且导致了一些调查应用代码的问题

  • 看看 Django 网站上提供的资源如何用于研究问题

  • 根据研究结果讨论解决问题的最佳方法,无论是针对特定问题还是一般问题

  • 了解其他获取帮助的途径,以及如何最好地利用它们

在 Django 中追踪问题

本书是使用写作时最新的 Django 版本编写的。一开始是 Django 1.1。然后,在写作过程中,发布了 Django 1.1.1,之后的所有内容都使用了 Django 1.1.1。该版本号中的三个 1 是主要、次要和微小的发布号。(缺少微小号,如 Django 1.1,意味着是 0。)Django 1.1.1 由于有明确的微小号,被称为微小发布。微小发布中唯一的更改是错误修复,因此微小发布与上一个版本完全兼容。虽然主要或次要版本号的更改可能涉及一些需要代码调整的不兼容更改,但更新到新的微小发布的唯一区别是减少了错误。因此,始终建议运行您正在使用的主要.次要版本的最新微小发布。

尽管有这样的建议和兼容性保证,有时候不升级到最新的可用版本是很诱人的。升级需要一些(可能很小,但不为零)工作量。此外,还有一个常识公理:如果它没有坏,就不要修理它。如果您实际上没有遇到任何问题,为什么要升级呢?

当 Django 1.1.1 发布时,我正好有这些想法。该发布恰好发生在写作中间第七章,“当车轮脱落时:理解 Django 调试页面”,这是一个充满了包含 Django 代码的跟踪的截图和控制台显示的章节。如果我在写作该章节的中间改变了 Django 代码库,即使只是微小的发布,谁知道早期和晚期章节的跟踪之间可能会引入什么微妙的差异?这样的差异可能会让敏锐的读者感到困惑。

如果我在中间升级,最安全的做法是重新做所有的示例,以确保它们是一致的。这是一个不太吸引人的选择,因为这既需要相当多的工作,又容易出错。因此,当 Django 1.1.1 发布时,我的最初倾向是延迟升级,至少直到下一章节的休息时间。

然而,最终我发现我确实不得不在章节中间升级,因为我遇到了一个 Django 错误,这个错误被 1.1.1 版本修复了。接下来的几节描述了遇到的错误,并展示了如何将其追踪到在 Django 1.1.1 中已经修复的问题。

重新审视第七章投票表单

回想一下,在第七章中,我们实现了显示活动调查的代码。这包括一个表单,允许用户为调查中的每个问题选择答案。对表单代码所做的最终更改之一涉及自定义错误格式。QuestionVoteForm的最终代码如下:

class QuestionVoteForm(forms.Form): 
    answer = forms.ModelChoiceField(widget=forms.RadioSelect, 
        queryset=None,                            
        empty_label=None,                            
        error_messages={'required': 'Please select an answer below:'})

    def __init__(self, question, *args, **kwargs): 
        super(QuestionVoteForm, self).__init__(*args, **kwargs) 
        self.fields['answer'].queryset = question.answer_set.all() 
        self.fields['answer'].label = question.question 
        self.error_class = PlainErrorList 

from django.forms.util import ErrorList 
class PlainErrorList(ErrorList): 
    def __unicode__(self): 
        return u'%s' % ' '.join([e for e in self]) 

__init__期间包含PlainErrorList类,并将表单实例的error_class属性设置为它,旨在将问题的错误显示从 HTML 无序列表(默认行为)更改为简单字符串。然而,当在 Django 1.1 下运行此代码,并尝试提交两个问题都未回答的调查以强制出现错误时,显示的结果是:

重温第七章的投票表单

在两个错误消息左侧添加了项目符号表明错误列表仍然被格式化为 HTML 无序列表。这也可以通过检查页面的 HTML 源代码来确认,其中包括每个错误消息的以下片段:

<ul class="errorlist"><li>Please select an answer below:</li></ul> 

似乎设置error_class属性没有任何效果。我们如何最好地追踪这样的问题?

实际上是否运行了正确的代码?

首先,我们需要确保正在运行的代码实际上是我们认为正在运行的代码。在这种情况下,当我遇到问题时,我可以看到开发服务器在添加PlainErrorList类和设置error_class属性的代码更改后重新启动,因此我非常确定正在运行正确的代码。尽管如此,在error_class赋值之前插入import pdb; pdb.set_trace()允许我确认代码已经存在并且正在按照我期望的方式运行:

> /dj_projects/marketr/survey/forms.py(14)__init__() 
-> self.error_class = PlainErrorList 
(Pdb) self.error_class 
<class 'django.forms.util.ErrorList'> 
(Pdb) s 
--Return-- 
> /dj_projects/marketr/survey/forms.py(14)__init__()->None 
-> self.error_class = PlainErrorList 
(Pdb) self.error_class 
<class 'survey.forms.PlainErrorList'> 
(Pdb) c 

在进入调试器之前,我们可以看到error_class的值为django.forms.util.ErrorList。通过赋值的步骤显示__init__方法即将返回,并再次检查error_class属性的值显示确实已将值更改为我们自定义的PlainErrorList。这一切看起来都很好。在表单创建代码的最后,error_class属性已设置为自定义类。为什么它没有被使用?

代码是否符合文档要求?

下一步(在移除添加的断点后)是再次检查文档。尽管似乎不太可能,也许还需要其他内容才能使用自定义错误类?重新检查文档后,似乎并没有。有关自定义错误类的完整文档如下:

代码是否符合文档要求?

提供的示例所做的事情与QuestionVoteForm所做的事情有一些细微差异。首先,提供的示例在表单创建时将错误类作为参数传递,因此它被传递给了表单的超类__init__。另一方面,QuestionVoteForm在超类__init__运行后手动设置error_class

这似乎不太可能是问题的原因,因为在子类__init__例程中覆盖值,就像我们在QuestoinVoteForm中所做的那样,是一个非常常见的习语。不过,我们可以通过尝试在 Python shell 中演示使用自定义error_class设置的方法来检查是否这种细微差异会导致问题,如文档中所示,对于QuestionVoteForm

kmt@lbox:/dj_projects/marketr$ python manage.py shell 
Python 2.5.2 (r252:60911, Oct  5 2008, 19:24:49) 
[GCC 4.3.2] on linux2 
Type "help", "copyright", "credits" or "license" for more information. 
(InteractiveConsole) 
>>> from survey.forms import QuestionVoteForm 
>>> from survey.models import Question 
>>> qvf = QuestionVoteForm(Question.objects.get(pk=1), data={}) 

在这里,我们已经创建了一个表单实例qvf,用于数据库中主键为1的问题。通过传递一个空的data字典,我们强制了一个没有answer值的表单提交的错误条件。文档显示,使用表单的as_p方法来显示这个表单应该显示使用表单的自定义错误类格式化的错误。我们可以检查QuestionVoteForm是否发生了这种情况:

>>> qvf.as_p() 
u'Please select an answer below:\n<p><label for="id_answer_0">What is your favorite type of TV show?</label> <ul>\n<li><label for="id_answer_0"><input type="radio" id="id_answer_0" value="1" name="answer" /> Comedy</label></li>\n<li><label for="id_answer_1"><input type="radio" id="id_answer_1" value="2" name="answer" /> Drama</label></li>\n<li><label for="id_answer_2"><input type="radio" id="id_answer_2" value="3" name="answer" /> Reality</label></li>\n</ul></p>' 
>>> 

在那里,我们看到as_p方法确实使用了自定义错误类:错误消息周围没有 HTML 无序列表。因此,错误类已设置,并且在使用as_p等例行程序显示表单时使用。

这导致了文档显示和调查应用程序代码实际执行之间的第二个差异。survey/active_survey.html模板不使用as_p来显示表单。相反,它分别打印答案字段的标签,答案字段的错误,然后是答案字段本身。

{% extends "survey/base.html" %} 
{% block content %} 
<h1>{{ survey.title }}</h1> 
<form method="post" action="."> 
<div> 
{% for qform in qforms %} 
    {{ qform.answer.label }} 
    {{ qform.answer.errors }} 
    {{ qform.answer }} 
{% endfor %} 
<button type="submit">Submit</button> 
</div> 
</form> 
{% endblock content %} 

这是否会导致自定义错误类不用于显示?你不会这样认为。尽管文档只显示自定义错误类与as_p一起使用,但在那里没有提到自定义错误类只被方便的显示方法如as_p使用。这样的限制将非常有限,因为方便的表单显示方法通常不适用于非平凡的表单。

似乎很明显,error_class属性的意图是覆盖错误显示,而不管表单以何种方式输出,但似乎并没有起作用。这是我们可能开始强烈怀疑 Django 中的一个错误,而不是应用程序代码中的某个错误或误用的地方。

搜索匹配的问题报告

接下来,我们要访问 Django 网站,看看是否有人报告了使用error_class的问题。从 Django 项目主页的Code链接(页面顶部横跨的链接的最右边)中选择Code链接,将显示 Django 代码跟踪器的主页面:

搜索匹配的问题报告

Django 项目使用 Trac,它提供了一个易于使用的基于 Web 的界面来跟踪错误和功能请求。使用 Trac,错误和功能请求是通过票务报告和跟踪的。Django 项目配置 Trac 的方式的具体细节,以及因此各种票务属性值的含义,可以在 Django 贡献文档页面上找到。特别是在这里找到的图表和描述:docs.djangoproject.com/en/dev/internals/contributing/#ticket-triage对于理解与票务相关的所有信息非常有帮助。

我们现在要做的是搜索 Django 项目票务,查找使用error_class的报告问题。一种方法是选择View Tickets选项卡并构建适当的搜索。当首次选择View Tickets时,默认情况下将显示列出所有未关闭票务的页面。例如:

搜索匹配的问题报告

用于生成报告的标准显示在标有Filters的框中。在这里,我们看到报告包括所有状态不是closed的票务,因为这是唯一未被选中的Status选项。为了获得更适合我们正在研究的报告,我们需要修改Filters框中的搜索标准。

首先,我们可以取消对票务状态的限制。我们对与error_class相关的所有报告感兴趣,而不管票务状态如何。我们可以通过点击包含约束的行的极右侧带有减号的框来删除现有的状态约束。

其次,我们需要为我们想要应用的搜索约束添加一个过滤器。为此,我们从添加过滤器下拉框中选择一个合适的选项。这个下拉框包含了我们可以搜索的票务属性的完整列表,比如报告人所有者状态组件。这些属性中大多数对我们当前感兴趣的搜索不相关。在列表中最有可能找到我们要找的内容的是摘要

票务摘要是问题的简要描述。我们希望这个摘要包含字符串error_class,以便包括我们遇到的问题的任何报告。在摘要上添加一个单一过滤器,并规定它包含字符串error_class,因此有望找到任何相关的票务。点击更新按钮以根据新标准刷新搜索结果,然后显示以下内容:

搜索匹配的问题报告

已经开启了三个包含error_class(或error class)的票务。其中两个已关闭,一个仍然打开(状态为)。根据显示的摘要,这三个中,排在前面的一个听起来可能是我们遇到的与error_class有关的问题,而另外两个则似乎不太相关。

点击票号或摘要可以获取列出的问题的更多详细信息,这两者都是查看完整票务详情的链接。查看完整详情将允许我们验证它是否与我们所看到的情况相同,并了解更多有关关闭时间和原因的详细信息。在这种情况下,我们看到以下内容:

搜索匹配的问题报告

这个票务有一个相当长的历史——从开启到最后一次活动经过了两年。简要重现问题的指南确实让人觉得这可能正是我们遇到的与error_class有关的问题。在顶部附近的票号后面列出的已修复解决方案听起来令人鼓舞,但不幸的是,这个票务没有关于修复问题的代码更改以及何时进行的详细信息。在滚动到票务历史中添加的各种评论的最后,我们看到最近的几次更新如下:

搜索匹配的问题报告

2009 年 8 月,用户peter2108有兴趣通过提供补丁来帮助推动票务,包括修复问题的测试(通过阅读完整历史,最初提供的补丁中缺少测试是这个票务长时间开放的原因之一)。然后,在 2009 年 10 月 16 日,peter2108已修复的解决方案关闭了票务。

一开始可能并不明显,但这种关闭票务的方式对于需要 Django 代码更改的票务来说并不典型。通常,当代码更改提交到 Django SVN 存储库时,票号会包含在提交消息中,并且相应的票务会自动更新,包括提交消息和更改集的链接。这个自动过程还会以已修复的解决方案关闭票务。这样就非常容易看到确切是哪个代码更改修复了问题,以及代码更改是何时进行的。

有时,自动过程无法正确运行,通常当发生这种情况时,有人会注意到并手动关闭票务,注明哪个代码更新修复了问题。但这也不是这里发生的情况。相反,看起来peter2108,他对看到错误修复感兴趣,只是注意到问题在某个时候消失了,并将票务标记为已修复。

我们可以猜测,基于同一个用户在 8 月份对问题进行了关闭,而在 10 月份又将问题关闭为已解决,修复可能是在 8 月 28 日至 10 月 16 日之间的某个时间点进入了代码库。然而,我们想要确定修复确切是何时进行的,这样我们就可以确定我们是否应该已经在我们运行的代码中拥有它,或者更新到最新版本是否会解决问题,或者修复是否仅在直接从 SVN 存储库中拉取的代码版本中可用。

回顾另外两个在摘要中提到error_class的票据,它们都无法帮助确定问题error_class究竟是何时修复的,因为它们描述的是完全不同的问题。那么,我们如何才能找到我们遇到的问题确切是何时修复的信息呢?对于这种情况,查看票据类型的搜索不够广泛,无法为我们提供所需的信息。幸运的是,有一种替代的搜索 Django 跟踪器的方法,我们可以使用它来找到缺失的信息。

搜索匹配问题报告的另一种方法

通过单击搜索选项卡而不是查看票据选项卡,可以找到这种替代搜索方式。这将显示一个带有单个文本输入框和三个复选框的页面,用于控制搜索位置:票据变更集Wiki

此页面提供了一种更广泛和不太有针对性的搜索方式。除了搜索票据数据外,默认情况下还搜索变更集和 Wiki 文章。即使关闭这些选项,仅票据搜索也比查看票据下可能的搜索范围更广。此页面的票据搜索涵盖了所有票据评论和更新,这些在查看票据下无法搜索。

使用此页面进行搜索的一个好处是它可能会找到查看票据搜索无法找到的相关结果。使用此页面进行搜索的一个缺点是,它可能会找到大量无关的结果,具体取决于在文本框中输入了什么搜索词。如果发生这种情况,您可以通过在文本框中输入更多必须匹配的单词来进一步限制显示的结果,这可能有所帮助。然而,在这种情况下,搜索一个像error_class这样不常见的字符串不太可能产生大量的结果。

因此,输入error_class并单击搜索按钮会导致以下结果:

搜索匹配问题报告的另一种方法

这次搜索产生的结果比查看票据搜索更多——12 个而不是 3 个。列出的第一个结果,票据#12001,与之前搜索找到的仍然打开的票据相同。先前搜索的其他结果也包含在完整列表中,只是在下面。但首先,我们可以看到一个变更集的结果,[11498],它在提交消息中提到了error_class,以及其关联的票据#10968。这个票据之前没有出现在我们尝试的原始搜索中,因为虽然它在完整描述中包含对error_class的引用,但字符串error_class不在票据摘要中。

单击#10968票据的详细信息后,显示它是我们遇到的相同问题的重复报告,并且在我们找到的另一个票据#6138中报告了该问题。通常,当这样的重复票据被打开时,它们会很快被关闭为重复,并引用描述问题的现有票据。

然而,如果没有人意识到新的票据是重复的,那么重复的票据可能会变成在检入代码库时引用的票据。这显然是在这种情况下发生的。我们可以在这个新票据的最后更新中看到自动生成的评论,当修复提交到 SVN 存储库时添加:

搜索匹配问题报告的另一种方法

该评论中的变更集编号是变更集的详细描述的链接。单击它,我们会看到以下内容:

搜索匹配问题报告的另一种方法

在这里,我们可以看到与此代码更改相关的所有详细信息:更改是何时进行的,由谁进行的,提交消息,更改的文件(或添加或删除的文件),更改的文件中的具体行以及这些更改是什么。对于我们现在正在研究的问题,大部分这些信息都不是我们真正需要了解的,但有时可能会派上用场。对于这个问题,我们想知道的是:哪个发布级别的代码包含了这个修复程序?我们将在下面考虑这个问题。

确定包含修复程序的发布版本

对于我们正在查看的特定情况,我们可以简单地根据日期判断,包含修复程序的第一个发布版本应该是 Django 1.1.1。在 Django 项目主页的网志上快速检查显示,Django 1.1 于 2009 年 7 月 29 日发布,Django 1.1.1 于 2009 年 10 月 9 日发布。在这些日期之间进行的所有错误修复应该包含在 1.1.1 版本中,因此 2009 年 9 月 11 日进行的修复应该包含在 Django 1.1.1 中。

有时事情可能不那么清楚。例如,我们可能不确定在发布当天进行的代码更改是否包含在发布中,或者是在发布后发生的。或者,我们可能不确定更改是被分类为错误修复还是新功能。对于这种情况,我们可以检查发布的修订号并将其与我们感兴趣的修订号进行比较。

Django 使用标记发布版本的标准子版本惯例;标记的发布版本可以在root/django/tags/releases下找到。我们可以通过首先选择浏览代码选项卡,然后依次选择每个路径组件来导航到此路径。以这种方式导航到 1.1.1 发布并在右上角单击修订日志会显示以下页面:

确定包含修复程序的发布版本

这表明 1.1.1 标记的发布版本是通过复制 1.1.X 发布分支创建的。创建标记的变更集是[11612],高于我们感兴趣的变更集(11498),因此我们期望我们关心的修复程序在 1.1.X 发布中。

但等一下。查看变更集 11498 的详细信息时,更改的文件位于主干(例如django/trunk/django/forms/forms.py),而不是 1.1.X 发布分支django/branches/releases/1.1.X。如果发布是通过复制 1.1.X 分支创建的,但修复只是在主干上进行的,那么它真的包含在 1.1.1 发布中吗?

答案是肯定的。通过单击此页面上的链接转到 1.1.X 发布分支,为其选择修订日志,然后向下滚动到底部,显示 1.1.X 发布分支是作为主干的副本在修订版本 11500 时创建的,比我们感兴趣的修订版本 11498 晚两个修订版本。因此,当最初创建 1.1.X 分支时,它包含了我们正在寻找的修复程序。

您可能会想知道为什么 1.1.X 分支直到 2009 年 9 月 11 日之后才创建,而 1.1 版本在 7 月底就已经发布了。原因是因为一旦创建了发布分支,就必须在两个不同的地方应用错误修复程序:主干和最新的发布分支。这比只在一个地方(主干)应用它们稍微多一些工作。因此,发布分支的创建通常会在发布后的一段时间内延迟一段时间,以便更轻松地进行错误修复。

由于在不存在发布分支的时间内,不能对主干进行与新功能相关的更改,因为发布分支必须仅包含错误修复,而不包含新功能。不过,这通常不是问题,因为在发布后不久,很少进行功能工作。通常每个人都需要一些时间来休息,并首先决定哪些功能可能会进入下一个发布版本。一旦下一个发布版本的一些功能工作接近需要被检入的时候,那么就会创建上一个发布版本的发布分支。从那时起,功能工作被检入主干,而错误修复被检入主干和发布分支。

如果修复尚未发布呢?

在这里,我们很幸运地遇到了一个已经修复的问题,并且修复已经在官方发布的版本中可用。在遇到这样的问题时,应该很容易地选择更新到最新的微版本以获得修复。如前所述,始终建议安装正在使用的特定 major.minor 版本的最新微版本。

但是,如果我们想要的修复是在最新可用版本之后的某个时间点进行的呢?那么我们应该怎么办?简单的技术答案是简单地检出包含修复的最新级别的主干或发布分支,并使用该代码。特别是如果使用发布分支,就不必担心采用任何代码不稳定性,因为发布分支中的唯一更改是错误修复。

然而,这种简单的技术答案可能违反了关于仅运行“发布级别”代码的本地政策。如果你在这样的环境中工作,可能需要克服一些额外的障碍,以便使用尚未在官方版本中提供的修复。采取的最佳方法可能会由你所处理的确切政策、你遇到的问题的严重程度以及在你自己的代码中找到解决方法的能力等因素决定。

如果修复尚未提交呢?

有时在研究问题时,结果会显示问题已经被报告,但尚未修复。在这种情况下,如何最好地继续可能取决于你对参与和贡献到 Django 的兴趣程度,以及匹配问题报告与修复之间的接近程度。如何参与贡献到 Django 的细节超出了本文的范围,但本节提供了一些基于你的兴趣程度的广泛指导方针。如果你有兴趣贡献,Django 网站提供了如何贡献的详细信息,网址为:docs.djangoproject.com/en/dev/internals/contributing/

如果你对尚未提交到代码库的代码不感兴趣,除了等待修复被提交之外,可能没有太多事情可做。唯一的例外是对于尚未被充分理解的问题。在这种情况下,你可能能够提供你遇到问题的具体细节,以帮助其他人更好地理解问题并开发修复方案。

如果你愿意尝试未提交的代码,你可能会更快地找到解决你遇到的问题的可行解决方案。在最好的情况下,你可能会发现与你遇到的问题匹配的票据已经附有一个有效的补丁。这是可能的,你所需要做的就是下载它并将其应用到你的 Django 代码副本中以解决问题。

然后,你需要决定是否能够并且愿意部署你的应用程序,使用一个已经应用了一些“自定义”修复的 Django 版本。如果不能,你可能想要帮助将工作补丁检查到代码库中,看看是否有任何遗漏的部分(比如测试)需要在修复被检查之前包含进去,如果有的话,提供这些遗漏的部分。然而,在某些情况下,没有任何遗漏,所需要的只是时间让修复进入代码库。

如果你找到一个有补丁的匹配票据,但它没有解决你所遇到的问题,那么这是有价值的信息,可以发布到票据上。不过,你可能首先要确保你的问题确实与你找到的票据中的问题相同。如果它确实是一个稍微不同的问题,那么为这个稍微不同的问题开一个新的票据可能更合适。

当你怀疑时,你可以随时在你认为匹配的票据中发布关于你所看到的问题以及现有补丁似乎无法解决它的信息。跟踪票据的其他人可能会提供反馈,告诉你你的问题是否相同,现有的补丁是否确实不太对,或者你是否真的在处理一个不同的问题。

在最坏的情况下,你可能会发现一个报告与你所经历的相同问题的票,但没有附加的补丁可供尝试。这对你来说并不是很有帮助,但却为你提供了最多的机会去贡献。如果你有时间并且愿意,你可以深入研究 Django 代码,看看是否能够提出一个补丁,然后将其发布到票据上,以帮助解决问题。

如果一个票已经关闭而没有修复呢?

有时在研究问题时,结果会出现一个匹配的报告(或多个报告),但没有进行任何修复而被关闭。在这种情况下可能会使用三种不同的解决方案:无效、worksforme 和 wontfix。如何最好地继续将取决于问题报告的具体情况以及用于关闭匹配问题票据的解决方案。

首先,无效的解决方案是非常广泛的。一个票可能因为很多不同的原因而被关闭为无效,包括:

  • 描述的问题根本不是问题,而是报告者代码中的一些错误,或者对某些功能应该如何工作的误解。

  • 描述的问题太模糊了。例如,如果一个票只提供了一个错误的回溯,但没有关于如何触发回溯的信息,那么没有人能够帮助跟踪并解决问题,所以它很可能会被关闭为无效。

  • 描述的问题确实是一个问题,但根本原因是 Django 之外的一些代码。如果在 Django 代码中无法解决问题,那么票据很可能会被关闭为无效。

在你找到一个被关闭为无效的匹配票据时,你应该阅读票据关闭时所做的评论。在票据因缺乏关于问题的信息而关闭时,如果你可以提供一些需要的缺失数据来解决问题,重新打开票据可能是合适的。否则,如果你不理解关闭的解释,或者不同意关闭的原因,最好在邮件列表中开始讨论(在下一节中讨论),以获得更多关于如何最好地解决你遇到的问题的反馈。

worksforme 解决方案非常直接,它表示关闭工单的人无法重现报告的问题。它和 invalid 一样,可能是在原始问题报告中没有足够的信息来重现问题时使用的。缺少的信息可能是导致问题的代码的具体信息,或者问题发生的环境的具体信息(操作系统,Python 版本,部署细节)。如果您能够重现一个被关闭为 worksforme 的问题,并且能够提供缺失的细节,使其他人能够做同样的事情,那么您应该随时重新打开工单并提供这些信息。

wontfix 解决方案也很直接。通常只有核心贡献者会关闭 wontfix 工单,这表示核心团队已经决定不修复特定的问题(通常是一个功能请求,而不是一个错误)。如果您不同意 wontfix 的决定,或者认为在做出决定时没有考虑到所有适当的信息,那么您不会通过简单地重新打开工单来改变任何人的想法。相反,您需要在 django-developers 邮件列表上提出这个问题,并看看是否能够得到更广泛的开发社区的共识,以便推翻 wontfix 的决定。

追踪未报告的问题

有时在研究问题时,找不到匹配的报告。在这种情况下,最好的处理方式可能取决于您对您遇到的问题是否是 Django 中的错误有多确定。如果您非常确定问题出在 Django 中,您可以直接打开一个新的工单来报告它。如果您不太确定,最好先从社区中获得一些反馈。以下部分将描述在哪里提问,提供一些关于提问的好建议,并描述如何打开一个新的工单。

在哪里提问

在任何 Django 网站页面上点击社区链接会弹出以下内容:

未报告的问题,追踪问题的地方

这个页面的左侧提供了链接到博客文章的链接,这些文章是由讨论 Django 的人写的。阅读这些文章是了解使用 Django 的人群的一个好方法,但我们现在感兴趣的是这个页面的右侧。在这里,我们可以看到与 Django 社区其他成员直接互动的方式的链接。

列表中的第一个是#django IRC 频道的链接。(IRC代表Internet Relay Chat。)这个选项提供了一个聊天式界面,可以与其他 Django 用户进行互动交流。这是在您想要快速获得关于您想要询问或讨论的任何内容的反馈时的一个不错的选择。然而,在聊天界面中进行详细的编码讨论可能会有困难。对于这种情况,其中一个邮件列表可能是一个更好的选择。

有两个邮件列表,如下所示:django-usersdjango-developers。第一个用于讨论如何使用 Django,第二个用于讨论 Django 本身的开发。如果你遇到了一个问题,你认为,但不确定,是 Django 的问题,django-users 是发布有关该问题的问题的正确地方。Django 核心开发团队的成员会阅读并回答用户列表上的问题,并提供反馈,告知问题是否应该被提出为一个工单或者是否应该被带到开发者列表进行进一步讨论。

这两个邮件列表都托管在 Google 群组中。先前显示的每个组名称实际上都是一个链接,您可以单击该链接直接转到该组的 Google 群组页面。从那里,您可以查看该组中的最近讨论列表,并阅读可能感兴趣的任何主题。Google 群组还提供搜索功能,但不幸的是,该功能并不总是正常工作,因此从该组的页面中搜索可能不会产生有用的结果。

如果您决定要发布到其中一个组,您首先需要加入该组。这有助于减少发布到组的垃圾邮件,因为潜在的垃圾邮件发送者必须首先加入。然而,有很多潜在的垃圾邮件发送者确实加入并尝试向列表发送垃圾邮件。因此,还有一个额外的反垃圾邮件措施:新成员发送的帖子将通过审核。

这种反垃圾邮件措施意味着您发送到这些列表中的第一篇帖子可能需要一些时间才能出现,因为它必须由其中一位志愿者审核批准。通常情况下,这不会花费太长时间,但可能需要多达几个小时。通常情况下,一旦用户收到一个明显合法的第一篇帖子,他们的状态将被更新,以指示他们的帖子不需要经过审核,因此随后的帖子将立即出现在组中。

提出问题以获得良好答案的提示

一旦您决定发布问题,下一个任务将是以最有可能产生一些有用答案的方式撰写问题。本节提供了一些建议,说明如何做到这一点。

首先,要具体说明你正在做什么。如果您有一些代码的行为与您的期望不符,请直接包含代码,而不是用散文描述代码的功能。通常,实际使用的代码的详细细节是理解问题的关键,这些细节在代码的散文描述中很容易丢失。

然而,如果代码过长或过宽,无法在电子邮件界面中轻松阅读,因为它会自动换行长行,最好不要在帖子中包含它。理想情况下,在这种情况下,您应该能够将重新创建问题所需的代码剪切到一个可以在电子邮件中轻松阅读的可管理大小,并发布它。

请注意,如果您这样做,最好先验证剪裁版本的代码是否正确(例如没有任何语法错误)并且是否出现您所询问的问题。否则,您可能只会收到回复,报告发布的代码根本不起作用,或者不显示您描述的行为。

如果您无法将必要的代码剪裁到可管理的大小,要么是因为您没有时间,要么是因为剪裁代码会使问题消失,您可以尝试将代码发布在 dpaste.com 之类的地方,并在问题中包含一个链接。但是,最好将需要演示问题的代码保持尽可能短。随着您发布或指向的代码越来越长,邮件列表上的人越来越少,他们会花时间去理解问题并帮助您找到解决方案。

除了具体说明您正在使用的代码外,还要具体说明您正在做什么来触发错误行为。当您访问自己的应用程序 URL 之一时,您是否观察到问题?当您在管理应用程序中执行某些操作时?当您从manage.py shell 尝试某些操作时?这对您可能显而易见,但如果您详细说明您正在做什么,它确实有助于他人重现问题。

其次,具体说明发生了什么,以及你期望发生的是什么。"它不起作用"不是具体的。"它死了"也不是,"它给了我一个错误信息"也不是。给出"不起作用"的具体表现。当你期望 Y 时,浏览器页面显示 X?一个声明 XYZ 的错误信息?一个回溯?在最后一种情况下,在问题中包含完整的回溯,因为这为可能试图帮助的人提供了宝贵的调试线索。

第三,如果你在问题中提到你的预期行为是基于文档的,那么请具体说明你所指的是哪个文档。Django 有广泛的文档,包括指南和参考信息。阅读你的问题并搜索你所引用的文档的人可能会轻易地找到一个完全不同的部分,并且很难理解你的意思。如果你提供了问题文档的具体链接,那么误解的可能性就会降低。

你可能已经注意到,所有这些提示中都有一个共同的主题:具体。是的,提供具体信息需要更多的工作。但是一个具体的问题更有可能得到有用的答案,而不是一个不明确和模糊的问题。如果你省略了具体信息,偶尔会有人发布一个指导你解决问题的答案。然而,更有可能的是,一个模糊的问题要么得不到回应,要么得到要求具体信息的回应,要么得到完全误解问题的回应。

打开一个新的票证来报告问题

如果你遇到一个看起来是 Django 代码中未报告和未修复的错误,下一步就是为此问题打开一个票证。当你从 Django 首页点击Code后选择New Ticket选项卡时,这个过程就变得非常直观了。

打开一个新的票证来报告问题

请确保阅读首先阅读列表。该列表中的许多信息在本章的早些部分已经涵盖了,但并非全部。特别是,最后一项指出了如何标记提交的代码片段或回溯,以便它们能够正确格式化。该注释包括最常被忽略的一种标记类型,并指向了关于如何特殊格式化文本的完整文档。请注意,你可以通过选择底部的预览按钮来检查格式化的效果——在按下提交之前尝试预览是一个很好的主意。

请注意,Django Trac 安装确实允许匿名提交和更新票证。然而,它也使用 Akismet 垃圾邮件过滤服务,这项服务有时会拒绝非垃圾邮件的提交。正如大黄框中所指出的,避免这种情况的最简单方法是注册一个账户(页面上的文字是一个链接,可以跳转到注册页面)。

在打开一个新的票证时,填写最重要的部分是简短的摘要和完整的描述。在简短的摘要中,尽量包含关键术语,这样新的票证就会在遇到相同问题的人的可能搜索中显示出来。在完整的描述中,之前关于具体性的所有建议都再次适用。如果你在邮件列表的讨论后得出结论认为需要打开一个票证,那么在问题中包含该讨论的链接是有帮助的。然而,在票证描述中也包含关于问题的基本信息也是很好的。

票据属性中的信息中,您可能不需要更改任何默认值,除了版本(如果您使用的版本与显示的版本不同)和有补丁(如果您将附加修复问题的补丁)。您可以尝试从列表中猜测正确的组件并包含一些适当的关键字,但这并非必要。

同样,您可以将里程碑设置为下一个发布版本,尽管这并不会使有人更有可能尽快解决问题。该字段通常只在发布的最后阶段密切关注,以记录哪些错误绝对必须在发布之前修复。

提交票据后,如果您使用包含电子邮件地址的登录,或在标有您的电子邮件或用户名的字段中指定了电子邮件地址,则票据的更新将自动发送到指定的电子邮件地址。因此,如果有人向票据添加评论,您将收到通知。这种情况的一个令人讨厌的例外是由于对代码库的提交而产生的自动生成的更新:这不会生成发送给票据报告者的电子邮件。因此,当票据被关闭为已修复时,您不一定会收到通知,而是必须手动从网站上检查其状态。

总结

现在我们讨论了在先前介绍的调试技术都未能成功解决某些问题时该怎么办。在本章中,我们:

  • 遇到了一个存在于 Django 1.1 中的错误,导致一些调查应用代码无法按预期行为

  • 走过了追踪问题的验证过程,发现问题是由 Django 而不是调查代码引起的

  • 看到在 Django 代码跟踪器中搜索揭示了问题是一个已在 Django 1.1.1 中修复的错误,这为问题提供了一个简单的解决方案

  • 讨论了当问题被追踪到尚未可用或在官方发布中不可用的修复程序时如何继续的选项

  • 描述了存在的各种社区资源,用于询问行为似乎令人困惑,但似乎尚未被报告为错误的问题

  • 讨论了撰写问题的提示,以便获得所需的有益回应

  • 描述了在 Django 代码中报告问题时打开新票的过程

在下一章中,我们将进入 Django 应用程序开发的最后阶段:转向生产。

第十一章:当是时候上线:转入生产环境

我们将在测试和调试 Django 应用程序的主题上涵盖的最后一个主题是转入生产环境。当应用程序代码全部编写、完全测试和调试完成时,就是设置生产 Web 服务器并使应用程序对真实用户可访问的时候了。由于应用程序在开发过程中已经经过了全面的测试和调试,这应该是直截了当的,对吗?不幸的是,情况并非总是如此。生产 Web 服务器环境与 Django 开发服务器环境之间存在许多差异。这些差异可能在转入生产过程中引发问题。在本章中,我们将看到其中一些差异是什么,它们可能引起什么类型的问题,以及如何克服它们。具体来说,我们将:

  • 配置一个带有mod_wsgi的 Apache Web 服务器来运行示例marketr项目。

  • 在开发 Apache 配置过程中遇到了一些问题。针对每个问题,我们将看看如何诊断和解决这些问题。

  • 对在 Apache 下运行的应用程序进行功能性压力测试,以确保它在负载下能够正确运行。

  • 修复功能性压力测试中暴露的任何代码错误。

  • 讨论在开发过程中使用 Apache 和mod_wsgi的可能性。

开发 Apache/mod_wsgi 配置

通常,转入生产环境将涉及在与开发时使用的不同机器上运行代码。生产服务器可能是专用硬件,也可能是从托管提供商那里获得的资源。无论哪种情况,它通常与开发人员编写代码时使用的机器完全分开。生产服务器需要安装任何先决条件包(例如,对于我们的示例项目,需要安装 Django 和 matplotlib)。此外,应用项目代码的副本通常需要从版本控制系统中提取,并放置在生产服务器上。

为了简化本章,我们将在与开发代码相同的机器上配置生产 Web 服务器。这将使我们能够跳过一些在实际转入生产过程中涉及的复杂性,同时仍然体验到在生产部署过程中可能出现的许多问题。在很大程度上,我们将跳过这样做时遇到的问题并不是特定于 Django 的,而是在将任何类型的应用程序从开发转入生产时需要处理的常见问题。我们将遇到的问题往往更具体于 Django。

将要开发的示例部署环境是使用mod_wsgi的 Apache,这是目前推荐的部署 Django 应用程序的环境。WSGI代表Web Server Gateway Interface。WSGI 是 Python 的标准规范,定义了 Web 服务器(例如 Apache)和用 Python 编写的 Web 应用程序或框架(例如 Django)之间的接口。

基本的 Apache Web 服务器不支持 WSGI。然而,Apache 的模块化结构允许通过插件模块提供此支持。因此,WSGI 的 Web 服务器端支持由 Graham Dumpleton 编写并积极维护的mod_wsgi提供。Django 本身确实实现了 WSGI 规范的应用程序端。因此,在mod_wsgi和 Django 之间不需要任何额外的适配器模块。

注意

在开发mod_wsgi之前,Apache 的mod_python模块是 Django 推荐的部署环境。尽管mod_python仍然可用且仍然被广泛使用,但其最近的发布已经超过三年。当前的源代码需要一个补丁才能与最新的 Apache 2.2.X 版本编译。未来,由于 Apache API 的更改,将需要更广泛的更改,但没有活跃的mod_python开发人员来进行这些更改。鉴于mod_python开发目前的停滞状态,我认为它现在不适合用于 Django 应用程序的部署。因此,这里不涵盖配置它的具体内容。如果出于某种原因您必须使用mod_python,那么在本章中遇到的许多问题也适用于mod_python,并且配置mod_python的具体内容仍包含在 Django 文档中。

Apache 和mod_wsgi都可以在各种不同的平台上轻松获取和安装。这里不会涵盖这些安装的细节。一般来说,使用机器的常规软件包管理服务来安装这些软件包可能是最简单的方法。如果这不可能,可以在网上找到有关下载和安装 Apache 的详细信息,网址为httpd.apache.org/,同样的信息也可以在code.google.com/p/modwsgi/找到mod_wsgi的信息。

本章展示的示例配置的开发机器运行 Ubuntu,这是 Linux 的基于 Debian 的版本。这种 Linux 的配置结构可能与您自己机器上使用的结构不匹配。然而,配置结构并不重要,重要的是配置中包含的 Apache 指令。如果您的机器不遵循 Debian 结构,您可以简单地将这里显示的指令放在主 Apache 配置文件中,通常命名为httpd.conf

在 Apache 下运行 WSGI 客户端应用程序的配置有两个部分,首先是一个 Python WSGI 脚本,它设置环境并标识将处理请求的 WSGI 客户端应用程序。其次是控制mod_wsgi操作并将特定 URL 路径的请求指向mod_wsgi的 Apache 配置指令。接下来将讨论为 Django marketr项目创建这两部分。

创建marketr项目的 WSGI 脚本。

Django 项目的 WSGI 脚本有三个责任。首先,它必须设置 Python 路径,包括 Django 项目所需但不在常规系统路径上的任何路径。在我们的情况下,martketr项目本身的路径将需要添加到 Python 路径中。项目使用的所有其他先决条件代码都已安装,因此它会自动在 Python site-packages 目录下找到。

其次,WSGI 脚本必须在环境中设置DJANGO_SETTINGS_MODULE变量,指向适当的设置模块。在我们的情况下,它需要设置为指向/dj_projects/marketr中的settings.py文件。

第三,WSGI 脚本必须将变量application设置为实现 WSGI 接口的可调用实例。对于 Django,这个接口由django.core.handlers.wsgi.WSGIHandler提供,因此marketr项目的脚本可以简单地将application设置为该类的实例。这里没有特定于marketr项目的内容——这部分 WSGI 脚本对所有 Django 项目都是相同的。

这个脚本应该放在哪里?将其直接放在/dj_projects/marketr中似乎是很自然的,与settings.pyurls.py文件一起,因为它们都是项目级文件。然而,正如mod_wsgi文档中所提到的,这将是一个不好的选择。Apache 需要配置以允许访问包含 WSGI 脚本的目录中的文件。因此,最好将 WSGI 脚本保存在与不应对网站用户可访问的任何代码文件分开的目录中。(特别是包含settings.py的目录,绝对不应该配置为对网站客户端可访问,因为它可能包含诸如数据库密码之类的敏感信息。)

因此,我们将在/dj_projects/marketr内创建一个名为apache的新目录,用于保存在 Apache 下运行项目相关的所有文件。在apache目录下,我们将创建一个wsgi目录,用于保存marketr项目的 WSGI 脚本,我们将其命名为marketr.wsgi。根据此脚本的前面提到的三个职责,实现/dj_projects/marketr/apache/wsgi/marketr.wsgi脚本的第一步可能是:

import os, sys 

sys.path = ['/dj_projects/marketr', ] + sys.path 
os.environ['DJANGO_SETTINGS_MODULE'] = 'marketr.settings' 

import django.core.handlers.wsgi 
application = django.core.handlers.wsgi.WSGIHandler() 

此代码将marketr项目目录添加到 Python 系统路径的最前面,将DJANGO_SETTINGS_MODULE环境变量设置为marketr.settings,并将application设置为实现 WSGI 应用程序接口的 Django 提供的可调用实例。当mod_wsgi被调用以响应已映射到此脚本的 URL 路径时,它将使用正确设置的环境调用适当的 Django 代码,以便 Django 能够处理请求。因此,下一步是开发 Apache 配置,将请求适当地路由到mod_wsgi和此脚本。

为 marketr 项目创建 Apache 虚拟主机

为了将 Django 项目与您可能已经使用 Apache 的其他任何内容隔离开来,我们将使用绑定到端口 8080 的 Apache VirtualHost来进行 Django 配置。以下指令指示 Apache 监听端口 8080 的请求,并定义一个虚拟主机来处理这些请求:

Listen 8080
<VirtualHost *:8080>
    WSGIScriptAlias / /dj_projects/marketr/apache/wsgi/marketr.wsgi
    WSGIDaemonProcess marketr
    WSGIProcessGroup marketr

    # Possible values include: debug, info, notice, warn, error, crit,
    # alert, emerg.
    LogLevel debug

    ErrorLog /dj_projects/marketr/apache/logs/error.log
    CustomLog /dj_projects/marketr/apache/logs/access.log combined
</VirtualHost>

请注意,这绝不是一个完整的 Apache 配置,而是需要添加到现有(或已发货的示例)配置中以支持处理定向到端口 8080 的marketr项目请求的内容。在VirtualHost容器内有三个指令控制mod_wsgi的行为,另外三个指令将影响此虚拟主机的日志处理方式。

第一个指令WSGIScriptAlias很简单。它将与其第一个参数匹配的所有请求映射到其第二个参数中指定的 WSGI 脚本,即/dj_projects/marketr/apache/wsgi/marketr.wsgi。此指令的效果将是将此虚拟主机的所有请求路由到前面部分定义的marketrWSGI 脚本。

接下来的两个指令,WSGIDaemonProcessWSGIProcessGroup,指示mod_wsgi将此虚拟主机的请求路由到一个独立的进程组,与用于处理请求的正常 Apache 子进程不同。这被称为以守护程序模式运行mod_wsgi。相比之下,让mod_wsgi使用正常的 Apache 子进程被称为以嵌入模式运行。

通常情况下,以守护程序模式运行更可取(有关详细信息,请参阅mod_wsgi文档),但在 Windows 上运行 Apache 时不支持此模式。因此,如果您在 Windows 机器上使用 Apache 服务器,则需要从配置中省略这两个指令。

在所示的指令中,WSGIDaemonProcess指令定义了一个名为marketr的进程组。这个指令支持几个额外的参数,可以用来控制,例如,组中的进程数量,每个进程中的线程数量,以及进程的用户和组。这里没有指定这些参数,所以mod_wsgi将使用其默认值。WSGIProcessGroup指令将先前定义的marketr组命名为处理这个虚拟主机请求的组。

下一个指令,LogLevel debug,将日志设置为最详细的设置。在生产环境中,更典型的设置可能是warn,但是当刚开始设置某些东西时,通常有必要让代码记录尽可能多的信息,所以我们将在这里使用debug

最后两个指令,ErrorLogCustomLog,为这个虚拟主机定义了错误和访问日志,与主要的 Apache 错误和访问日志不同。这可以方便地将与新项目相关的日志信息与 Apache 可能处理的其他流量隔离开来。在这种情况下,我们已经指示 Apache 将日志放置在/dj_projects/marketr/apache目录下的logs目录中。

激活新的 Apache 配置

上一节的配置指令应该放在哪里?正如前面所述,答案取决于 Apache 在您的机器上的配置细节。对于由单个httpd.conf文件组成的 Apache 配置,您可以简单地将指令放在该文件的末尾。尽管这对于更结构化的配置也可能有效,但最好避免混淆并使用提供的结构。因此,本节将描述如何将先前列出的定义集成到基于 Debian 的配置中,因为这是示例项目所使用的机器类型。

对于基于 Debian 的 Apache 配置,Listen指令应放置在/etc/apache2/ports.conf中。VirtualHost指令及其所有内容应放置在/etc/apache2/sites-available下的一个文件中。但是,在这个例子中,虚拟主机配置已放置在一个名为/dj_projects/marketr/apache/conf/marketr的文件中,以便/dj_projects目录可以包含项目的完整配置信息。我们可以通过为其创建一个符号链接,使这个文件也出现在sites-available目录中:

kmt@lbox:/etc/apache2/sites-available$ sudo ln -s /dj_projects/marketr/apache/conf/marketr 

请注意,一般用户无法在/etc/apache2/sites-available下创建或修改文件,因此需要使用sudo命令以超级用户的身份执行所请求的命令。这对于所有修改 Apache 配置或控制其操作的命令都是必要的。

一旦包含虚拟主机配置的文件放置在sites-available中,就可以使用a2ensite命令启用新站点:

kmt@lbox:/etc/apache2/sites-available$ sudo a2ensite marketr 
Enabling site marketr. 
Run '/etc/init.d/apache2 reload' to activate new configuration! 

a2ensite命令在/etc/apache2/sites-enabled目录中为sites-available目录中指定的文件创建一个符号链接。还有一个伴随命令a2dissite,它通过在sites-enabled中删除指定文件的符号链接来禁用站点。(请注意,如果愿意,您也可以手动管理符号链接,而不使用这些命令。)

正如a2ensite的输出所示,需要重新加载 Apache 才能使新的站点配置生效。在这种情况下,由于添加了Listen指令,需要完全重新启动 Apache。这可以通过运行/etc/init.d/apache2命令并指定restart作为参数来完成。当我们尝试这样做时,响应如下:

激活新的 Apache 配置

屏幕右侧的 [fail] 看起来不太好。显然在重新启动期间出了一些问题,但是是什么呢?答案在于重新启动 Apache 时使用的命令的输出中找不到,它只报告成功或失败。相反,Apache 错误日志包含了失败原因的详细信息。此外,对于与服务器启动相关的失败,主要的 Apache 错误日志可能包含详细信息,而不是特定于站点的错误日志。在这台机器上,主要的 Apache 错误日志文件是/var/log/apache2/error.log。查看该文件的末尾,我们找到了以下内容:

(2)No such file or directory: apache2: could not open error log file /dj_projects/marketr/apache/logs/error.log. 
Unable to open logs 

问题在于新的虚拟主机配置指定了一个不存在的错误日志文件目录。Apache 不会自动创建指定的目录,因此我们需要手动创建它。这样做并再次尝试重新启动 Apache 会产生更好的结果:

激活新的 Apache 配置

[ OK ] 确实看起来比 [fail] 好得多;显然这一次 Apache 能够成功启动。我们现在已经到了拥有有效 Apache 配置的地步,但可能还有一些工作要做才能获得一个可用的配置,接下来我们将看到。

调试新的 Apache 配置

下一个测试是看 Apache 是否能成功处理发送到新虚拟主机端口的请求。为了做到这一点,让我们尝试从 Web 浏览器中检索项目根(主页)。结果看起来不太好:

调试新的 Apache 配置

现在可能出了什么问题?在这种情况下,主要的 Apache 错误日志对于错误的原因是沉默的。相反,是为marketr虚拟站点配置的错误日志提供了问题的指示。检查该文件,我们看到/dj_projects/marketr/apache/logs/error.log的完整内容现在是:

[Mon Dec 21 17:59:01 2009] [info] mod_wsgi (pid=18106): Attach interpreter ''. 
[Mon Dec 21 17:59:01 2009] [info] mod_wsgi (pid=18106): Enable monitor thread in process 'marketr'. 
[Mon Dec 21 17:59:01 2009] [debug] mod_wsgi.c(8301): mod_wsgi (pid=18106): Deadlock timeout is 300\. 
[Mon Dec 21 17:59:01 2009] [debug] mod_wsgi.c(8304): mod_wsgi (pid=18106): Inactivity timeout is 0\. 
[Mon Dec 21 17:59:01 2009] [info] mod_wsgi (pid=18106): Enable deadlock thread in process 'marketr'. 
[Mon Dec 21 17:59:01 2009] [debug] mod_wsgi.c(8449): mod_wsgi (pid=18106): Starting 15 threads in daemon process 'marketr'. 
[Mon Dec 21 17:59:01 2009] [debug] mod_wsgi.c(8455): mod_wsgi (pid=18106): Starting thread 1 in daemon process 'marketr'. 
[Mon Dec 21 17:59:01 2009] [debug] mod_wsgi.c(8455): mod_wsgi (pid=18106): Starting thread 2 in daemon process 'marketr'. 

[… identical messages for threads 3 through 13 deleted …]

(pid=18106): Starting thread 14 in daemon process 'marketr'. 
[Mon Dec 21 17:59:01 2009] [debug] mod_wsgi.c(8455): mod_wsgi (pid=18106): Starting thread 15 in daemon process 'marketr'. 
[Mon Dec 21 17:59:45 2009] [error] [client 127.0.0.1] client denied by server configuration: /dj_projects/marketr/apache/wsgi/marketr.wsgi 

除了最后一个之外,这些消息都没有指示问题。相反,它们是由mod_wsgi记录的信息和调试级别消息,根据虚拟主机配置中LogLevel debug的设置。这些消息显示mod_wsgi报告了它正在使用的各种值(死锁超时,不活动超时),并显示mod_wsgi在守护进程marketr中启动了 15 个线程。一切看起来都很好,直到最后一行,这是一个错误级别的消息。

最后一条消息的具体内容并没有比 Web 浏览器显示的光秃秃的 Forbidden 更有帮助。消息确实表明marketr.wsgi脚本涉及其中,并且请求被 服务器配置拒绝。在这种情况下,问题不在于文件不存在,而是服务器已经配置为不允许访问它。

这个特定问题的原因在这台机器上的 Apache 配置的其他地方,这是一个问题,根据您的整体 Apache 配置,您可能会遇到或者不会遇到。问题在于这台机器的 Apache 配置已经设置为拒绝访问除了明确启用访问的所有目录中的文件。从安全的角度来看,这种类型的配置是好的,但它确实使配置变得有点更加费力。在这种情况下,需要的是一个Directory块,允许访问包含marketr.wsgi脚本的目录中的文件:

    <Directory /dj_projects/marketr/apache/wsgi> 
        Order allow,deny 
        Allow from all 
    </Directory> 

Apache 三遍访问控制系统的细节超出了本书的范围;如果您感兴趣,Apache 文档详细描述了这个过程。对于我们的目的,值得注意的是这个Directory块允许所有客户端访问/dj_projets/marketr/apache/wsgi中的文件,这应该是可以接受的,并足以解决浏览器对marketr项目主页最初返回的 Forbidden

Directory块应放在marketr项目的VirtualHost块内。更改配置需要重新启动 Apache,之后我们可以再次尝试访问项目主页。这次我们看到以下内容:

调试新的 Apache 配置

好消息是我们已经解决了Forbidden错误。坏消息是我们并没有走得更远。再次返回到浏览器的页面对于调试问题没有什么用,而网站的错误日志记录了问题的详细信息。这次在文件的末尾我们发现:

[Mon Dec 21 18:05:43 2009] [debug] mod_wsgi.c(8455): mod_wsgi (pid=18441): Starting thread 15 in daemon process 'marketr'. 
[Mon Dec 21 18:05:49 2009] [info] mod_wsgi (pid=18441): Create interpreter 'localhost.localdomain:8080|'. 
[Mon Dec 21 18:05:49 2009] [info] [client 127.0.0.1] mod_wsgi (pid=18441, process='marketr', application='localhost.localdomain:8080|'): Loading WSGI script '/dj_projects/marketr/apache/wsgi/marketr.wsgi'. 
[Mon Dec 21 18:05:49 2009] [error] [client 127.0.0.1] mod_wsgi (pid=18441): Exception occurred processing WSGI script '/dj_projects/marketr/apache/wsgi/marketr.wsgi'. 
[Mon Dec 21 18:05:49 2009] [error] [client 127.0.0.1] Traceback (most recent call last): 
[Mon Dec 21 18:05:49 2009] [error] [client 127.0.0.1]   File "/usr/lib/python2.5/site-packages/django/core/handlers/wsgi.py", line 230, in __call__ 
[Mon Dec 21 18:05:49 2009] [error] [client 127.0.0.1]     self.load_middleware() 
[Mon Dec 21 18:05:49 2009] [error] [client 127.0.0.1]   File "/usr/lib/python2.5/site-packages/django/core/handlers/base.py", line 33, in load_middleware 
[Mon Dec 21 18:05:49 2009] [error] [client 127.0.0.1]     for middleware_path in settings.MIDDLEWARE_CLASSES: 
[Mon Dec 21 18:05:49 2009] [error] [client 127.0.0.1]   File "/usr/lib/python2.5/site-packages/django/utils/functional.py", line 269, in __getattr__ 
[Mon Dec 21 18:05:49 2009] [error] [client 127.0.0.1]     self._setup() 
[Mon Dec 21 18:05:49 2009] [error] [client 127.0.0.1]   File "/usr/lib/python2.5/site-packages/django/conf/__init__.py", line 40, in _setup 
[Mon Dec 21 18:05:49 2009] [error] [client 127.0.0.1]     self._wrapped = Settings(settings_module) 
[Mon Dec 21 18:05:49 2009] [error] [client 127.0.0.1]   File "/usr/lib/python2.5/site-packages/django/conf/__init__.py", line 75, in __init__ 
[Mon Dec 21 18:05:49 2009] [error] [client 127.0.0.1]     raise ImportError, "Could not import settings '%s' (Is it on sys.path? Does it have syntax errors?): %s" % (self.SETTINGS_MODULE, e) 
[Mon Dec 21 18:05:49 2009] [error] [client 127.0.0.1] ImportError: Could not import settings 'marketr.settings' (Is it on sys.path? Does it have syntax errors?): No module named marketr.settings 

显然,marketr.wsgi脚本这次确实被使用了,因为回溯显示 Django 代码已被调用。但是环境设置并不完全正确,因为 Django 无法导入指定的marketr.settings设置模块。这是一个常见的错误,几乎总是由两种情况之一引起的:要么 Python 路径没有正确设置,要么 Apache 进程运行的用户没有读取设置文件(以及包含它的目录)的权限。

在这种情况下,快速检查/dj_projects/marketr目录及其文件的权限显示它们是可读的:

kmt@lbox:/dj_projects/marketr$ ls -la 
total 56 
drwxr-xr-x 7 kmt kmt 4096 2009-12-21 18:42 . 
drwxr-Sr-x 3 kmt kmt 4096 2009-12-20 09:46 .. 
drwxr-xr-x 5 kmt kmt 4096 2009-12-21 17:58 apache 
drwxr-xr-x 2 kmt kmt 4096 2009-11-22 11:40 coverage_html 
drwxr-xr-x 4 kmt kmt 4096 2009-12-20 09:50 gen_utils 
-rw-r--r-- 1 kmt kmt    0 2009-11-22 11:40 __init__.py 
-rw-r--r-- 1 kmt kmt  130 2009-12-20 09:49 __init__.pyc 
-rwxr-xr-x 1 kmt kmt  546 2009-11-22 11:40 manage.py 
-rwxr--r-- 1 kmt kmt 5800 2009-12-20 09:50 settings.py 
-rw-r--r-- 1 kmt kmt 2675 2009-12-20 09:50 settings.pyc 
drwxr-xr-x 3 kmt kmt 4096 2009-12-20 09:50 site_media 
drwxr-xr-x 5 kmt kmt 4096 2009-12-20 19:42 survey 
-rwxr--r-- 1 kmt kmt  734 2009-11-22 11:40 urls.py 
-rw-r--r-- 1 kmt kmt  619 2009-12-20 09:50 urls.pyc 

因此,问题似乎不太可能与 Web 服务器进程访问settings.py文件的能力有关。但是,请注意,如果您运行的是使用安全增强内核(SELinux 内核)的 Linux 版本,则ls -l显示的权限信息可能会误导。这个内核有一个复杂的文件访问控制结构,需要额外的配置(超出本书的范围)才能允许 Web 服务器进程访问其自己指定区域之外的文件。

不过,在这种情况下,机器并没有运行 SELinux 内核,并且权限信息显示任何进程都可以读取settings.py文件。因此,问题很可能在路径设置中。请回忆一下marketr.wsgi脚本中的路径和设置规范:

sys.path = ['/dj_projects/marketr', ] + sys.path 
os.environ['DJANGO_SETTINGS_MODULE'] = 'marketr.settings' 

这个路径无法导入指定为marketr.settings的设置文件,因为路径和模块规范中的marketr部分都被重复了。Python 在尝试找到模块并使用路径上的第一个元素时,将尝试找到一个名为/dj_projects/marketr/marketr/settings.py的文件。这将失败,因为实际文件是/dj_projects/marketr/settings.py。除非/dj_projects单独在sys.path上,否则 Python 将无法加载marketr.settings

因此,一个解决方法是在路径设置中包含/dj_projects

sys.path = ['/dj_projects/marketr', '/dj_projects', ] + sys.path 

不过,需要为一个项目添加两个不同的项目路径似乎有点奇怪。这两个都真的必要吗?第一个是必要的,因为在调查应用程序代码中,例如,我们使用了以下形式的导入:

from survey.models import Survey 
from survey.forms import QuestionVoteForm 

由于这些导入中没有包含marketr,因此必须在 Python 路径的一个元素中包含它。在运行开发服务器时,/dj_projects/marketr目录是当前路径,自动包含在 Python 路径中,因此这些导入有效。在 Apache 下运行时,必须在路径中包含/dj_projects/marketr才能使这些导入工作。

或者,我们可以更改surveygen_utils应用程序中的所有导入,使用以下形式:

from marketr.survey.models import Survey 
from marketr.survey.forms import QuestionVoteForm 

然而,这种方法将这些应用程序紧密地绑定到marketr项目,使得在该项目之外重新使用它们变得更加困难。我认为最好的做法是使应用程序独立,不在它们的导入中包含包含项目的名称。

那么/dj_projects呢?是否真的需要在路径中包含它?我们是否可以通过将设置模块指定为简单的settings而不是marketr.settings来消除需要在路径中包含它的需要?是的,这将使我们摆脱特定的错误,但当处理设置文件中的ROOT_URLCONF值时,我们很快会遇到另一个类似的错误。ROOT_URLCONF也在其规范中包括marketr

ROOT_URLCONF = 'marketr.urls' 

我们也可以更改它,并希望这是最后一个问题,但最好的方法可能是在 Web 服务器下运行时简单地包括/dj_projects在路径中。

您可能会想知道在开发服务器下运行时如何将/dj_projects包含在路径中,因为当前目录的父目录通常不包含在 Python 路径中,就像当前目录一样。答案是,开发服务器的设置代码将项目目录的父目录放在 Python 路径中。对于刚开始学习 Python 的人来说,这可能有所帮助,但长远来看,这往往会引起混乱,因为对于不是刚开始学习 Python 的人来说,这是令人惊讶的行为。

然而,要从这一点继续,我们只需在 Python 路径中包括/dj_projects以及/dj_projects/marketr,如前所示。请注意,在守护程序模式下运行mod_wsgi时,不需要重新加载或重新启动 Apache 即可使其获取 WSGI 脚本的更改。更改 WSGI 脚本本身足以导致mod_wsgi自动重新启动其守护进程。因此,我们只需要保存修改后的文件,然后再次尝试访问项目主页。这次我们看到以下内容:

调试新的 Apache 配置

我们再次有好消息和坏消息。我们确实取得了进展,Django 代码运行良好,足以返回调试页面,这是令人鼓舞的,比起不得不在 Apache 错误日志中搜索问题,这更容易处理。不幸的是,我们得到调试页面而不是项目主页意味着在 Web 服务器下运行时环境仍然存在一些问题。

这次异常信息表明matplotlib代码需要对其配置数据的目录具有写访问权限。它显然尝试创建一个名为/var/www/.matplotlib的目录,但失败了。消息表明,如果设置一个名为MPLCONFIGDIR的环境变量指向一个可写目录,我们可能会解决这个问题。我们当然可以在marketr.wsgi脚本中设置这个环境变量,就像设置DJANGO_SETTINGS_MODULE环境变量一样:

os.environ['DJANGO_SETTINGS_MODULE'] = 'marketr.settings' 
os.environ['MPLCONFIGDIR'] = '/dj_projects/marketr/apache/.matplotlib' 

我们还需要创建指定的目录,并使 Web 服务器进程可以对其进行写操作。最简单的方法是将目录的所有者更改为 Web 服务器进程运行的用户,这台机器上的用户是www-data

kmt@lbox:/dj_projects/marketr/apache$ mkdir .matplotlib 
kmt@lbox:/dj_projects/marketr/apache$ sudo chown www-data .matplotlib/

或者,虚拟主机配置中的WSGIDaemonProcess指令可以更改为指定不同的用户。但是,默认情况下,唯一具有对/dj_projects目录下目录的写访问权限的用户是我的用户kmt,我宁愿不让 Web 服务器进程以写访问权限运行我的所有文件。因此,更容易的方法是让 Web 服务器继续以www-data运行,并明确允许它根据需要访问目录。请注意,如果您使用 SQLite 作为数据库,还需要设置数据库文件的权限,以便 Apache 进程可以读取和写入它。

我们已经解决了最后一个问题吗?保存更改后的marketr.wsgi文件并重试项目主页,会出现以下内容:

调试新的 Apache 配置

最后成功,但是有点。主页上没有显示调查,因为已经过了足够长的时间,我们一直在处理的那个已关闭的survey现在已经关闭了太长时间,无法列出。因此,在主页上没有太多有趣的东西可看。测试的下一个自然步骤是转到管理员应用程序,并更改调查的closes日期,以便它出现在主页上。尝试这样做会显示一些我们尚未设置的配置,接下来将讨论。

配置 Apache 以提供静态文件

尝试在 Apache 下访问管理员应用程序,我们得到:

CoApache/mod_wsgi 配置新配置,调试配置 Apache 以提供静态文件

这看起来很像我们的示例项目页面,没有任何自定义样式。但是,与我们的示例项目不同,管理员应用程序确实有它使用的样式表,在运行开发服务器时正确加载。这是由开发服务器中的专用代码完成的。在 Apache 下运行时,我们需要配置它(或其他 Web 服务器)来提供管理员应用程序的静态文件。

我们该如何做呢?所有管理员的静态文件都将使用相同的前缀引用,由settings.py中的ADMIN_MEDIA_PREFIX指定。此设置的默认值为/media/。因此,我们需要指示 Apache 直接从管理员的媒体目录树中提供带有此前缀的文件,而不是将请求路由到mod_wsgi和我们的 Django 项目代码。

实现这一目标的 Apache 指令是(请注意,下面的AliasDirectory行由于页面宽度限制而被拆分,这些指令需要放在 Apache 配置文件中的单行上):

Alias /media /usr/lib/python2.5/site-packages/django/contrib/admin/media/ 
<Directory /usr/lib/python2.5/site-packages/django/contrib/admin/media> 
    Order allow,deny 
    Allow from all 
</Directory> 

第一个指令Alias设置了从以/media开头的 URL 路径到实际文件的映射,这些文件位于(在此计算机上)/usr/lib/python2.5/site-packages/django/contrib/admin/media/下。接下来的Directory块指示 Apache 允许所有客户端访问管理员媒体所在的目录中的文件。与marketr.wsgi脚本的Directory块一样,只有在您的 Apache 配置已经设置为默认拒绝访问所有目录时才需要这样做。

这些指令应该放在marketr项目虚拟主机的VirtualHost块中。然后需要重新加载 Apache 以识别配置更改。在浏览器中重新加载管理员页面,然后会出现带有正确自定义样式的页面:

CoApache/mod_wsgi 配置新配置,调试配置 Apache 以提供静态文件

请注意,不仅管理员有静态文件。在第九章中,当你甚至不知道要记录什么时:使用调试器,我们将一些静态文件的使用添加到了marketr项目中。具体来说,由 matplotlib 生成的图像文件以显示调查结果被作为静态文件提供。与管理员媒体文件不同,这些文件不会被开发服务器自动提供,因此我们不得不在marketr项目的urls.py文件中为它们添加一个条目,指定它们由 Django 静态服务器视图提供:

    (r'^site_media/(.*)$', 'django.views.static.serve', 
        {'document_root': settings.MEDIA_ROOT, 'show_indexes': True}),

这个配置仍然可以在 Apache 下提供文件,但是不建议在生产中使用静态服务器。除了是一种非常低效的提供静态文件的方式之外,静态服务器代码还没有经过安全审计。因此,在生产中,这个 URL 模式应该从urls.py文件中删除,并且应该配置 Apache(或其他服务器)直接提供这些文件。

让 Apache 提供这些文件的指令是:

    Alias /site_media /dj_projects/marketr/site_media 
    <Directory /dj_projects/marketr/site_media> 
        Order allow,deny 
        Allow from all 
    </Directory> 

这些与为管理员媒体文件所需的指令几乎完全相同,只是修改为指定用于站点媒体文件的 URL 路径前缀和这些文件的实际位置。

这就是全部吗?还不是。与管理媒体文件不同,marketr项目使用的图像文件实际上是由marketr项目代码按需生成的。如果我们删除现有的图像文件并尝试访问已完成调查的详细页面,当 Web 服务器进程尝试创建其中一个图像文件时,我们将会收到错误,如下所示:

CoApache/mod_wsgi configurationnew configuration, debuggingnfiguring Apache to serve static files

为了解决这个问题,Web 服务器代码需要对包含文件的目录具有写访问权限。这可以通过将目录/dj_projects/marketr/site_media/piecharts的所有者更改为www-data来实现,就像为 matplotlib 配置目录所做的那样。在我们进行了这个更改之后,尝试重新加载调查详细页面会显示 Web 服务器现在能够创建图像文件,如下所示:

CoApache/mod_wsgi configurationnew configuration, debuggingnfiguring Apache to serve static files

我们现在已经在 Apache 下成功运行了项目。接下来,我们将考虑是否存在任何额外的潜在问题,这些问题可能是由于开发和生产 Web 服务器环境之间的差异而导致的。

测试多线程行为

在上一节中,我们遇到了在开发服务器和 Apache 下运行时的一些环境差异。其中一些(例如,文件权限和 Python 路径差异)导致了在我们能够使项目在 Apache 下正常运行之前必须克服的问题。我们观察到的一个差异是多线程,但我们还没有遇到与之相关的问题。

当我们在上一节中检查错误日志时,我们可以看到mod_wsgi已经启动了一个包含 15 个线程的进程,每个线程都准备处理一个传入的请求。因此,几乎同时到达服务器的多个请求将被分派到不同的线程进行处理,并且它们的执行步骤可能在实时中任意交错。这在开发服务器中永远不会发生,因为开发服务器是严格单线程的,确保每个请求在处理完之前不会启动下一个请求的处理。这也不会发生在前五章中介绍的任何测试工具中,因为它们也都以单线程方式进行测试。

在第九章中,我们已经注意到需要牢记潜在的多线程问题。在那一章中,我们编写了用于显示调查结果的图像文件生成代码。这些图像是在调查关闭后首次收到显示调查请求时按需生成的。生成图像并将其写入磁盘需要一定的时间,很明显,代码需要正确处理这样一种情况:当收到对调查结果的第二个请求时,第一个请求的处理尚未完成。

在那一章中,我们学习了如何在调试器中使用断点来强制多个线程按特定顺序执行。通过这种方式,我们看到了如何测试以确保代码在多线程环境中可能出现的最坏情况下的交错执行场景中的行为是否正常。

但我们不仅需要关注那些需要大量时间的操作,比如生成图像或写文件,还需要关注在多线程环境下的高请求负载下,即使通常很快的请求处理也可能被中断并与同时处理的其他请求的处理交错。在多处理器机器上,甚至不需要中断一个请求:第二个请求可能会在第二个处理器上真正同时运行。

marketr项目中是否有任何代码可能在多线程环境下无法正常工作?可能有。通常,首先要考虑潜在的多线程问题的代码是更新数据的任何代码。对于survey应用程序,有一个视图在服务器上更新数据:接收和记录发布的调查结果的视图。

当我们在多线程环境中运行时,我们是否确定调查结果记录代码在运行时能够正常工作,其中可能会同时运行许多副本?由于我们还没有测试过,所以不能确定。但是现在我们已经在多线程环境中运行代码,我们可以尝试测试它并查看结果。

使用 siege 生成负载

在多线程环境中测试代码的可用性只是有效测试多线程行为所需的一半。另一半是一些方式来生成服务器处理的许多同时请求。有许多不同的工具可以用于此。我们将在这里使用的是称为siege的工具,这是由 Jeffrey Fulmer 编写的一个免费的命令行工具。有关下载和安装siege的信息可以在www.joedog.org/index/siege-home找到。

安装后,siege非常容易使用。调用它的最简单方法是在命令行上传递一个 URL。它将启动几个线程,并不断请求传递的 URL。在运行时,它会显示它正在做什么以及有关它正在接收的响应的关键信息。例如:

kmt@lbox:/dj_projects/marketr$ siege http://localhost:8080/ 
** SIEGE 2.66 
** Preparing 15 concurrent users for battle. 
The server is now under siege... 
HTTP/1.1 200   0.06 secs:     986 bytes ==> / 
HTTP/1.1 200   0.04 secs:     986 bytes ==> / 
HTTP/1.1 200   0.04 secs:     986 bytes ==> / 
HTTP/1.1 200   0.02 secs:     986 bytes ==> / 
HTTP/1.1 200   0.03 secs:     986 bytes ==> / 
HTTP/1.1 200   0.03 secs:     986 bytes ==> / 
HTTP/1.1 200   0.03 secs:     986 bytes ==> / 
HTTP/1.1 200   0.03 secs:     986 bytes ==> / 
HTTP/1.1 200   0.04 secs:     986 bytes ==> / 

在这里,我们看到调用siege来不断请求项目主页。在启动时,它报告了它的版本,并打印出它将使用多少线程来进行同时请求。默认值如此,为 15;-c(用于并发)命令行开关可以用来更改。然后,siege打印出有关它发送的每个请求的信息。对于每个请求,它打印出所使用的协议(这里都是HTTP/1.1),收到的响应代码(200),响应到达所花费的时间(在.02.06秒之间),响应中的字节数(986),最后是请求的 URL 路径。

默认情况下,siege将一直运行,直到通过Ctrl-C中断。中断时,它将停止生成负载,并报告结果的统计信息。例如:

HTTP/1.1 200   0.11 secs:     986 bytes ==> /
HTTP/1.1 200   0.47 secs:     986 bytes ==> /
^C
Lifting the server siege...      done.
Transactions:                    719 hits
Availability:                 100.00 %
Elapsed time:                  35.02 secs
Data transferred:               0.68 MB
Response time:                  0.21 secs
Transaction rate:              20.53 trans/sec
Throughput:                     0.02 MB/sec
Concurrency:                    4.24
Successful transactions:         719
Failed transactions:               0
Longest transaction:            0.79
Shortest transaction:           0.02

这个工具发出了略多于 700 个请求,所有请求都收到了响应,正如报告所示,可用性达到了 100%,没有失败的交易。报告的性能数字很有趣,但由于我们目前是在一个开发机器上运行,调试仍然打开,现在读取性能数字还为时过早。我们真正想要检查的是,在高负载的多线程环境下调用处理调查响应的代码是否正确。我们将考虑下一步该如何做。

测试结果记录代码的负载

我们如何使用siege来测试记录调查答案的代码?首先,我们需要在数据库中有一个仍然开放的调查,因此将接受发布的答复。最简单的方法是使用管理应用程序,并将现有的电视趋势调查的“关闭”日期更改为将来的某个时间。同时,我们可以将调查中所有答案的答复计数更改为 0,这将使我们能够轻松地判断我们使用siege生成的所有答复是否被正确处理。

接下来,我们需要确定要指定给siege的 URL,以便它可以为调查表单发布有效数据。最简单的方法是在浏览器中打开显示调查表单的页面,并检查 HTML 源代码,看看表单字段的名称和每个字段的有效值是什么。在这种情况下,当我们检索http://localhost:8080/1/时,显示表单的源 HTML 如下:

<form method="post" action="."> 
<div> 

 What is your favorite type of TV show? 

 <ul>
<li><label for="id_answer_0"><input type="radio" id="id_answer_0" value="1" name="answer" /> Comedy</label></li>
<li><label for="id_answer_1"><input type="radio" id="id_answer_1" value="2" name="answer" /> Drama</label></li>
<li><label for="id_answer_2"><input type="radio" id="id_answer_2" value="3" name="answer" /> Reality</label></li>
</ul> 

 How many new shows will you try this Fall? 

 <ul>
<li><label for="id_1-answer_0"><input type="radio" id="id_1-answer_0" value="4" name="1-answer" /> Hardly any: I already watch too much TV!</label></li>
<li><label for="id_1-answer_1"><input type="radio" id="id_1-answer_1" value="5" name="1-answer" /> Maybe 3-5</label></li>
<li><label for="id_1-answer_2"><input type="radio" id="id_1-answer_2" value="6" name="1-answer" /> I'm a TV fiend, I'll try them all at least once!</label></li>
</ul> 

<button type="submit">Submit</button> 
</div> 
</form>

表单有两个单选组输入,一个名为answer,一个名为1-answeranswer的有效选择是1231-answer的有效选择是456。因此,我们希望指示siegehttp://localhost:8080/1/发送answer13之间的值,并且1-answer46之间的值。任意选择两个问题的第一个选项的方法是将 URL 指定为"http://localhost:8080/1/ POST answer=1&1-answer=4"。请注意,由于其中包含空格和&,在命令行上传递此 URL 时需要使用引号。

为了获得可预测的生成请求数,我们可以指定-r命令行开关,指定测试重复的次数。如果我们保留默认的并发线程数为 15,并指定 5 次重复,在测试结束时,我们应该看到两个选择的答案每个都有 5*15,或 75 票。让我们试一试:

kmt@lbox:/dj_projects/marketr$ siege -r 5 "http://localhost:8080/1/ POST answer=1&1-answer=4" 
** SIEGE 2.66 
** Preparing 15 concurrent users for battle. 
The server is now under siege... 
HTTP/1.1 302   0.12 secs:       0 bytes ==> /1/ 
HTTP/1.1 302   0.19 secs:       0 bytes ==> /1/ 
HTTP/1.1 200   0.02 secs:     543 bytes ==> /thanks/1/ 
HTTP/1.1 302   0.15 secs:       0 bytes ==> /1/ 
HTTP/1.1 302   0.19 secs:       0 bytes ==> /1/ 
HTTP/1.1 302   0.37 secs:       0 bytes ==> /1/ 
HTTP/1.1 200   0.02 secs:     543 bytes ==> /thanks/1/ 
HTTP/1.1 302   0.30 secs:       0 bytes ==> /1/ 

这里的输出与第一个示例有些不同。survey应用程序对调查响应的成功 POST 是一个 HTTP 重定向(状态 302)。siege工具,就像浏览器一样,响应接收到的重定向,请求重定向响应中指定的位置。因此,先前的输出显示了 POST 请求成功,然后对调查感谢页面的后续重定向也成功。

此测试运行的输出末尾是:

HTTP/1.1 302   0.03 secs:       0 bytes ==> /1/
HTTP/1.1 200   0.02 secs:     543 bytes ==> /thanks/1/
HTTP/1.1 200   0.01 secs:     543 bytes ==> /thanks/1/
done.
Transactions:                    150 hits
Availability:                 100.00 %
Elapsed time:                   9.04 secs
Data transferred:               0.04 MB
Response time:                  0.11 secs
Transaction rate:              16.59 trans/sec
Throughput:                     0.00 MB/sec
Concurrency:                    1.85
Successful transactions:         150
Failed transactions:               0
Longest transaction:            0.56
Shortest transaction:           0.01

看起来不错。事务总数是请求的帖子数量的两倍,表明所有 POST 请求都返回了重定向,因此它们都被成功处理。因此,从客户端的角度来看,测试似乎运行成功。

但是服务器上的投票计数是否符合我们的预期?答案 1(喜剧)和 4(几乎没有:我已经看了太多电视了!)每个都被发布了 75 次,因此我们期望它们每个都有 75 票,而所有其他答案都没有。在管理应用程序中检查第一个问题的投票计数,我们看到以下内容:

加载测试结果记录代码

类似地,检查第二个问题,我们看到以下内容:

加载测试结果记录代码

这不好。虽然应该为 0 的votes值确实都是0,但是应该为 75 的两个votes值分别是4034。根据发送给客户端的结果,服务器似乎成功处理了所有请求。然而,显然许多投票实际上并没有被记录。这是怎么发生的?答案在试图记录发布的调查响应的代码中,我们将在下面检查。

修复结果记录代码

请回想一下,记录发布的调查答案的代码位于survey/views.py中的display_active_survey函数中。此代码处理 GET 和 POST 请求。在 POST 的情况下,用于验证和记录提交值的代码是:

    if request.method == 'POST': 
        chosen_answers = [] 
        for qf in qforms: 
            if not qf.is_valid(): 
                logging.debug("form failed validation: %r", qf.errors)
                break; 
            chosen_answers.append(qf.cleaned_data['answer']) 
        else: 
            for answer in chosen_answers: 
                answer.votes += 1 
                answer.save(force_update=True) 
            return HttpResponseRedirect(reverse('survey_thanks', args=(survey.pk,))) 

当单个线程依次运行时,此代码运行良好并且行为正常。但是,如果多个线程(来自相同或不同的进程)同时运行,都尝试增加相同答案的votes值,那么这段代码很可能会丢失投票。问题在于检索当前的votes值,增加它并保存新值不是原子操作。而是在可能与另一个线程同时交错进行的三个不同步的步骤中完成。

考虑两个并发运行的线程,都试图记录对主键值为 1 的Answer进行投票。(为简单起见,我们假设调查中只有一个问题。)第一个线程进入这段代码,并通过for qf in qforms循环验证表单。在这个循环中,将从数据库中读取所选答案的当前votes值。假设第一个线程读取的主键为 1 的答案的votes值为 5。

现在,在第一个线程能够完成其工作并将votes字段的递增值保存到数据库中之前,第二个线程(通过抢占式调度或多处理器执行)进入for qf in qforms循环。这第二个线程正在处理的发布的表单数据也指定了对主键为 1 的答案的投票。这第二个线程还读取了该答案的votes值的当前值为 5。现在我们有一个问题:两个线程都意图递增相同答案的votes值,都读取了相同的现有值,并且都将递增该值并保存结果。两个线程一起只会导致votes计数增加一次:一个投票实际上将会丢失。

我们如何解决这个问题?对于在数据库中对现有字段的值进行递增(或执行其他算术操作)的简单情况,可以相对容易地避免这个问题。我们可以稍微改变for answer in chosen_answers循环中的代码,使用 Django 的F表达式来描述votes的期望结果,而不是给它一个明确的数值。更改后的代码如下:

            for answer in chosen_answers: 
                from django.db.models import F
                answer.votes = F('votes') + 1 
                answer.save(force_update=True) 

votes的值中使用F表达式将导致 Django 构造一个UPDATE SQL 语句的形式:

UPDATE `survey_answer` SET `answer` = Comedy, `question_id` = 1, `votes` = `survey_answer`.`votes` + 1 WHERE `survey_answer`.`id` = 1

这种UPDATE语句将确保递增操作是原子的责任推到数据库服务器上。通常情况下,这就是您希望放置这种责任的地方,因为这正是数据库服务器应该正确和高效地执行的操作。

如果我们现在保存这个代码更改,将所有的投票计数重置为 0,并重新运行siege测试,问题应该就解决了。但事实并非如此!在运行测试后再次检查votes值显示相同的行为:对于应该具有 75 值的两个答案中,一个值为 43,另一个值为 39。为什么代码更改没有解决问题呢?

在这种情况下的问题是,代码更改没有被正在运行的 Web 服务器进程看到。在 Apache 下运行时,对 Django 应用程序代码的更改不会自动导致处理请求的进程重新加载。因此,现有的运行进程将继续使用旧代码。在守护程序模式下,触摸 WSGI 脚本将在接收到下一个请求时触发重新加载。或者,重新启动 Apache 将确保加载新代码。正如我们将在本章后面看到的,还可以编写 WSGI 脚本,以便在检测到代码更改时自动重新启动守护进程。

目前,由于现有的 WSGI 脚本不监视源代码更改,并且我们正在守护程序模式下运行,触摸 WSGI 脚本是加载应用程序代码更改的最简单方法。如果我们这样做,再次使用管理应用程序将投票计数重置为 0,并再次尝试siege测试,我们会发现当测试完成时,两个选择的答案的投票确实是正确的值,即 75。

额外的负载测试说明

虽然我们已经成功地发现并修复了接收和记录调查结果的代码中的多线程问题,但我们还没有进行足够的测试,以确保应用程序的其余部分在典型的生产环境中能够正常运行。完整的测试将涉及对所有视图进行负载测试,无论是单独还是与其他视图组合,并确保服务器正确响应。构建这样的测试超出了本书的范围,但这里包括了一些关于这个过程的注释。

首先,对于我们发现的问题,我们很幸运地发现一个非常简单的代码更改,即使用F表达式,可以轻松地使数据库更新具有原子性。对于其他情况,Django 可能会或可能不会提供一个简单的 API 来帮助确保更新的原子性。例如,对于创建对象,Django 确实有一个原子的get_or_create函数。对于更复杂的情况,比如涉及更新不同对象中的多个值的情况,可能没有一个简单的 Django API 可用于确保原子性。

在这些情况下,有必要使用数据库支持来维护数据一致性。一些数据库提供事务来帮助处理这个问题,Django 反过来提供了一个 API,允许应用程序控制事务行为。其他数据库不支持事务,但提供更低级别的支持,比如锁定表的能力。Django 不提供表锁定的 API,但它允许应用程序构建和执行任意(原始)SQL,因此应用程序仍然可以使用这样的功能。使用原始 SQL API 的缺点是,应用程序通常无法在不同的数据库上移植。

在创建新应用程序时,应仔细考虑应用程序需要执行的数据库更新类型。如果可能的话,最好构造数据,以便所有更新都可以使用简单的原子 API。如果不可能,那么可能需要使用数据库事务或更低级别的锁定支持。可用的选项范围可能会受到使用的数据库的限制(如果它是预定的),同样,用于确保数据一致性的特定技术的选择可能会限制应用程序最终能够正确运行的数据库。

其次,虽然仔细考虑和编码将有助于确保不会出现多线程意外,就像我们发现的一个 bug,但显式测试这类问题是一个好主意。不幸的是,这并不是第一至第五章涵盖的测试工具所支持的,这些工具都专注于验证正确的单线程行为。因此,通常需要一些额外的工作来增加单元测试套件,以确保在生产环境中的正确行为(可能还有一定的性能水平)。个别开发人员通常不太可能经常运行这些额外的测试,但是有这些测试可用,并在将任何代码更新放入生产环境之前运行它们,将会在长远节省麻烦。

在开发过程中使用 Apache/mod_wsgi

正如本章中所描述的,从使用 Django 开发服务器切换到像 Apache 与mod_wsgi这样的生产服务器可能会遇到各种各样的问题。有些问题很容易克服,其他可能需要更多的努力。通常在开发周期的后期遇到这些困难是不方便的,因为通常很少有时间进行代码更改。使过渡更加顺利的一种方法是在开发过程中使用生产服务器配置。这是一个值得认真考虑的想法。

使用生产服务器(即带有mod_wsgi的 Apache)在开发过程中可能会遇到的一个可能的反对意见是,安装和正确配置 Apache 很困难。要求个别开发人员这样做对他们来说要求过多。然而,安装通常并不困难,而且今天大多数开发机器都可以轻松运行 Apache 而不会对其他活动造成任何性能影响。

配置 Apache 确实可能令人生畏,因为有许多配置指令和可选模块需要考虑。然而,并不需要成为 Apache 配置专家才能成功地使用默认配置并修改它以支持运行 Django 应用程序。结果可能不会经过精细调整以在重载情况下获得良好的性能,但在开发测试期间并不需要对配置进行调整。

使用 Apache 在开发过程中的第二个反对意见可能是相对不便,与开发服务器相比。开发服务器的控制台提供了一种轻松查看正在进行的操作的方式;需要查看 Apache 日志文件有点麻烦。这是真的,但只是一个非常小的不便。

更严重的不便之处是需要确保运行的 Web 服务器进程在开发过程中重新启动以获取代码更改。很容易习惯于开发服务器的自动重启,并忘记需要做一些事情(即使只是简单地触摸 WSGI 脚本文件)来确保 Web 服务器使用最新的代码。

然而,实际上可以设置 Django 项目的 WSGI 脚本以与开发服务器相同的方式运行。也就是说,WSGI 脚本可以启动一个代码监视线程,检查更改的 Python 源文件,并在必要时触发自动重新加载。有关此内容的详细信息可以在code.google.com/p/modwsgi/wiki/ReloadingSourceCode找到。使用该页面上包含的代码,带有mod_wsgi配置的 Apache 几乎可以像 Django 开发服务器一样方便。

开发服务器的一个便利之处尚未涵盖的是能够轻松在代码中设置断点并进入 Python 调试器。即使在 Apache 下运行时也是可能的,但是为此 Apache 需要在控制台会话中以特殊模式启动,以便它有一个控制台允许调试器与用户交互。如何做到这一点的详细信息可以在code.google.com/p/modwsgi/wiki/DebuggingTechniques找到。

总之,几乎可以从 Apache/mod_wsgi设置中获得 Django 开发服务器的所有便利。在开发过程中使用这样的配置可以帮助轻松过渡到生产环境,并且值得在开发机器上安装和配置 Apache 与mod_wsgi的早期额外努力。

总结

我们现在已经讨论完将 Django 应用程序转移到生产环境的过程。在本章中,我们:

  • 开发了一个配置来支持在 Apache 下使用mod_wsgi运行marketr项目。

  • 在将项目运行在 Apache 下遇到了一些问题。针对每个问题,我们都看到了如何诊断和解决。

  • 考虑在新环境中可以进行哪些额外的测试,考虑到其能够同时运行多个线程。

  • 为记录发布的调查响应的代码开发了一个测试,并观察到在生产环境下在重载情况下代码无法正确运行。

  • 修复了在结果记录代码中发现的问题,并讨论了可能需要修复更复杂的多线程问题的其他技术。

  • 讨论了在开发过程中使用 Apache 和mod_wsgi的可能性。这种配置几乎可以和 Django 开发服务器一样方便,而在开发过程中使用生产环境设置可以帮助减少最终转移到生产环境时遇到的问题数量。

posted @ 2024-05-20 16:47  绝不原创的飞龙  阅读(33)  评论(0编辑  收藏  举报