第 5 章:留言板网站

在本章中,我们将创建第一个基于数据库的网站——一个留言板应用。我们将学习关系数据库、编写 Django 模型、执行查询,并使用强大的内置管理界面进行操作。我们还会编写基于函数和基于类的视图,最后编写更高级的测试来确保一切正常工作。

初始设置

由于本书中已经设置过几个 Django 项目了,我们可以快速过一遍开始新项目的标准命令。我们需要做以下事情:

  • 在桌面上为代码创建一个名为 message-board 的新目录
  • 设置一个新的 Python 虚拟环境并激活它

在新的命令行终端中,输入以下命令:

# Windows
$ cd onedrive\desktop\code
$ mkdir message-board
$ cd message-board
$ python -m venv .venv
$ .venv\Scripts\Activate.ps1
(.venv) $

# macOS
$ cd ~/desktop/code
$ mkdir message-board
$ cd message-board
$ python3 -m venv .venv
$ source .venv/bin/activate
(.venv) $

然后,通过执行以下操作完成设置:

  • 在新的虚拟环境中安装 Django 和 Black
  • 创建一个名为 django_project 的新项目
  • 创建一个名为 posts 的新应用
(.venv) $ python -m pip install django~=5.0.0
(.venv) $ python -m pip install black
(.venv) $ django-admin startproject django_project .
(.venv) $ python manage.py startapp posts

最后一步,更新 django_project/settings.py,将新应用 posts 添加到 INSTALLED_APPS 部分的底部,告知 Django。

# django_project/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "posts",  # 新增
]

然后,执行 migrate 命令,基于 Django 的默认设置创建初始数据库。

(.venv) $ python manage.py migrate

现在本地目录中存在一个 db.sqlite3 文件,包含本地数据库和 Django 的默认表。启动本地服务器以确认一切正常工作。

(.venv) $ python manage.py runserver

在浏览器中导航到 http://127.0.0.1:8000/ 查看熟悉的 Django 欢迎页面。

Django 的一个主要设计理念——你将在本书中反复看到——是它允许自定义。例如,我们不必使用端口 8000 进行本地开发。我们可以将其更改为任何其他可用端口,比如 8080。按 Ctrl + c 停止服务器,然后通过传入所需的端口号重新启动。

(.venv) $ python manage.py runserver 8080

如果你刷新 http://127.0.0.1:8000/,会看到错误消息,但切换到 http://127.0.0.1:8080/ 会显示我们的 Hello, World! 问候语。

Django 欢迎页面(端口 8080)

在本书中我们不需要从端口 8000 切换出去,但如果你在更复杂的 Django 项目中需要不同的端口号,只需一个命令行定制即可。

数据库

在实现留言板模型之前,有必要回顾一下数据库、ORM 和 Django 是如何协同工作的。数据库是存储和访问不同类型数据的地方,主要有两种数据库类型:关系型和非关系型。

关系型数据库将信息存储在包含列和行的表中,大致类似于 Excel 电子表格。列定义了可以存储什么信息;行包含实际数据。通常,不同表中的数据之间存在某种关系,因此使用”关系型数据库”来描述这种具有表、列和行结构的数据库。

SQL(结构化查询语言)通常用于与关系型数据库交互,执行基本的 CRUD(创建、读取、更新、删除)操作,并定义关系类型(例如多对一关系。我们稍后将了解更多关于这些的知识)。

非关系型数据库是指任何不使用关系型数据库固有的表、字段、行和列来构建其数据的数据库:例如文档型、键值型、图型和宽列型数据库。

关系型数据库最适合数据一致且结构化、实体之间关系至关重要的场景。非关系型数据库在数据非结构化、需要在大小或形状上具有灵活性且未来可以更改的情况下具有优势。关系型数据库存在时间更长,使用更广泛,而许多非关系型数据库最近是为了特定的云使用场景而设计的。

