
很多人学Django都卡在同一个地方教程看得懂真要自己动手从零写一个DjangoBlogMVT三层怎么拆、多个应用之间怎么协作脑子里立刻一团浆糊。我最早照着官方投票教程敲了三遍自以为拿捏了MVT架构结果动手做博客项目时光是处理文章列表要显示作者昵称这个小需求就绕了快两个小时。这篇文章就围绕我当时做DjangoBlog的真实过程展开讲讲MVT架构具体到一个博客项目里该怎么落users、blog、comment这些应用之间怎么划边界、怎么配合以及所有我差点翻车的细节。适合刚学完Django基础、准备做第一个完整项目的同学也适合被业务代码绕晕、想回头理清架构思路的开发者。当时做这个项目我给自己定的目标是不写单文件的一堆乱七八糟视图不把逻辑全堆在views.py里而是真按照一个可维护的博客系统来搭。做完之后回头看收获最大其实不是我会用Django了而是搞清楚了一个项目该在什么地方做决定。1. 为什么拿博客练手Django从懂语法到搭多应用系统的真实跨度1.1 一个博客系统恰好覆盖了MVT架构的完整链路我在带过一些新人之后发现不少人喜欢拿用户管理系统练手但那玩意儿业务太薄无非是增删改查根本逼不出架构问题。博客系统不一样它天然带着几个应用需求用户注册登录、文章管理、评论互动、标签分类、搜索分页。随便哪一条拉出来都能牵扯到Django的几个核心机制。我的项目拆了四个应用users负责用户扩展字段和登录态blog负责文章、分类和标签comment负责评论search负责站内检索。为什么这么拆核心逻辑是按业务域分而不是按功能分。很多初学者喜欢按操作分搞一个create_app装所有跟创建有关的视图这是典型错误因为业务上的不同对象之间根本没有协作关系硬塞在一个应用里只会让代码越来越厚重。1.2 大多数人学MVT架构时最大的误区MVT这三个字母教程里都会讲M是模型V是视图T是模板。问题在于很多人把它理解成三段式代码组织——model里放数据库字段、view里放函数、template里放HTML。这么理解不算错但太表层。我自己的体会是MVT架构解决的核心问题不是代码放哪儿而是数据流的方向。请求进来之后URL层决定把这个请求交给哪个应用处理视图层负责向模型层要数据、把数据装进上下文模板层只管拿现成的数据去渲染。整个过程是单向流动的请求 → 路由 → 视图 → 模型返回数据 → 模板渲染 → 响应。如果脑子里没有这条数据流的图写着写着就会开始手痒在模板里写查询、在模型里写HTTP逻辑、在视图里拼大段大段的HTML字符串。这些我全干过后来全部老老实实拆开了。下面我会用一个具体需求把这条链路串起来讲比空谈概念有用得多。2. 多应用拆分博客项目从零开始的边界划分与数据模型设计2.1 先把应用边界画清楚再动手写代码动手之前我在纸上把业务对象列了一遍用户、文章、分类、标签、评论。然后问自己几个问题用户能不能独立存在能。所以users是一个独立应用。文章依赖用户吗依赖文章有作者。但文章本身有独立的生命周期和操作逻辑所以blog独立。评论依赖文章吗依赖评论必然挂在某篇文章下。但评论的操作逻辑增删改查、审核跟文章管理完全不是一回事所以comment独立。标签和分类呢它们本质上是文章的一种元数据放blog应用里就够了没必要单独拆。这个判断过程很重要。拆应用不只是为了代码好看而是为了后续迭代的时候改评论模块不会误伤文章模块各自的路由、模板、迁移文件完全隔离开。我把这个叫变更隔离这是多应用协作最大的好处。2.2 users应用自定义用户模型我强烈建议你在第一步就做很多Django教程默认用内置的User模型然后通过OneToOneField去扩展用户资料。这么做不是不行但在真实项目里很别扭。因为一旦你后面想做登录用邮箱而不是用户名、想加头像和个性签名内置模型根本没法直接改。我的做法是这样的在项目初始化之后的第一个步骤就指定自定义用户模型# users/models.py from django.contrib.auth.models import AbstractUser from django.db import models class BlogUser(AbstractUser): nickname models.CharField(昵称, max_length50, blankTrue) avatar models.ImageField(头像, upload_toavatars/%Y/%m/, blankTrue) bio models.TextField(个人简介, blankTrue) class Meta: db_table blog_user verbose_name 博客用户 verbose_name_plural verbose_name def __str__(self): return self.nickname or self.username然后在配置文件的AUTH_USER_MODEL里指向它# settings.py AUTH_USER_MODEL users.BlogUser注意这一步必须在第一次makemigrations之前完成否则Django会生成默认auth_user表后面想切自定义模型会非常麻烦。这是我踩过的坑有个demo项目就是做了一半才想加头像字段结果迁移记录一堆冲突最后只能删库重来。2.3 blog与comment跨应用的外键关系怎么设计跨应用协作在Django里最典型的表现就是外键指向另一个应用的模型。我的文章模型大概是这样的# blog/models.py from django.conf import settings from django.db import models from django.utils.text import slugify class Category(models.Model): name models.CharField(分类名, max_length50) slug models.SlugField(别名, max_length100, uniqueTrue) class Tag(models.Model): name models.CharField(标签名, max_length50) slug models.SlugField(别名, max_length100, uniqueTrue) class Post(models.Model): title models.CharField(标题, max_length200) slug models.SlugField(链接别名, max_length200, uniqueTrue) content models.TextField(正文) author models.ForeignKey( settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, related_nameposts, verbose_name作者, ) category models.ForeignKey( Category, on_deletemodels.SET_NULL, nullTrue, blankTrue, related_nameposts, verbose_name分类, ) tags models.ManyToManyField(Tag, related_nameposts, blankTrue, verbose_name标签) created_at models.DateTimeField(创建时间, auto_now_addTrue) updated_at models.DateTimeField(更新时间, auto_nowTrue) class Meta: ordering [-created_at] def save(self, *args, **kwargs): if not self.slug: self.slug slugify(self.title) super().save(*args, **kwargs)评论模型放在comment应用里外键指向blog.Post# comment/models.py from django.conf import settings from django.db import models from django.db.models import F class Comment(models.Model): post models.ForeignKey( blog.Post, on_deletemodels.CASCADE, related_namecomments, verbose_name所属文章, ) author models.ForeignKey( settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, related_namecomments, verbose_name评论者, ) content models.TextField(评论内容) created_at models.DateTimeField(评论时间, auto_now_addTrue) class Meta: ordering [-created_at]这里我重点说明一个设计细节related_name不是随便起的。它是反向查询的名字直接影响跨应用查询的写法和性能。上面设了Post.comments和Post.tags所以在视图里我可以很自然地写post.comments.all()、post.tags.all()这就是跨应用协作在代码层面的表现——一个模型通过外键把另一个应用的数据拉到自己身边。外键的on_delete也要认真想。文章被删了评论没有意义就应该级联删掉所以CASCADE。分类被删了文章不能跟着没所以用SET_NULL而且外键字段要设nullTrue。我见过不少新人一上来全用CASCADE结果删个测试分类把几十篇文章全带没了那个心疼是真的。3. MVT三件套的真实分工模型、视图、模板在处理一个请求时到底谁干什么3.1 模型层不只是字段定义查询粒度才是关键很多教程把模型层讲成数据库表的映射这没问题但不够。模型层真正值钱的地方在于它应该把常用的查询逻辑封装起来而不是让视图层到处写filter条件。举个例子我的首页需要展示已发布文章列表带作者昵称、评论数、分类名。如果直接在视图里写posts Post.objects.filter(statuspublished)这能跑但每次渲染都要在模板里做反向查询性能很差。我的做法是在Post模型里写一个管理器方法# blog/models.py from django.db import models from django.db.models import Count, F, Prefetch class PublishedPostManager(models.Manager): def get_queryset(self): return ( super() .get_queryset() .filter(statuspublished) .select_related(author, category) .prefetch_related(tags) .annotate(comment_countCount(comments)) ) class Post(models.Model): # ... 字段定义省略 objects models.Manager() published PublishedPostManager()然后在视图里只写一行posts Post.published.all()这里面有两个关键点。第一个是select_related和prefetch_related的区分外键用select_related多对多和反向外键用prefetch_related。前者通过SQL的JOIN把关联数据一次查出来后者先用一条查询查出主表再按外键值批量查出关联表。一开始我老搞混后来自己给自己总结了一句话select_related是顺着外键的方向去JOINprefetch_related是倒着反向关系去批量查。第二个关键是annotate(comment_countCount(comments))。这是跨应用协作里的经典场景要在文章列表里显示评论数但评论数据不在blog应用里而是属于comment应用。通过反向关系名comments做聚合一条SQL就能把评论数带出来避免了在循环里逐条执行查询这种经典N1问题。3.2 视图层只做三件事取数、组装、响应我把视图看作是连接层往下接住模型查出来的数据往上塞给模板去渲染。业务逻辑原则上不放在视图函数里这是我后来才想明白的。比如文章详情的视图早期版本我是这么写的def post_detail(request, slug): post Post.objects.select_related(author, category).get(slugslug) comments post.comments.select_related(author).all() tags post.tags.all() return render(request, blog/detail.html, {post: post, comments: comments, tags: tags})后来发现如果用户提交了评论逻辑就会越堆越多要不要处理表单、要不要校验登录、要不要更新评论数。我把这些逻辑拆到了表单和模型方法里视图收敛成下面这样from django.shortcuts import get_object_or_404, render from django.views.decorators.http import require_POST from django.http import HttpResponseRedirect from django.urls import reverse from comment.forms import CommentForm def post_detail(request, slug): post get_object_or_404(Post.published, slugslug) comments post.comments.select_related(author).all() form CommentForm() return render( request, blog/detail.html, {post: post, comments: comments, form: form}, ) require_POST def post_comment(request, slug): post get_object_or_404(Post.published, slugslug) form CommentForm(request.POST) if form.is_valid(): comment form.save(commitFalse) comment.post post comment.author request.user comment.save() return HttpResponseRedirect(reverse(blog:detail, args[slug]))注意我这里用了require_POST只允许POST请求进入这个函数。这样评论必须是登录用户提交而且提交成功后用HttpResponseRedirect跳回去这就是很基础的POST-Redirect-GET模式防止用户刷新页面重复提交评论。视图到底该多厚我的标准是一个视图函数内不做超过两层的业务判断一旦超过就提炼成模型方法或者表单逻辑。这样每个函数都能一眼看懂它要干什么。3.3 模板层只负责展示不写查询、不写逻辑这是MVT里最容易失控的一层。Django模板语言为了安全故意砍掉了函数调用和复杂表达式但这也导致很多人不敢在模板里干活。我的准则是模板里只能出现以下三种东西——变量、简单的属性访问、for循环和if判断。凡是需要计算、过滤、截断、拼接的逻辑一律在视图或模型中处理好。比如文章详情页我要根据评论数量显示不同的文案。一开始我在模板里写了一大段{% if comments.count 0 %}后来发现count是个方法模板里虽然能调用但每次渲染都会多一次数据库查询。正确做法是在视图或模型中提前算好comment_count post.comments.count()然后模板只做判断{% if comment_count 0 %} p还没人评论抢个沙发。/p {% else %} p{{ comment_count }} 条评论/p {% endif %}同理文章摘要我不在模板里做{{ post.content|truncatechars:100 }}以外的复杂处理。如果首页需要的摘要样式极其复杂我会在模型里加一个方法get_excerpt()把截断、清理HTML、加省略号这些逻辑全部封装起来模板只调用这个方法。如界你会奇怪为什么模板层这么笨那恰恰是Django的设计哲学——模板应该是最容易被替换的。今天你用Django默认模板引擎明天想换成别的模板引擎或者做前后端分离接口只要视图层返回的都是干净的数据结构切换就毫无痛苦。我至今没换过但这个理念让我一直保持了视图的清爽。4. 多应用协作的粘合剂URL路由、模板继承与跨应用查询4.1 URL命名空间让路由成为应用间的通信协议四个应用之间必然有互相跳转的情况。文章详情页里要跳转到用户主页用户在个人中心里可以看到它发布的全部文章评论列表里也要在用户名和文章标题之间来回跳。唯一可靠的方式是给每个应用的URL都加上命名空间。我在项目配置文件里这样注册# config/urls.py from django.contrib import admin from django.urls import path, include urlpatterns [ path(admin/, admin.site.urls), path(, include(blog.urls, namespaceblog)), path(users/, include(users.urls, namespaceusers)), path(comments/, include(comment.urls, namespacecomment)), path(search/, include(search.urls, namespacesearch)), ]然后在blog/urls.py里# blog/urls.py from django.urls import path from . import views app_name blog urlpatterns [ path(, views.post_list, nameindex), path(post/slug:slug/, views.post_detail, namedetail), path(post/slug:slug/comment/, views.post_comment, namecomment), path(category/slug:slug/, views.category_detail, namecategory), path(tag/slug:slug/, views.tag_detail, nametag), ]这里app_name blog就是给这个应用的URL取命名空间。模板里跳转时用冒号语法a href{% url blog:detail post.slug %}{{ post.title }}/a命名空间的真正价值不在于好记而在于你可以在不同应用里出现同名URL比如users里面也有一个detail用户详情两个{% url detail %}不会冲突。这个坑我确实踩过早期写demo不用命名空间结果一个页面里有三个detail链接模板渲染时全部指向最后一个排查了半小时。从那以后任何项目的路由我都强制用命名空间没有例外。4.2 模板复用base.html、block 与 include 的分工多应用协作在模板层面的体现是我觉得最容易被新人忽略的。各应用有自己的模板文件夹但渲染出来的整体页面风格必须统一。Django用模板继承解决这个问题我用一个templates/base.html作为母版!-- templates/base.html -- !DOCTYPE html html langzh-cn head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title{% block title %}我的博客{% endblock %}/title {% load static %} link relstylesheet href{% static css/style.css %} /head body header nav a href{% url blog:index %}首页/a {% if user.is_authenticated %} a href{% url users:profile %}{{ user.nickname }}/a a href{% url users:logout %}退出/a {% else %} a href{% url users:login %}登录/a {% endif %} /nav /header main {% block content %}{% endblock %} /main footer{% block footer %}博客底部信息{% endblock %}/footer /body /html然后每个应用的模板都继承这个母版。比如blog/templates/blog/post_list.html{% extends base.html %} {% block title %}最新文章 - 我的博客{% endblock %} {% block content %} div classpost-list {% for post in posts %} article h2a href{% url blog:detail post.slug %}{{ post.title }}/a/h2 div classmeta 作者a href{% url users:profile post.author.username %}{{ post.author.nickname }}/a 分类a href{% url blog:category post.category.slug %}{{ post.category.name }}/a 评论{{ post.comment_count }} /div p{{ post.content|truncatechars:100 }}/p /article {% empty %} p暂时没有文章。/p {% endfor %} /div {% endblock %}模板继承最大的优点是增量覆盖母版里定义好的导航栏、页脚所有页面自动复用子模板只需要写自己关心的那部分block。什么内容该放block、什么该放在base.html里写死我的经验是共用且不变的部分写死共用且可变的部分放block某个应用私有的内容不要出现在base里。还有一个容易混淆的是include。include是把一个模板片段嵌进来它不涉及继承关系适合放独立的、可重复的组件比如评论框。我在文章详情页里写{% include comment/comment_form.html %}这个评论框组件接收一个form变量无论谁调用它只要传入form就能渲染。区别就是extends是我是子页面我继承父母的样子include是我这里插一个公共部件父母儿女都能用。4.3 跨应用反向关联查询怎么把文章的评论和用户的文章一次查出来多应用协作最核心的痛点就是查询跨应用的数据。除了前面提到的select_related、prefetch_related还有一个很常见的场景在某个用户的个人主页里要显示这个用户发布过的所有文章以及他写过的所有评论。如果用默认的反向查询写法是这样posts BlogUser.objects.get(usernameusername).posts.all() comments BlogUser.objects.get(usernameusername).comments.all()这里每个posts.all()和comments.all()都会触发一次全新的数据库查询而且如果页面还要显示每篇文章的评论数那就是更大的灾难。我优化后的写法是from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404, render User get_user_model() def user_profile(request, username): user get_object_or_404( User.objects.prefetch_related(posts, comments), usernameusername, ) posts user.posts.select_related(category).annotate( comment_countCount(comments, distinctTrue) ) comments user.comments.select_related(post).all() return render( request, users/profile.html, {profile_user: user, posts: posts, comments: comments}, )这里prefetch_related(posts, comments)把该用户关联的两类数据一次性查出来posts上再做select_related(category)去避免文章列表页对分类的逐条查询。写代码时注意prefetch_related要在get_object_or_404的查询集上执行我见过有人先取出用户对象再调用prefetch_related结果因为懒加载机制压根没有预取效果。除此外跨应用的反向关联名字必须统一、语义清晰。related_nameposts和related_namecomments看名字就知道是这个用户的所有文章和这个用户的所有评论。如果随手写related_nameitems回头连自己都看不懂是在查什么跨应用调试时尤其痛苦。5. 上线前的几个硬坑静态文件、分页、搜索与表单交互5.1 静态文件与媒体文件开发环境没事部署环境翻车Django里静态文件CSS、JS、图片和上传的媒体文件用户头像、文章配图是两套不同机制。开发时用python manage.py runserver自动处理一上真实环境就全裸奔。我自己在部署demo时栽过一个跟头CSS样式全丢页面裸奔成一坨纯文本。原因很简单我用nginx之类的服务器静态文件要给出去但Django默认并不会帮你拷贝静态文件到一个地方需要在settings.py里配置并执行收集命令# settings.py STATIC_URL /static/ STATICFILES_DIRS [BASE_DIR / static] STATIC_ROOT BASE_DIR / staticfiles MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media上线前跑python manage.py collectstatic这个命令会把各应用static/目录和STATICFILES_DIRS里的文件统统复制到STATIC_ROOT然后让服务器去那个目录取。模板里的{% load static %}和{% static css/style.css %}对应的是STATIC_URL跟开发时一样的写法部署环境里路径才会正确。media路径同理用户上传的头像会保存到MEDIA_ROOT下需要保证这个目录在服务器上有写权限并且部署服务器把/media/路径指向这个目录。文件的url我建议永远用{{ user.avatar.url }}这种动态属性而不是手写/media/xxx.jpg否则一旦改了存储方案或路径规则全站引用全部报废。5.2 分页器的PageNotAnInteger坑99%的人都会遇到博客首页文章一多就得分页Django自带的Paginator用起来很爽但有一个经典坑用户手改URL里的?pageabc程序直接抛PageNotAnInteger。我的处理思路不是报错而是容错降级——非法参数一律当成第一页。# blog/views.py from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger def post_list(request): posts Post.published.all() paginator Paginator(posts, 10) page_number request.GET.get(page) try: page_obj paginator.get_page(page_number) except (PageNotAnInteger, EmptyPage): page_obj paginator.get_page(1) return render(request, blog/post_list.html, {page_obj: page_obj})其实Paginator.get_page()内部已经对PageNotAnInteger和EmptyPage做了容错——这是Django 2.0之后的行为非法页码返回第一页超出范围返回最后一页。所以我上面那段try/except在当代Django里可以删掉直接get_page就够了。真正需要自己处理的是翻页链接的生成。模板里要用page_obj.has_previous、page_obj.previous_page_number这些属性去构造上一页/下一页链接同时要保持当前查询参数。我早期做分页只传page忘了带keyword结果搜索页翻到第二页搜索词全丢了用户看到的是一个跟关键词无关的结果集。后来我在模板里这样处理{% if page_obj.has_previous %} a href?page{{ page_obj.previous_page_number }}{% if keyword %}keyword{{ keyword }}{% endif %}上一页/a {% endif %}凡是带筛选条件的列表页查询参数都必须跟着分页链接走。这个细节不解决分页功能等于半个残废。5.3 站内搜索的实现从icontains到搜索方案的取舍博客的站内搜索我最早用最简单粗暴的写法一篇文章标题和正文里做icontains匹配。posts Post.objects.filter( Q(title__icontainskeyword) | Q(content__icontainskeyword) )这个方案在文章量几百篇时没有任何问题但一旦上万篇文章性能就捉急了因为icontains在数据库里基本会变成LIKE %keyword%这种查询无法利用索引只能全表扫描查询会越来越慢且没有相关性排序——比如你想找一篇标题完全匹配的文章它和最不相关的段落命中混在一起。做这个博客的目的既然是学习我建议你体验一把完整的搜索方案我用的是PostgreSQL自带的全文检索Django在contrib.postgres里提供了SearchVector和SearchQueryfrom django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank from django.db.models import Q def search(request, keyword): vector SearchVector(title, weightA) SearchVector(content, weightB) query SearchQuery(keyword) posts Post.published.annotate( rankSearchRank(vector, query) ).filter(rank__gte0.01).order_by(-rank) return render(request, search/results.html, {posts: posts, keyword: keyword})这里给标题和正文分配了不同权重标题权重A正文权重B这样标题命中的文章排名会更靠前搜索结果的实用程度一下子就不一样了。如果用的不是PostgreSQL也可以落地一些更轻的方案比如给搜索词做分词后Q对象拼接。但无论如何别把能搜出来当成搜索功能的终点相关性排序才是让人感觉这个网站搜索挺聪明的分水岭。5.4 评论表单中的CSRF、登录校验与防止重复提交评论功能看着简单做起来其实藏着几个容易忽略的问题。第一是CSRF保护Django默认开启所以表单里必须加{% csrf_token %}否则每次提交都会报403。第二是登录状态校验我这里有装饰器版的实现# comment/views.py from django.contrib.auth.decorators import login_required login_required require_POST def post_comment(request, slug): post get_object_or_404(Post.published, slugslug) form CommentForm(request.POST) if form.is_valid(): comment form.save(commitFalse) comment.post post comment.author request.user comment.save() return HttpResponseRedirect(reverse(blog:detail, args[slug]))login_required会替你把未登录用户重定向到登录页登录后还能自动跳回原页面。第三是防止重复提交前面提到的HttpResponseRedirect很关键。如果没有这个重定向用户提交评论后停留在一个POST请求的响应页上刷新页面就会重复提交数据库里多出好几条一模一样的评论。POST-Redirect-GET这个模式在一切涉及用户输入→写数据库的流程里都必须成为肌肉记忆。关于表单的commitFalse这是Django ModelForm的一个方便之处。它允许你先拿到一个还没入库的模型实例把外键字段比如post和author手动填上再save()。如果用户只填了评论内容就要靠视图层把post和author塞进去。我遇到不少初学者在这里卡住以为form.save()是整个保存其实commitFalse才是处理表单之外字段的标准姿势。5.5 个人实践中总结出的调试与迭代节奏写完这个DjangoBlog之后我最大的体会是新手最容易犯的错误不是某个具体的Django API不会用而是架构意识缺失。这里的架构不是那种大而空的企业级架构而是小项目里的下一步我该在哪里加代码的判断力。我自己总结了一套很实用的判断标准当我要加一个新功能时先问自己三个问题——这个功能的数据从哪来决定写不写Model、这个功能的页面用什么状态展示决定写不写View和Template、这个功能和哪个业务域的关系最密切决定放进哪个应用。如果答案是跟用户有关就放users跟文章有关就放blog你的项目永远乱不了。我还有一个习惯每次写完一个功能就回头审视一次自己的视图函数。如果某个视图超过30行我会强迫自己拆分。不是拆成更小的函数而是把逻辑下沉到模型方法或者表单逻辑里。这样做的结果是代码量总数差不多但每个文件的可读性和定位问题的速度提升了不是一个量级。就拿文章详情页来说最初版本的视图函数有60多行又是取文章、又是取评论、又是处理表单混在一起。后来拆完视图剩15行模型里多了一个get_comment_count()表单逻辑归到表单类。当一个报错出现时我能立刻判断问题在哪个文件而不是在一大坨代码里来回滚动找线索。做技术博客这个项目不用急着做到多精致。先让四个应用都跑通再回头优化查询、重构模板最后才考虑部署。一步步来Django的多应用协作能力会在你越来越熟悉的过程中变成一种直觉。