最近我把经典的Django投票应用从零开始完整做了一遍。这个项目在教程里通常被当成Django的“Hello World”,但真正动手之后我发现,它几乎把Django最核心的机制都串起来了:模型设计、数据库迁移、路由分发、视图函数、模板引擎、表单提交、Admin后台,甚至测试。如果你能独立把这个项目的每个文件讲清楚,基本就摸到了Django的门槛。
这个应用能做的事情非常具体:管理员在后台创建投票问题和多个选项,前端页面展示问题与可选项,用户选择其中一个选项提交,随后看到投票结果和票数统计。听起来很简单,但把“投票”这个业务抽象成数据库里的数据关系,再通过HTTP请求把数据完整流转起来,并没有想象中那么轻松。适合谁来参考?刚学完Python基础准备入门Web框架的开发者,想系统梳理Django MTV模式的人,或者正在用官方教程练手却卡在某一步的同学。
下面按我实际动手时的推进顺序来聊,每个环节都会讲清楚“为什么这么做”以及我踩过的坑。
1. 项目整体设计与选型思路
1.1 拆解投票业务背后的数据关系
先把业务需求拆开看。一个最小可用的投票功能至少有三种页面:问题列表页、问题详情页(带投票表单)、结果页。支撑这三类页面的数据关系其实很明确:
- 一个问题(Question)包含一段问题文本和发布时间。
- 一个问题下挂多个选项(Choice),每个选项有文字和得票数。
- 每个选项只属于某一个问题,“问题 — 选项”是一对多关系。
这个问题关系正是数据库里最典型的外键场景。用生活化的类比来说,问题就是一个订单,选项就是订单里的商品明细。商品明细不能脱离订单独立存在,所以Choice要用ForeignKey指向Question。存放票数的字段也不是随便选一个整数类型就完事,还要考虑并发场景下票数累加是否安全,这一点我在后面投票流程部分会专门展开。
我在动手前还列了一个简单的需求清单,建议你也这么干:
| 功能模块 | 核心任务 | 技术落点 |
|---|---|---|
| 问题展示 | 列表展示最近发布的问题 | ORM查询 + 模板循环 |
| 投票操作 | 用户选择选项并提交 | POST表单 + CSRF校验 |
| 结果统计 | 展示选项票数或百分比 | 模型聚合 + 模板渲染 |
| 数据维护 | 后台增删改查题目选项 | Django Admin |
| 投票约束 | 防止同一浏览器重复投票 | Session记录 |
1.2 为什么这里选择Django而不是其他框架
说实话,如果只是练接口,Flask或FastAPI也能做,而且代码量更少。但我最终还是用Django,原因是这个项目要覆盖“完整Web应用”的各个环节:有后台管理、有数据库迁移、有表单处理、有模板渲染。用Flask的话,ORM、Admin、Migrate这几块都要自己东拼西凑。而Django自带全套基建,尤其是后台管理界面,几乎是零成本就给运营人员提供了一套数据维护工具。
Django的MTV模式也适合这种数据结构清晰的项目。模型Model管数据,模板Template管展示,视图View管业务逻辑。你可以把整个项目理解成一家餐厅:模型是仓库里的菜谱,视图是后厨做菜的流程,模板是端上桌时摆盘的样式。三者边界清楚了,后面想加功能或者改页面都很快。
2. 环境准备与项目结构:先把地基打牢
2.1 创建项目与应用的命令细节
开发环境我建议用虚拟环境,这是避免依赖冲突最省心的方式。Python版本我用的3.11,Django版本选的4.2 LTS。创建流程就是两行命令:
python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install django django-admin startproject poll_project python manage.py startapp polls这里有一个新手最容易绕晕的概念:项目(Project)和应用(App)的区别。项目是整个站点,它是一个容器;应用是站点里的独立功能模块,比如这里是投票模块。以后你加用户系统、加评论系统,都是往这个项目里继续挂新应用。用一个稍微夸张点的类比:项目是楼盘,应用是楼盘里不同户型的样板间,样板间之间共用水电管道(配置文件),但功能各自自治。
创建完应用之后,一定别忘了在settings.py的INSTALLED_APPS里注册:
INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "polls.apps.PollsConfig", ]否则Django完全不认识你新建的应用,后面执行迁移和加载模板都会出各种奇怪的问题。
2.2 目录结构与职责对照表
项目建好后,我先把每个关键文件的作用理了一遍。看清楚了这张表,整个项目就不再是黑盒:
| 文件路径 | 职责定位 |
|---|---|
manage.py | 项目的命令行入口,所有管理操作都通过它执行 |
poll_project/settings.py | 全局配置:数据库、时区、语言、静态文件、应用注册 |
poll_project/urls.py | 根路由,负责把不同路径分发到各个应用的子路由 |
polls/models.py | 定义数据模型,也就是数据库表结构的设计图 |
polls/views.py | 业务逻辑,接收请求并返回响应 |
polls/urls.py | 投票应用的路由表,需自行创建 |
polls/admin.py | 后台管理界面的注册与配置 |
polls/templates/ | 存放HTML模板文件 |
这里有个关键动作:polls/urls.py默认是没有的,你必须手动创建,然后在项目的根urls.py里通过include把它挂载上去:
from django.contrib import admin from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), path("polls/", include("polls.urls")), ]这就是Django的路由分发机制,根路由只做粗粒度的转发,具体URL长什么样由每个应用自己定义。
3. 模型设计与数据库迁移:一张表看懂数据关系
3.1 模型字段到底该怎么选
模型是整个应用的基石。我最终的models.py是这样写的:
from django.db import models from django.utils import timezone class Question(models.Model): question_text = models.CharField(max_length=200) pub_date = models.DateTimeField("date published", default=timezone.now) def __str__(self): return self.question_text class Choice(models.Model): question = models.ForeignKey(Question, on_delete=models.CASCADE) choice_text = models.CharField(max_length=200) votes = models.IntegerField(default=0) def __str__(self): return self.choice_text几个字段选择背后有讲究。question_text用CharField而不是TextField,因为题目通常是短文本,加上max_length约束可以避免用户输入超长内容。pub_date用DateTimeField,并且默认值传了一个timezone.now而不是 Python 原生的datetime.now。这是因为Django开启USE_TZ = True后,所有时间都以UTC时区存储,如果用datetime.now会拿到本地时间,容易在展示时出现时间错乱。
votes我用的是IntegerField(default=0),虽然投票数不可能是负数,但在基础版本里没有必要引入数据库层负数校验。如果要做得更严谨,用PositiveIntegerField也是可以的,但要注意部分数据库对无符号整数支持不一致,迁移时可能产生兼容问题。Choice里用ForeignKey(Question, on_delete=models.CASCADE),表示问题被删除时,它名下的所有选项一并删除,避免数据库里出现悬空的选项记录。
3.2 迁移文件是怎么生成的
写完模型之后,执行命令:
python manage.py makemigrations polls python manage.py migrate很多新手只知道照做,不关心背后发生了什么。makemigrations其实是把你对模型的改动生成一份“变更记录”,这个记录存放在polls/migrations/目录下,本质是一个Python文件。而migrate则是把这份变更记录真正同步到数据库。我习惯把迁移文件类比成Git提交:它记录的是“从上一个版本到当前版本数据库结构改了哪些”,而不是当前表的完整状态。这也是为什么你可以通过python manage.py sqlmigrate polls 0001直接查看要执行的SQL语句,而不会影响数据库。
这里最常见的坑就是:改了模型却忘记执行迁移。我见过太多人改了models.py,然后直接跑服务器,结果报no such column错误。记一条铁律:每一次修改模型之后,先makemigrations,再migrate,两只命令连成肌肉记忆。
4. 路由、视图与模板:请求流转的完整链路
4.1 路由配置与动态参数
投票应用的路由表我是这样配的:
from django.urls import path from . import views app_name = "polls" urlpatterns = [ path("", views.index, name="index"), path("<int:question_id>/", views.detail, name="detail"), path("<int:question_id>/results/", views.results, name="results"), path("<int:question_id>/vote/", views.vote, name="vote"), ]这里有几个细节值得说。<int:question_id>是Django的路径转换器,它把URL里这一段强制限制为整数类型。这么做的好处是,访问/polls/abc/的时候,Django会直接返回404,而不是进入视图再做类型转换和异常处理。很多初学者喜欢在视图里写int(request.GET.get(...))然后包一层try,其实在路由层就做掉能省很多事。
name参数的意义不只是给URL起个名字。模板里用{% url 'polls:detail' question.id %}可以反向解析出当前URL,这样以后调整URL路径时,不需要到模板和视图里逐个改字符串。app_name = "polls"是用来区分多个应用里同名视图的命名空间,如果没有它,模板里写{% url 'detail' %}时Django会分不清到底是哪个应用的detail页面。
4.2 视图函数的三层进化
视图是请求进入后的业务处理层。我建议刚上手时按三层递进的方式写,便于理解:
# 第一层:直接返回文本 def index(request): return HttpResponse("Hello, polls index.")# 第二层:查询数据并渲染模板 from django.shortcuts import render from .models import Question def index(request): latest_question_list = Question.objects.order_by("-pub_date")[:5] return render(request, "polls/index.html", { "latest_question_list": latest_question_list, })# 第三层:加入404兜底 from django.shortcuts import get_object_or_404 def detail(request, question_id): question = get_object_or_404(Question, pk=question_id) return render(request, "polls/detail.html", {"question": question})get_object_or_404内部其实就是try: return Question.objects.get(...)然后捕获DoesNotExist并抛出Http404。为什么要用它而不是手动try/except?因为这类“取单条记录,不存在就404”的操作在Web开发里太常见了,Django替你把样板代码收了起来。用它的前提是你真的希望不存在时返回404页面,如果有些接口需要返回空对象或者空列表,还是要手动处理。
4.3 模板里只放展示逻辑
模板文件放在应用下的templates/polls/目录里,注意目录层级不能省。polls/index.html内容如下:
{% if latest_question_list %} <ul> {% for question in latest_question_list %} <li><a href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a></li> {% endfor %} </ul> {% else %} <p>还没有任何投票问题。</p> {% endif %}模板语言里最常用的就是{{ 变量 }}和{% 标签 %}。我重点提醒一点:模板不要放复杂业务计算。比如结果页要显示百分比,有的人会直接在模板里写一大段乘除,这非常难维护。正确做法是在视图或模型方法里把百分比计算好,再传递到模板。模板只负责循环、判断、展示。
5. 投票核心流程:从表单提交到防重复投票
5.1 表单与CSRF保护
投票页面的表单是整套项目里最关键的交互入口:
<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> {% endfor %} <input type="submit" value="投票"> </form>method="post"表示这个表单提交会修改服务器数据。这里强制要求加{% csrf_token %},这是Django内置的跨站请求伪造防护机制。简单理解:服务器给每个用户会话下发一个随机令牌,提交请求时令牌会一并返回,服务器校验令牌有效才接受请求。如果不加这个标签,提交时Django会直接报CSRF verification failed,这不是故障,而是安全防护在起作用。
5.2 投票视图与并发安全
对应表单的vote视图是这样的:
from django.db.models import F from django.http import HttpResponseRedirect from django.urls import reverse from django.shortcuts import get_object_or_404, render 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": "你没有选择任何选项。", }) else: selected_choice.votes = F("votes") + 1 selected_choice.save() return HttpResponseRedirect(reverse("polls:results", args=(question.id,)))这里我特意把selected_choice.votes = F("votes") + 1而不是selected_choice.votes += 1,是有原因的。后者在并发场景下存在更新丢失风险:两个人同时读取到票数是10,都加1,最后可能只变成11而不是12。F表达式会把加1操作放到数据库层面执行,让数据库自己处理原子更新。这个细节在小项目里可能体现不出来,但一旦真正上线,并发投票时就会体现出差别。
用户提交后没有直接渲染结果页,而是先redirect到结果页,这种模式叫POST/Redirect/GET。核心目的是防止刷新浏览器时重复提交表单。如果投票成功后直接render结果页,用户按一下F5,浏览器会重新提交POST请求,票数就被重复累加了。跳转后刷新的是GET请求,天然是安全的幂等操作。
5.3 用Session做轻量级防重复投票
如果你不想引入完整的用户登录系统,又想避免同一个人反复刷票,最简单的做法就是利用会话记录投票状态:
def vote(request, question_id): question = get_object_or_404(Question, pk=question_id) vote_key = f"voted_question_{question.id}" if request.session.get(vote_key): return HttpResponseRedirect(reverse("polls:results", args=(question.id,))) # 正常处理选项累加... request.session[vote_key] = True return HttpResponseRedirect(reverse("polls:results", args=(question.id,)))这种方式通过Cookie关联的Session ID来标识浏览器,能做到“一个浏览器只能投一次票”。它不是绝对安全,因为清掉Cookie又可以再投,但在没有账号体系的场景下是最低成本的有效方案。我的建议是:先做好基础版本,再叠加这个防重复机制,不要一开始就加入用户登录,把学习焦点分散了。
6. Admin后台与测试:正式项目必备的两把刀
6.1 注册模型到后台
投票应用的管理端是Django自带的一大亮点。最简单的注册只有一行:
from django.contrib import admin from .models import Question, Choice admin.site.register(Question) admin.site.register(Choice)此时在后台里可以看到两张独立的表,但选项和问题之间没有联动关系,维护体验很割裂。稍微优化一下,用内联方式把选项嵌入问题的编辑页:
from django.contrib import admin from .models import Choice, Question class ChoiceInline(admin.TabularInline): model = Choice extra = 2 @admin.register(Question) class QuestionAdmin(admin.ModelAdmin): list_display = ("question_text", "pub_date") list_filter = ["pub_date"] search_fields = ["question_text"] inlines = [ChoiceInline] @admin.register(Choice) class ChoiceAdmin(admin.ModelAdmin): list_display = ("choice_text", "question", "votes")配置list_display可以自定义列表页展示哪些字段,list_filter会在右侧生成按时间筛选的控件,search_fields开启搜索框。这三样配置在数据量稍大时能明显提升后台操作效率。inlines则解决了我刚才说的问题:编辑问题的时候,下方直接内联出可编辑选项,不用来回切换页面。
6.2 写一点测试,心里更有底
Django框架自带自动化测试体系,不需要额外装pytest就能跑。我刚做完这个项目时写了几个核心用例:
from django.test import TestCase from django.urls import reverse from .models import Question class QuestionVoteTests(TestCase): def setUp(self): self.question = Question.objects.create(question_text="你更喜欢哪个语言?") self.choice = self.question.choice_set.create(choice_text="Python", votes=0) def test_vote_increments_choice_votes(self): self.client.post(reverse("polls:vote", args=(self.question.id,)), {"choice": self.choice.id}) self.choice.refresh_from_db() self.assertEqual(self.choice.votes, 1) def test_vote_without_choice_shows_error(self): response = self.client.post(reverse("polls:vote", args=(self.question.id,)), {}) self.assertContains(response, "你没有选择任何选项")refresh_from_db()是新手经常忽视的细节:Django的 ORM 对象在数据库发生变更后不会自动同步,必须重新刷新才能拿到最新值。测试的价值不只是验证正确性,更是给你后续改代码兜底。把测试写完再跑python manage.py test,一条命令帮我揪出过不少低级错误。
7. 常见问题与排查技巧实录
7.1 问题速查表
做完整套流程后,我把几个高频问题整理成了一线排查表,遇到问题直接按表查:
| 现象 | 常见原因 | 解决方式 |
|---|---|---|
makemigrations后提示No changes detected | 应用未注册到INSTALLED_APPS,或模型未改动 | 检查应用是否在INSTALLED_APPS中 |
| 访问页面一直404 | URL正则不匹配或应用路由未包含进根路由 | 检查urls.py中的路径与命名空间 |
| 页面显示但CSS完全不生效 | 开发环境下静态文件配置缺失 | 确认STATIC_URL及模板正确加载静态文件 |
| 后台时间与本地时间不一致 | USE_TZ与TIME_ZONE配置冲突 | 查看settings.py的时区设置 |
| 表单提交报CSRF验证失败 | 模板中未添加{% csrf_token %} | 在POST表单内加入CSRF令牌 |
DoesNotExist异常一堆 | 用get()但未做异常捕获 | 改用get_object_or_404或捕获DoesNotExist |
| 开发服务器显示端口被占用 | 上一个进程没有完全退出 | 找到占用进程并结束,或指定其他端口运行 |
7.2 我反复踩过的坑
第一个坑是STATIC_URL相关的。很多人做投票应用时想加一点CSS装饰页面,结果发现样式总是加载不出来。原因是开发环境下Django的静态文件服务默认没开启,需要在settings.py里设置STATIC_URL = "/static/",并在模板里用{% load static %}标签引用静态文件。这个在部署到生产环境时还要换成collectstatic收集统一目录,又是另一套流程。
第二个坑是路由顺序。Django匹配URL时是从上往下按顺序执行的,如果你在urlpatterns里写了path("", views.index)又写了path("<int:question_id>/", views.detail),两者不会冲突。但如果把<int:question_id>/和vote/之类的静态路径顺序写反,比如先写了<int:question_id>/再写<int:question_id>/vote/,后者匹配时其实也能正确匹配,因为路由带上了完整路径。真正的坑是<slug:xxx>/这类转换器过宽,会把本应落到其他路由的请求截胡。所以规范做法是把静态路径写在动态路径之前,或者用URL路径的精确层级区隔开。
第三个坑是关于refresh_from_db()之类数据的“新鲜度”。我在测试中遇到过明明数据库里票数已经变了,self.choice.votes还是旧值,一度以为逻辑写错。后来才意识到同一个ORM对象在内存里缓存了旧数据。在开发调试时,如果发现数据变化不符合预期,先检查你是从request.POST拿的新对象,还是之前查出来一直没刷新的旧对象。
8. 这个项目的后续扩展方向
如果已经能跑通投票应用的完整流程,下一步我建议按这个顺序加东西:
- 给
Question增加was_published_recently之类的辅助方法,把与展示相关的判断逻辑收敛到模型层。 - 引入用户认证体系,把投票行为关联到具体用户,并在模型层增加唯一约束来真正防重复投票。
- 把结果页的“票数列表”升级成带进度条或图表的展示,百分比计算放在视图层完成。
- 把前后端分离,用Django REST framework 暴露
Question和Choice的API,前端单独写页面调用。 - 部署上线时,用
gunicorn托管Django服务,Nginx处理静态文件与反向代理,并配置好collectstatic。
走通这些扩展之后,你会发现原来的投票应用像是在骨架模型上不断填充肌肉。数据模型是一切的起点,模型设计好了,后面不管是加接口还是换页面都顺很多。
最后说点个人体会。我做这个投票应用最大的收获不是学会了几个Django命令,而是意识到Web开发的核心是“数据流”:用户请求进入路由,路由找到视图,视图读写模型,模型映射数据库,再通过模板把结果渲染回浏览器。Django把这些环节的衔接做得非常规整,只要项目结构一开始是干净的,后面加功能就像往链路上挂新节点。做这个项目时我也走了不少弯路,比如一门心思在视图里堆业务逻辑,后来才慢慢养成“胖模型、瘦视图”的习惯,能放在模型里的计算就放模型,能放在模板里的展示就放模板。这套思路的回报是长期且稳定的。希望这篇分享能让你少踩几个坑,剩下的实战体验,还得你自己动手才能真正内化。