数据库设计和实现是一个完整的计算机工程领域,非常深入且相当有趣,但远远超出了本书的范围。对我们的目的而言,重要的结论是这两种类型的数据库都存在。不过,Django 只内置了对关系型数据库的支持,因此我们将专注于这一点。

Django 的 ORM

ORM(对象关系映射器)是一种强大的编程技术,使处理数据和关系型数据库变得容易得多。就 Django 而言,它的 ORM 意味着我们可以编写 Python 代码来定义数据库模型;我们不必自己编写原始 SQL。我们也不必担心每种数据库在解释 SQL 时的细微差别。

虽然 ORM 抽象了大量工作,但我们仍然需要基本了解关系型数据库才能正确实现它们。例如,在编写任何实际代码之前,让我们看一下留言板模型中的数据结构。我们只有一个名为”Post”的表和一个包含消息内容的字段”text”。如果我们把这个画成一个简单的模式,它看起来像这样:

Post 模式
--------
Post
--------
TEXT

包含实际消息行的实际数据库表看起来像这样:

Post 数据库表
--------
TEXT
--------
My first message board post.
A 2nd post!
A third message.

数据库模型

现在我们已经知道数据库表应该是什么样子的,让我们使用 Django 的 ORM 用 Python 来定义它。打开 posts/models.py 文件,查看 Django 提供的默认代码:

# posts/models.py
from django.db import models

# Create your models here

Django 导入了一个 models 模块,帮助我们构建新的数据库模型,这些模型将”建模”我们数据库中数据的特征。对于每个要创建的数据库模型,方法是子类化(即扩展)django.db.models.Model,然后添加我们的字段。输入以下代码,我们将马上进行回顾:

# posts/models.py
from django.db import models

class Post(models.Model):  # 新增
    text = models.TextField()

我们创建了一个名为 Post 的新数据库模型,它有一个数据库字段 text。我们还指定了它将保存的内容类型为 TextField()。Django 提供了许多模型字段,支持常见类型的内容,如字符、日期、整数、电子邮件等。我们稍后会探讨这些。现在,我们已经编写了第一个模型!

激活模型

创建模型后,下一步是激活它。从现在开始,每当我们创建或修改现有模型时,都需要通过两步过程更新 Django:

  1. 首先,使用 makemigrations 命令创建迁移文件。迁移文件记录对数据库模型的任何更改,这意味着我们可以随时间跟踪更改,并在必要时调试错误。
  2. 其次,使用 migrate 命令构建数据库,该命令执行迁移文件中的指令。

确保通过按 Control+c 停止本地服务器,然后运行 python manage.py makemigrations postspython manage.py migrate 命令。

