ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Django MVT架构实战:从零搭建多应用博客系统的完整指南

Django MVT架构实战:从零搭建多应用博客系统的完整指南

很多人学Django都卡在同一个地方:教程看得懂,真要自己动手从零写一个DjangoBlog,MVT三层怎么拆、多个应用之间怎么协作,脑子里立刻一团浆糊。我最早照着官方投票教程敲了三遍,自以为拿捏了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_length=50, blank=True) avatar = models.ImageField("头像", upload_to="avatars/%Y/%m/", blank=True) bio = models.TextField("个人简介", blank=True) 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_length=50) slug = models.SlugField("别名", max_length=100, unique=True) class Tag(models.Model): name = models.CharField("标签名", max_length=50) slug = models.SlugField("别名", max_length=100, unique=True) class Post(models.Model): title = models.CharField("标题", max_length=200) slug = models.SlugField("链接别名", max_length=200, unique=True) content = models.TextField("正文") author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="posts", verbose_name="作者", ) category = models.ForeignKey( Category, on_delete=models.SET_NULL, null=True, blank=True, related_name="posts", verbose_name="分类", ) tags = models.ManyToManyField(Tag, related_name="posts", blank=True, verbose_name="标签") created_at = models.DateTimeField("创建时间", auto_now_add=True) updated_at = models.DateTimeField("更新时间", auto_now=True) 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_delete=models.CASCADE, related_name="comments", verbose_name="所属文章", ) author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="comments", verbose_name="评论者", ) content = models.TextField("评论内容") created_at = models.DateTimeField("评论时间", auto_now_add=True) class Meta: ordering = ["-created_at"]

这里我重点说明一个设计细节:related_name不是随便起的。它是反向查询的名字,直接影响跨应用查询的写法和性能。上面设了Post.comments和Post.tags,所以在视图里我可以很自然地写post.comments.all()、post.tags.all(),这就是跨应用协作在代码层面的表现——一个模型通过外键把另一个应用的数据"拉"到自己身边。

外键的on_delete也要认真想。文章被删了,评论没有意义,就应该级联删掉,所以CASCADE。分类被删了,文章不能跟着没,所以用SET_NULL,而且外键字段要设null=True。我见过不少新人一上来全用CASCADE,结果删个测试分类把几十篇文章全带没了,那个心疼是真的。

3. MVT三件套的真实分工:模型、视图、模板在处理一个请求时到底谁干什么

3.1 模型层不只是字段定义,查询粒度才是关键

很多教程把模型层讲成"数据库表的映射",这没问题,但不够。模型层真正值钱的地方在于:它应该把常用的查询逻辑封装起来,而不是让视图层到处写filter条件。

举个例子,我的首页需要展示"已发布文章列表,带作者昵称、评论数、分类名"。如果直接在视图里写:

posts = Post.objects.filter(status="published")

这能跑,但每次渲染都要在模板里做反向查询,性能很差。我的做法是在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(status="published") .select_related("author", "category") .prefetch_related("tags") .annotate(comment_count=Count("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是"顺着外键的方向去JOIN",prefetch_related是"倒着反向关系去批量查"。

第二个关键是annotate(comment_count=Count("comments"))。这是跨应用协作里的经典场景:要在文章列表里显示评论数,但评论数据不在blog应用里,而是属于comment应用。通过反向关系名comments做聚合,一条SQL就能把评论数带出来,避免了在循环里逐条执行查询这种"经典N+1问题"。

3.2 视图层只做三件事:取数、组装、响应

我把视图看作是"连接层":往下接住模型查出来的数据,往上塞给模板去渲染。业务逻辑原则上不放在视图函数里,这是我后来才想明白的。

比如文章详情的视图,早期版本我是这么写的:

def post_detail(request, slug): post = Post.objects.select_related("author", "category").get(slug=slug) 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, slug=slug) 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, slug=slug) form = CommentForm(request.POST) if form.is_valid(): comment = form.save(commit=False) 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", namespace="blog")), path("users/", include("users.urls", namespace="users")), path("comments/", include("comment.urls", namespace="comment")), path("search/", include("search.urls", namespace="search")), ]

然后在blog/urls.py里:

# blog/urls.py from django.urls import path from . import views app_name = "blog" urlpatterns = [ path("", views.post_list, name="index"), path("post/<slug:slug>/", views.post_detail, name="detail"), path("post/<slug:slug>/comment/", views.post_comment, name="comment"), path("category/<slug:slug>/", views.category_detail, name="category"), path("tag/<slug:slug>/", views.tag_detail, name="tag"), ]

