1. 从一个四不像的练手项目说起如果你已经看完了Django的官方教程前几章跟着敲完了投票应用大概会有一种我好像会了又好像什么都不会的割裂感。教程里每个知识点都是拆开的——这一章讲模型下一章讲视图再下一章讲模板但真正让你从零搭一个能跑通增删改查、有页面跳转、有数据校验的小系统时你会发现脑子里那根线是断的。这个MVT综合演示案例就是专门用来接这根线的。所谓MVT就是Model模型- View视图- Template模板这套Django的核心架构模式。它跟传统的MVC最大的区别在于Django自己把Controller那层吃掉了——URL分发器加上框架本身的请求处理机制承担了控制器的角色留给开发者的是数据层、业务逻辑层和表现层这三块。这个案例要做的就是用一个足够小、但功能闭环的场景把这三层怎么协作、数据怎么流动、每一层该写什么不该写什么全部串一遍。我选的是一个简易图书借阅登记的场景。为什么选这个因为它天然包含了一对多的关系一个分类下有多本书、有状态流转借出/归还、有表单提交和校验、有列表和详情页的跳转麻雀虽小五脏俱全。你跟着走完一遍回头再看自己之前写的那些散装代码会突然明白它们各自应该待在哪个位置。这篇文章适合两类人一类是刚学完Django基础语法、急需一个完整项目来巩固认知的新手另一类是写过一些Django代码但总是把业务逻辑到处乱塞、项目一复杂就失控的开发者。我会把每一步的为什么这么做讲清楚而不只是告诉你怎么做。代码可以直接抄但更希望你能理解背后的取舍。2. 项目整体设计与MVT分层思路拆解2.1 为什么先设计再动手数据流决定分层很多人写Django的习惯是打开编辑器就开始建app、写model写到一半发现字段不够用回头改model改完发现视图里取数据的方式也得跟着变最后模板里渲染的变量名对不上一路改下来心态爆炸。这个问题的根源在于你没有先想清楚数据怎么流动。MVT的本质是一条单向数据流用户发起请求 → URL分发器匹配路由 → 视图函数接收请求 → 视图向Model层要数据 → Model层查数据库返回数据对象 → 视图把数据交给Template渲染 → 返回HTML给用户。这条链路里每一层只跟相邻的层打交道Model不认识ViewTemplate也不认识Model它只认识View传给它的上下文变量。理解这一点你就知道为什么不能在模板里直接写数据库查询也不能在Model里写HTTP响应。所以我在动手前先画了一张数据流草图这里用文字描述用户访问图书列表页视图函数调用Book.objects.all()拿到查询集把查询集塞进context字典模板遍历这个查询集渲染成表格。用户点击借阅按钮触发一个带book_id的URL视图函数根据id取出对应书籍修改它的借阅状态字段保存后重定向回列表页。整个流程里Model负责定义数据结构和存取方法View负责编排逻辑Template负责展示各司其职。2.2 项目结构规划app划分与目录组织Django项目可以只用一个app搞定所有事但那是玩具做法。真实项目里按业务边界拆分app是基本素养。这个案例虽然小我依然拆成两个app一个叫library管图书和分类一个叫accounts管借阅人信息。为什么要拆因为图书管理和用户管理是两块独立的业务将来图书要加封面、加ISBN用户要加头像、加联系方式改动互不影响。如果你把所有模型塞一个app里迁移文件会越来越臃肿后期想拆都难。目录结构大致是这样项目根目录下有个config文件夹放settings和根urls然后library和accounts两个app平级。每个app内部models.py放数据模型views.py放视图逻辑urls.py放该app的路由templates文件夹下按app名再建一层子目录放模板。这里有个新手常踩的坑模板目录一定要加app名这一层否则两个app里有同名模板时Django会按app的注册顺序找到第一个就返回导致你改了半天的模板根本没生效。这个坑我当年排查了快一个小时最后发现是模板路径冲突。2.3 技术选型背后的考量为什么不用DRF、不用前端框架有人会问都什么年代了还用Django的模板系统不上前后端分离吗这个问题要分场景。这个案例的目标是演示MVT模式本身如果用DRF做API加Vue做前端那MVT里的T就被架空了你学到的是RESTful API设计不是MVT。对于刚入门的人来说先把服务端渲染这条链路走通理解请求-响应周期、模板上下文、表单CSRF这些基础概念比一上来就搞前后端分离要扎实得多。数据库我选的是SQLite零配置文件即数据库适合演示。但我会在settings里把数据库配置单独拎出来讲方便你后面换成PostgreSQL或MySQL。表单处理用的是Django的Form类而不是手动在视图里request.POST.get()一个个取字段。为什么因为Form类自带字段校验、错误信息收集、CSRF防护还能直接渲染成HTML省掉大量重复代码。手动取字段的写法在字段少的时候看着简单一旦字段超过五个校验逻辑就会变成一坨if-else维护成本极高。3. 核心细节解析与实操要点3.1 Model层字段设计与关系定义的门道先看libraryapp的模型设计。分类和书籍是一对多关系用ForeignKey。这里有个细节on_delete参数在Django 2.0之后是必填的我选的是PROTECT而不是CASCADE。为什么因为如果某个分类下还有书你直接删分类那些书就变成孤儿数据了。PROTECT会阻止删除并抛出异常逼你先处理完底下的书。这是数据完整性的保护生产环境里非常有用。当然如果你确实想级联删除用CASCADE也行但要想清楚后果。书籍模型里is_borrowed用布尔字段标记借阅状态borrower用外键关联到用户模型borrowed_date记录借出时间。这里我用nullTrue, blankTrue允许借阅人和借出时间为空因为新书入库时还没被借走。注意null和blank的区别null是数据库层面的空值blank是表单校验层面的允许为空。对于字符串字段Django官方建议不要用nullTrue因为字符串的空值用空字符串表示更一致但日期和布尔字段用null是合理的。# library/models.py from django.db import models from django.conf import settings class Category(models.Model): name models.CharField(max_length50, uniqueTrue) description models.TextField(blankTrue) def __str__(self): return self.name class Meta: verbose_name_plural Categories class Book(models.Model): title models.CharField(max_length200) author models.CharField(max_length100) category models.ForeignKey( Category, on_deletemodels.PROTECT, related_namebooks ) is_borrowed models.BooleanField(defaultFalse) borrower models.ForeignKey( settings.AUTH_USER_MODEL, on_deletemodels.SET_NULL, nullTrue, blankTrue, related_nameborrowed_books ) borrowed_date models.DateField(nullTrue, blankTrue) def __str__(self): return self.titlerelated_name这个参数值得单独说。它定义了反向查询的名字。比如有了related_namebooks你就可以通过category.books.all()拿到该分类下所有书。如果不设默认是book_set也能用但可读性差。我习惯给每个外键都设related_name让代码读起来像自然语言。3.2 View层函数视图还是类视图怎么选Django的视图有两种写法函数视图FBV和类视图CBV。新手容易纠结用哪个。我的建议是先写函数视图理解清楚请求-响应流程再学类视图。类视图把GET和POST的处理拆成不同方法还内置了通用视图ListView、DetailView等代码量少但抽象层次高出问题时不好调试。这个案例我主要用函数视图只在列表页用了一个ListView做对比让你看到两种写法的差异。视图里最重要的原则是保持瘦视图。什么意思视图函数只负责三件事接收请求参数、调用模型层拿数据、把数据交给模板。业务逻辑如果复杂应该抽到模型的方法里或者单独的service模块。比如借书这个操作涉及检查书是否已被借、设置借阅人、记录日期、保存这一串逻辑我封装成了Book模型的一个方法borrow()视图里只调一行。这样做的好处是将来如果有个定时任务也要执行借书逻辑直接调模型方法就行不用复制视图里的代码。# library/views.py from django.shortcuts import render, get_object_or_404, redirect from django.contrib.auth.decorators import login_required from django.views.generic import ListView from .models import Book, Category from .forms import BookForm def book_list(request): books Book.objects.select_related(category, borrower).all() categories Category.objects.all() context { books: books, categories: categories, } return render(request, library/book_list.html, context) login_required def borrow_book(request, book_id): book get_object_or_404(Book, idbook_id) if book.is_borrowed: return redirect(library:book_list) book.borrow(request.user) return redirect(library:book_list)注意select_related这个用法。当你在模板里要显示每本书的分类名和借阅人名时如果不加select_relatedDjango会对每本书单独查一次分类表和用户表这就是经典的N1查询问题。10本书就是21次查询100本就是201次。加上select_relatedDjango会用JOIN一次性把关联数据取出来查询次数降到1次。这个优化在数据量小的时候感觉不出来数据一多就是性能杀手。3.3 Template层模板继承与上下文变量的正确姿势模板层最容易犯的错误是在模板里写业务逻辑。Django模板语言故意做得很弱就是不让你在里面写复杂逻辑。但总有人想方设法绕过去比如在模板里做复杂的条件判断、字符串处理。我的原则是模板里只做展示相关的简单判断比如{% if book.is_borrowed %}任何需要计算、需要查数据库的操作都在视图里做完再传进来。模板继承是减少重复代码的利器。我建了一个base.html里面放导航栏、页脚、CSS引入这些每个页面都有的东西然后定义几个block子模板只需要填自己那部分内容。这样改导航栏只需要改一个文件。新手常犯的错是每个页面都复制一份完整的HTML改一个链接要改十几个文件改漏一个就出现死链。!-- templates/base.html -- !DOCTYPE html html head title{% block title %}图书借阅系统{% endblock %}/title /head body nav a href{% url library:book_list %}图书列表/a {% if user.is_authenticated %} span欢迎{{ user.username }}/span a href{% url accounts:logout %}退出/a {% else %} a href{% url accounts:login %}登录/a {% endif %} /nav main {% block content %}{% endblock %} /main /body /html{% url %}标签用命名URL而不是硬编码路径这是必须养成的习惯。硬编码/library/books/这种写法一旦你改了URL配置所有模板里的链接全挂。用命名URL改路由只需要改urls.py一处。命名空间library:book_list这种带冒号的是为了避免不同app之间的URL名称冲突两个app都有list视图时不加命名空间Django会不知道反解哪个。4. 实操过程与核心环节实现4.1 环境搭建与项目初始化先把环境弄干净。我强烈建议每个项目都用虚拟环境不要往全局Python里装包。用python -m venv venv创建然后激活。Windows下是venv\Scripts\activateMac和Linux下是source venv/bin/activate。激活后命令行前面会出现(venv)标识看到这个才说明你在虚拟环境里。装Django用pip install django想指定版本就pip install django4.2。我选4.2 LTS版本长期支持稳定。装完用django-admin startproject config .创建项目注意最后那个点它表示在当前目录创建而不是再套一层文件夹。然后python manage.py startapp library和python manage.py startapp accounts创建两个app。创建完第一件事是去settings.py里注册app。找到INSTALLED_APPS列表把library和accounts加进去。这一步忘了的话Django不会加载你的模型迁移时会说没有检测到变更。然后是数据库配置默认的SQLite配置长这样DATABASES { default: { ENGINE: django.db.backends.sqlite3, NAME: BASE_DIR / db.sqlite3, } }想换PostgreSQL的话把ENGINE改成django.db.backends.postgresql再加NAME、USER、PASSWORD、HOST、PORT五个参数。但换数据库之前记得装对应的驱动包PostgreSQL是psycopg2-binaryMySQL是mysqlclient。驱动没装的话迁移时会报模块找不到的错。4.2 模型迁移与数据初始化模型写完后执行python manage.py makemigrations生成迁移文件再执行python manage.py migrate应用到数据库。这两个命令的区别要搞清楚makemigrations是把你的模型改动翻译成数据库能懂的迁移脚本migrate是真正执行这些脚本去改数据库表结构。很多人改完模型直接跑migrate发现没生效就是因为漏了makemigrations。迁移完成后我需要一些初始数据来测试。手动在admin里一条条加太慢我用Django的fixture或者数据迁移来做。这里演示一个更直接的方式——写个管理命令。在library/management/commands/目录下建一个seed_data.py继承BaseCommand在里面批量创建分类和书籍。这样执行python manage.py seed_data就能一键灌数据重复执行也不会重复创建用get_or_create。# library/management/commands/seed_data.py from django.core.management.base import BaseCommand from library.models import Category, Book class Command(BaseCommand): help 初始化演示数据 def handle(self, *args, **options): categories [计算机, 文学, 历史, 科学] for name in categories: Category.objects.get_or_create(namename) books_data [ (Django实战, 张三, 计算机), (Python进阶, 李四, 计算机), (百年孤独, 马尔克斯, 文学), (人类简史, 赫拉利, 历史), ] for title, author, cat_name in books_data: cat Category.objects.get(namecat_name) Book.objects.get_or_create( titletitle, defaults{author: author, category: cat} ) self.stdout.write(self.style.SUCCESS(演示数据初始化完成))注意get_or_create的用法它返回一个元组(对象, 是否新建)。第一个参数是查询条件defaults里是新建时才用的字段值。这样重复执行命令不会报唯一约束冲突也不会产生重复数据。这个模式在写初始化脚本时非常常用。4.3 表单处理与CSRF防护实战借书和还书操作我用表单提交而不是GET请求直接改数据。为什么因为GET请求应该是幂等的、只读的用它来修改数据违反HTTP语义而且容易被浏览器预加载或者爬虫误触发。用POST表单加上Django的CSRF token才能保证操作安全。Django的Form类在这里帮了大忙。我定义一个BookForm字段包括书名、作者、分类。分类字段用ModelChoiceField它会自动从Category表里拉数据生成下拉选项。表单的is_valid()方法会执行所有字段的校验包括必填检查、长度检查、类型检查。校验失败时form.errors里会有详细的错误信息模板里直接渲染就能显示给用户。# library/forms.py from django import forms from .models import Book class BookForm(forms.ModelForm): class Meta: model Book fields [title, author, category] widgets { title: forms.TextInput(attrs{class: form-control}), author: forms.TextInput(attrs{class: form-control}), }模板里渲染表单时{% csrf_token %}这一行绝对不能少。少了它Django会直接返回403禁止访问。这个token是Django自动生成的用来防止跨站请求伪造攻击。它的原理是服务端生成一个随机字符串同时存在cookie和表单隐藏域里提交时比对两者是否一致。攻击者伪造的请求里没有这个token就会被拒绝。注意如果你在调试时遇到403错误先检查表单里有没有{% csrf_token %}再检查settings里的CsrfViewMiddleware有没有被注释掉。这两个是最常见的原因。4.4 URL路由设计与命名空间配置URL配置看起来简单但设计得好不好直接影响项目的可维护性。我的原则是根urls.py只做分发具体路由放在各app的urls.py里。根urls里用include()把library/开头的请求转给library app处理accounts/开头的转给accounts app。这样每个app的路由自成体系复制到别的项目也能用。# config/urls.py from django.contrib import admin from django.urls import path, include urlpatterns [ path(admin/, admin.site.urls), path(library/, include(library.urls)), path(accounts/, include(accounts.urls)), ] # library/urls.py from django.urls import path from . import views app_name library urlpatterns [ path(, views.book_list, namebook_list), path(book/int:book_id/borrow/, views.borrow_book, nameborrow_book), path(book/int:book_id/return/, views.return_book, namereturn_book), path(book/add/, views.add_book, nameadd_book), ]app_name这个变量定义了命名空间配合模板里的{% url library:book_list %}使用。URL里的int:book_id是路径转换器它会把URL里那一段解析成整数传给视图函数。如果传的不是整数比如/book/abc/borrow/Django会直接返回404不用你在视图里手动校验。Django内置了str、int、slug、uuid几种转换器够用大部分场景。5. 常见问题与排查技巧实录5.1 模板找不到、静态文件404的排查路径模板报TemplateDoesNotExist九成是路径问题。Django找模板的顺序是先看settings里TEMPLATES配置的DIRS列表再遍历每个已注册app下的templates目录。如果你把模板放在library/templates/library/book_list.html那视图里写render(request, library/book_list.html)才能找到。少写一层library/Django就会去别的app里找找不到就报错。静态文件404是另一个高频问题。开发环境下DEBUGTrue时Django会自动服务静态文件但前提是你在INSTALLED_APPS里加了django.contrib.staticfiles并且urls.py里有staticfiles_urlpatterns。生产环境更复杂需要collectstatic把静态文件收集到一个目录再由Web服务器比如Nginx来服务。新手经常在开发时正常、一部署就样式全丢就是没做collectstatic。问题现象可能原因排查方法TemplateDoesNotExist模板路径写错或目录层级不对检查app是否注册、templates目录层级、render路径静态文件404未配置staticfiles或未collectstatic检查INSTALLED_APPS、DEBUG模式、运行collectstatic403 Forbidden缺少CSRF token检查表单内{% csrf_token %}NoReverseMatchURL名称写错或命名空间不对检查urls.py里的name和app_nameOperationalError: no such table未执行migrate运行makemigrations和migrate5.2 N1查询与性能优化实录前面提过select_related这里展开讲一下怎么发现和解决N1问题。最直接的方法是装django-debug-toolbar它会在页面侧边栏显示这次请求执行了多少条SQL、每条是什么。我第一次看到列表页执行了20多条SQL时吓了一跳明明只查了10本书。加上select_related后降到1条页面加载速度肉眼可见地变快。对于多对多关系或者反向外键select_related搞不定要用prefetch_related。比如你想在分类列表页显示每个分类下的书籍数量用prefetch_related(books)会先查所有分类再用一条IN查询把所有相关书籍拉回来在Python层面做关联而不是每个分类查一次。这两个方法的区别是select_related用SQL JOIN适合一对一和外键prefetch_related用额外查询加Python关联适合多对多和反向关系。实操心得不要过早优化。先用debug-toolbar看哪里查询多再针对性加select_related或prefetch_related。盲目加反而可能因为JOIN太多导致查询变慢。5.3 用户认证与权限控制的坑借书操作需要登录我用login_required装饰器实现。未登录用户访问借书URL时Django会自动重定向到登录页并在URL里带上?next/library/book/1/borrow/登录成功后跳回原页面。这个next参数的处理有个安全细节Django默认只允许跳转到同域URL防止开放重定向攻击。如果你自己写登录视图一定要用url_has_allowed_host_and_scheme校验next参数别直接redirect(request.GET[next])那是个安全漏洞。另一个坑是settings.LOGIN_URL的配置。login_required默认跳转到/accounts/login/如果你的登录页在别的路径要在settings里设LOGIN_URL /auth/login/。不设的话用户会被重定向到一个404页面体验很差。还有LOGIN_REDIRECT_URL控制登录成功后的默认跳转不设的话默认跳/accounts/profile/这个页面Django没帮你建也是404。5.4 数据库迁移冲突的处理多人协作时迁移文件冲突是家常便饭。两个人各自改了同一个模型生成了两个迁移文件合并代码后migrate就会报错。处理方法是先python manage.py showmigrations看哪些迁移没应用再python manage.py migrate --fake把冲突的迁移标记为已应用前提是数据库结构已经是对的或者手动合并迁移文件。最稳妥的做法是每次改模型前先pull最新代码改完尽快提交迁移文件减少冲突窗口。如果迁移已经乱到无法收拾开发阶段可以删掉数据库文件和所有迁移文件除了__init__.py重新makemigrations和migrate。但生产环境千万别这么干数据会全丢。生产环境的迁移要谨慎每次migrate前先备份数据库这是铁律。6. 从演示案例到真实项目的扩展思路这个案例跑通后你可以沿着几个方向继续深挖。第一个方向是加权限分级比如普通用户只能借书管理员才能添加和删除书籍。Django的权限系统基于用户组和权限点可以在admin里给用户组分配权限视图里用permission_required装饰器校验。第二个方向是加借阅历史记录把每次借还操作写进一张日志表这样能查某本书被谁借过、某个人借过哪些书。第三个方向是加搜索和分页列表页数据一多就需要分页Django的Paginator类几行代码就能搞定搜索则可以用Q对象做多字段模糊匹配。我个人在实际操作中的体会是MVT模式最大的价值不是让你写代码更快而是让你在项目变大时知道该改哪里。需求来了先想这是数据结构的变动改Model、业务逻辑的变动改View还是展示的变动改Template定位准了改动范围就小。最怕的是一锅粥式的代码改一个按钮颜色结果把数据库查询也改坏了。这个案例虽然简单但把这条分界线画清楚了后面你写再复杂的系统心里都有张地图。最后分享一个小技巧每次写完一个功能花两分钟在admin后台里手动操作一遍数据看看模型定义有没有问题、关联关系对不对。admin是Django送你的调试利器很多人却只把它当生产工具用。开发阶段用admin快速验证数据模型比写测试用例还快。等模型稳定了再补上自动化测试这样既保证了开发速度又守住了质量底线。
阅读完成 · 觉得有帮助?