
做Django开发这么久我一直觉得“投票应用”是最适合新手完整跑通的第一个真实项目。别看它就是一个“看问题、选选项、看结果”的小玩具它把Model和数据库打交道的方式、View里怎么接请求、Template怎么渲染页面、后台怎么管理数据这条路完整走一遍比背一百个知识点都管用。这篇文章我就拿这个投票应用当例子从零开始一步步搭顺便把我这些年踩过的坑和习惯做法都写进去。不管你是刚装好Python、还没写过一行Django代码的纯新手还是学完教程但对路由、模板、ORM之间的关系还有点模糊照着这套流程走一遍基本能把Django的MTV模式整个串起来。1. 项目准备与基础环境搭建1.1 Python和Django版本怎么选很多新手上来就装最新版Django其实没必要。我个人的习惯是优先选LTS版本Django的LTS是三年维护周期社区资料多第三方插件兼容性好遇到问题一搜基本都有答案。目前比较稳妥的选择是Django 4.2 LTSPython用3.10或3.11都可以这两个版本在Windows、macOS、Linux上都有预编译包装依赖时不会因为编译问题卡住。如果已经装了更高版本的Python比如3.12也别慌Django 4.2官方支持到Python 3.12正常使用没问题。但你要是用一些依赖C扩展的数据库驱动比如某些版本的MySQL驱动就得留意一下是否支持你的Python版本。投票应用这种规模的项目用SQLite数据库就够了完全没必要为了“练手”去专门配MySQLSQLite在Django里的配置是零成本的后面我会细说。提示项目名尽量不要叫django、test之类的名字容易和Django内部模块产生冲突。用mysite、myproject这类都行。1.2 虚拟环境与项目创建先建一个干净的虚拟环境避免污染全局Python环境。我用的是Python自带的venv不需要额外装VirtualenvWrapper那些东西python -m venv myvenv # Windows myvenv\Scripts\activate # macOS/Linux source myvenv/bin/activate看到命令行前面出现(myvenv)就算激活成功。然后安装Django并创建项目pip install django django-admin startproject mysite cd mysite python manage.py startapp polls这里稍微解释一下两个容易混的概念startproject生成的是整个站点项目里面是全局配置startapp生成的是一个功能模块后面写的模型、视图、模板都放在这个app目录里。投票应用的业务逻辑应该全部塞进polls这个app里而不是写在项目根目录。这样做的最大好处是一个项目里可以挂多个app各自职责清晰以后想复用某个功能模块直接把整个app复制走就行。创建完polls之后先打开mysite/settings.py在INSTALLED_APPS列表里把polls注册进去这是新手最容易漏掉的一步INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, polls, # 新增这一行 ]不注册app后面执行迁移的时候Django根本找不到你的模型甚至会直接报“App polls could not be found”之类的错。1.3 settings.py里必须调整的几个地方初始化项目里的settings.py有几个默认值是给美国开发者准备的我们得改一下LANGUAGE_CODE zh-hans TIME_ZONE Asia/ShanghaiLANGUAGE_CODE影响后台admin界面的语言改成zh-hans后管理界面直接变中文省得自己翻译。TIME_ZONE影响模型里DateTimeField字段的存储和展示如果不改成Asia/Shanghai你存进去的时间会和北京时间差8个小时。数据库配置这块不用动默认的sqlite3配置就已经能跑得很舒服了DATABASES { default: { ENGINE: django.db.backends.sqlite3, NAME: BASE_DIR / db.sqlite3, } }为什么不建议新手一上来就换MySQL因为SQLite是文件型数据库整个库就是一个db.sqlite3文件没有独立的数据库服务进程开发和调试时特别方便。等你的项目真的要上线、需要处理高并发写入时再考虑切到PostgreSQL也不迟。投票应用这种读多写少的场景SQLite在本地开发阶段完全够用。2. 数据模型设计投票业务的核心数据结构2.1 业务拆解与模型类设计投票应用的核心业务说白了就两件事一个投票问题Question和这个投票下的若干个选项Choice。用户进入页面看到问题列表点进某个问题选择一个选项投票最后看到各选项的票数结果。这个业务映射到Django的模型上最少需要两张表from django.db import models class Question(models.Model): question_text models.CharField(max_length200) pub_date models.DateTimeField(date published) 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我在写这个模型时有三个习惯第一每个模型必须定义__str__方法。不定义的话你在后台看到的是Question object (1)这种天书一样的内容而定义了之后后台列表里直接显示你想要的文本排查数据时真的很省心。第二外键on_delete参数一定要写。Django 2.0以后外键强制要求on_delete你写漏了会给你报错。CASCADE的含义是“问题被删除时属于它的所有选项也被自动删除”这符合投票业务的预期不可能问题没了、选项还在。第三votes字段用default0而不是直接让它为空。这样当新建一个选项时票数自动从0开始保证了数据完整性。如果你不想让票数为负可以考虑用PositiveIntegerField但为了简单演示IntegerField加默认值就够用了。注意字段名里如果带date之类的单词也没问题但不建议用Django内部的保留字遇到奇怪的报错先想想是不是字段名起得太随意了。2.2 一次完整的迁移流程与背后的原理模型写完之后接下来做的事是整个Django里最“魔法”的一步——迁移。先执行python manage.py makemigrations polls这会生成一个polls/migrations/0001_initial.py文件。你可以打开这个文件看看里面是用Python描述的表结构相当于“迁移剧本”。然后再执行python manage.py migrate这条命令才是真正把“剧本”应用到数据库生成实际的表和字段。很多新手搞不清makemigrations和migrate的区别我打一个比方makemigrations是“起草合同”只生成一个文件不动数据库migrate是“签署合同”按文件内容真正去执行修改数据库结构。以后你想改模型比如给Question增加一个author字段流程永远是改模型类执行makemigrations执行migrate三步缺一不可。如果想知道某次迁移到底对数据库做了什么事Django还有个实用命令python manage.py sqlmigrate polls 0001它会打印出对应的SQL语句比如建表的CREATE TABLE、加索引的CREATE INDEX。我强烈建议新手跑一次这条命令看看Django是如何把你写的Python类翻译成SQL的。看清楚这段之后你对ORM的理解会有一个质的提升。3. 视图、路由与模板让投票业务跑起来3.1 视图函数怎么设计才清晰投票应用至少需要四个页面问题列表页index、问题详情页detail、投票结果页results、处理投票动作的后端逻辑vote。对应的视图函数我都放在polls/views.py里from django.shortcuts import get_object_or_404, render from django.http import HttpResponseRedirect from django.urls import reverse from django.utils import timezone from .models import Choice, Question def index(request): latest_question_list Question.objects.order_by(-pub_date)[:5] context {latest_question_list: latest_question_list} return render(request, polls/index.html, context) def detail(request, question_id): question get_object_or_404(Question, pkquestion_id) return render(request, polls/detail.html, {question: question}) def results(request, question_id): question get_object_or_404(Question, pkquestion_id) return render(request, polls/results.html, {question: 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 1 selected_choice.save() return HttpResponseRedirect(reverse(polls:results, args(question.id,)))里面有几个关键点要展开说order_by(-pub_date)的负号表示倒序也就是说按发布日期从新到旧排列中间[:5]是切片只拿最近5条数据。这种“链式调用”正是ORM的优雅之处查询条件一层层叠加逻辑清晰。get_object_or_404是一个语法糖它的逻辑是如果能查到就用这条数据查不到就直接抛404错误省得你写一遍try-except和Http404。这个函数太常用了我几乎每个详情页都这么用。request.POST[choice]是从POST表单里取字段值。这里有个细节如果用户没选任何选项就提交表单request.POST[choice]会抛出KeyError所以要用try-except抓住。如果你用request.POST.get(choice)这个方法来取值它会返回None但你没法区分“用户没选”和“选择的选项id是None”这两种情况所以我还是倾向于结合Choice.DoesNotExist一起判断。投票逻辑最后用redirect跳转而不是直接渲染结果页这背后藏着一个Web开发的经典原则——PRG模式Post/Redirect/Get。如果用户提交POST后我们直接返回结果页面用户按一下F5刷新浏览器会“重新提交表单”导致票数加两次。而重定向之后刷新操作只会重新GET结果页不会再重复提交。这个细节面试也常问值得记一下。3.2 URL路由配置从项目级到应用级Django的路由分两层项目级mysite/urls.py和应用级polls/urls.py。项目级文件负责把/polls/开头的请求转发给polls应用处理from django.contrib import admin from django.urls import include, path urlpatterns [ path(admin/, admin.site.urls), path(polls/, include(polls.urls)), ]然后新建polls/urls.pyfrom 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/3/vote/转换器会把question_id3传给vote视图。用int而不是字符串是因为它天然拒绝非数字的输入/polls/abc/vote/直接返回404不会进入视图函数省了一道类型校验。app_name polls这句很多人会漏。它的作用是在多个应用都有index视图时可以用polls:index来区分避免路由名字冲突。我之前在一个项目里没写这个结果两个app的URL都叫index模板里{% url index %}直接解析错乱。后来养成了习惯每个app的urls.py第一句就是app_name。3.3 模板目录结构与渲染流程Django默认在每个app下找templates目录。我们建polls/templates/polls/index.html目录层级必须是templates下面再套一层polls这样可以避免多个app的模板文件重名时互相覆盖。Django的模板查找器会按INSTALLED_APPS的顺序逐个app找templates目录如果你的模板不套polls这个子目录以后项目里有个newsapp也有index.html两个就会打架。index.html的写法{% load static %} !DOCTYPE html html head meta charsetUTF-8 title投票应用/title link relstylesheet href{% static polls/style.css %} /head body h1最新投票/h1 {% 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 %} /body /html这里{% url polls:detail question.id %}会按路由名反推出URL好处是以后路由地址变了模板不用改。{% static polls/style.css %}对应的是静态文件目录下的polls/style.css。detail.html里核心是渲染表单h1{{ question.question_text }}/h1 {% if error_message %}pstrong{{ error_message }}/strong/p{% endif %} 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 }}/labelbr {% endfor %} input typesubmit value投票 /formquestion.choice_set.all是通过外键反向查询这个问题的全部选项。外键字段question是在Choice模型里声明的Django自动为Question生成了choice_set这个反向管理器。如果你觉得choice_set这个名字不够语义化可以在外键里加related_namechoices然后就能用question.choices.all()了。3.4 静态文件加载不出来的经典坑热搜词里有一条“vscode写img标签 在django的static文件中显示不了”这个问题我几乎每周都能在答疑群里看到。静态文件显示不出来的原因通常是以下几种第一模板里忘了写{% load static %}。没有这句{% static %}模板标签就是无效的浏览器拿到的URL会是个普通文本根本加载不出图片和CSS。第二settings.py里STATIC_URL设置不对。默认是/static/你通常不需要改。但如果项目根目录下没有static这个文件夹你需要手动建一个并且告诉项目去哪找额外静态文件STATIC_URL /static/ STATICFILES_DIRS [ BASE_DIR / static, ]BASE_DIR就是项目根目录用路径拼接的方式指向static文件夹。然后在项目根目录下建个static/polls/style.css模板里就能用{% static polls/style.css %}引用。第三服务器没重启。Django的开发服务器默认会监听静态文件变化但有时候改了settings.py里的路径变量进程缓存没刷新就会出现一直404的情况。CtrlC停掉再重新runserver问题往往就解决了。注意python manage.py runserver在调试模式下能直接服务静态文件但上线部署时静态文件是由Nginx这类的服务器处理的Django自己不管。这是另一个话题开发阶段先别纠结。4. 表单提交与业务逻辑处理4.1 表单方法和CSRF保护投票表单我用了methodpost而不是get因为投票操作会改变数据库里的票数属于“写操作”。麻烦的是Django强制要求POST表单带上{% csrf_token %}否则会报403错误。给你一个通俗解释CSRF跨站请求伪造就好比有人在你不知情的情况下冒充你本人提交了一份申请。Django的解决办法是给每个用户的会话发一个一次性令牌提交表单时令牌必须匹配不匹配就拒绝请求这样第三方网站无法伪造你的提交。别嫌这个麻烦这是Web安全的基本功。表单里还有一个细节input typeradio namechoice value{{ choice.id }}name必须是choice因为后端request.POST[choice]取的就是这个字段value是选项数据库里的id这样后端才知道你选的是哪个选项。4.2 并发安全给票数自增提个醒上面vote视图里我用了selected_choice.votes 1再save()这个写法在单用户、低并发下完全没问题但如果有两个人几乎同时投票可能会出现“丢失更新”的问题。具体说就是A和B同时读到票数是100A加1得到101写回B加1也得到101写回最终票数只加了1。更稳妥的写法是用Django的F()表达式from django.db.models import F 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,)))F(votes) 1是让数据库在SQL层面执行原子自增而不是先把值取到Python里算完再写回。对投票应用这种并发场景我推荐直接用F()表达式。不过要注意用F()更新后当前Python变量里的votes值还是旧的如果你想立即读取最新票数需要调用selected_choice.refresh_from_db()。4.3 用通用视图给代码“瘦身”Django最爽的一点是很多常见的视图逻辑可以有现成的通用视图直接用。比如index和detail、results这种只负责展示的页面完全可以用ListView和DetailView来写代码量能砍掉一半from django.views import generic from django.utils import timezone 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 Question.objects.filter(pub_date__ltetimezone.now()).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.htmlListView会自动查询模型列表DetailView会自动按主键查询单条记录默认的模板名、上下文变量名都有约定。比如DetailView默认上下文变量名是question按模型名小写模板里直接用就行了。然后路由也要跟着改path(, views.IndexView.as_view(), nameindex), path(int:pk/, views.DetailView.as_view(), namedetail), path(int:pk/results/, views.ResultsView.as_view(), nameresults),这里把question_id变成了pk因为通用视图内部是用主键查数据的。至于vote视图因为它要处理POST请求、要修改数据、还要处理异常情况用函数视图反而更清晰所以我一般保留函数形式。不是所有地方都非得用类视图混着用完全没问题。5. 管理后台与运营数据零代码实现后台管理5.1 创建管理员账号Django自带的后台管理功能是我推荐新手一定要玩透的。先执行python manage.py createsuperuser按提示输入用户名、邮箱可留空、密码密码输入时屏幕上不会显示任何字符这是终端的正常行为不是卡住了。然后启动服务浏览器访问http://127.0.0.1:8000/admin/用刚才的账号登录。初始状态下后台只有用户和组的管理看不到Question和Choice因为这两个模型还没“注册”进admin。5.2 注册模型并定制后台展示打开polls/admin.py写from django.contrib import admin from .models import Choice, Question class ChoiceInline(admin.TabularInline): model Choice extra 3 class QuestionAdmin(admin.ModelAdmin): list_display (question_text, pub_date, was_published_recently) list_filter [pub_date] search_fields [question_text] fieldsets [ (None, {fields: [question_text]}), (日期信息, {fields: [pub_date]}), ] inlines [ChoiceInline] admin.site.register(Question, QuestionAdmin)list_display控制列表页显示的列把pub_date和自定义方法was_published_recently都加进去后台列表就能直观地看到创建时间和发布时间。list_filter会在页面右侧生成一个按发布日期筛选的过滤面板search_fields给问题标题加上搜索框这些都是运营时最常用的功能。ChoiceInline的extra 3表示在后台添加问题页面会多出三行空白的选项输入框正好对应投票应用的选项录入场景新建一个问题时顺便把选项都填了。这也是Django后台“开箱即用”火力十足的地方不需要写一行前端代码后台管理功能就全套齐活了。5.3 后台权限模型为什么不用自己造用户系统学Django到一定阶段很多人会想给自己的应用加用户登录、权限管理其实后台的auth应用已经内置了一套完整解决方案Group和Permission。普通用户可以分配权限可以指定是“仅查看”还是“可以编辑”再配合Django自带装饰器一个简单的权限体系就搭起来了。网上有个热词叫“django rabc”说的就是用Django实现基于角色的权限控制。我的建议是投票应用这种小项目先别搞复杂的权限体系把auth自带的用户、组、权限用法摸熟等真正需要“运营人员在后台管数据、普通用户在前台投票”时你自然知道该从哪里下手。6. 常见问题与排查技巧实录6.1 静态文件显示不了的排查清单现象可能原因解决方法页面能打开但CSS完全不生效模板没写{% load static %}模板顶部加一行{% load static %}图片/样式404STATIC_URL配错或static目录不在搜索范围检查settings.py加STATICFILES_DIRS浏览器一直显示旧样式浏览器缓存CtrlF5强制刷新改了静态文件后无效开发服务器缓存重启runserver模板里用了绝对路径/static/xxx.css硬编码路径不利于部署改用{% static %}标签我前年带一个项目时就遇到过特别刁钻的问题图片在本地开发环境显示正常部署到服务器上却全挂了。后来发现是部署时设置了DEBUGFalseDjango默认不再代理静态文件需要在urls.py里临时加一条static路由或者把静态文件收集到统一目录让服务器管理。这个坑等你自己部署时一定会再踩一次先记住这个关键词collectstatic。6.2 ORM里删除对象的关键细节刚学ORM的很容易踩一个坑Question.objects.filter(pub_date__lt2024-01-01).delete()和question.delete()的行为完全不一样。前者返回一个(总数, {模型名: 删除数量})的元组表示删了多少条后者是删除单条对象并自动触发外键级联删除。级联删除值得单独提醒在Question上调用.delete()Django会自动把关联的Choice也删掉这是on_deletemodels.CASCADE的效果。但如果你用的是filter().delete()Django是按批量删除来处理的它不会逐个调用模型的delete()方法所以如果你在模型里重写了delete()方法比如删掉投票时还要发通知批量删除不会触发这个自定义逻辑。这一条逻辑听上去隐蔽但实际排查时能节省大量时间。6.3 后台显示中文乱码或时间不对如果你的admin界面显示英文检查settings.py里的LANGUAGE_CODE是不是zh-hans。改完记得重启服务。如果后台录入的时间比当前时间差8个小时那就是TIME_ZONE没改的典型症状。这两个配置改完后Django还会自动处理数据库时间的时区转换你存进去的是UTC时间展示时才会转成你设置的时区所以不要看到数据库里时间不对就手动改数据要改就改配置。6.4 端口占用与迁移冲突开发服务器启动时提示“端口8000被占用”我的处理方式是先换端口试一下python manage.py runserver 8001如果非要查是什么占用了8000端口Windows上用netstat -ano | findstr 8000macOS/Linux上用lsof -i:8000。按返回的PID去进程管理器里结束进程。这个操作很常用不会用的话值得专门记一下。迁移冲突最常见的是两个人的迁移文件都改动了同一个模型Django会提示你合并迁移。最简单的方式是删掉冲突的那几个迁移文件重新makemigrations仅限未上线的项目生产环境就要谨慎对待迁移历史了千万别手滑删掉已经执行过的迁移记录。6.5 进一步实时推送投票结果到底怎么玩学习过程中很多人会问“投票后能不能不刷新页面就更新结果”这就涉及WebSocket了。简单说HTTP是一次性请求-响应模式服务器不能主动往浏览器推数据而WebSocket可以建立一条长连接服务器有数据变化就主动推给前端。Django本身不内置WebSocket需要借助Channels这个第三方库再配合异步视图和Redis之类的通道层。我给一个思路参考用Channels把投票结果变化作为消息发到WebSocket组里前端用JavaScript的WebSocket对象接住消息后动态更新饼图或进度条。真正落地时比较麻烦的不是Django端而是前端要处理断线重连、消息格式约定、幂等更新等一系列细节。投票应用练到这个阶段你已经不是在学Django了而是在学整个Web开发的实时交互体系。最后的体会把这个投票应用从头到尾搭一遍我最大的感受是它看起来只是“跟着官方教程抄一遍”但你亲手敲完每行代码、排掉每个报错之后对Django的URL分发、模型外键、模板继承、静态文件机制这些基础概念就有了肌肉记忆。以后再去做博客、CMS、企业官网很多思路都能往这套流程上套。最后分享一个我自己的小技巧每完成一个功能就提交一次git。养成这个习惯后你改坏代码能随时回退更重要的是你回头看自己提交的每个commit能清楚地看到项目是怎么一步步从“能跑”变成“好用”的。很多新手学Django死在“学了一堆API但不知道先做什么”上那我建议就从这个投票应用开始跟着上面写的第一行命令先让页面跑起来再说。