拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Django官网Demo深度拆解:从模型迁移到视图模板的完整实战

Django官网Demo深度拆解:从模型迁移到视图模板的完整实战

很多人学 Django 的第一反应,是把官网那份写了十几年的 poll 投票应用 demo 从头到尾敲一遍,敲完就扔到一边,转头去翻各种"企业级项目实战"视频,结果面对真实业务还是一头雾水。我当年也是这么过来的,后来带过几个新人之后才想明白:问题不在教材,而在大多数人根本没把这份 demo 当回事——它不像看上去那么"幼稚",里面每一条设计都对应着一个真实项目的核心诉求。这篇文章就以"编写你的第一个 Django 应用(官网 demo)"为主线,把一个新手在这个 demo 里会遇到的所有关键环节,包括项目初始化、模型迁移、admin 后台、视图路由、模板渲染、表单处理、测试和静态文件,从头到尾拆开揉碎讲一遍,附带我在实操中踩过的一些坑和思考。不管你是刚接触 Python Web,还是写过一点 Flask 想转 Django,照着这条链路走一遍,应该比单纯复制代码更有收获。

1. 为什么官网 demo 这 7 个 part 值得认真走一遍

先说说这份 demo 到底讲了什么。Django 官网的入门教程(Writing your first Django app)分 7 个 part:从创建项目、编写模型、接入 admin,到视图、模板、表单、通用视图,再到测试和静态文件。整体做下来你会得到一个非常简单的投票应用:用户可以查看问题、选择选项、提交投票,后台可以管理问题和选项。功能虽然简单,但这条链路几乎覆盖了 Django 开发全生命周期里最常见环节:数据库建模、迁移、后台配置、路由映射、请求处理、页面渲染、用户输入校验、自动化测试,以及静态资源托管。

很多初学者觉得这 demo 太"玩具",原因在于它没有权限系统、没有分页、没有 API,页面也丑得不行。但请不要低估它。我见过不少工作了两三年的 Django 开发,对QuerySet的惰性求值、select_related的触发时机、通用视图内部到底怎么组装上下文的,讲得含含糊糊——这些底层东西官网 demo 都有涉及,只是初学者没注意到。官网这份 demo 表面上是教功能,实际上是在教你 Django 的"骨架逻辑":请求进来之后怎么被路由分发、ORM 怎么把 Python 代码翻译成 SQL、表单 POST 之后为什么要重定向、模板系统如何隔离前后端。把这些底层机制弄明白,往后看任何项目代码都不会觉得陌生。

另外,demo 里每一步都会提到"为什么这么做"。比如为什么要在自定义模型里定义__str__,为什么数据库迁移要做成独立的变更文件,为什么视图返回要用HttpResponseRedirect而不是直接渲染模板。这些"为什么"恰恰是开发经验的分水岭。照着敲一遍代码只是完成了 20%,把每个决策背后的原因想明白,才是这份 demo 真正值钱的地方。

我自己重刷这份 demo 是在做第三个 Django 项目的时候,当时被一个 Generic Relation 的问题卡得头疼,回头翻官网教程,突然发现早期 tutorial 里讲ForeignKey的那一段已经暗示了答案。所以这篇文章我不仅仅复述官网代码,更想分享一份"过来人版本的 demo 笔记"。

2. 环境准备与项目初始化:第一行命令别踩坑

2.1 Python 版本和虚拟环境的选择

开始之前先解决环境问题。Django 目前(基于 5.x 版本)对 Python 的要求是 3.10 及以上。我个人建议直接用系统里最新的稳定版 Python 3.12 或 3.13,不用纠结兼容性,Django 跟进新的 Python 版本速度一直比较稳。不要图省事直接用全局 Python 装 Django——不同项目的依赖版本很容易打架,你在这个项目里需要 Django 4.2,另一个老项目锁定的是 Django 3.2,共用一个环境迟早要出事。

我用的是 Python 自带的venv:

python3 -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install django

创建完虚拟环境后顺手把pip升级一下,有些老版本 pip 解析 Django 依赖可能出现问题。装完之后验证版本:

python -m django --version

能打出版本号,说明环境就绪。这里有个小坑:有同学在虚拟环境外执行了django-admin,结果提示找不到命令,其实是虚拟环境没有激活。另外注意 Windows 用户在 PowerShell 里运行激活脚本前可能还需要设置执行策略,否则会出现关于脚本被禁止运行的报错,临时用Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass绕过即可。

2.2 项目脚手架的生成与目录结构拆解

