ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Django MVT实战:从零构建图书借阅系统,彻底搞懂Model-View-Template分层

Django MVT实战:从零构建图书借阅系统,彻底搞懂Model-View-Template分层

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不认识View,Template也不认识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记录借出时间。这里我用null=True, blank=True允许借阅人和借出时间为空,因为新书入库时还没被借走。注意null和blank的区别:null是数据库层面的空值,blank是表单校验层面的允许为空。对于字符串字段,Django官方建议不要用null=True,因为字符串的空值用空字符串表示更一致,但日期和布尔字段用null是合理的。

# library/models.py from django.db import models from django.conf import settings class Category(models.Model): name = models.CharField(max_length=50, unique=True) description = models.TextField(blank=True) def __str__(self): return self.name class Meta: verbose_name_plural = "Categories" class Book(models.Model): title = models.CharField(max_length=200) author = models.CharField(max_length=100) category = models.ForeignKey( Category, on_delete=models.PROTECT, related_name="books" ) is_borrowed = models.BooleanField(default=False) borrower = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="borrowed_books" ) borrowed_date = models.DateField(null=True, blank=True) def __str__(self): return self.title

related_name这个参数值得单独说。它定义了反向查询的名字。比如有了related_name="books",你就可以通过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, id=book_id) if book.is_borrowed: return redirect("library:book_list") book.borrow(request.user) return redirect("library:book_list")

注意select_related这个用法。当你在模板里要显示每本书的分类名和借阅人名时,如果不加select_related,Django会对每本书单独查一次分类表和用户表,这就是经典的N+1查询问题。10本书就是21次查询,100本就是201次。加上select_related,Django会用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\activate,Mac和Linux下是source venv/bin/activate。激活后命令行前面会出现(venv)标识,看到这个才说明你在虚拟环境里。

装Django用pip install django,想指定版本就pip install django==4.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-binary,MySQL是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(name=name) books_data = [ ("Django实战", "张三", "计算机"), ("Python进阶", "李四", "计算机"), ("百年孤独", "马尔克斯", "文学"), ("人类简史", "赫拉利", "历史"), ] for title, author, cat_name in books_data: cat = Category.objects.get(name=cat_name) Book.objects.get_or_create( title=title, 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, name="book_list"), path("book/<int:book_id>/borrow/", views.borrow_book, name="borrow_book"), path("book/<int:book_id>/return/", views.return_book, name="return_book"), path("book/add/", views.add_book, name="add_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是另一个高频问题。开发环境下,DEBUG=True时Django会自动服务静态文件,但前提是你在INSTALLED_APPS里加了django.contrib.staticfiles,并且urls.py里有staticfiles_urlpatterns。生产环境更复杂,需要collectstatic把静态文件收集到一个目录,再由Web服务器(比如Nginx)来服务。新手经常在开发时正常、一部署就样式全丢,就是没做collectstatic。

问题现象可能原因排查方法
TemplateDoesNotExist模板路径写错或目录层级不对检查app是否注册、templates目录层级、render路径
静态文件404未配置staticfiles或未collectstatic检查INSTALLED_APPS、DEBUG模式、运行collectstatic
403 Forbidden缺少CSRF token检查表单内{% csrf_token %}
NoReverseMatchURL名称写错或命名空间不对检查urls.py里的name和app_name
OperationalError: no such table未执行migrate运行makemigrations和migrate

5.2 N+1查询与性能优化实录

前面提过select_related,这里展开讲一下怎么发现和解决N+1问题。最直接的方法是装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快速验证数据模型,比写测试用例还快。等模型稳定了,再补上自动化测试,这样既保证了开发速度,又守住了质量底线。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进