资讯动态

从零开发Django投票应用:模型、视图、模板与部署全解析

发布时间:2026/10/24 4:43:59 来源:尧图企业网站定制
最近我把经典的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.11Django版本选的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_length200) pub_date models.DateTimeField(date published, defaulttimezone.now) def __str__(self): return self.question_text class Choice(models.Model): question models.ForeignKey(Question, on_deletemodels.CASCADE) choice_text models.CharField(max_length200) votes models.IntegerField(default0) 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(default0)虽然投票数不可能是负数但在基础版本里没有必要引入数据库层负数校验。如果要做得更严谨用PositiveIntegerField也是可以的但要注意部分数据库对无符号整数支持不一致迁移时可能产生兼容问题。Choice里用ForeignKey(Question, on_deletemodels.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, nameindex), path(int:question_id/, views.detail, namedetail), path(int:question_id/results/, views.results, nameresults), path(int:question_id/vote/, views.vote, namevote), ]这里有几个细节值得说。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, pkquestion_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 %} lia 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 %} methodpost {% csrf_token %} {% for choice in question.choice_set.all %} input typeradio namechoice idchoice{{ forloop.counter }} value{{ choice.id }} label forchoice{{ forloop.counter }}{{ choice.choice_text }}/label {% endfor %} input typesubmit value投票 /formmethodpost表示这个表单提交会修改服务器数据。这里强制要求加{% 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, pkquestion_id) try: selected_choice question.choice_set.get(pkrequest.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, pkquestion_id) vote_key fvoted_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_textPython, votes0) 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中访问页面一直404URL正则不匹配或应用路由未包含进根路由检查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把这些环节的衔接做得非常规整只要项目结构一开始是干净的后面加功能就像往链路上挂新节点。做这个项目时我也走了不少弯路比如一门心思在视图里堆业务逻辑后来才慢慢养成“胖模型、瘦视图”的习惯能放在模型里的计算就放模型能放在模板里的展示就放模板。这套思路的回报是长期且稳定的。希望这篇分享能让你少踩几个坑剩下的实战体验还得你自己动手才能真正内化。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