编写您的第一个Django应用, 第4部分

您之前看到了 教程 3 。让我们继续开发 Web 投票应用,通过学习编写 简单的表单处理来简化我们的代码。

编写一个简单的表单

让我们更新上一节的投票详情的模板( “polls/detail.html” ) ,添加一个 <form> 元素:

polls/templates/polls/detail.html
<h1>{{ question.question_text }}</h1>

{% if error_message %}<p><strong>{{ error_message }}</strong></p>{% endif %}

<form action="{% url 'polls:vote' question.id %}" method="post">
{% csrf_token %}
{% for choice in question.choice_set.all %}
    <input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}" />
    <label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br />
{% endfor %}
<input type="submit" value="Vote" />
</form>

快速介绍:

  • 上面的模板为每个问题显示了一个单选按钮。单选按钮的 value 是每个关联问题选项的 ID , 单选按钮的 name"choice" ,这就意味着,当用户选中其中一个按钮并提交表单时, 会发送 POST 数据 choice=# , # 即是选中的选项的 ID ,这是 HTML 表单最基本的概念。
  • 我们将表单的 action 设置为 {% url 'polls:vote' question.id %} ,同时我们设置了 method="post" ,设置 method="post" (而不是 method="get" )很重要,因为 我们提交这个表单的动作会改变服务端的数据。当您创建一个会影响服务端数据的表单时,记得使用 method="post" 。这个技巧不仅对 Django 而言,是一个 Web 开发的最佳实践。
  • forloop.counter 表示当前 for 标签循环了多少次。
  • 因为我们正在创建一个 POST 表单(可以改变服务端的数据),我们可能需要关心跨站请求策略( CSRF )。 谢天谢地,在这里您不需要太过担心,因为 Django 提供了一个非常易用的机制来保护它。简单来说, 所有的提交至系统内的 POST 表单只要带上 {% csrf_token %} 模板标签即可。

现在,让我们创建一个能处理所提交数据的 Django 视图。如果您记得,在 教程 3 中,我们已经创建了一个 URL 配置,请看下行:

polls/urls.py
url(r'^(?P<question_id>[0-9]+)/vote/$', views.vote, name='vote'),

我们同样也已经创建了一个没有任何作用的 vote() 函数。现在让我们真正实现它,添加下列内容 到 polls/views.py

polls/views.py
from django.shortcuts import get_object_or_404, render
from django.http import HttpResponseRedirect, HttpResponse
from django.urls import reverse

from .models import Choice, Question
# ...
def vote(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    try:
        selected_choice = question.choice_set.get(pk=request.POST['choice'])
    except (KeyError, Choice.DoesNotExist):
        # 重新打印“问题”投票表单
        return render(request, 'polls/detail.html', {
            'question': question,
            'error_message': "You didn't select a choice.",
        })
    else:
        selected_choice.votes += 1
        selected_choice.save()
        # 您在处理完 POST 数据后请使用 HttpResponseRedirect 跳转页面,通过这种方式
        # 可以防止用户不小心点击后退按钮后重复提交表单
        return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))

这些代码里有一些我们之前没介绍过的内容:

  • request.POST 是一个可以像字典那样使用的 对象,允许您通过键访问所提交的数据。在这里,通过 request.POST['choice'] 可以得到 所选中问题选项的 ID ,以字符串类型的形式。注意,通过 request.POST 得到的值始终是字符串。

    注意 Django 同样为访问 GET 数据提供了 request.GET 。 但是我们这里明确使用 request.POST ,确保 我们的值是通过 POST 请求传过来的。

  • 如果您没有在 POST 数据时提供 choice ,那么访问 request.POST['choice'] 会抛出 KeyError 异常。上面的代码会检查 KeyError 异常,并在 choice 没有提供时重新打印“问题”投票表单。

  • 当增加选项的计数时,我们的代码会返回 HttpResponseRedirect 而不是一个普通的 HttpResponseHttpResponseRedirect 携带一个参数:希望用户被跳转到的 URL (请看下面了解我们是如何构造这个 URL 的)

    正如上面的 Python 注释所标注的,您应该在成功处理 POST 数据后返回一个 HttpResponseRedirect ,这同样是一个 Web 开发的最佳实践而非仅 Django 才这么做。

  • 我们在 HttpResponseRedirect 的构造器中使用了 reverse() 函数。这个函数能帮助我们避免对一个视图函数的 URL 进行硬编码。调用时给定视图的名称,以及 URL 模式中所需的参数信息。在这里,配合 教程 3 中的 URL 设置, reverse() 调用会返回如下的字符串

    '/polls/3/results/'
    

    3 即是 question.id 的值。这个重定向 URL 会调用 'results' 视图来显示最终的页面。

正如 教程 3 中提到的, request 是一个 HttpRequest 对象。 想了解更多关于 HttpRequest 的信息的,请看 :doc:` 请求和 响应文档 </ref/request-response>` 。

当用户对某个“问题”投了票, vote() 视图会跳转至问题的统计结果页面,让我们来编写这个视图:

polls/views.py
from django.shortcuts import get_object_or_404, render