环境就绪后,用django-admin创建项目:

django-admin startproject mysite

mysite是项目名,可以按自己喜好改。执行完会生成这样的结构:

mysite/ manage.py mysite/ __init__.py settings.py urls.py asgi.py wsgi.py

很多第一次接触 Django 的人看到嵌套两层mysite会发懵,这里解释一下:外层mysite/是项目的容器目录,名字随意,只是给你放项目的根文件夹;内层mysite/是真正的 Python 包,要跑起来的关键文件都在这里。manage.py是项目管理的入口命令工具,后面所有python manage.py xxx的操作都靠它。内层各文件职责如下:

  • settings.py:整个项目的总配置文件,数据库、应用、模板、静态文件、中间件、语言时区等全部在这。
  • urls.py:根 URL 路由表,所有请求进来先到这里匹配。
  • wsgi.py/asgi.py:分别对应同步和异步的服务器入口,部署上线时用。
  • __init__.py:让目录成为 Python 包的标记文件。

初次配置里最容易忽略的是settings.py中的TIME_ZONE,默认是 UTC。如果你不改成Asia/Shanghai,后面往数据库里写时间会出现 8 小时偏差,查日志调 bug 时非常痛苦。我习惯在创建完项目的第一时间就把时区和语言改掉:

LANGUAGE_CODE = "zh-hans" TIME_ZONE = "Asia/Shanghai" USE_TZ = True

USE_TZ保持True,Django 会在数据库里存 UTC 时间,渲染时自动转换到TIME_ZONE,逻辑更严谨。

接着启动开发服务器验证一下:

python manage.py runserver

默认监听 8000 端口,浏览器访问http://127.0.0.1:8000/,能看到一个火箭小图标说明项目跑起来了。runserver有自动重载功能,改代码后服务会自己重启,不用手动干预,这在开发期非常省心。但有一点注意:runserver只是一个轻量级开发服务器,没有经过安全加固和性能优化,绝对不要把它直接暴露在公网或者用于生产环境,否则等于把家门敞开让人进来逛。

2.3 创建一个应用(app)而不是塞进项目里

接下来是创建第一个应用。很多人不理解项目和 app 的关系,这里做个类比:项目是一个"架子",里面可以挂多个功能模块;app 就是具体的功能模块,比如一个电商项目里可能有goods(商品)、orders(订单)、users(用户)三个 app。Django 推崇"一个 app 只干一件事",所以官网 demo 里专门创建一个polls应用来放投票功能:

python manage.py startapp polls

创建后目录下会多一个polls/文件夹,里面有models.py、views.py、admin.py、tests.py、apps.py、migrations/等文件。这里最容易漏的一步是:把新创建的 app 注册到项目里。打开mysite/settings.py,在INSTALLED_APPS列表中加入"polls"。

为什么要注册?因为 Django 会扫描每个注册 app 里的模型、admin 配置、模板标签、迁移文件等,不注册的话,你写的模型不会进入数据库,admin 后台也看不到它。官网 demo 在第 2 部分才开始让你注册,但我建议创建完 app 立刻注册,避免后面迁移时一脸疑惑"为什么我的模型没生成表"。

3. 模型层:ORM 的套路与迁移机制

3.1 从需求到 Django 模型的映射思路

投票应用的核心数据有两块:一个是问题(Question),一个是选项(Choice)。官网这里用了非常经典的建模案例,正好演示了 Dango 里最核心的两类字段和关系:

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

CharField对应数据库的 varchar,DateTimeField对应 datetime,IntegerField对应 int,ForeignKey对应外键关联。写模型的时候心里要有一张"Python 类型 ↔ 数据库类型"的映射表,这样你能预判数据库最终长什么样。ForeignKey里的on_delete=models.CASCADE表示:如果一个问题被删了,它的所有选项也跟着删。这个参数是 Django 2.0 之后必须显式指定的,没有默认行为,目的就是逼开发者提前想清楚级联删除策略。业务不同,策略也不同:有的场景要PROTECT(有子记录就不允许删父记录),有的要SET_NULL(父记录删除后子记录置空)。官网 demo 用 CASCADE 是因为投票场景里选项离开问题没有任何意义。

__str__方法在 django admin 后台加一个展示名,否则后台列表里所有记录都会显示成"Question object (1)",根本分不清哪条是哪条。这个习惯一定要从第一个模型开始养成。

3.2 迁移机制:makemigrations 和 migrate 的关系

模型定义好之后,接下来是 Django 最值得一提的设计——数据库迁移。

