Django 实战:做一个个人博客 · 第 4 / 8 篇(展开目录)

全系列用 Django 5.2 LTS 做一个个人博客:游客看文章,作者登录后写文章,管理员维护分类和账号。Django1—Django8 是文章序号,不是 Django 版本号。

  1. Django1:起步——安装、运行与项目结构
  2. Django2:配置与路由——一个请求在 Django 里怎么走
  3. Django3:模型与迁移——把博客的数据存进数据库
  4. Django4:ORM——查询、更新、统计与事务
  5. Django5:页面——视图、模板、搜索与分页
  6. Django6:表单与后台——让作者在网页上写文章
  7. Django7:登录与权限——谁能改哪篇文章
  8. Django8:上线与维护——测试、部署、备份与发布

下载可运行的示例项目(ZIP)

约 20 分钟 · 全程在 python manage.py shell 中操作

本篇目标: 用 Python 代码代替 SQL 完成日常数据操作,并知道什么时候会悄悄多查很多次数据库。

前提: 已完成第 3 篇,数据库里有 first-django-post 这篇文章和 admin 账号。请在练习数据库上操作。

1. QuerySet:一个还没执行的查询

from blog.models import Category, Post, Tag

posts = Post.objects.filter(status=Post.Status.PUBLISHED)

Post 是模型,objects 是管理器,filter() 返回一个 QuerySet。可以把它理解成“一条可以继续加条件的查询”,而不是已经装进内存的列表。

QuerySet 是惰性的:只有在遍历、list()、切片取值、len() 或打印时才真正访问数据库。所以可以放心地一层层组合条件。

2. 增:创建记录和关系

from django.contrib.auth import get_user_model

author = get_user_model().objects.get(username="admin")
category, _ = Category.objects.get_or_create(name="技术")
tag, _ = Tag.objects.get_or_create(name="Python")

post = Post(
    title="使用 ORM 创建一篇文章",
    slug="orm-practice-post",
    body="这篇文章专门用于查询练习。",
    author=author,
    category=category,
)
post.full_clean()
post.save()
post.tags.add(tag)

三步分别是:full_clean() 验证字段 → save() 写入文章表 → tags.add() 写入标签中间表。

这段只运行一次

再运行会因为 slug 重复而报错。Post.objects.create(...) 也能一步保存,但它不会自动调用 full_clean()。

3. 查:一条、多条、不存在

post = Post.objects.get(slug="first-django-post")
posts = Post.objects.filter(author=author, status=Post.Status.PUBLISHED)
latest = posts.first()
has_posts = posts.exists()
total = posts.count()
写法 返回 没找到时
get(...) 一个对象 抛 DoesNotExist;找到多条则抛 MultipleObjectsReturned
filter(...) QuerySet 空 QuerySet
first() 第一个对象 None
exists() 布尔值 False
count() 整数 0

在视图里查单个对象,常用 get_object_or_404(),它把“没找到”直接变成 404 页面(第 5 篇)。

4. 条件、跨关系查询与排序

from django.db.models import Q
from django.utils import timezone
from datetime import timedelta

recent = Post.objects.filter(created_at__gte=timezone.now() - timedelta(days=7))
matches = Post.objects.filter(
    Q(title__icontains="Django") | Q(body__icontains="Django"),
    status=Post.Status.PUBLISHED,
)
by_author = Post.objects.filter(author__username="admin")
by_tag = Post.objects.filter(tags__name__in=["Django", "Python"]).distinct()
latest_ten = Post.objects.filter(status="published").order_by("-created_at", "-pk")[:10]

双下划线 有两种用法:

  • created_at__gte:字段 + 比较方式(大于等于)。常用的还有 exact、icontains、in、lt。
  • author__username:跨关系访问,相当于 SQL 的 JOIN。

条件组合: 多个参数默认是“且”;“或”要用 Q(...) | Q(...)。

去重: 一篇文章同时有 Django 和 Python 两个标签时,按标签筛选会返回两行,所以加 .distinct()。

5. 只取需要的字段,以及分组统计

from django.db.models import Count

rows = list(Post.objects.values("id", "title", "status")[:5])
titles = list(Post.objects.values_list("title", flat=True)[:5])

categories = Category.objects.annotate(
    published_count=Count("posts", filter=Q(posts__status=Post.Status.PUBLISHED))
).order_by("-published_count", "name")

for category in categories:
    print(category.name, category.published_count)
  • values() 返回字典,values_list(..., flat=True) 返回单值列表。它们比完整对象轻,但不能再调用模型方法。
  • annotate() 给每一行加一个统计列(这里是每个分类的已发布文章数);aggregate() 则对整组数据算出一个汇总值。
  • filter=Q(...) 只统计已发布的文章,同时保留了一篇公开文章都没有的分类(数量为 0)。