这里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 lang="zh-cn"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{% block title %}我的博客{% endblock %}</title> {% load static %} <link rel="stylesheet" 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 class="post-list"> {% for post in posts %} <article> <h2><a href="{% url 'blog:detail' post.slug %}">{{ post.title }}</a></h2> <div class="meta"> 作者:<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(username=username).posts.all() comments = BlogUser.objects.get(username=username).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"), username=username, ) posts = user.posts.select_related("category").annotate( comment_count=Count("comments", distinct=True) ) 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_name="posts"和related_name="comments"看名字就知道是"这个用户的所有文章"和"这个用户的所有评论"。如果随手写related_name="items",回头连自己都看不懂是在查什么,跨应用调试时尤其痛苦。

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里的?page=abc,程序直接抛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__icontains=keyword) | Q(content__icontains=keyword) )

这个方案在文章量几百篇时没有任何问题,但一旦上万篇文章,性能就捉急了,因为icontains在数据库里基本会变成LIKE '%keyword%',这种查询无法利用索引,只能全表扫描,查询会越来越慢,且没有相关性排序——比如你想找一篇标题完全匹配的文章,它和最不相关的段落命中混在一起。

做这个博客的目的既然是学习,我建议你体验一把完整的搜索方案,我用的是PostgreSQL自带的全文检索,Django在contrib.postgres里提供了SearchVector和SearchQuery:

from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank from django.db.models import Q def search(request, keyword): vector = SearchVector("title", weight="A") + SearchVector("content", weight="B") query = SearchQuery(keyword) posts = Post.published.annotate( rank=SearchRank(vector, query) ).filter(rank__gte=0.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, slug=slug) form = CommentForm(request.POST) if form.is_valid(): comment = form.save(commit=False) comment.post = post comment.author = request.user comment.save() return HttpResponseRedirect(reverse("blog:detail", args=[slug]))

@login_required会替你把未登录用户重定向到登录页,登录后还能自动跳回原页面。第三是"防止重复提交",前面提到的HttpResponseRedirect很关键。如果没有这个重定向,用户提交评论后停留在一个POST请求的响应页上,刷新页面就会重复提交,数据库里多出好几条一模一样的评论。POST-Redirect-GET这个模式,在一切涉及"用户输入→写数据库"的流程里都必须成为肌肉记忆。

关于表单的commit=False,这是Django ModelForm的一个方便之处。它允许你先拿到一个还没入库的模型实例,把外键字段(比如post和author)手动填上,再save()。如果用户只填了评论内容,就要靠视图层把post和author塞进去。我遇到不少初学者在这里卡住,以为form.save()是整个保存,其实commit=False才是处理"表单之外字段"的标准姿势。

5.5 个人实践中总结出的调试与迭代节奏

写完这个DjangoBlog之后,我最大的体会是:新手最容易犯的错误,不是某个具体的Django API不会用,而是"架构意识"缺失。这里的架构不是那种大而空的企业级架构,而是小项目里的"下一步我该在哪里加代码"的判断力。

我自己总结了一套很实用的判断标准:当我要加一个新功能时,先问自己三个问题——这个功能的数据从哪来?(决定写不写Model)、这个功能的页面用什么状态展示?(决定写不写View和Template)、这个功能和哪个业务域的关系最密切?(决定放进哪个应用)。如果答案是"跟用户有关就放users,跟文章有关就放blog",你的项目永远乱不了。

我还有一个习惯:每次写完一个功能,就回头审视一次自己的视图函数。如果某个视图超过30行,我会强迫自己拆分。不是拆成更小的函数,而是把逻辑下沉到模型方法或者表单逻辑里。这样做的结果是,代码量总数差不多,但每个文件的可读性和定位问题的速度提升了不是一个量级。

就拿文章详情页来说,最初版本的视图函数有60多行,又是取文章、又是取评论、又是处理表单,混在一起。后来拆完,视图剩15行,模型里多了一个get_comment_count(),表单逻辑归到表单类。当一个报错出现时,我能立刻判断问题在哪个文件,而不是在一大坨代码里来回滚动找线索。

做技术博客这个项目,不用急着做到多精致。先让四个应用都跑通,再回头优化查询、重构模板,最后才考虑部署。一步步来,Django的多应用协作能力,会在你越来越熟悉的过程中变成一种直觉。

RELATED READING

延伸阅读

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