(.venv) $ python manage.py makemigrations posts
Migrations [for](https://docs.djangoproject.com/en/5.0/ref/templates/builtins/#std:templatetag-for) 'posts':
  posts/migrations/0001_initial.py
    - Create model Post

(.venv) $ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, posts, sessions
Running migrations:
  Applying posts.0001_initial... OK

你不必在 makemigrations 后面包含应用名称。如果只运行 makemigrations 而不指定应用,则会为整个 Django 项目中所有可用的更改创建迁移文件。在我们这样只有单个应用的小型项目中没有问题,但大多数 Django 项目都有多个应用!因此,如果您在多个应用中进行了模型更改,生成的迁移文件将包含所有这些更改——这并不理想!迁移文件应该尽可能小巧简洁,这样更容易在将来调试或在需要时回滚更改。因此,作为最佳实践,养成在执行 makemigrations 命令时始终包含应用名称的习惯!

Django 管理后台

Django 的一个杀手级功能是其强大的管理界面,可以直观地与数据交互。它源于 Django 最初是作为一个报纸 CMS(内容管理系统)开始的。理念是记者可以在管理后台中编写和编辑他们的报道,而无需接触”代码”。随着时间的推移,内置的 admin 应用已经发展成为一个出色的开箱即用工具,用于管理 Django 项目的各个方面。

要使用 Django 管理后台,我们必须首先创建一个可以登录的超级用户。在命令行终端中,输入 python manage.py createsuperuser 并根据提示输入用户名、电子邮件和密码:

(.venv) $ python manage.py createsuperuser
Username (leave blank to use 'wsv'): wsv
Email: will@learndjango.com
Password:
Password (again):
Superuser created successfully.

当你输入密码时,出于安全原因,它不会在命令行终端中显示为可见字符。对于本地开发,我经常使用 testpass123。使用 python manage.py runserver 重新启动 Django 服务器,然后在浏览器中访问 http://127.0.0.1:8000/admin/。你会看到管理后台的登录页面:

管理后台登录页面

输入刚刚创建的用户名和密码登录。接下来你会看到 Django 管理后台首页:

管理后台首页

Django 对多种语言有出色的支持,所以如果你想用除英语之外的其他语言查看管理后台、表单和其他默认消息,可以尝试调整 django_project/settings.py 中的 [LANGUAGE_CODE](https://docs.djangoproject.com/en/5.0/ref/settings/#language-code) 配置,默认设置为美式英语 en-us

但是我们的 posts 应用在哪里?为什么它没有显示在管理后台主页上?就像我们必须明确将新应用添加到 INSTALLED_APPS 配置中一样,我们也必须更新应用的 admin.py 文件,才能让它在管理后台中显示。

在文本编辑器中,打开 posts/admin.py 并添加以下代码来显示 Post 模型。

# posts/admin.py
from django.contrib import admin
from .models import Post

admin.site.register(Post)

Django 知道它应该在管理后台页面上显示我们的 posts 应用及其数据库模型 Post。如果你刷新浏览器,你会看到它出现了:

更新后的管理后台首页

让我们为数据库创建第一条留言板帖子。点击 Posts 对面的 + Add 按钮,在 Text 表单字段中输入内容。

管理后台新建条目

然后点击”Save”按钮,将重定向到主 Post 页面。然而,如果你仔细观察,会发现一个问题:我们的新条目被称为”Post object (1)“,这描述性不够强!

管理后台帖子列表

让我们改变这一点。在 posts/models.py 文件中,添加一个名为 __str__ 的新方法,为模型提供人类可读的表示。在这里,我们将让它显示 text 字段的前 50 个字符。

# posts/models.py
from django.db import models

class Post(models.Model):
    text = models.TextField()

    def __str__(self):  # 新增
        return self.text[:50]

如果你在浏览器中刷新管理后台页面,你会看到现在它以更具描述性和更有帮助的方式表示我们的数据库条目。

管理后台可读的帖子

好多了!在所有模型上添加 __str__() 方法以提高可读性是一个最佳实践。

让我们使用同样的方法再添加两条条目,这样我们在下一节中总共有三条数据可用。你可以使用右上角的”Add Post +“按钮。

管理后台显示三条帖子

基于函数的视图

要在首页上显示留言板帖子,我们需要连接视图、模板和 URL。这个模式现在应该开始感觉熟悉了。

让我们从视图开始。我们首先编写一个基于函数的视图,然后切换到通用基于类的视图。在 posts/views.py 文件中,替换默认文本并输入以下 Python 代码:

# posts/views.py
from django.shortcuts import render
from .models import Post

def post_list(request):
    posts = Post.objects.all()
    return render(request, "post_list.html", {"posts": posts})

第一行,我们导入了 [render()](https://docs.djangoproject.com/en/5.0/topics/http/shortcuts/#render) 快捷函数,它将模板与上下文字典结合并返回一个 HttpResponse 对象。然后,我们从 models.py 文件中导入数据库模型 Post

我们定义了一个函数 post_list,按照 Django 惯例将 request 对象命名为”request”。然后,我们将变量 posts 设置为包含所有 Post 对象的数据库查询。然后使用 render() 返回 request 对象,将模板作为第二个参数,第三个参数中定义一个上下文字典 “posts”,其值匹配我们在前一行设置的 posts 变量。

让我们更详细地分析 Post.objects.all(),这是通过 Django ORM 进行数据库查询的第一个例子。

  1. Post:指的是我们的模型类,它代表数据库中的一张表,每一行对应 Post 模型的一个实例
  2. objects:这是 Post 模型的默认管理器。管理器提供了与数据库交互和执行查询的方式。默认情况下,Django 为每个 Django 模型类添加一个名为”objects”的管理器。
  3. all():这是管理器提供的一个方法,返回一个包含数据库中所有 Post 模型实例的 QuerySet。QuerySet 是一个用于检索对象的数据库查询集合。

QuerySet API 参考是官方文档中非常宝贵的资源,涵盖了所有可用的方法。你不需要记住所有的方法。相反,传统的方法是在查询数据时遇到问题,然后搜索内置方法。你很可能会发现已经存在一个。

模板和 URL

我们已经有了模型和视图,只剩下模板和 URL 需要配置了。让我们从模板开始。创建一个新的项目级别目录 templates

(.venv) $ mkdir templates

然后,更新 django_project/settings.py 文件中的 DIRS 字段,让 Django 在这个新的 templates 目录中查找。

# django_project/settings.py
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],  # 新增
        "APP_DIRS": True,
        ...
    },
]