python manage.py makemigrations polls python manage.py migrate

makemigrations做的事是:扫描 app 的模型变化,生成迁移文件(放在polls/migrations/下),这个文件是 Python 代码,记录"我要新创建一张 Question 表、一张 Choice 表"。migrate做的事才是真正把这些迁移文件应用到数据库。

我见过不少朋友把两个命令搞混,甚至只执行migrate,然后发现数据库里没表,怀疑自己装错了。实际上migrate只负责"执行从未执行过的迁移",如果没有任何迁移文件,它当然不会凭空建表。正确流程永远是先makemigrations再做migrate。你也可以执行python manage.py makemigrations --check来干跑检查,在 CI 里用来判断模型和迁移文件是否同步。

默认配置下 Django 用的是项目根目录的db.sqlite3文件,SQLite 的好处是零配置,适合 demo 阶段。但要注意:SQLite 对并发写入支持较弱,而且DateTimeField之类的类型约束也和 MySQL、PostgreSQL 有差异。如果目标就是练手,SQLite 没问题;如果想贴近生产,建议早点切换到 PostgreSQL,环境上可以用 Docker 快速起一个。

3.3 用 shell 和你建的表打交道

模型和迁移只是第一步,真正要熟练的是用 ORM 做增删改查。官网 demo 建议直接进shell体验:

python manage.py shell

这里推荐装一个ipython,交互体验更好,装上后 Django shell 会自动使用它。在 shell 里可以试试这些操作:

from django.utils import timezone from polls.models import Question, Choice q = Question(question_text="你最近在学什么框架?", pub_date=timezone.now()) q.save() Question.objects.all() # 查询所有 Question.objects.filter(question_text__contains="框架") # 模糊查询 Question.objects.get(pk=1) # 按主键查询 q.choice_set.create(choice_text="Django", votes=0) # 通过关联创建 q.choice_set.count() # 关联数量 Question.objects.first().delete() # 删除对象

关于"删除对象"这是数据库操作的常见需求。Django 的删除有好几种玩法:obj.delete()只删单个对象;QuerySet.delete()可以批量删,例如Question.objects.filter(pub_date__lt=timezone.now()).delete()会把你筛选出的所有问题一次性删掉;ForeignKey(on_delete=...)则处理联动删除。如果你在生产环境执行批量删除,务必先.count()看数量,再决定要不要删,养成随手加事务的习惯(transaction.atomic())能避免误操作无法回滚的问题。

还有get和filter的区别值得说一下:get返回单个对象,结果多于一条或者不存在都会抛异常(分别是MultipleObjectsReturned和DoesNotExist);filter返回一个QuerySet,不匹配就返回空查询集,不会抛错。实际代码里很多人该用filter时用get,结果一查多就崩;该用get时用filter,结果后面直接用对象.属性又抛出AttributeError,这就是典型的查询 API 边界没弄清楚。

Django ORM 的惰性求值也值得一提:Question.objects.all()这一行并不会立刻执行 SQL,只有当你真正去迭代它、取切片、调用list()时才会查数据库。这个特性让 ORM 可以链式拼接各种过滤条件而不会产生多条 SQL。你需要理解这个特性,才能在设计复杂查询时避免 N+1 查询的问题——比如循环里查每道题的选项,每轮都触发一次数据库访问,几层循环下来请求就慢到不行。处理方式是在查询后用prefetch_related("choice_set")一次捞全,这就是另一个进阶话题了,但根源在惰性求值这里。

4. admin 后台:白嫖的管理界面,但要理解它的魔法

4.1 创建超级用户与模型注册

Django 自带一个功能完备的后台管理界面,这是很多人选择 Django 的重要原因——不用另写管理页面,后台就出来了。官网 demo 里接入 admin 只要两步:

第一步,创建超级用户:

python manage.py createsuperuser

按提示输入用户名、邮箱、密码(密码输入时不显示,容易让人以为卡住了)。需要注意用户名和密码都会被用于登录后台,密码强度不够会提示重输。第二步,把模型注册到polls/admin.py:

from django.contrib import admin from .models import Choice, Question admin.site.register(Question) admin.site.register(Choice)

然后启动服务,访问http://127.0.0.1:8000/admin/,输入刚才的账号密码,就能看到 Questions 和 Choices 的管理入口了。点进去可以增删改查,UI 基本不用调。

