本系列目录

本系列以 Django 5.2 LTS 和个人博客为例。Django1—Django8 是文章序号。

  1. Django1:部署——从本地启动到服务器运行
  2. Django2:配置——看懂项目结构与请求流程
  3. Django3:数据库——模型设计、关系与迁移
  4. Django4:ORM——增删改查、事务与查询优化
  5. Django5:页面——视图、模板、搜索与分页
  6. Django6:使用——表单、文章管理与 Admin
  7. Django7:权限——登录、会话与访问控制
  8. Django8:维护——测试、日志、备份与升级

下载全部教程与可运行示例(ZIP)。解压后进入 django-series/example/,按第一篇运行。

查看完整项目源码 · 查看示例验证范围

博客的核心不是页面样式,而是文章能保存、能分类、能找到作者,并能区分草稿和公开内容。本篇先从业务规则出发设计模型,再说明迁移如何把 Python 定义变成数据库结构。

沿用 Django 5.2 和 blog 应用。学习时使用 SQLite;第一篇的容器部署使用 PostgreSQL 16。两者共用同一份模型,但数据库功能和并发行为仍有区别。Django 数据库说明

1. 先写业务规则,再写字段

我们的博客约定:一篇文章有一位作者;作者可以有多篇文章;文章最多属于一个分类;文章可以拥有多个标签,同一个标签也可以标记多篇文章;草稿默认不对游客展示。

User(用户)     1 ───── N Post(文章)
Category(分类) 1 ───── N Post(文章,分类可为空)
Post(文章)    N ───── N Tag(标签)
                         ↑
                 Django 创建中间关系表
业务数据 字段类型 原因
标题 CharField 有明确长度上限
网址标识 SlugField(unique=True) 适合作为 URL 片段,并要求唯一
正文 TextField 可保存较长文本
作者 ForeignKey 多篇文章属于一个用户
分类 可空的 ForeignKey 可以暂时未分类
标签 ManyToManyField 一篇文章可对应多个标签
状态 CharField 与枚举 使用有限的业务状态
创建/更新时间 DateTimeField 记录生命周期

slug 是读者看到的可读地址标识,数据库仍有自增 id 主键。编辑和删除页使用 pk,公开详情页使用 slug;不要假定两者必须相同。

2. 完整模型代码

将以下内容保存到 blog/models.py

from django.conf import settings
from django.db import models
from django.urls import reverse


class Category(models.Model):
    name = models.CharField("名称", max_length=50, unique=True)

    def __str__(self):
        return self.name


class Tag(models.Model):
    name = models.CharField("名称", max_length=30, unique=True)

    def __str__(self):
        return self.name


class Post(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "草稿"
        PUBLISHED = "published", "已发布"

    title = models.CharField("标题", max_length=200)
    slug = models.SlugField("网址标识", max_length=200, unique=True)
    body = models.TextField("正文")
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
        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, blank=True, related_name="posts", verbose_name="标签")
    status = models.CharField("状态", max_length=10, choices=Status.choices, default=Status.DRAFT)
    views = models.PositiveIntegerField("阅读计数", default=0)
    created_at = models.DateTimeField("创建时间", auto_now_add=True)
    updated_at = models.DateTimeField("更新时间", auto_now=True)

    class Meta:
        ordering = ["-created_at", "-pk"]
        indexes = [models.Index(fields=["status", "-created_at", "-id"], name="post_status_created_idx")]
        constraints = [models.CheckConstraint(
            condition=models.Q(status__in=["draft", "published"]),
            name="post_status_valid",
        )]

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("blog:detail", kwargs={"slug": self.slug})

__str__() 决定对象在后台和调试输出中的可读名称。get_absolute_url() 用路由名称生成详情地址,不在模型里硬编码 /p/

3. 几个容易混淆的字段参数

null=True 主要控制数据库是否允许 SQL NULL;blank=True 主要控制 Django 验证层是否允许空值。分类既可在数据库里为空,也可在表单里不选,所以两者都设置。普通文本字段一般使用空字符串表示空,不必再额外引入 NULL。

default=Status.DRAFT 为通过模型创建的新对象提供默认状态。choices 让表单生成选项并参与模型验证,但不能单独当成数据库级约束。本例另加 CheckConstraint,让直接 SQL 和绕过表单的更新也不能写入不存在的状态。

unique=True 会在数据库层要求网址标识唯一。ModelForm 可以提前提示冲突,数据库约束负责最后把关。save() 不会自动调用 full_clean();命令行脚本或批量导入如果需要完整模型验证,要主动调用。模型字段参考模型验证

4. 删除关联对象时会发生什么

关系 本例策略 实际结果
文章 → 作者 PROTECT 仍有文章引用的作者不能直接删除
文章 → 分类 SET_NULL 删除分类后,文章保留并变成未分类
文章 ↔ 标签 多对多关系 移除标签关系不删除文章正文

删除账号是一个业务决定。本例保留文章并阻止直接删除作者;如果实际产品要求注销账号,需要另外设计归档、匿名化或内容删除流程。

related_name="posts" 提供反向查询入口,例如 user.posts.all()category.posts.all()。关联用户时使用 settings.AUTH_USER_MODEL,避免把具体用户表写死。若要采用自定义用户模型,最好在新项目首次迁移前决定,详见第七篇。

5. 迁移到底做了什么

修改 models.py 并不会自动修改数据库。完整流程是:

python manage.py makemigrations blog
python manage.py sqlmigrate blog 0001
python manage.py migrate
python manage.py showmigrations blog
命令 作用
makemigrations 对比模型状态与迁移历史,生成变更文件
sqlmigrate 展示某个迁移在当前数据库后端上的 SQL
migrate 按依赖顺序把尚未执行的迁移应用到数据库
showmigrations 查看迁移及其执行状态

完整示例已经带有 0001_initial.py。直接使用它时,第一次执行 makemigrations 显示 No changes detected 属于正常结果;执行 migrate 后,showmigrations 应显示 [X] 0001_initial

迁移文件是项目历史的一部分,应与代码一起保存。数据库中的 django_migrations 记录哪些迁移已执行。不要为了消除报错随意删迁移文件、清空迁移记录或使用 --fake;它们可能让“Django 认为的表结构”与真实结构不一致。迁移机制

6. 插入一篇完整示例文章

先按第一篇执行 createsuperuser 创建名为 admin 的账号;如果用了其他名字,替换下面的用户名。进入项目 shell:

python manage.py shell

然后逐段执行:

from django.contrib.auth import get_user_model
from blog.models import Category, Post, Tag

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

post, created = Post.objects.get_or_create(
    slug="first-django-post",
    defaults={
        "title": "我的第一篇 Django 文章",
        "body": "今天完成了数据库模型和迁移。",
        "author": author,
        "category": category,
        "status": Post.Status.PUBLISHED,
    },
)
post.tags.add(tag)
print(post.pk, post.title, post.get_absolute_url())

预期生成 /p/first-django-post/。重复执行不会创建同 slug 的第二篇文章;但 get_or_create() 找到已有对象时也不会用 defaults 覆盖原内容。tags.add() 必须在文章拥有主键后执行,因为中间表需要引用文章 ID。

可以在 shell 中运行 list(post.tags.values_list("name", flat=True)),预期包含 Django。若已使用完整示例,打开详情地址即可查看;若还在手工搭建,先完成第五篇的视图和模板。

7. 模型变更案例:给旧文章增加摘要

以下是独立练习,不属于配套示例的初始模型。假设数据库已经有 100 篇文章,现在新增可选摘要,添加:

summary = models.CharField("摘要", max_length=300, blank=True, default="")

然后执行:

python manage.py makemigrations blog
python manage.py migrate --plan
python manage.py migrate

如果业务要求摘要将来必填,更稳妥的步骤是先允许旧记录为空,再通过数据迁移或受控脚本回填,确认全部记录符合要求后收紧验证。直接为大量旧数据增加一个没有默认值的非空字段,需要处理已有行如何取值,不能仅修改 Python 类就期待数据库自行推断。

数据迁移使用迁移参数提供的历史模型,例如 apps.get_model("blog", "Post"),不直接导入当前 models.py。这样从零重放迁移时,才能使用当时的字段结构。大表变更还要评估锁表和执行时长,在接近实际数据规模的环境演练。

8. 从 SQLite 切换到 PostgreSQL

第一篇的 Compose 已配置 PostgreSQL。它通过以下变量让 settings.py 选择数据库:

DB_ENGINE=postgres
POSTGRES_DB=blog
POSTGRES_USER=blog_app
POSTGRES_PASSWORD=你的应用数据库密码
POSTGRES_HOST=db
POSTGRES_PORT=5432

POSTGRES_HOST=db 只在 Compose 网络中成立。本机 Python 连接宿主机安装的 PostgreSQL 时,通常使用 127.0.0.1;云数据库则填写对应地址,并按提供方要求配置网络与 TLS。第一篇的数据库没有公开端口,所以不能直接从宿主机连接它的 5432。

更换连接并运行 migrate 只会在新数据库建立结构,不会把 SQLite 里的文章自动复制过去。对于本教程规模的小数据集,可以在停止写入后进行逻辑导出与导入:

# 当前终端不应设置 PostgreSQL 相关变量;这里明确选择 SQLite。
DB_ENGINE=sqlite python manage.py dumpdata auth.user auth.group blog \
  --natural-foreign --natural-primary --indent 2 --output blog-export.json

# 目标必须是新建且已完成初始化的 PostgreSQL 数据库。
docker compose run --rm web python manage.py migrate
docker compose run --rm -v "$PWD/blog-export.json:/tmp/blog-export.json:ro" \
  web python manage.py loaddata /tmp/blog-export.json

本命令适用于本系列默认用户模型,导入前不要在目标库另外创建同名用户和文章。它搬运用户、用户组和博客数据,不搬会话与管理操作日志;由迁移重新生成的内容类型、权限通过自然键引用。导出文件包含账号信息和密码哈希,应限制访问,验证结束后妥善归档或删除。

导入后分别检查用户数、文章数、标签关系,实际登录并抽查页面。重要生产数据的跨库迁移需要独立演练、停写计划和回退方案,不能把这段小案例等同于通用无停机迁移。

9. 常见问题

no such table: blog_post 通常意味着当前数据库还没迁移,或程序连到了另一个库。UNIQUE constraint failed 表示唯一值冲突,例如重复 slug。ProtectedError 可能正是删除作者时触发了本例的保护策略。database is locked 常见于 SQLite 并发写入或长事务,应先缩短事务并检查并发来源;增加等待时间不等于获得 PostgreSQL 的锁机制。

下一篇 Django4:ORM 将在这些表上完成查询、更新、统计与事务练习。


上一篇:Django2:配置——看懂项目结构与请求流程 · 下一篇:Django4:ORM——增删改查、事务与查询优化