在文本编辑器中,创建一个名为 templates/post_list.html 的新文件。我们的模板上下文包含一个名为 posts 的字典,我们需要通过 for 模板标签对其进行循环。我们将创建一个名为 post 的变量,然后可以访问我们想要显示的字段 text,即 post.text

<!-- templates/post_list.html -->
<h1>Message Board Homepage</h1>
<ul>
{% for post in posts %}
    <li>{{ post.text }}</li>
{% endfor %}
</ul>

最后一步是设置 URL。让我们从 django_project/urls.py 文件开始,在该文件中包含我们的 posts 应用,并在第二行添加 include

# django_project/urls.py
from django.contrib import admin
from django.urls import path, include  # 新增

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("posts.urls")),  # 新增
]

然后,在文本编辑器中,在 posts 应用内创建一个新的 urls.py 文件并更新如下:

# posts/urls.py
from django.urls import path
from .views import post_list

urlpatterns = [
    path("", post_list, name="post_list"),
]

使用 python manage.py runserver 重新启动服务器,导航到我们的首页,这里列出了我们的留言板帖子。

显示三条帖子的首页

如果你导航到 Django 管理后台并添加或删除留言板帖子,首页将更新以反映这些更改。

ListView

在上一章中,我们编写了基于函数的视图,然后切换到内置的通用 [TemplateView](https://docs.djangoproject.com/en/5.0/ref/class-based-views/base/#django.views.generic.base.TemplateView) 在首页显示模板文件。列出数据库模型中的所有项目是如此常见,以至于也存在一个名为 ListView 的通用基于类视图。出于教学目的,我们现在将切换到它。关于基于函数的视图和通用基于类的视图,没有对错之分:这只是个人偏好的问题。

posts/views.py 文件中,注释掉现有的基于函数视图的代码,并为基于类视图的实现添加新代码。在第一行导入 ListView,然后创建一个继承自它的 PostList 类。我们定义所需的模型 Post,然后是模板名称 post_list.html

# posts/views.py
# from django.shortcuts import render
# from .models import Post
#
# def post_list(request):
#     posts = Post.objects.all()
#     return render(request, "post_list.html", {"posts": posts})

from django.views.generic import ListView  # 新增
from .models import Post

class PostList(ListView):  # 新增
    model = Post
    template_name = "post_list.html"

当前模板使用一个名为 posts 的上下文字典。默认情况下,ListView 返回一个名为 <model>_list 的上下文变量,其中 <model> 是我们的模型名称。由于我们的模型名为 post,我们需要在 for 循环中遍历 post_list。模板的其余部分保持不变。

<!-- templates/post_list.html -->
<h1>Message board homepage</h1>
<ul>
{% for post in post_list %}
    <li>{{ post.text }}</li>
{% endfor %}
</ul>

最后一步是使用新的视图名称 PostList 更新 posts/urls.py。我们可以注释掉或删除之前的代码。记住,我们还需要添加 as_view() 方法来返回一个可调用的视图。

# posts/urls.py
from django.urls import path
# from .views import post_list
from .views import PostList  # 新增

urlpatterns = [
    # path("", post_list, name="post_list"),
    path("", PostList.as_view(), name="home"),  # 新增
]

就这样!如果你刷新首页,它应该像以前一样工作。我们编写了一个新视图,更新了模板中的上下文变量名称,并更新了 URL 文件。

初始提交

一切正常,所以现在是初始化目录并创建 .gitignore 文件的好时机。

我们可以使用 git init 命令从命令行初始化一个新的 Git 仓库。

(.venv) $ git init

然后,在文本编辑器中,在根目录创建一个新的 .gitignore 文件并添加三行代码,这样 .venv 目录、Python 字节码和 db.sqlite3 文件就不会被跟踪。

.venv/
__pycache__/
*.sqlite3

如果你现在运行 git status.venv 目录、__pycache__ 目录和 db.sqlite3 文件将被忽略。使用 git add -A 添加预期的文件/目录,并写入一个初始提交信息。

(.venv) $ git status
(.venv) $ git add -A
(.venv) $ git commit -m "initial commit"

测试

现在我们的项目使用了一个数据库,我们需要使用 [TestCase](https://docs.djangoproject.com/en/5.0/topics/testing/tools/#django.test.TestCase),它允许我们创建一个测试数据库。换句话说,我们不需要在实际数据库上运行测试,而是可以创建一个单独的测试数据库,填充样本数据,并针对它进行测试,这是一种更安全、更高效的方法。

我们的 Post 模型只包含一个字段 text,所以让我们设置数据,然后检查它是否正确地存储在数据库中。所有测试方法必须以 test 开头,这样 Django 才知道要测试它们!

我们将使用 [setUpTestData()](https://docs.djangoproject.com/en/5.0/topics/testing/tools/#django.test.TestCase.setUpTestData) 钩子来创建测试数据:它比 Python 的 unittest 中的 setUp() 钩子快得多,因为它只创建一次测试数据,而不是每个测试都创建。不过,仍然经常看到依赖 setUp() 的 Django 项目。将任何这类测试转换为 setUpTestData 是加快测试套件速度的可靠方法,应该这样做!

setUpTestData() 是一个类方法,这意味着它是一个可以转换为类的方法。要使用它,我们将使用 @[classmethod](https://docs.python.org/3/library/functions.html?highlight=classmethod#classmethod) 函数装饰器。根据 PEP 8 规范,在 Python 中,最佳实践是始终使用 cls 作为类方法的第一个参数。代码如下所示:

# posts/tests.py
from django.test import TestCase
from .models import Post

class PostTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.post = Post.objects.create(text="This is a test!")

    def test_model_content(self):
        self.assertEqual(self.post.text, "This is a test!")

顶部我们导入了 TestCase 和 Post 模型。然后,我们创建了一个测试类 PostTests,它继承 TestCase 并使用内置方法 setUpTestData 来创建初始数据。在这个例子中,我们只有一条数据存储为 cls.post,可以在后续测试中通过 self.post 引用。我们的第一个测试 test_model_content 使用 assertEqual 检查 text 字段的内容是否符合预期。

在命令行中使用 python manage.py test 命令运行测试。

(.venv) $ python manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
Destroying test database for alias 'default'...

测试通过了!为什么输出说只运行了一个测试,而我们有两个函数?再次强调,只有以 test 开头的函数才会被执行!所以,虽然我们可以使用 setup 函数和类来帮助测试,但除非一个函数被正确命名,否则它不会通过 python manage.py test 命令执行。

现在是时候检查我们的 URL、视图和模板了。对于留言板页面,我们希望检查以下四项内容:

  • URL 存在于 / 并返回 200 HTTP 状态码
  • URL 可以通过其名称 “home” 访问
  • 使用了正确的模板 “post_list.html”
  • 首页内容与数据库中的预期内容匹配

由于这个项目只有一个网页,我们可以将所有测试包含在现有的 PostTests 类中。确保在文件顶部导入 reverse,并添加以下四个测试:

# posts/tests.py
from django.test import TestCase
from django.urls import reverse  # 新增
from .models import Post

class PostTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.post = Post.objects.create(text="This is a test!")

    def test_model_content(self):
        self.assertEqual(self.post.text, "This is a test!")

    def test_url_exists_at_correct_location(self):  # 新增
        response = self.client.get("/")
        self.assertEqual(response.status_code, 200)

    def test_url_available_by_name(self):  # 新增
        response = self.client.get(reverse("home"))
        self.assertEqual(response.status_code, 200)

    def test_template_name_correct(self):  # 新增
        response = self.client.get(reverse("home"))
        self.assertTemplateUsed(response, "post_list.html")

    def test_template_content(self):  # 新增
        response = self.client.get(reverse("home"))
        self.assertContains(response, "This is a test!")

如果你重新运行测试,应该会看到它们全部通过。

(.venv) $ python manage.py test
Found 5 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 5 tests in 0.006s
OK
Destroying test database for alias 'default'...

在上一章中,我们讨论了单元测试在独立且高度详细的情况下效果最好。然而,这里可以认为下面的三个测试只是在测试首页是否按预期工作:它使用了正确的 URL 名称、预期的模板名称以及包含预期的内容。我们可以将这三个测试合并为一个单元测试 test_homepage

# posts/tests.py
from django.test import TestCase
from django.urls import reverse  # 新增
from .models import Post

class PostTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.post = Post.objects.create(text="This is a test!")

    def test_model_content(self):
        self.assertEqual(self.post.text, "This is a test!")

    def test_url_exists_at_correct_location(self):
        response = self.client.get("/")
        self.assertEqual(response.status_code, 200)

    def test_homepage(self):  # 新增
        response = self.client.get(reverse("home"))
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "post_list.html")
        self.assertContains(response, "This is a test!")

最后一次运行测试以确认它们全部通过。

(.venv) $ python manage.py test
Found 3 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 3 tests in 0.005s
OK
Destroying test database for alias 'default'...

最终,我们希望测试套件尽可能覆盖代码功能,同时仍然易于理解和维护。这个更新更容易阅读和理解。

测试已经足够了;是时候将更改提交到 Git 了。

(.venv) $ git add -A
(.venv) $ git commit -m "added tests"

GitHub

我们还需要将代码存储到 GitHub 上。既然你已经从之前的章节中拥有了 GitHub 账户,请创建一个名为 message-board 的新仓库。选择”Private”单选按钮。

在下一页上,滚动到”…or push an existing repository from the command line”处。将那里的两条命令复制粘贴到你的终端中,命令应该如下所示,只是将 wsvincent(我的用户名)替换为你的 GitHub 用户名:

(.venv) $ git remote add origin https://github.com/wsvincent/message-board.git
(.venv) $ git branch -M main
(.venv) $ git push -u origin main

结论

我们已经构建并测试了第一个数据库驱动的应用,学会了如何创建数据库模型、通过管理面板更新它以及编写测试。我们还同时了解了基于函数和基于类的视图方法。在下一章中,我们将构建一个更复杂的博客应用,包含用户注册和登录功能,允许用户创建/编辑/删除自己的帖子,然后添加 CSS 进行样式美化。