做Django开发这么久,我一直觉得“投票应用”是最适合新手完整跑通的第一个真实项目。别看它就是一个“看问题、选选项、看结果”的小玩具,它把Model和数据库打交道的方式、View里怎么接请求、Template怎么渲染页面、后台怎么管理数据,这条路完整走一遍,比背一百个知识点都管用。这篇文章我就拿这个投票应用当例子,从零开始一步步搭,顺便把我这些年踩过的坑和习惯做法都写进去。不管你是刚装好Python、还没写过一行Django代码的纯新手,还是学完教程但对路由、模板、ORM之间的关系还有点模糊,照着这套流程走一遍,基本能把Django的MTV模式整个串起来。
1. 项目准备与基础环境搭建
1.1 Python和Django版本怎么选
很多新手上来就装最新版Django,其实没必要。我个人的习惯是优先选LTS版本,Django的LTS是三年维护周期,社区资料多,第三方插件兼容性好,遇到问题一搜基本都有答案。目前比较稳妥的选择是Django 4.2 LTS,Python用3.10或3.11都可以,这两个版本在Windows、macOS、Linux上都有预编译包,装依赖时不会因为编译问题卡住。
如果已经装了更高版本的Python,比如3.12,也别慌,Django 4.2官方支持到Python 3.12,正常使用没问题。但你要是用一些依赖C扩展的数据库驱动,比如某些版本的MySQL驱动,就得留意一下是否支持你的Python版本。投票应用这种规模的项目,用SQLite数据库就够了,完全没必要为了“练手”去专门配MySQL,SQLite在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/Shanghai'LANGUAGE_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_length=200) pub_date = models.DateTimeField('date published') 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我在写这个模型时有三个习惯:
第一,每个模型必须定义__str__方法。不定义的话,你在后台看到的是Question object (1)这种天书一样的内容,而定义了之后,后台列表里直接显示你想要的文本,排查数据时真的很省心。
第二,外键on_delete参数一定要写。Django 2.0以后外键强制要求on_delete,你写漏了会给你报错。CASCADE的含义是“问题被删除时,属于它的所有选项也被自动删除”,这符合投票业务的预期:不可能问题没了、选项还在。
第三,votes字段用default=0,而不是直接让它为空。这样当新建一个选项时,票数自动从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, pk=question_id) return render(request, 'polls/detail.html', {'question': question}) def results(request, question_id): question = get_object_or_404(Question, pk=question_id) return render(request, 'polls/results.html', {'question': 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 += 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.py:
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/3/vote/,转换器会把question_id=3传给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 charset="UTF-8"> <title>投票应用</title> <link rel="stylesheet" href="{% static 'polls/style.css' %}"> </head> <body> <h1>最新投票</h1> {% 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 %} </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 %}<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="投票"> </form>question.choice_set.all是通过外键反向查询这个问题的全部选项。外键字段question是在Choice模型里声明的,Django自动为Question生成了choice_set这个反向管理器。如果你觉得choice_set这个名字不够语义化,可以在外键里加related_name='choices',然后就能用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的情况。Ctrl+C停掉再重新runserver,问题往往就解决了。
注意:
python manage.py runserver在调试模式下能直接服务静态文件,但上线部署时静态文件是由Nginx这类的服务器处理的,Django自己不管。这是另一个话题,开发阶段先别纠结。
4. 表单提交与业务逻辑处理
4.1 表单方法和CSRF保护
投票表单我用了method="post",而不是get,因为投票操作会改变数据库里的票数,属于“写操作”。麻烦的是Django强制要求POST表单带上{% csrf_token %},否则会报403错误。
给你一个通俗解释:CSRF(跨站请求伪造)就好比有人在你不知情的情况下,冒充你本人提交了一份申请。Django的解决办法是给每个用户的会话发一个一次性令牌,提交表单时令牌必须匹配,不匹配就拒绝请求,这样第三方网站无法伪造你的提交。别嫌这个麻烦,这是Web安全的基本功。
表单里还有一个细节:<input type="radio" name="choice" value="{{ choice.id }}">,name必须是choice,因为后端request.POST['choice']取的就是这个字段;value是选项数据库里的id,这样后端才知道你选的是哪个选项。
4.2 并发安全:给票数自增提个醒
上面vote视图里我用了selected_choice.votes += 1再save(),这个写法在单用户、低并发下完全没问题,但如果有两个人几乎同时投票,可能会出现“丢失更新”的问题。具体说就是:A和B同时读到票数是100,A加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, 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,)))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__lte=timezone.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.html'ListView会自动查询模型列表,DetailView会自动按主键查询单条记录,默认的模板名、上下文变量名都有约定。比如DetailView默认上下文变量名是question(按模型名小写),模板里直接用就行了。
然后路由也要跟着改:
path('', views.IndexView.as_view(), name='index'), path('<int:pk>/', views.DetailView.as_view(), name='detail'), path('<int:pk>/results/', views.ResultsView.as_view(), name='results'),这里把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 %} |
| 图片/样式404 | STATIC_URL配错或static目录不在搜索范围 | 检查settings.py,加STATICFILES_DIRS |
| 浏览器一直显示旧样式 | 浏览器缓存 | Ctrl+F5强制刷新 |
| 改了静态文件后无效 | 开发服务器缓存 | 重启runserver |
模板里用了绝对路径/static/xxx.css | 硬编码路径不利于部署 | 改用{% static %}标签 |
我前年带一个项目时就遇到过特别刁钻的问题:图片在本地开发环境显示正常,部署到服务器上却全挂了。后来发现是部署时设置了DEBUG=False,Django默认不再代理静态文件,需要在urls.py里临时加一条static路由,或者把静态文件收集到统一目录让服务器管理。这个坑等你自己部署时一定会再踩一次,先记住这个关键词:collectstatic。
6.2 ORM里删除对象的关键细节
刚学ORM的很容易踩一个坑:Question.objects.filter(pub_date__lt='2024-01-01').delete()和question.delete()的行为完全不一样。前者返回一个(总数, {模型名: 删除数量})的元组,表示删了多少条;后者是删除单条对象,并自动触发外键级联删除。
级联删除值得单独提醒:在Question上调用.delete(),Django会自动把关联的Choice也删掉,这是on_delete=models.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 8000,macOS/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但不知道先做什么”上,那我建议就从这个投票应用开始,跟着上面写的第一行命令,先让页面跑起来再说。