6. 改:save() 和 update() 的区别

post = Post.objects.get(slug="orm-practice-post")
post.title = "使用 ORM 修改一篇文章"
post.save(update_fields=["title", "updated_at"])

changed = Post.objects.filter(pk=post.pk, status=Post.Status.DRAFT).update(
    status=Post.Status.PUBLISHED,
    updated_at=timezone.now(),
)
print(changed)
obj.save() QuerySet.update()
作用对象 一个已加载的对象 符合条件的所有行
执行方式 走模型的保存流程 直接执行一条 SQL UPDATE
auto_now 字段 自动更新 不会自动更新,要手动赋值
返回值 无 受影响的行数

save(update_fields=[...]) 只写列出的字段;想让 updated_at 也更新,就要把它写进列表。

changed 的值有用:条件里写了“仍是草稿”,如果返回 0,说明这篇文章已经不是草稿了。

7. F 表达式:在数据库里做加法

独立练习

示例网站的页面不会调用这段代码。

from django.db.models import F

Post.objects.filter(pk=post.pk).update(views=F("views") + 1)
post.refresh_from_db()
print(post.views)

如果两个请求同时“读出 10 → 各自加 1 → 都保存成 11”,就丢了一次计数。F("views") + 1 把加法交给数据库在一条语句里完成,基于当前值递增,不会丢。

内存里的 post 对象不会自动变,用 refresh_from_db() 重新读取。

8. 事务:要么全成功,要么全回滚

需求:修改文章标题和标签,必须同时成功。

from django.db import transaction

with transaction.atomic():
    post = Post.objects.get(slug="orm-practice-post")
    post.title = "事务中修改的文章标题"
    post.save(update_fields=["title", "updated_at"])
    post.tags.set([tag])

atomic() 块里任何一步抛出异常,之前的数据库写入都会撤销;正常结束才提交。

事务管不了数据库以外的事

已经发出的邮件、写出的文件、调用过的外部接口都不会回滚。需要“提交成功后再发通知”时,用 transaction.on_commit()。也不要在事务里做耗时的网络请求,会长时间占着数据库连接和锁。

捕获数据库错误时,把 try/except 放在 atomic() 块的外面。

9. N+1 问题:模板里一个循环,数据库被查了几十次

一页显示 10 篇文章,每篇都要显示作者、分类和标签。如果直接写:

posts = Post.objects.filter(status=Post.Status.PUBLISHED)

模板循环里每访问一次 post.author、post.category、post.tags.all,都会再查一次数据库。10 篇文章就是 1 + 10 × 3 次查询——这就是 N+1 问题。

在下面的演示里拖动文章数,再勾选两个优化选项,看查询次数的变化:

交互演示加载中…(需要启用 JavaScript)

解决方法是在查询时告诉 Django 你会用到哪些关系:

posts = (
    Post.objects.filter(status=Post.Status.PUBLISHED)
    .select_related("author", "category")
    .prefetch_related("tags")
)
方法 适用于 做法
select_related() 外键、一对一(“一个”) 在同一条 SQL 里 JOIN 进来
prefetch_related() 多对多、反向外键(“多个”) 额外一条查询,再在 Python 里配对

优化后,分页列表固定是 3 次查询(总数、文章 + 作者 + 分类、标签),与文章数量无关。第 8 篇有一个测试专门检查这个数字。

预取之后别再 filter

post.tags.all() 会用预取结果;但 post.tags.filter(...) 是一个新查询,又会访问数据库。

10. 删:先确认范围

practice = Post.objects.get(slug="orm-practice-post")
print(practice.pk, practice.title)
practice.delete()

这会删除文章和它的标签关系,但标签本身还在。

小心批量删除

Post.objects.all().delete() 会清空整张表。删除前先打印要删的对象,确认范围。

小练习

自己先写,再展开对照。

1. 列出最近 7 天的公开文章标题。
list(Post.objects.filter(
    status=Post.Status.PUBLISHED,
    created_at__gte=timezone.now() - timedelta(days=7),
).values_list("title", flat=True))
2. 统计每位作者的已发布文章数。
from django.contrib.auth import get_user_model
from django.db.models import Count, Q

get_user_model().objects.annotate(
    published=Count("posts", filter=Q(posts__status="published"))
).values("username", "published")

posts 来自模型里的 related_name="posts"。

3. 为什么 update() 之后,内存里的 post.status 还是旧值?

update() 直接修改数据库,不会碰已加载到内存的对象。调用 post.refresh_from_db() 重新读取即可。