关于 admin 权限:能访问后台的前提是用户is_staff为 True,超管is_superuser当然拥有全部权限。生产环境里千万别给普通操作人员超管角色,后台权限控制可以细粒度到每个 app、每个模型的增删改查,甚至行级——这些在django.contrib.admin权限模型上都是基于"用户-组-权限"的经典设计。官网 demo 不涉及权限,但你可以了解has_change_permission之类的钩子,后面做 CMS 类项目会非常有用。

4.2 让列表页更好用的几个小改动

只注册模型的话,admin 列表页会显得很"素"。三条记录看不出区别,关联关系也展示不全。官网 demo 提到几个小优化点,我在实际操作中觉得性价比极高:

在admin.py里面用装饰器自定义管理类:

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", "was_published_recently") list_filter = ["pub_date"] search_fields = ["question_text"] inlines = [ChoiceInline]

list_display会把你指定的字段/方法作为列表页的列展示;list_filter右侧直接出一个按日期过滤的筛选器;search_fields会对 question_text 做搜索。ChoiceInline允许你在编辑一个 Question 的页面里直接增删改它关联的 Choice,这比去另一个页面维护记录舒服得多。

不过list_display里有方法时,Django 是不允许对那列排序的,因为数据库层面没有这个字段。如果一定要按"是否最近发布"排序,就得在模型里给was_published_recently加admin_order_field指定排序字段,或者干脆单独查一个布尔字段出来。这算一个小坑,先知道有这回事,用到时再查。

4.3 admin 背后的机制和局限

admin 界面的"魔法"本质上是 Django 的模型元数据和 Form 机制的组合。Django 读取模型的_meta信息,自动推断每个字段用什么控件、怎么校验、怎么渲染。这套机制非常成熟,但也要知道它有局限:

  • admin 默认不支持特别复杂的前端交互。想在里面做树形结构、拖拽排序、复杂筛选,需要借助第三方库,或者干脆定制化开发。
  • admin 直接操作数据库记录,权限边界必须卡好。如果线上环境不加区分给所有运营开 admin,误删数据是迟早的事。
  • admin 的样式基于 Django 自带模板,虽然能用django-unfold之类的主题包美化,但本质还是"后台管理系统",不是给真实用户看的界面。真实用户的界面由你的视图和模板决定。

所以我的态度是:admin 是开发期和内部运营的利器,但不应该把它直接搬去当用户端管理台。你要清楚什么时候"白嫖后台",什么时候"单独造一个管理界面"。

5. 视图与路由:从请求到响应的完整链路

5.1 第一个视图和 URLconf 的原理

官方 demo 写了第一个最简单的视图:

from django.http import HttpResponse def index(request): return HttpResponse("Hello, world. You're at the polls index.")

然后把它映射到 URL:

# polls/urls.py from django.urls import path from . import views urlpatterns = [ path("", views.index, name="index"), ] # mysite/urls.py from django.urls import include, path urlpatterns = [ path("polls/", include("polls.urls")), path("admin/", admin.site.urls), ]

这里有两个理解难点,先拆开说。

一是path()的name参数是干什么的。它是这个 URL 的"名字",后面模板里用{% url 'index' %}反向解析时靠它找到地址。为什么要绕一圈?因为硬编码 URL(比如直接在模板里写/polls/)在项目重构改名后必须手动改所有地方,用 name 关联就只需要改一处 URLconf。官网教程后面专门有一步"去掉模板里的硬编码 URL",换成{% url %}标签,目的就是让你体会这个设计。

二是include("polls.urls")。项目根 URLconf 不会直接把所有 app 的路由写一遍,那样几百个路由堆在一个文件里根本没法维护。正确做法是每个 app 维护自己的urls.py,根路由用include把二级路由挂载进来。这样 append 层级清晰,各 app 互不干扰。URL 匹配时,Django 会从上到下逐个匹配urlpatterns,命中第一个就停止。所以顺序很重要——一个常见的坑是把"<int:question_id>/"这种动态路由写在""前面,结果访问""时也会被动态段截胡。

5.2 动态参数、404 与请求处理的链路

官网 demo 的下一个版本是让视图接收问题 ID:

def detail(request, question_id): try: question = Question.objects.get(pk=question_id) except Question.DoesNotExist: raise Http404("Question does not exist") return render(request, "polls/detail.html", {"question": question})

同时 URL 改成:

path("<int:question_id>/", views.detail, name="detail"),

<int:question_id>是 Django 的路径转换器,int表示这里只匹配整数,匹配到的值会作为参数question_id传给视图函数。除了int,还有str、slug、uuid、path等类型,可以按需选择。用int直接就把"非数字请求"挡在路由层外面,不让它进入视图,这是 Django URLconf 的常用手段。

