跳转至

第 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",  # new
]

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

(.venv) $ python manage.py migrate

现在一个 db.sqlite3 文件已经出现在我们的本地数据库中,包含 Django 默认表。启动我们的本地服务器来确认一切正常运行。

(.venv) $ python manage.py runserver

在你的 Web 浏览器中,导航到 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 的细微差异。相反,Django ORM 支持五种关系型数据库:SQLite、PostgreSQL、MySQL、MariaDB 和 Oracle。它还附带了迁移支持,提供了一种随时间跟踪和同步数据库变化的方式。总之,Django ORM 为开发者节省了大量时间,这也是 Django 如此高效的主要原因之一。

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

Post Schema
Post
--------
TEXT

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

Post Database Table
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):  # new
    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 '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 Admin

Django 的杀手级功能之一是其强大的管理界面,可以可视化地与数据交互。它的由来是因为 Django 最初是一个报社 CMS(内容管理系统)。其理念是记者可以在管理界面中编写和编辑他们的文章,而无需接触"代码"。随着时间的推移,内置的管理应用已经发展成为一个出色的开箱即用工具,用于管理 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 服务器,然后在你的 Web 浏览器中访问 http://127.0.0.1:8000/admin/。你应该会看到管理界面的登录页面:

管理界面登录页面

使用你刚才创建的用户名和密码登录。接下来你将看到 Django 管理界面主页:

管理界面主页

Django 对多种语言有出色的支持,所以如果你想以英语以外的语言查看管理界面、表单和其他默认消息,请尝试调整 django_project/settings.py 中的 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):  # new
        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() 快捷函数,它将模板与上下文字典组合在一起并返回一个 HttpResponse 对象。然后,我们从 models.py 文件中导入我们的数据库模型 Post。

我们定义了一个函数 post_list,并按照 Django 约定将请求对象命名为"request"。然后,我们设置一个变量 posts,将其赋值为包含所有 Post 对象的数据库查询结果。接着我们使用 render() 返回请求对象,将模板定义为第二个参数,然后在第三个参数中定义一个名为"posts"的上下文字典,其值与我们在上一行设置的 posts 变量匹配。

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

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

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"],  # new
        "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  # new

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

然后,在你的文本编辑器中,在 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 来在主页上显示模板文件。列出数据库模型中的所有项目是如此常见,以至于存在一个专门用于此目的的泛型类视图,叫做 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  # new
from .models import Post

class PostList(ListView):  # new
    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  # new

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

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

初始提交

一切正常,现在是初始化目录并创建 .gitignore 文件的好时机。我们可以使用 git init 命令从命令行初始化一个新的 Git 仓库。

(.venv) $ git init

然后,在你的文本编辑器中,在根目录中创建一个新的 .gitignore 文件,并添加三行,以便不跟踪 .venv 目录、Python 字节码和 db.sqlite 文件。

# .gitignore
.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,它将让我们创建一个测试数据库。换句话说,我们不需要在实际数据库上运行测试,而是可以创建一个单独的测试数据库,用示例数据填充它并对其进行测试,这是一种更安全且性能更好的方法。

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

我们将使用钩子 setUpTestData() 来创建测试数据:它比使用 Python unittest 中的 setUp() 钩子要快得多,因为它只在每个测试用例中创建一次测试数据,而不是每个测试都创建一次。然而,在 Django 项目中仍然常见使用 setUp()。将此类测试转换为 setUpTestData 是加速测试套件的可靠方法,应该这样做!

setUpTestData() 是一个 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  # new
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):  # new
        response = self.client.get("/")
        self.assertEqual(response.status_code, 200)

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

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

    def test_template_content(self):  # new
        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  # new
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):  # new
        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 进行样式美化。