def results(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    return render(request, 'polls/results.html', {'question': question})

这几乎和 教程 3 中的 detail() 视图一模一样,唯一的区别是 模板名称。我们稍后会解决这个冗余的问题。

现在,让我们创建一个 polls/results.html 模版:

polls/templates/polls/results.html
<h1>{{ question.question_text }}</h1>

<ul>
{% for choice in question.choice_set.all %}
    <li>{{ choice.choice_text }} -- {{ choice.votes }} vote{{ choice.votes|pluralize }}</li>
{% endfor %}
</ul>

<a href="{% url 'polls:detail' question.id %}">Vote again?</a>

好了,在浏览器中打开 /polls/1/ 试试投下票。您会看到一个显示着最新的投票计数的页面。 如果您不进行任何投票就提交表单,您会看到错误信息。

注解

我们的 vote() 视图有一个小问题。它首先取出数据库中的 selected_choice 对象, 接着计算得出新的 votes 值,最后将其存回数据库。如果两位用户 ** 几乎同一时间 ** 访问 您的站点并对同一个问题的相同选项做了投票,可能会发生错误:计数只加 1 。比方说取出 votes 的值是 42 ,在同一时刻,处理两位用户的请求都做出了 42 + 1 = 43 的计算,最后存入数据库 的都是 43 这个值,但其实 44 才是我们期望的值。

这个问题被称为 * 竞态条件 * ,如果您感兴趣,可以通过阅读 Avoiding race conditions using F() 来了解如何避免这类问题。

使用通用视图:精简代码

教程 3 中的 detail()results() 视图都非常简单 ——以及,正如上面提到的,冗余了,同样的情况还有 index() 视图函数。

这些视图反映了 Web 开发过程中的一种常见的例子:根据 URL 中的参数向数据库取出数据,加载一个模板 渲染并返回响应。因为这种情况实在太常见了,因此 Django 提供了快捷方式——一个被称为“通用视图”的机制。

通用视图将公共模式进行抽象,使得您无须编写 Python 代码就能实现应用所需的功能。

让我们使用通用视图机制改写投票应用,您会看到我们将删除一堆之前写过的代码。我们需要分几步来完成这个转换:

  1. 改写 URL 配置
  2. 删除部分旧的,不需要的视图
  3. 引入 Django 通用视图

继续阅读以了解细节:

为什么要重新组织代码?

一般来说,当我们编写 Django 应用时,您会评估当前问题是否使用通用视图更合适,接着您会在一开始 就使用它,而不是像现在这样半路重构代码。但是本教程到现在为止一直“故意地”使用一种最“笨”的办法指导 您编写视图,是因为我们希望专注核心概念。

您总应该在拿起计算器使用它前先了解基本的加减乘除四则运算。

改写 URL 配置

首先,打开 polls/urls.py URL 配置并修改它:

polls/urls.py
from django.conf.urls import url

from . import views

app_name = 'polls'
urlpatterns = [
    url(r'^$', views.IndexView.as_view(), name='index'),
    url(r'^(?P<pk>[0-9]+)/$', views.DetailView.as_view(), name='detail'),
    url(r'^(?P<pk>[0-9]+)/results/$', views.ResultsView.as_view(), name='results'),
    url(r'^(?P<question_id>[0-9]+)/vote/$', views.vote, name='vote'),
]

注意第二个和第三个的正则匹配模式已经从 <question_id> 修改为了 <pk>

修改视图

接着,让我们来删除旧的 indexdetail 、 和 results 视图,并使用 Django 的 通用视图进行改写。要这么做,先打开 polls/views.py 文件,并作下面的修改:

polls/views.py
from django.shortcuts import get_object_or_404, render
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views import generic

from .models import Choice, Question


class IndexView(generic.ListView):
    template_name = 'polls/index.html'
    context_object_name = 'latest_question_list'

    def get_queryset(self):
        """Return the last five published questions."""
        return Question.objects.order_by('-pub_date')[:5]


class DetailView(generic.DetailView):
    model = Question
    template_name = 'polls/detail.html'


class ResultsView(generic.DetailView):
    model = Question
    template_name = 'polls/results.html'


def vote(request, question_id):
    ... # 和之前一样,不需要修改

这里我们使用了两种通用视图 ListViewDetailView 。这两种视图分别对应 “显示一系列的对象的列表”和“显示一个特定类型对象的详情”这两种抽象概念。

  • 每种通用视图都需要知道它交互的模型时什么,通过 model 属性指定。
  • DetailView 通用视图期望从 URL 中 获取到以 "pk" 命名的主键值,为此我们把 question_id 修改为 pk

默认情况下, DetailView 通用视图使用 文件名是 <app name>/<model name>_detail.html 的模板。在我们的例子中,它 会使用 "polls/question_detail.html" 。属性 template_name 是为了 告诉 Django 使用指定的文件名而不是默认取的那个值。我们同样为 results 视图指定 了 template_name —— 确保结果视图和详情视图具备不同的渲染结果,虽然它们背后都是 class:~django.views.generic.detail.DetailView 的子类。

类似的, ListView 通用视图默认会取 <app name>/<model name>_list.html 作为模板名;我们使用 template_name 告诉 ListView 使用已有的 "polls/index.html"

在过去的教程章节中,模板里使用了 questionlatest_question_list 上下文变量。 对于 DetailView 来说, question 变量已经自动提供了——因为我们使用了 Django 模型 ( Question ), Django 会自动推导出最合适的上下文变量名 question 。然而,对于 ListView 而言,自动生成的变量名是 question_list 。为此我们通过 context_object_name 属性来覆盖这一设定。设置 context_object_name 属性,指定我们希望使用的``latest_question_list`` 值。当然您也可以通过修改模板中的对应值来达到一样的目的,但是我们选择更简单的方式来完成这件事。

运行服务器,您就会看到最新的使用通用视图实现的投票应用。

对通用视图更详细的细节,请阅读 通用视图文档

当您熟悉了表单和通用视图后,请阅读 本教程的第5部分 来学习如何测试 您的投票应用。