视图中手动捕获DoesNotExist再抛Http404,是一个展示"异常处理"的经典例子。后来官网又优化成get_object_or_404这种快捷方式:

from django.shortcuts import get_object_or_404 question = get_object_or_404(Question, pk=question_id)

一个函数搞定"取不到就返回 404"的逻辑,少写好几行。这类快捷函数还有get_list_or_404,用于取列表时为空就 404。在 restful 风格 API 里这可能不符合"空列表应该返回 200"的语义,但在传统服务端渲染场景下非常好用。

这里可以顺带梳理一遍 Django 的请求链路:浏览器请求http://127.0.0.1:8000/polls/3/打到runserver,Django 先走中间件,再把请求交给 URLconf 解析路由,匹配到polls/urls.py里的views.detail后调用视图函数,视图通过 ORM 查询数据库拿到 Question 对象,然后交由模板引擎渲染出 HTML,最后通过HttpResponse返回给浏览器。中间件负责所有上游逻辑(比如 auth 认证、CSRF 校验、session 处理),你会经常在settings.py的MIDDLEWARE列表里看到它们——在后续实际项目中,理解中间件顺序能帮你排查很多诡异的 bug,比如自定义中间件放错位置导致请求还没到视图就被拦截了。

5.3 路由命名最佳实践与常见误用

官网 demo 里给 URL 起名时用了index、detail、results、vote这种短名。但项目大了之后,多个 app 里面都可能出现detail、index这样的名字,{% url 'detail' %}会让你死循环查看到底指的哪个 app。所以官网教程在后面的 part 里专门讲了命名空间,给polls/urls.py加一个app_name = "polls",模板里用{% url 'polls:detail' question.id %}。

app_name一旦设置,模板里的 URL 名就必须带前缀,没有前缀会直接报错。这个设计就是为了避免多 app 命名冲突。我在做多 app 项目时,习惯给所有 URL 名加 app 前缀,即使只有一个 app 也这么干,形成肌肉记忆后就不会踩"URL namespace 冲突"的坑。

顺带一个路由的小细节:URL 中单词分隔符。Django 社区和官方约定用下划线还是短横线?PEP8 对 URL 没有强制,Django 自带代码里两种都有,但我个人强烈建议 URL 里用短横线(question-detail)、Python 变量名/函数名里用下划线(question_detail)。因为 URL 是要出现在地址栏里的,短横线在视觉上比重复杂的下划线干净,而且搜索引擎对短横线分隔的语义化 URL 更友好。

6. 模板层:Django 模板引擎的语法与继承机制

6.1 render 函数与模板查找顺序

前面视图里已经用了render(request, "polls/detail.html", {"question": question})这个快捷函数。它的本质是:加载模板、用上下文数据渲染模板、返回HttpResponse。因此完整写法是:

from django.template import loader loader.get_template("polls/detail.html").render(context, request)

render帮你把三步合成一步,最常见的写法。这里有个初学者容易犯的错:模板路径写错。Django 查找模板是按APP_DIRS配置,默认会去每个已注册 app 的templates/目录下找。所以在pollsapp 下创建模板时,路径应该是polls/templates/polls/detail.html。注意里面有个重复的polls——外层polls/templates是模板根目录,内层polls/是命名空间,防止多个 app 出现同名模板时互相覆盖。如果不加这层命名空间,你写两个名字都叫detail.html的文件,Django 只会找到第一个,非常容易出莫名其妙的问题。

6.2 模板语法核心:变量、标签、过滤器和继承

Django 模板语言核心就四个东西:

  • 变量:{{ question.question_text }},输出变量值,不能写 Python 逻辑表达式,只能用 django 内置的 attribute 和 list index 访问。
  • 过滤器:{{ question.pub_date|date:"Y-m-d" }},格式化日期等,本质是处理变量后再输出。
  • 标签:{% if %}、{% for %}、{% url %}、{% include %},执行逻辑、控制流。
  • 继承:{% extends "base.html" %}和{% block %},模板复用和组合。

官网 demo 给了一个最常见的循环和条件判断组合:

{% 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>No polls are available.</p> {% endif %}

这里面有两个关键认知需要注意。第一,模板标签里的变量是视图上下文传进去的,不是模板自己从数据库取的。也就是说,数据获取逻辑必须在视图里完成,模板只能负责展示,这是 Django MTV 架构规约的核心。第二,模板里的{% url %}是反向解析,它在渲染时根据路由 name 和参数算出一个 URL 来,相当于把 URL 和视图函数绑定成了"一处修改,全局生效"。

模板继承是我认为 Django 模板系统最值得利用的能力。搞一个小规模的 demo 似乎用不上 base.html,但你要养成一开始就搭一套"基础模板 + 多个子模板"结构的习惯。比如:

<!-- polls/templates/polls/base.html --> <!DOCTYPE html> <html lang="zh-hans"> <head> <meta charset="UTF-8"> <title>{% block title %}投票系统{% endblock %}</title> </head> <body> <header>这里是全站带头部</header> {% block content %} {% endblock %} <footer>这里是全站尾部</footer> </body> </html>

子模板里:

{% extends "polls/base.html" %} {% block content %} ... {% endblock %}

继承最大的好处是:全站公共区域(导航、页脚、统计脚本)只维护一处,子页面只管自己的核心内容。官网 demo 虽然没说这个知识点是重点,但后面几乎所有 Django 项目都是这么组织的。

6.3 上下文对象与模板中的请求参数

模板里有时需要访问当前登录用户、请求参数、request 对象等。默认情况下,模板里能直接用{{ request }},只要你渲染模板时传入request(render已经默认绑定了)。例如在模板里判断用户是否登录:{% if user.is_authenticated %},这里的user其实是 context processor 注入的。

Django 的模板上下文处理器(context processor)可以在所有模板里注入全局变量而不需要每个视图显式传入。settings.py里的TEMPLATES配置中OPTIONS.context_processors就是干这个的。默认自带的一组已经能提供request、user、messages、csrf_token等常用变量,理解这一点能大幅减少视图里重复传参的代码,也避免了"模板里取不到对象"的疑惑。

有的新手写模板时会试图在模板里直接调用方法,例如{{ question.choice_set.all }},得到的结果很可能是一串对象格式字符串而不是期望的数据,因为模板引擎默认不调用带参数的方法(其实不带参数的会被调用,但choice_set.all返回 QuerySet,直接输出当然不理想)。正确姿势是视图里先把choice_set.all()查出来放进 context,再在模板里遍历。另一个办法是给模型加@property装饰的属性和模板友好的格式化方法,不过总体原则没变——视图和模型负责数据处理,模板只负责展示。

7. 表单处理:POST 流程中的 CSRF、校验与重定向

7.1 写一个 POST 表单和对应的视图

demo 走到投票这一段,就会涉及表单处理了。用户在前台选择一个选项,点击提交,后端把票数加一,然后跳转结果页。官网的vote视图是这样写的:

from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render 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() return HttpResponseRedirect(reverse("polls:results", args=(question.id,)))

这个视图展示了几个真实项目的关键习惯:

  • request.POST["choice"]从 POST 表单里取用户提交的值,如果 key 不存在会有KeyError。表单里没选就提交,那choice键根本不在 POST 数据里。所以在 try 块里同时捕获Choice.DoesNotExist,因为用户也可能伪造一个不存在的选项 ID。
  • 校验不通过时不抛 500,而是把原页面重新渲染一遍,带上error_message,让用户看到"你没选选项"。
  • 校验通过后,执行数据变更,然后HttpResponseRedirect重定向到结果页,而不是直接渲染结果页模板。

第三点是不少新手最容易忽视的:为什么数据提交成功后一定要重定向?答案是为了防止用户误刷新时表单被重复提交。刷新页面会重新 POST 一次,票数就 +1 两次,这显然不对。重定向之后地址栏变成了结果页地址,再刷新是 GET 请求,票数不会再次变化,这个模式叫 POST/Redirect/GET(PRG)。Django 官网专门强调这一点,很多入门教程却没有说透,我见过实际线上项目因为这个原因出现重复下单的,所以这里一定要养成肌肉记忆。

7.2 CSRF 校验:为什么模板里要写 csrf_token

在你写表单的 HTML 时,如果用了<form method="post">,模板里必须有一行{% csrf_token %}。这是 Django 内置的跨站请求伪造防护。

原理很简单:Django 在 session 里生成一个随机 token,放到渲染后的页面里;浏览器提交 POST 时带上这个 token;Django 中间件比对提交的 token 和 session 里的 token,不一致就拒绝请求。这样攻击者在第三方站点伪造的表单提交,因为没有正确 token,就会被拦截。如果在模板里漏写{% csrf_token %},runserver控制台会直接给你一个 403 页面,明确说 CSRF verification failed。

有些初学者图省事,恨不得把 CSRF 中间件去掉。我的态度很明确:除非你做的是完全开放的只读 API(并且自己有其他防护手段),否则千万别关。CSRF 防护成本极低但收益极高,线上被 CSRF 打的案例太多了。如果你做的是前后端分离,Django 官方的做法是使用@csrf_exempt配合 token 认证,或者用django-cors-headers管理跨域,总之不是一关了事。

7.3 用表单类还是手写表单:demo 阶段该怎么选

官网 demo 在手写表单环节只用了原生 HTMLform,没有引入 Django Form。为什么?因为 tutorial 的进度还没到那一步。实际开发时,我强烈建议尽快切换到 Django Form 或 ModelForm 体系。

拿投票这个场景来说,用 Django Form 可以写一个叫VoteForm的类,字段是单选选项,内置各种校验逻辑,视图里再判断form.is_valid(),错误信息自动绑定到表单对象上,模板里{{ form.as_p }}就能直接渲染。好处是代码更少、错误处理更统一。ModelForm 特别适合"从模型自动生成表单"的场景,比如管理后台编辑 Question 时,Django admin 内部就是 ModelForm 机制。

不过话说回来,官网 demo 让你先手写一次表单是有道理的——它能让你看见"没有框架帮你时"表单处理的所有细节:取值、校验、错误回传、保存、重定向。有了这个底层感知,后面再用 Form 类时你能理解它到底帮你干了什么,而不是把它当黑盒。

8. 通用视图、测试与静态文件:demo 和真实项目的分界线

8.1 从函数视图到通用视图:模板名和 context 的特殊对应关系

官网教程最后把index、detail、results三个视图从函数方式改写成了通用视图(generic class-based views)。这也是很多人在实际项目里走的路线:函数视图用来理解原理,生产代码里大量用通用视图减少重复。

改写后的代码大致长这样:

from django.views import generic from django.utils import timezone 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默认会把Question.objects.all()塞到一个叫question_list的上下文变量里;DetailView默认是按 pk 取出单个模型对象,放在question变量里。你会发现这里的上下文变量名不再是之前的latest_question_list,这就是为什么官网必须让你重写context_object_name。

刚转通用视图时最容易报错:模板里引用了latest_question_list,但视图没有这变量,于是页面空白或者报VariableDoesNotExist。这个调试方法要记住:浏览器打开开发者工具看响应内容,Django 在 DEBUG 模式下会直接给你渲染异常的详细页面。如果模板变量取不到,往往就是context_object_name没对上。另外,DetailView里默认会尝试把 URL 中的pk(或者slug)参数作为查询依据。所以 URL 里如果你写的是question_id而不是pk,通用视图会报错,直接用pk=xxx的 URL 转换器是最省事的做法。

8.2 写测试:django demo 里的测试该长什么样

官网教程专门有一节讲测试,很多人会跳过,认为"小 demo 不需要测试"。这个认知要改。测试是 demo 里练的内功,等你做真实项目时,没有测试的基础,改一行业务代码都得提心吊胆半天。

官网剧透了两种典型的测试写法。

第一种是模型方法测试。比如测试was_published_recently()方法在昨天、现在、未来三个时间点的返回值是否正确。这一类测试直接调用模型方法,不需要发起 HTTP 请求。

第二种是视图测试,用 Django 自带的测试客户端模拟请求:

from django.test import TestCase from django.urls import reverse from django.utils import timezone from .models import Question def create_question(question_text, days): time = timezone.now() + datetime.timedelta(days=days) return Question.objects.create(question_text=question_text, pub_date=time) class QuestionIndexViewTests(TestCase): def test_no_questions(self): response = self.client.get(reverse("polls:index")) self.assertEqual(response.status_code, 200) self.assertContains(response, "No polls are available.") self.assertQuerysetEqual(response.context["latest_question_list"], []) def test_past_question(self): question = create_question("过去的问题", days=-30) response = self.client.get(reverse("polls:index")) self.assertQuerysetEqual(response.context["latest_question_list"], [question]) def test_future_question(self): create_question("未来的问题", days=30) response = self.client.get(reverse("polls:index")) self.assertContains(response, "No polls are available.")

这段测试里值得琢磨的是test_future_question。它测的是IndexView.get_queryset里过滤pub_date__lte=timezone.now()这个条件的结果——未来发布的问题不应该出现在列表中。如果你不写测试,这个过滤逻辑写错(比如gt写成lt)可能很久都发现不了。有了测试,每次跑python manage.py test就能一次性告诉你所有预期是否符合。

还有投票接口的测试,可以用self.client.post模拟表单提交:

response = self.client.post(reverse("polls:vote", args=(question.id,)), {"choice": choice.id}) self.assertEqual(response.status_code, 302)

这里断言的是重定向,因为vote成功后会返回HttpResponseRedirect。如果你测出 302,说明 PRG 模式生效了;如果是 200,那说明视图直接渲染了结果页,哪里出了问题就要回头查。学会用状态码断言逻辑流走向,是后端测试的核心技能。

测试跑得多了你也会发现:Django 默认的测试框架会在内存里建一个独立的测试数据库,每次跑测试都是全新环境,所以你可以在测试里大胆创建数据,不用担心污染开发库。测试速度慢的话可以换用 pytest-django,但对 demo 级别来说标准unittest就够了。

8.3 静态文件:从 admin 的 CSS 到自己页面的样式

最后一块是静态文件。Django 对静态文件有一套独立的管理体系,和模板完全是两条路。模板是后端渲染的,静态文件(CSS、JS、图片)是直接发给浏览器的。

流程大致是:在每个 app 下面建一个static/目录,和templates/类似,也要带命名空间。比如polls/static/polls/style.css。模板里用:

{% load static %} <link rel="stylesheet" href="{% static 'polls/style.css' %}">

{% static %}标签会根据settings.STATIC_URL(默认/static/)生成实际 URL。开发时runserver自动伺服这些静态文件;部署到生产环境时,需要用collectstatic命令把所有 app 的静态文件收集到一个统一目录(STATIC_ROOT),再由 Nginx 之类的 Web 服务器托管。这一步新手容易忽略:本地开发一切正常,一上线页面样式全丢,基本就是collectstatic这步没做或者 Nginx 没配。

官网 demo 的样式处理刻意保持简单,但知识点足够:静态文件路径的组织方式直接决定了多 app 项目的静态资源会不会互相覆盖。和模板同理,polls/static/polls/这种"双重目录"是推荐的规范做法,路径冲突的坑很多,但提前遵守规范就能完全避免。

8.4 demo 做完之后的三个自然延伸方向

如果你把官网 7 个 part 完整走完,其实已经具备了一个 Django 开发者的基础骨架。接下来可以按自己的方向深挖。我根据自己的经验列三个比较常见的方向:

第一个方向是接口化。传统服务端渲染的 demo 只面向页面,但现代项目很多需要给小程序或 app 提供 JSON 接口。这个方向建议先搞定 Django Rest Framework(DRF),学序列化器、视图集、认证权限。你会发现前面学的模型、URL 路由、admin 这些底层概念全部能复用,只是响应的格式从 HTML 变成了 JSON。

第二个方向是前后端交互。有同学在热搜词里看到"python django websocket 实现后台有数据前端推送",这就是典型的实时推送场景。Django 本身偏向同步 HTTP 请求,做 WebSocket 需要引入channels库,启动 ASGI 服务器,把 websocket 路由和消费者写好。这个方向建议先掌握基础的 HTTP 请求响应,再上手异步,否则容易懵。

第三个方向是认证与用户体系。官网 demo 里没有注册登录,但真实项目一开始就要考虑。Django 自带的 auth 应用可以省掉很多事,密码自动加密、session 管理、权限系统都是现成的。你自己写认证时也能体会到为什么"Django 的账号体系别自己造轮子"——安全设计的水很深,交给框架比自己硬写靠谱得多。

而且如果你继续深入,会发现django-unfold这类美化主题包、django-cookie里设置 token 的姿势、agno这类智能体框架 demo 里如何和 Django 结合,都是把基础 demo 往后延伸到具体业务场景的自然产物。核心点仍然是:先把官网 demo 的底层机制吃透,后面这些新名词都只是各种场景下对同一套基础机制的组合与定制。

写到这里,以我做过几个真实项目的体会收个尾:官网 demo 不是用来"刷完"的,而是用来"想通"的。每一个看似多余的步骤,比如注册 app、写迁移、设置命名空间、做重定向、写测试,背后都是真实生产项目每天都在经历的硬骨头。我自己见过太多人快速抄完 demo 就冲向新框架,结果连 URL 里pk和id的区别都讲不清楚。所以如果你能静下心把这篇 demo 笔记里的每一个环节都动手验证一遍,顺手把官网每个 part 的课后练习(比如"给 Question 增加一个自定义字段,让迁移正常生成并应用")都做完,再开始下一个项目,你的起点就已经比大多数人高出一截了。别急着求快,把地基打牢,后面写项目会越写越顺。

返回列表